Skip to main content

waggle_core/
matcher.rs

1//! The sealed variant matcher (design docs `03 §3`, `06 §2`).
2//!
3//! **Sealed by construction**: selection is a private algorithm behind one
4//! free function — there is no trait to implement, no hook to override, no
5//! configuration that alters ranking. "Same context → same projection" is
6//! the trust claim agents depend on; determinism must not be forkable.
7//! Expressiveness grows by adding *dimensions* to [`MatchExpr`] — a
8//! visible, versioned act — never by swapping algorithms.
9//!
10//! The algorithm, normative (`06 §2`):
11//! 1. a variant **matches** iff every constrained dimension accepts the
12//!    context;
13//! 2. **specificity** = number of constrained dimensions (0–4);
14//! 3. highest specificity wins; ties break by **declaration order**;
15//! 4. mint guarantees a catch-all, so over minted manifests selection is
16//!    total (hostile inputs yield `None`, never a panic).
17
18use crate::context::ResolverContext;
19use crate::manifest::{Constraint, MatchExpr, Variant};
20
21/// The outcome of selection: which variant, and where it sat. The index is
22/// what `Event.variant` records (doc `02` — manifest-referencing, so
23/// I-1-compatible) and what the authoring feedback loop keys on (`06 §6`).
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct Selected<'m> {
26    /// Position in the manifest's declaration order.
27    pub index: u8,
28    /// The winning variant.
29    pub variant: &'m Variant,
30}
31
32/// Select the variant for `ctx` from `variants` (declaration order).
33///
34/// Returns `None` only when nothing matches — impossible for manifests
35/// produced by [`crate::mint`], which guarantees a catch-all.
36#[must_use]
37pub fn select_variant<'m>(variants: &'m [Variant], ctx: &ResolverContext) -> Option<Selected<'m>> {
38    let mut best: Option<(u8, Selected<'m>)> = None;
39    for (i, variant) in variants.iter().enumerate() {
40        if !matches(&variant.match_expr, ctx) {
41            continue;
42        }
43        let spec = variant.match_expr.specificity();
44        let candidate_is_better = match &best {
45            None => true,
46            // Strictly greater: on ties, the earlier declaration stands.
47            Some((best_spec, _)) => spec > *best_spec,
48        };
49        if candidate_is_better {
50            #[allow(clippy::cast_possible_truncation)] // manifests are small; index fits u8
51            let selected = Selected {
52                index: i as u8,
53                variant,
54            };
55            best = Some((spec, selected));
56        }
57    }
58    best.map(|(_, s)| s)
59}
60
61/// Does `expr` accept `ctx`? Private — part of the sealed algorithm.
62fn matches(expr: &MatchExpr, ctx: &ResolverContext) -> bool {
63    constraint_accepts(&expr.model_family, ctx.model_family.as_deref())
64        && constraint_accepts(&expr.harness, ctx.harness.as_deref())
65        && expr
66            .modalities
67            .is_none_or(|required| ctx.modalities.contains(required))
68        && expr
69            .posture
70            .as_ref()
71            .is_none_or(|allowed| allowed.contains(&ctx.posture))
72}
73
74/// `Any` accepts everything; `OneOf` requires a declared value in the set.
75/// An *undeclared* context value fails a constrained dimension — a variant
76/// asking for `claude` must not serve an anonymous consumer.
77fn constraint_accepts(c: &Constraint, value: Option<&str>) -> bool {
78    match c {
79        Constraint::Any => true,
80        Constraint::OneOf(allowed) => {
81            value.is_some_and(|v| allowed.iter().any(|a| a.eq_ignore_ascii_case(v)))
82        }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use crate::context::ConsumerKind;
90    use crate::manifest::{ModalitySet, Posture, VariantBody};
91
92    fn inline(tag: &str) -> VariantBody {
93        VariantBody::Inline {
94            content_type: "text/plain".into(),
95            data: tag.into(),
96        }
97    }
98
99    fn tag(v: &Variant) -> &str {
100        match &v.body {
101            VariantBody::Inline { data, .. } => data,
102            VariantBody::Media(_) => "media",
103        }
104    }
105
106    fn agent(family: Option<&str>, modalities: ModalitySet, posture: Posture) -> ResolverContext {
107        ResolverContext {
108            kind: ConsumerKind::Agent,
109            model_family: family.map(str::to_owned),
110            harness: None,
111            modalities,
112            posture,
113        }
114    }
115
116    #[test]
117    fn empty_variant_list_yields_none_never_panics() {
118        assert!(select_variant(&[], &ResolverContext::human()).is_none());
119    }
120
121    #[test]
122    fn undeclared_context_value_fails_a_constrained_dimension() {
123        let variants = vec![Variant {
124            match_expr: MatchExpr {
125                model_family: Constraint::OneOf(vec!["claude".into()]),
126                ..MatchExpr::default()
127            },
128            body: inline("claude-only"),
129            revalidate_after_ms: None,
130        }];
131        let anon = agent(None, ModalitySet::TEXT, Posture::Headless);
132        assert!(
133            select_variant(&variants, &anon).is_none(),
134            "a variant asking for claude must not serve an anonymous consumer"
135        );
136    }
137
138    #[test]
139    fn family_match_is_case_insensitive() {
140        let variants = vec![Variant {
141            match_expr: MatchExpr {
142                model_family: Constraint::OneOf(vec!["claude".into()]),
143                ..MatchExpr::default()
144            },
145            body: inline("c"),
146            revalidate_after_ms: None,
147        }];
148        let ctx = agent(Some("Claude"), ModalitySet::TEXT, Posture::Headless);
149        assert_eq!(select_variant(&variants, &ctx).unwrap().index, 0);
150    }
151
152    #[test]
153    fn modalities_are_superset_matched() {
154        let variants = vec![Variant {
155            match_expr: MatchExpr {
156                modalities: Some(ModalitySet::VISION),
157                ..MatchExpr::default()
158            },
159            body: inline("needs-eyes"),
160            revalidate_after_ms: None,
161        }];
162        let with_eyes = agent(
163            None,
164            ModalitySet::TEXT.with(ModalitySet::VISION),
165            Posture::Attended,
166        );
167        let without = agent(None, ModalitySet::TEXT, Posture::Attended);
168        assert!(select_variant(&variants, &with_eyes).is_some());
169        assert!(select_variant(&variants, &without).is_none());
170    }
171
172    #[test]
173    fn specificity_beats_declaration_order_but_ties_do_not() {
174        let variants = vec![
175            Variant {
176                match_expr: MatchExpr::any(),
177                body: inline("catch-all"),
178                revalidate_after_ms: None,
179            },
180            Variant {
181                match_expr: MatchExpr {
182                    model_family: Constraint::OneOf(vec!["gpt".into()]),
183                    harness: Constraint::OneOf(vec!["codex".into()]),
184                    ..MatchExpr::default()
185                },
186                body: inline("gpt+codex"),
187                revalidate_after_ms: None,
188            },
189            Variant {
190                match_expr: MatchExpr {
191                    model_family: Constraint::OneOf(vec!["gpt".into()]),
192                    ..MatchExpr::default()
193                },
194                body: inline("gpt-early"),
195                revalidate_after_ms: None,
196            },
197            Variant {
198                match_expr: MatchExpr {
199                    harness: Constraint::OneOf(vec!["codex".into()]),
200                    ..MatchExpr::default()
201                },
202                body: inline("codex-late"),
203                revalidate_after_ms: None,
204            },
205        ];
206        let mut ctx = agent(Some("gpt"), ModalitySet::TEXT, Posture::Headless);
207        ctx.harness = Some("codex".into());
208        // Specificity 2 beats both specificity-1 variants and the catch-all.
209        let sel = select_variant(&variants, &ctx).unwrap();
210        assert_eq!(tag(sel.variant), "gpt+codex");
211
212        // Remove the specificity-2 winner: the two specificity-1 variants
213        // tie; declaration order (gpt-early, index 1 of the remaining list)
214        // must win.
215        let tied: Vec<Variant> = variants
216            .iter()
217            .filter(|v| tag(v) != "gpt+codex")
218            .cloned()
219            .collect();
220        let sel = select_variant(&tied, &ctx).unwrap();
221        assert_eq!(
222            tag(sel.variant),
223            "gpt-early",
224            "ties break by declaration order"
225        );
226    }
227}