Skip to main content

sim_lib_control/
matching.rs

1//! Bounded, evidence-carrying handler class selection.
2
3use sim_kernel::{ClassId, ClassRef, Cx, Result};
4
5use crate::Raised;
6
7/// Finite work allowance passed unchanged to the class-relation provider.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub struct ClassMatchBudget {
10    /// Maximum class-relation work admitted by the caller.
11    pub work: usize,
12}
13
14/// Inspectable proof retained from a bounded subclass query.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct ClassMatchEvidence {
17    /// Stable raised-class identity tested by the provider.
18    pub raised: ClassId,
19    /// Stable candidate handler identity tested by the provider.
20    pub candidate: ClassId,
21    /// Work charged by the bounded provider.
22    pub performed_work: usize,
23}
24
25/// Exact result supplied by a bounded class-relation provider such as CLASS_2.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum BoundedSubclassOutcome {
28    /// Positive subclass evidence.
29    Subclass(ClassMatchEvidence),
30    /// Conclusive negative subclass evidence.
31    NotSubclass(ClassMatchEvidence),
32    /// The provider rejected a malformed or cyclic parent graph.
33    InvalidClassGraph {
34        /// Provider explanation of the invalid graph.
35        reason: String,
36    },
37    /// The provider could not decide within the supplied allowance.
38    BudgetExhausted {
39        /// Configured work ceiling.
40        limit: usize,
41        /// Work completed before exhaustion.
42        performed_work: usize,
43    },
44}
45
46/// Exact outcome of bounded handler class selection.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub enum ClassMatchOutcome {
49    /// Both subclass evidence and the language predicate accepted the candidate.
50    Matched(ClassMatchEvidence),
51    /// Subclass evidence rejected the candidate, or language policy narrowed it.
52    NotMatched(ClassMatchEvidence),
53    /// The declared parent relation is invalid.
54    InvalidClassGraph {
55        /// Provider explanation of the invalid graph.
56        reason: String,
57    },
58    /// The finite work allowance was exhausted before an answer existed.
59    BudgetExhausted {
60        /// Configured work ceiling.
61        limit: usize,
62        /// Work completed before exhaustion.
63        performed_work: usize,
64    },
65    /// The caller-supplied language predicate could not decide a positive candidate.
66    PolicyFailure {
67        /// Stable policy error text.
68        reason: String,
69        /// Positive subclass evidence presented to the policy.
70        evidence: ClassMatchEvidence,
71    },
72}
73
74/// Matches a raised class using bounded subclass evidence and explicit policy.
75///
76/// `bounded_subclass` is the adapter seam for the owning class organ: it must
77/// return its checked, bounded evidence rather than a bare boolean. The
78/// `language_predicate` is invoked only for positive subclass evidence. It may
79/// narrow a match for language-specific rules, but cannot widen a negative.
80pub fn match_raised_class(
81    cx: &mut Cx,
82    raised: &Raised,
83    candidate: ClassRef,
84    budget: ClassMatchBudget,
85    bounded_subclass: impl FnOnce(
86        &mut Cx,
87        &ClassRef,
88        &ClassRef,
89        ClassMatchBudget,
90    ) -> BoundedSubclassOutcome,
91    mut language_predicate: impl FnMut(&mut Cx, &Raised, &ClassRef) -> Result<bool>,
92) -> ClassMatchOutcome {
93    let Some(raised_class) = raised.class_ref().object().as_class() else {
94        return ClassMatchOutcome::InvalidClassGraph {
95            reason: "Raised class field is not a class object".into(),
96        };
97    };
98    let Some(candidate_class) = candidate.object().as_class() else {
99        return ClassMatchOutcome::InvalidClassGraph {
100            reason: "candidate handler is not a class object".into(),
101        };
102    };
103    let expected = (raised_class.id(), candidate_class.id());
104    match bounded_subclass(cx, raised.class_ref(), &candidate, budget) {
105        BoundedSubclassOutcome::NotSubclass(evidence) => {
106            match validate_evidence(evidence, expected, budget) {
107                Ok(evidence) => ClassMatchOutcome::NotMatched(evidence),
108                Err(outcome) => outcome,
109            }
110        }
111        BoundedSubclassOutcome::InvalidClassGraph { reason } => {
112            ClassMatchOutcome::InvalidClassGraph { reason }
113        }
114        BoundedSubclassOutcome::BudgetExhausted {
115            limit,
116            performed_work,
117        } => ClassMatchOutcome::BudgetExhausted {
118            limit,
119            performed_work,
120        },
121        BoundedSubclassOutcome::Subclass(evidence) => {
122            let evidence = match validate_evidence(evidence, expected, budget) {
123                Ok(evidence) => evidence,
124                Err(outcome) => return outcome,
125            };
126            match language_predicate(cx, raised, &candidate) {
127                Ok(true) => ClassMatchOutcome::Matched(evidence),
128                Ok(false) => ClassMatchOutcome::NotMatched(evidence),
129                Err(error) => ClassMatchOutcome::PolicyFailure {
130                    reason: error.to_string(),
131                    evidence,
132                },
133            }
134        }
135    }
136}
137
138fn validate_evidence(
139    evidence: ClassMatchEvidence,
140    expected: (ClassId, ClassId),
141    budget: ClassMatchBudget,
142) -> std::result::Result<ClassMatchEvidence, ClassMatchOutcome> {
143    if (evidence.raised, evidence.candidate) != expected {
144        return Err(ClassMatchOutcome::PolicyFailure {
145            reason: "bounded subclass evidence names different class identities".into(),
146            evidence,
147        });
148    }
149    if evidence.performed_work > budget.work {
150        return Err(ClassMatchOutcome::PolicyFailure {
151            reason: "bounded subclass evidence exceeds the supplied work budget".into(),
152            evidence,
153        });
154    }
155    Ok(evidence)
156}
157
158#[cfg(test)]
159mod tests {
160    use std::sync::Arc;
161
162    use sim_kernel::{
163        Args, Callable, Class, CodecId, Error, Object, ObjectCompat, Origin, ReadConstructorRef,
164        ShapeRef, SourceId, Span, Symbol, TableRef, Value,
165    };
166
167    use super::*;
168
169    struct TestClass {
170        id: ClassId,
171        display: &'static str,
172    }
173
174    impl Object for TestClass {
175        fn display(&self, _cx: &mut Cx) -> Result<String> {
176            Ok(self.display.into())
177        }
178        fn as_any(&self) -> &dyn std::any::Any {
179            self
180        }
181    }
182    impl ObjectCompat for TestClass {
183        fn class(&self, _cx: &mut Cx) -> Result<ClassRef> {
184            Err(Error::Eval("unused".into()))
185        }
186        fn as_callable(&self) -> Option<&dyn Callable> {
187            Some(self)
188        }
189        fn as_class(&self) -> Option<&dyn Class> {
190            Some(self)
191        }
192    }
193    impl Callable for TestClass {
194        fn call(&self, _cx: &mut Cx, _args: Args) -> Result<Value> {
195            Err(Error::Eval("unused".into()))
196        }
197    }
198    impl Class for TestClass {
199        fn id(&self) -> ClassId {
200            self.id
201        }
202        fn symbol(&self) -> Symbol {
203            Symbol::qualified("test", self.display)
204        }
205        fn constructor_shape(&self, _cx: &mut Cx) -> Result<ShapeRef> {
206            Err(Error::Eval("unused".into()))
207        }
208        fn instance_shape(&self, _cx: &mut Cx) -> Result<ShapeRef> {
209            Err(Error::Eval("unused".into()))
210        }
211        fn read_constructor(&self, _cx: &mut Cx) -> Result<Option<ReadConstructorRef>> {
212            Ok(None)
213        }
214        fn members(&self, _cx: &mut Cx) -> Result<TableRef> {
215            Err(Error::Eval("unused".into()))
216        }
217    }
218
219    fn class(cx: &mut Cx, id: u32, display: &'static str) -> ClassRef {
220        cx.factory()
221            .opaque(Arc::new(TestClass {
222                id: ClassId(id),
223                display,
224            }))
225            .unwrap()
226    }
227
228    fn raised(cx: &mut Cx, class: ClassRef, profile: &str) -> Raised {
229        Raised::new(
230            class,
231            cx.factory().string("payload".into()).unwrap(),
232            Origin {
233                codec: CodecId(1),
234                source: SourceId("matching-test".into()),
235                span: Span { start: 0, end: 0 },
236                trivia: Vec::new(),
237            },
238            Symbol::qualified("test", profile),
239        )
240        .unwrap()
241    }
242
243    fn evidence(raised: ClassId, candidate: ClassId, performed_work: usize) -> ClassMatchEvidence {
244        ClassMatchEvidence {
245            raised,
246            candidate,
247            performed_work,
248        }
249    }
250
251    #[test]
252    fn three_deep_hierarchy_matches_at_each_level_from_bounded_evidence() {
253        let mut cx = sim_kernel::testing::bare_cx();
254        let classes = [
255            class(&mut cx, 9100, "Root"),
256            class(&mut cx, 9101, "Middle"),
257            class(&mut cx, 9102, "Leaf"),
258        ];
259        let raised = raised(&mut cx, classes[2].clone(), "profile");
260        for (work, candidate) in classes.into_iter().rev().enumerate() {
261            let raised_id = raised.class_ref().object().as_class().unwrap().id();
262            let candidate_id = candidate.object().as_class().unwrap().id();
263            let outcome = match_raised_class(
264                &mut cx,
265                &raised,
266                candidate,
267                ClassMatchBudget { work: 8 },
268                |_, _, _, _| {
269                    BoundedSubclassOutcome::Subclass(evidence(raised_id, candidate_id, work + 1))
270                },
271                |_, _, _| Ok(true),
272            );
273            assert!(matches!(outcome, ClassMatchOutcome::Matched(_)));
274        }
275    }
276
277    #[test]
278    fn invalid_graph_and_exhaustion_remain_distinct_from_negative() {
279        let mut cx = sim_kernel::testing::bare_cx();
280        let class = class(&mut cx, 9110, "Cycle");
281        let raised = raised(&mut cx, class.clone(), "profile");
282        let invalid = match_raised_class(
283            &mut cx,
284            &raised,
285            class.clone(),
286            ClassMatchBudget { work: 8 },
287            |_, _, _, _| BoundedSubclassOutcome::InvalidClassGraph {
288                reason: "cycle: A -> B -> A".into(),
289            },
290            |_, _, _| Ok(true),
291        );
292        assert!(matches!(
293            invalid,
294            ClassMatchOutcome::InvalidClassGraph { .. }
295        ));
296
297        let exhausted = match_raised_class(
298            &mut cx,
299            &raised,
300            class,
301            ClassMatchBudget { work: 1 },
302            |_, _, _, budget| BoundedSubclassOutcome::BudgetExhausted {
303                limit: budget.work,
304                performed_work: 1,
305            },
306            |_, _, _| Ok(true),
307        );
308        assert!(matches!(
309            exhausted,
310            ClassMatchOutcome::BudgetExhausted { .. }
311        ));
312    }
313
314    #[test]
315    fn negative_evidence_cannot_be_widened_by_display_or_profile_policy() {
316        let mut cx = sim_kernel::testing::bare_cx();
317        let raised_class = class(&mut cx, 9120, "Same");
318        let candidate = class(&mut cx, 9121, "Same");
319        let raised_id = raised_class.object().as_class().unwrap().id();
320        let candidate_id = candidate.object().as_class().unwrap().id();
321        let raised = raised(&mut cx, raised_class, "Symbol");
322        let mut policy_called = false;
323        let outcome = match_raised_class(
324            &mut cx,
325            &raised,
326            candidate,
327            ClassMatchBudget { work: 8 },
328            |_, _, _, _| BoundedSubclassOutcome::NotSubclass(evidence(raised_id, candidate_id, 1)),
329            |_, _, _| {
330                policy_called = true;
331                Ok(true)
332            },
333        );
334        assert!(matches!(outcome, ClassMatchOutcome::NotMatched(_)));
335        assert!(
336            !policy_called,
337            "policy must not widen negative subclass evidence"
338        );
339        let production_source = include_str!("matching.rs")
340            .split("#[cfg(test)]")
341            .next()
342            .unwrap();
343        assert!(!production_source.contains(".display("));
344        assert!(!production_source.contains(".profile()"));
345    }
346}