Skip to main content

problemreductions/models/misc/
minimum_decision_tree.rs

1//! Minimum Decision Tree problem implementation.
2//!
3//! Given a set of objects distinguished by binary tests, find a decision tree
4//! that identifies each object with minimum total external path length
5//! (sum of depths of all leaves).
6
7use crate::registry::{CreateSpec, ProblemSchemaEntry};
8use crate::traits::Problem;
9use crate::types::Min;
10use serde::{Deserialize, Serialize};
11
12inventory::submit! {
13    ProblemSchemaEntry {
14        name: "MinimumDecisionTree",
15        display_name: "Minimum Decision Tree",
16        aliases: &[],
17        dimensions: &[],
18        category: crate::registry::ProblemCategory::Misc,
19        module_path: module_path!(),
20        description: "Find decision tree identifying objects with minimum total path length",
21        fields: MinimumDecisionTreeCreateSpec::FIELDS,
22    }
23}
24
25/// Minimum Decision Tree problem.
26///
27/// Given objects distinguished by binary tests, find a decision tree
28/// minimizing the total external path length (sum of leaf depths).
29///
30/// The configuration encodes a flattened complete binary tree of depth
31/// `num_objects - 1`. Each internal node stores either a test index
32/// (0..num_tests-1) or a sentinel value `num_tests` meaning "leaf".
33///
34/// # Example
35///
36/// ```
37/// use problemreductions::models::misc::MinimumDecisionTree;
38/// use problemreductions::{Problem, BruteForce};
39///
40/// let problem = MinimumDecisionTree::new(
41///     vec![
42///         vec![true, true, false, false],   // T0
43///         vec![true, false, false, false],   // T1
44///         vec![false, true, false, true],    // T2
45///     ],
46///     4,
47///     3,
48/// );
49/// let solver = BruteForce::new();
50/// let value = solver.solve(&problem).unwrap();
51/// ```
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct MinimumDecisionTree {
54    /// Binary matrix: test_matrix[j][i] = true iff object i passes test j.
55    test_matrix: Vec<Vec<bool>>,
56    /// Number of objects.
57    num_objects: usize,
58    /// Number of tests.
59    num_tests: usize,
60}
61
62#[derive(Debug, Deserialize, crate::CreateSpec)]
63struct MinimumDecisionTreeCreateSpec {
64    /// Binary test matrix as JSON.
65    #[create(codec = "json")]
66    test_matrix: Vec<Vec<bool>>,
67    /// Number of objects.
68    num_objects: usize,
69    /// Number of tests.
70    num_tests: usize,
71}
72
73impl TryFrom<MinimumDecisionTreeCreateSpec> for MinimumDecisionTree {
74    type Error = crate::registry::ConstructionError;
75    fn try_from(spec: MinimumDecisionTreeCreateSpec) -> Result<Self, Self::Error> {
76        if spec.num_objects < 2 {
77            return Err("num_objects must be at least 2".into());
78        }
79        if spec.num_tests == 0 {
80            return Err("num_tests must be positive".into());
81        }
82        if spec.test_matrix.len() != spec.num_tests {
83            return Err("test_matrix row count must equal num_tests".into());
84        }
85        if spec
86            .test_matrix
87            .iter()
88            .any(|row| row.len() != spec.num_objects)
89        {
90            return Err("each test_matrix row must have num_objects columns".into());
91        }
92        for a in 0..spec.num_objects {
93            for b in a + 1..spec.num_objects {
94                if !(0..spec.num_tests)
95                    .any(|test| spec.test_matrix[test][a] != spec.test_matrix[test][b])
96                {
97                    return Err(
98                        format!("objects {a} and {b} are not distinguished by any test").into(),
99                    );
100                }
101            }
102        }
103        Ok(Self {
104            test_matrix: spec.test_matrix,
105            num_objects: spec.num_objects,
106            num_tests: spec.num_tests,
107        })
108    }
109}
110
111impl MinimumDecisionTree {
112    /// Create a new MinimumDecisionTree problem.
113    ///
114    /// # Panics
115    /// - If num_objects < 2 or num_tests < 1
116    /// - If test_matrix dimensions don't match
117    /// - If tests don't distinguish all object pairs
118    pub fn new(test_matrix: Vec<Vec<bool>>, num_objects: usize, num_tests: usize) -> Self {
119        assert!(num_objects >= 2, "Need at least 2 objects");
120        assert!(num_tests >= 1, "Need at least 1 test");
121        assert_eq!(
122            test_matrix.len(),
123            num_tests,
124            "test_matrix must have num_tests rows"
125        );
126        for (j, row) in test_matrix.iter().enumerate() {
127            assert_eq!(
128                row.len(),
129                num_objects,
130                "test_matrix[{j}] must have num_objects columns"
131            );
132        }
133        // Check that every pair of objects is distinguished by at least one test
134        for a in 0..num_objects {
135            for b in (a + 1)..num_objects {
136                let distinguished = (0..num_tests).any(|j| test_matrix[j][a] != test_matrix[j][b]);
137                assert!(
138                    distinguished,
139                    "Objects {a} and {b} are not distinguished by any test"
140                );
141            }
142        }
143        Self {
144            test_matrix,
145            num_objects,
146            num_tests,
147        }
148    }
149
150    /// Get the number of objects.
151    pub fn num_objects(&self) -> usize {
152        self.num_objects
153    }
154
155    /// Get the number of tests.
156    pub fn num_tests(&self) -> usize {
157        self.num_tests
158    }
159
160    /// Get the test matrix.
161    pub fn test_matrix(&self) -> &[Vec<bool>] {
162        &self.test_matrix
163    }
164
165    /// Number of internal node slots in the flattened complete binary tree.
166    fn num_tree_slots(&self) -> usize {
167        (1usize << (self.num_objects - 1)) - 1
168    }
169
170    /// Sentinel value meaning "this node is a leaf".
171    fn leaf_sentinel(&self) -> usize {
172        self.num_tests
173    }
174
175    /// Simulate the decision tree for all objects and return total external path length,
176    /// or None if the tree is invalid (doesn't identify all objects uniquely).
177    fn simulate(&self, config: &[usize]) -> Result<Option<i64>, crate::traits::EvaluationError> {
178        let sentinel = self.leaf_sentinel();
179        let max_slots = self.num_tree_slots();
180        let mut seen_leaves = std::collections::HashSet::new();
181        let mut total_depth = 0_i64;
182
183        for obj in 0..self.num_objects {
184            let mut node = 0usize;
185            let mut depth = 0usize;
186
187            loop {
188                if node >= max_slots || config[node] == sentinel {
189                    // Two objects at same leaf — invalid
190                    if !seen_leaves.insert(node) {
191                        return Ok(None);
192                    }
193                    let depth = i64::try_from(depth).map_err(|_| {
194                        crate::traits::EvaluationError::IntegerOverflow(
195                            "converting decision-tree depth to i64".to_string(),
196                        )
197                    })?;
198                    total_depth = total_depth.checked_add(depth).ok_or_else(|| {
199                        crate::traits::EvaluationError::IntegerOverflow(
200                            "summing decision-tree external path length".to_string(),
201                        )
202                    })?;
203                    break;
204                }
205
206                let test_idx = config[node];
207                debug_assert!(test_idx < self.num_tests);
208
209                let result = self.test_matrix[test_idx][obj];
210                node = if result { 2 * node + 2 } else { 2 * node + 1 };
211                depth += 1;
212
213                if depth > self.num_objects {
214                    return Ok(None);
215                }
216            }
217        }
218
219        Ok(Some(total_depth))
220    }
221}
222
223impl Problem for MinimumDecisionTree {
224    const NAME: &'static str = "MinimumDecisionTree";
225    type Solution = Vec<usize>;
226    type Value = Min<i64>;
227
228    crate::problem_parameters![("num_objects", num_objects), ("num_tests", num_tests),];
229
230    fn evaluate(
231        &self,
232        config: &Self::Solution,
233    ) -> Result<Min<i64>, crate::traits::EvaluationError> {
234        Ok({
235            if config.len() != self.num_tree_slots() {
236                return Err(crate::traits::EvaluationError::InvalidConfiguration(
237                    "decision-tree encoding length does not match the instance".into(),
238                ));
239            }
240            Min(self.simulate(config)?)
241        })
242    }
243
244    fn variant() -> Vec<(&'static str, &'static str)> {
245        crate::variant_params![]
246    }
247}
248
249impl crate::solvers::BruteForceProblem for MinimumDecisionTree {
250    fn dimensions(&self) -> Vec<usize> {
251        // Each internal node can hold test 0..num_tests-1 or sentinel (leaf)
252        vec![self.num_tests + 1; self.num_tree_slots()]
253    }
254}
255
256crate::declare_variants! {
257    default MinimumDecisionTree => "num_tests^num_objects" create MinimumDecisionTreeCreateSpec,
258}
259
260crate::register_brute_force! {
261    MinimumDecisionTree,
262}
263
264#[cfg(feature = "example-db")]
265pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::ModelExampleSpec> {
266    vec![crate::example_db::specs::ModelExampleSpec {
267        id: "minimum_decision_tree",
268        instance: Box::new(MinimumDecisionTree::new(
269            vec![
270                vec![true, true, false, false],
271                vec![true, false, false, false],
272                vec![false, true, false, true],
273            ],
274            4,
275            3,
276        )),
277        // T0 at root, T2 left, T1 right, rest are leaves (sentinel=3)
278        optimal_config: serde_json::json!(vec![0, 2, 1, 3, 3, 3, 3]),
279        optimal_value: serde_json::json!(8),
280    }]
281}
282
283#[cfg(test)]
284#[path = "../../unit_tests/models/misc/minimum_decision_tree.rs"]
285mod tests;