Skip to main content

organism_intent/
resolution.rs

1//! Intent resolution — maps intents to the packs, capabilities, and invariants
2//! needed for convergence.
3//!
4//! Four resolution levels:
5//!
6//! 1. **Declarative** — intent explicitly declares which packs it needs
7//! 2. **Structural** — resolver matches fact prefixes to pack metadata
8//! 3. **Semantic** — huddle matches outcome description to pack capabilities
9//! 4. **Learned** — prior calibration from execution history predicts pack needs
10//!
11//! Resolution runs after admission, before planning. The output is an
12//! `IntentBinding` that tells the runtime which agents to register
13//! with the Converge engine.
14
15use converge_pack::UnitInterval;
16use serde::{Deserialize, Serialize};
17
18// ── Typed identifiers ──────────────────────────────────────────────
19//
20// CapabilityRequirementId and InvariantId are Organism-owned typed
21// wrappers over the human-readable strings that flow through intent
22// resolution. Same shape as `SuggestorDescriptorId`, `ProviderId`,
23// `FormationTemplateId` — `#[serde(transparent)]` so the wire form
24// stays a bare string. Conversion impls let existing string-literal
25// call sites flow through without churn.
26
27macro_rules! string_id_newtype {
28    ($name:ident, $doc:literal) => {
29        #[doc = $doc]
30        #[derive(
31            Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, Default,
32        )]
33        #[serde(transparent)]
34        pub struct $name(String);
35
36        impl $name {
37            #[must_use]
38            pub fn new(id: impl Into<String>) -> Self {
39                Self(id.into())
40            }
41            #[must_use]
42            pub fn as_str(&self) -> &str {
43                &self.0
44            }
45            #[must_use]
46            pub fn into_inner(self) -> String {
47                self.0
48            }
49        }
50        impl std::fmt::Display for $name {
51            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52                f.write_str(&self.0)
53            }
54        }
55        impl AsRef<str> for $name {
56            fn as_ref(&self) -> &str {
57                &self.0
58            }
59        }
60        impl std::borrow::Borrow<str> for $name {
61            fn borrow(&self) -> &str {
62                &self.0
63            }
64        }
65        impl std::ops::Deref for $name {
66            type Target = str;
67            fn deref(&self) -> &str {
68                &self.0
69            }
70        }
71        impl From<&str> for $name {
72            fn from(s: &str) -> Self {
73                Self(s.to_string())
74            }
75        }
76        impl From<String> for $name {
77            fn from(s: String) -> Self {
78                Self(s)
79            }
80        }
81        impl From<&String> for $name {
82            fn from(s: &String) -> Self {
83                Self(s.clone())
84            }
85        }
86        impl From<$name> for String {
87            fn from(id: $name) -> Self {
88                id.0
89            }
90        }
91        impl PartialEq<str> for $name {
92            fn eq(&self, other: &str) -> bool {
93                self.0 == other
94            }
95        }
96        impl PartialEq<&str> for $name {
97            fn eq(&self, other: &&str) -> bool {
98                self.0.as_str() == *other
99            }
100        }
101        impl PartialEq<String> for $name {
102            fn eq(&self, other: &String) -> bool {
103                &self.0 == other
104            }
105        }
106        impl PartialEq<$name> for &str {
107            fn eq(&self, other: &$name) -> bool {
108                *self == other.0.as_str()
109            }
110        }
111        impl PartialEq<$name> for String {
112            fn eq(&self, other: &$name) -> bool {
113                self == &other.0
114            }
115        }
116    };
117}
118
119string_id_newtype!(
120    CapabilityRequirementId,
121    "Identifier of a capability requested by an intent (e.g. `\"web\"`, `\"ocr\"`, `\"vision\"`). Distinct from `converge_kernel::formation::SuggestorCapability` (a closed enum on the Suggestor profile side); this is the open-world string label that crosses intent → resolver → registry."
122);
123string_id_newtype!(
124    InvariantId,
125    "Identifier of an invariant the intent requires the convergence loop to honor (e.g. `\"lead_has_source\"`, `\"claim_has_provenance\"`). Names a check registered with the runtime, not the check itself."
126);
127
128// ── Intent Binding ─────────────────────────────────────────────────
129
130/// The output of intent resolution. Tells the runtime what to wire up.
131#[derive(Debug, Clone, Default, Serialize, Deserialize)]
132pub struct IntentBinding {
133    /// Which domain packs to register with the engine.
134    pub packs: Vec<PackRequirement>,
135    /// Which capabilities the intent needs (OCR, web, vision, etc.).
136    pub capabilities: Vec<CapabilityRequirement>,
137    /// Additional invariants to enforce beyond pack defaults.
138    pub invariants: Vec<InvariantId>,
139    /// How the binding was resolved.
140    pub resolution: ResolutionTrace,
141}
142
143/// A domain pack needed by the intent.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct PackRequirement {
146    pub pack_name: String,
147    pub reason: String,
148    pub confidence: UnitInterval,
149    pub source: ResolutionLevel,
150}
151
152/// A capability needed by the intent.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct CapabilityRequirement {
155    pub capability: CapabilityRequirementId,
156    pub reason: String,
157    pub confidence: UnitInterval,
158    pub source: ResolutionLevel,
159}
160
161/// Which resolution level produced the binding.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum ResolutionLevel {
165    /// Intent explicitly declared its packs.
166    Declarative,
167    /// Resolver matched fact prefixes to pack metadata.
168    Structural,
169    /// Huddle matched outcome to pack descriptions.
170    Semantic,
171    /// Prior calibration predicted from execution history.
172    Learned,
173}
174
175/// How the resolution was performed — for traceability.
176#[derive(Debug, Clone, Default, Serialize, Deserialize)]
177pub struct ResolutionTrace {
178    pub levels_attempted: Vec<ResolutionLevel>,
179    pub levels_contributed: Vec<ResolutionLevel>,
180    /// Number of prior episodes consulted (level 4).
181    pub prior_episodes_consulted: usize,
182    /// Confidence that the binding is complete.
183    pub completeness_confidence: UnitInterval,
184}
185
186// ── Declarative Binding (Level 1) ──────────────────────────────────
187
188/// Builder for declaring an intent's resource needs explicitly.
189/// This is what apps use today.
190///
191/// ```rust,ignore
192/// let binding = DeclarativeBinding::new()
193///     .pack("customers", "lead qualification workflow")
194///     .pack("linkedin_research", "enrich with LinkedIn data")
195///     .capability("web", "capture company website")
196///     .invariant("lead_has_source")
197///     .build();
198/// ```
199#[derive(Debug, Clone, Default)]
200pub struct DeclarativeBinding {
201    packs: Vec<PackRequirement>,
202    capabilities: Vec<CapabilityRequirement>,
203    invariants: Vec<InvariantId>,
204}
205
206impl DeclarativeBinding {
207    #[must_use]
208    pub fn new() -> Self {
209        Self::default()
210    }
211
212    #[must_use]
213    pub fn pack(mut self, name: impl Into<String>, reason: impl Into<String>) -> Self {
214        self.packs.push(PackRequirement {
215            pack_name: name.into(),
216            reason: reason.into(),
217            confidence: UnitInterval::ONE,
218            source: ResolutionLevel::Declarative,
219        });
220        self
221    }
222
223    #[must_use]
224    pub fn capability(
225        mut self,
226        name: impl Into<CapabilityRequirementId>,
227        reason: impl Into<String>,
228    ) -> Self {
229        self.capabilities.push(CapabilityRequirement {
230            capability: name.into(),
231            reason: reason.into(),
232            confidence: UnitInterval::ONE,
233            source: ResolutionLevel::Declarative,
234        });
235        self
236    }
237
238    #[must_use]
239    pub fn invariant(mut self, name: impl Into<InvariantId>) -> Self {
240        self.invariants.push(name.into());
241        self
242    }
243
244    #[must_use]
245    pub fn build(self) -> IntentBinding {
246        IntentBinding {
247            packs: self.packs,
248            capabilities: self.capabilities,
249            invariants: self.invariants,
250            resolution: ResolutionTrace {
251                levels_attempted: vec![ResolutionLevel::Declarative],
252                levels_contributed: vec![ResolutionLevel::Declarative],
253                prior_episodes_consulted: 0,
254                completeness_confidence: UnitInterval::ONE,
255            },
256        }
257    }
258}
259
260// ── Resolution Trait ───────────────────────────────────────────────
261
262/// Resolves an intent to its resource binding.
263///
264/// Implementations exist for each level. The runtime chains them:
265/// declarative first, then structural fills gaps, semantic adds
266/// uncertain matches, learned adjusts confidences from history.
267pub trait IntentResolver: Send + Sync {
268    fn level(&self) -> ResolutionLevel;
269    fn resolve(&self, intent: &super::IntentPacket, current: &IntentBinding) -> IntentBinding;
270}
271
272// ── Semantic Resolver (Level 3) ────────────────────────────────────
273
274/// Outcome → pack matcher. The semantic level delegates to an implementation
275/// of this trait so the resolver itself stays free of vendor-specific LLM
276/// imports. Constructor injection per the Plug Boundary doctrine: the
277/// resolver declares a need (semantic outcome matching with reasons), and a
278/// concrete matcher fulfils it.
279///
280/// Returned tuples: `(pack_name, confidence, reason)`. Confidence should be
281/// in `[0.0, 1.0]`; reason is a short human-readable explanation that lands
282/// in the resulting `PackRequirement.reason`.
283pub trait SemanticMatcher: Send + Sync {
284    fn match_packs(&self, outcome: &str) -> Vec<(String, f64, String)>;
285}
286
287/// Level 3 — Semantic resolver. Asks a `SemanticMatcher` to map the intent's
288/// outcome description to candidate packs.
289///
290/// Adds matches that aren't already in the binding; never overwrites existing
291/// pack entries. Records contribution under `ResolutionLevel::Semantic`.
292pub struct SemanticResolver<M: SemanticMatcher> {
293    matcher: M,
294}
295
296impl<M: SemanticMatcher> SemanticResolver<M> {
297    #[must_use]
298    pub fn new(matcher: M) -> Self {
299        Self { matcher }
300    }
301}
302
303impl<M: SemanticMatcher> IntentResolver for SemanticResolver<M> {
304    fn level(&self) -> ResolutionLevel {
305        ResolutionLevel::Semantic
306    }
307
308    fn resolve(&self, intent: &super::IntentPacket, current: &IntentBinding) -> IntentBinding {
309        let mut binding = current.clone();
310        let already_bound: std::collections::HashSet<String> =
311            binding.packs.iter().map(|p| p.pack_name.clone()).collect();
312
313        let mut contributed = false;
314        for (pack_name, confidence, reason) in self.matcher.match_packs(&intent.outcome) {
315            if already_bound.contains(&pack_name) {
316                continue;
317            }
318            binding.packs.push(PackRequirement {
319                pack_name,
320                reason,
321                confidence: UnitInterval::clamped(confidence),
322                source: ResolutionLevel::Semantic,
323            });
324            contributed = true;
325        }
326
327        update_trace(
328            &mut binding.resolution,
329            ResolutionLevel::Semantic,
330            contributed,
331        );
332        binding
333    }
334}
335
336// ── Learned Resolver (Level 4) ─────────────────────────────────────
337
338/// Lightweight projection of a past `LearningEpisode` for resolver use.
339/// Kept here (not in `organism-learning`) so the `intent` crate stays at the
340/// bottom of the dependency tree. The `learning` crate provides an adapter
341/// from `LearningEpisode` to this shape.
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct EpisodeSummary {
344    /// Outcome description from the original intent.
345    pub outcome: String,
346    /// Packs that were registered for this episode's run.
347    pub packs_used: Vec<String>,
348    /// Whether the run reached a passing outcome.
349    pub passed: bool,
350}
351
352/// Source of past episodes for learned resolution. Constructor-injected on
353/// `LearnedResolver` so the resolver doesn't depend on a specific store
354/// implementation.
355pub trait EpisodeRecall: Send + Sync {
356    fn similar_episodes(&self, intent: &super::IntentPacket) -> Vec<EpisodeSummary>;
357}
358
359/// Level 4 — Learned resolver. Looks at past episodes that match the current
360/// intent and weights pack requirements by historical success rate.
361///
362/// For each pack that appeared in a similar passing episode, either bumps
363/// confidence on an existing entry (capped at `1.0`) or adds the pack with
364/// confidence proportional to its historical success rate.
365pub struct LearnedResolver<R: EpisodeRecall> {
366    recall: R,
367    /// Bump applied to an already-bound pack's confidence per matching
368    /// passing episode. Defaults to `0.05`. Capped at `1.0` after summation.
369    confidence_bump: f64,
370}
371
372impl<R: EpisodeRecall> LearnedResolver<R> {
373    #[must_use]
374    pub fn new(recall: R) -> Self {
375        Self {
376            recall,
377            confidence_bump: 0.05,
378        }
379    }
380
381    #[must_use]
382    pub fn with_confidence_bump(mut self, bump: f64) -> Self {
383        self.confidence_bump = bump.clamp(0.0, 1.0);
384        self
385    }
386}
387
388impl<R: EpisodeRecall> IntentResolver for LearnedResolver<R> {
389    fn level(&self) -> ResolutionLevel {
390        ResolutionLevel::Learned
391    }
392
393    fn resolve(&self, intent: &super::IntentPacket, current: &IntentBinding) -> IntentBinding {
394        let mut binding = current.clone();
395        let episodes = self.recall.similar_episodes(intent);
396        let consulted = episodes.len();
397        if consulted == 0 {
398            update_trace(&mut binding.resolution, ResolutionLevel::Learned, false);
399            binding.resolution.prior_episodes_consulted += 0;
400            return binding;
401        }
402
403        let total = consulted as f64;
404        let passing = episodes.iter().filter(|e| e.passed).count() as f64;
405        let success_rate = if total > 0.0 { passing / total } else { 0.0 };
406
407        // Tally pack appearances across passing episodes.
408        let mut pack_passing_count: std::collections::HashMap<String, usize> =
409            std::collections::HashMap::new();
410        for ep in episodes.iter().filter(|e| e.passed) {
411            for pack in &ep.packs_used {
412                *pack_passing_count.entry(pack.clone()).or_default() += 1;
413            }
414        }
415
416        let mut contributed = false;
417        for (pack_name, count) in pack_passing_count {
418            let weight = (count as f64 / total).clamp(0.0, 1.0);
419            if let Some(existing) = binding.packs.iter_mut().find(|p| p.pack_name == pack_name) {
420                let bump = self.confidence_bump * weight * (count as f64);
421                existing.confidence = UnitInterval::clamped(existing.confidence.as_f64() + bump);
422                contributed = true;
423            } else {
424                binding.packs.push(PackRequirement {
425                    pack_name: pack_name.clone(),
426                    reason: format!(
427                        "{count} passing episode(s) used pack '{pack_name}' (success rate {success_rate:.2})",
428                    ),
429                    confidence: UnitInterval::clamped(weight),
430                    source: ResolutionLevel::Learned,
431                });
432                contributed = true;
433            }
434        }
435
436        update_trace(
437            &mut binding.resolution,
438            ResolutionLevel::Learned,
439            contributed,
440        );
441        binding.resolution.prior_episodes_consulted += consulted;
442        binding
443    }
444}
445
446// ── Ladder Runner ──────────────────────────────────────────────────
447
448/// Runs a chain of resolvers in level order, accumulating the binding.
449///
450/// The ladder is the public composition surface for Levels 1–4: caller hands
451/// in the resolvers they want active (typically all four), the ladder runs
452/// them in sequence, and the resulting `IntentBinding.resolution` carries a
453/// `ResolutionTrace` reflecting everything that fired.
454pub struct LadderResolver {
455    resolvers: Vec<Box<dyn IntentResolver>>,
456}
457
458impl LadderResolver {
459    #[must_use]
460    pub fn new() -> Self {
461        Self {
462            resolvers: Vec::new(),
463        }
464    }
465
466    #[must_use]
467    pub fn with(mut self, resolver: Box<dyn IntentResolver>) -> Self {
468        self.resolvers.push(resolver);
469        self
470    }
471
472    /// Run the ladder over the given intent, starting from `seed`.
473    pub fn resolve(&self, intent: &super::IntentPacket, seed: IntentBinding) -> IntentBinding {
474        let mut binding = seed;
475        for resolver in &self.resolvers {
476            binding = resolver.resolve(intent, &binding);
477        }
478        recompute_completeness(&mut binding.resolution);
479        binding
480    }
481}
482
483impl Default for LadderResolver {
484    fn default() -> Self {
485        Self::new()
486    }
487}
488
489// ── Trace helpers ──────────────────────────────────────────────────
490
491fn update_trace(trace: &mut ResolutionTrace, level: ResolutionLevel, contributed: bool) {
492    if !trace.levels_attempted.contains(&level) {
493        trace.levels_attempted.push(level);
494    }
495    if contributed && !trace.levels_contributed.contains(&level) {
496        trace.levels_contributed.push(level);
497    }
498}
499
500fn recompute_completeness(trace: &mut ResolutionTrace) {
501    if trace.levels_attempted.is_empty() {
502        trace.completeness_confidence = UnitInterval::ZERO;
503        return;
504    }
505    let attempted = trace.levels_attempted.len() as f64;
506    let contributed = trace.levels_contributed.len() as f64;
507    trace.completeness_confidence = UnitInterval::clamped(contributed / attempted);
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn declarative_binding_builds_correctly() {
516        let binding = DeclarativeBinding::new()
517            .pack("customers", "lead qualification")
518            .pack("linkedin_research", "enrich leads")
519            .capability("web", "capture company page")
520            .invariant("lead_has_source")
521            .build();
522
523        assert_eq!(binding.packs.len(), 2);
524        assert_eq!(binding.capabilities.len(), 1);
525        assert_eq!(binding.invariants.len(), 1);
526        assert_eq!(binding.packs[0].pack_name, "customers");
527        assert_eq!(binding.packs[0].source, ResolutionLevel::Declarative);
528        assert!((binding.resolution.completeness_confidence.as_f64() - 1.0).abs() < f64::EPSILON);
529    }
530
531    #[test]
532    fn declarative_binding_empty() {
533        let binding = DeclarativeBinding::new().build();
534        assert!(binding.packs.is_empty());
535        assert!(binding.capabilities.is_empty());
536        assert!(binding.invariants.is_empty());
537        assert_eq!(
538            binding.resolution.levels_attempted,
539            vec![ResolutionLevel::Declarative]
540        );
541        assert_eq!(
542            binding.resolution.levels_contributed,
543            vec![ResolutionLevel::Declarative]
544        );
545        assert_eq!(binding.resolution.prior_episodes_consulted, 0);
546    }
547
548    #[test]
549    fn declarative_binding_pack_confidence_is_one() {
550        let binding = DeclarativeBinding::new().pack("test", "reason").build();
551        assert!((binding.packs[0].confidence.as_f64() - 1.0).abs() < f64::EPSILON);
552    }
553
554    #[test]
555    fn declarative_binding_capability_confidence_is_one() {
556        let binding = DeclarativeBinding::new()
557            .capability("ocr", "doc processing")
558            .build();
559        assert!((binding.capabilities[0].confidence.as_f64() - 1.0).abs() < f64::EPSILON);
560    }
561
562    #[test]
563    fn declarative_binding_multiple_invariants() {
564        let binding = DeclarativeBinding::new()
565            .invariant("inv_a")
566            .invariant("inv_b")
567            .invariant("inv_c")
568            .build();
569        assert_eq!(binding.invariants, vec!["inv_a", "inv_b", "inv_c"]);
570    }
571
572    #[test]
573    fn declarative_binding_default() {
574        let binding = DeclarativeBinding::default();
575        assert!(binding.packs.is_empty());
576        assert!(binding.capabilities.is_empty());
577        assert!(binding.invariants.is_empty());
578    }
579
580    #[test]
581    fn intent_binding_default() {
582        let binding = IntentBinding::default();
583        assert!(binding.packs.is_empty());
584        assert!(binding.capabilities.is_empty());
585        assert!(binding.invariants.is_empty());
586        assert!(binding.resolution.levels_attempted.is_empty());
587        assert!(binding.resolution.levels_contributed.is_empty());
588        assert_eq!(binding.resolution.prior_episodes_consulted, 0);
589        assert!((binding.resolution.completeness_confidence.as_f64() - 0.0).abs() < f64::EPSILON);
590    }
591
592    #[test]
593    fn resolution_trace_default() {
594        let trace = ResolutionTrace::default();
595        assert!(trace.levels_attempted.is_empty());
596        assert!(trace.levels_contributed.is_empty());
597        assert_eq!(trace.prior_episodes_consulted, 0);
598        assert!((trace.completeness_confidence.as_f64() - 0.0).abs() < f64::EPSILON);
599    }
600
601    #[test]
602    fn resolution_level_all_variants_distinct() {
603        let variants = [
604            ResolutionLevel::Declarative,
605            ResolutionLevel::Structural,
606            ResolutionLevel::Semantic,
607            ResolutionLevel::Learned,
608        ];
609        for (i, a) in variants.iter().enumerate() {
610            for (j, b) in variants.iter().enumerate() {
611                assert_eq!(i == j, a == b);
612            }
613        }
614    }
615
616    #[test]
617    fn resolution_level_serde_roundtrip() {
618        for level in [
619            ResolutionLevel::Declarative,
620            ResolutionLevel::Structural,
621            ResolutionLevel::Semantic,
622            ResolutionLevel::Learned,
623        ] {
624            let json = serde_json::to_string(&level).unwrap();
625            let back: ResolutionLevel = serde_json::from_str(&json).unwrap();
626            assert_eq!(level, back);
627        }
628    }
629
630    #[test]
631    fn resolution_level_snake_case() {
632        assert_eq!(
633            serde_json::to_string(&ResolutionLevel::Declarative).unwrap(),
634            "\"declarative\""
635        );
636        assert_eq!(
637            serde_json::to_string(&ResolutionLevel::Structural).unwrap(),
638            "\"structural\""
639        );
640        assert_eq!(
641            serde_json::to_string(&ResolutionLevel::Semantic).unwrap(),
642            "\"semantic\""
643        );
644        assert_eq!(
645            serde_json::to_string(&ResolutionLevel::Learned).unwrap(),
646            "\"learned\""
647        );
648    }
649
650    #[test]
651    fn pack_requirement_serde_roundtrip() {
652        let req = PackRequirement {
653            pack_name: "customers".into(),
654            reason: "lead workflow".into(),
655            confidence: UnitInterval::clamped(0.85),
656            source: ResolutionLevel::Structural,
657        };
658        let json = serde_json::to_string(&req).unwrap();
659        let back: PackRequirement = serde_json::from_str(&json).unwrap();
660        assert_eq!(back.pack_name, "customers");
661        assert_eq!(back.reason, "lead workflow");
662        assert!((back.confidence.as_f64() - 0.85).abs() < f64::EPSILON);
663        assert_eq!(back.source, ResolutionLevel::Structural);
664    }
665
666    #[test]
667    fn capability_requirement_serde_roundtrip() {
668        let req = CapabilityRequirement {
669            capability: "vision".into(),
670            reason: "document scanning".into(),
671            confidence: UnitInterval::clamped(0.7),
672            source: ResolutionLevel::Semantic,
673        };
674        let json = serde_json::to_string(&req).unwrap();
675        let back: CapabilityRequirement = serde_json::from_str(&json).unwrap();
676        assert_eq!(back.capability, "vision");
677        assert_eq!(back.source, ResolutionLevel::Semantic);
678    }
679
680    #[test]
681    fn intent_binding_serde_roundtrip() {
682        let binding = DeclarativeBinding::new()
683            .pack("dd", "due diligence")
684            .capability("web", "scraping")
685            .invariant("hypothesis_has_source")
686            .build();
687
688        let json = serde_json::to_string(&binding).unwrap();
689        let back: IntentBinding = serde_json::from_str(&json).unwrap();
690        assert_eq!(back.packs.len(), 1);
691        assert_eq!(back.capabilities.len(), 1);
692        assert_eq!(back.invariants, vec!["hypothesis_has_source"]);
693        assert_eq!(
694            back.resolution.levels_attempted,
695            vec![ResolutionLevel::Declarative]
696        );
697    }
698
699    #[test]
700    fn resolution_trace_serde_roundtrip() {
701        let trace = ResolutionTrace {
702            levels_attempted: vec![ResolutionLevel::Declarative, ResolutionLevel::Structural],
703            levels_contributed: vec![ResolutionLevel::Declarative],
704            prior_episodes_consulted: 42,
705            completeness_confidence: UnitInterval::clamped(0.95),
706        };
707        let json = serde_json::to_string(&trace).unwrap();
708        let back: ResolutionTrace = serde_json::from_str(&json).unwrap();
709        assert_eq!(back.levels_attempted.len(), 2);
710        assert_eq!(back.levels_contributed.len(), 1);
711        assert_eq!(back.prior_episodes_consulted, 42);
712        assert!((back.completeness_confidence.as_f64() - 0.95).abs() < f64::EPSILON);
713    }
714
715    // ── Level 3: Semantic ──────────────────────────────────────────
716
717    use chrono::{Duration, Utc};
718
719    fn intent(outcome: &str) -> super::super::IntentPacket {
720        super::super::IntentPacket::new(outcome, Utc::now() + Duration::hours(1))
721    }
722
723    struct StubMatcher(Vec<(&'static str, f64, &'static str)>);
724
725    impl SemanticMatcher for StubMatcher {
726        fn match_packs(&self, _outcome: &str) -> Vec<(String, f64, String)> {
727            self.0
728                .iter()
729                .map(|(p, c, r)| ((*p).to_string(), *c, (*r).to_string()))
730                .collect()
731        }
732    }
733
734    #[test]
735    fn semantic_resolver_adds_unbound_packs() {
736        let matcher = StubMatcher(vec![
737            ("customers", 0.7, "outcome mentions leads"),
738            ("legal", 0.6, "compliance keyword detected"),
739        ]);
740        let resolver = SemanticResolver::new(matcher);
741        let binding = resolver.resolve(&intent("qualify inbound leads"), &IntentBinding::default());
742
743        assert_eq!(binding.packs.len(), 2);
744        assert!(
745            binding
746                .packs
747                .iter()
748                .all(|p| p.source == ResolutionLevel::Semantic)
749        );
750        assert_eq!(
751            binding.resolution.levels_contributed,
752            vec![ResolutionLevel::Semantic]
753        );
754    }
755
756    #[test]
757    fn semantic_resolver_skips_already_bound_packs() {
758        let seed = DeclarativeBinding::new()
759            .pack("customers", "explicit declaration")
760            .build();
761        let matcher = StubMatcher(vec![("customers", 0.7, "outcome mentions leads")]);
762        let resolver = SemanticResolver::new(matcher);
763        let binding = resolver.resolve(&intent("qualify inbound leads"), &seed);
764
765        // Customers stays Declarative; semantic level didn't contribute.
766        assert_eq!(binding.packs.len(), 1);
767        assert_eq!(binding.packs[0].source, ResolutionLevel::Declarative);
768        assert!(
769            binding
770                .resolution
771                .levels_attempted
772                .contains(&ResolutionLevel::Semantic)
773        );
774        assert!(
775            !binding
776                .resolution
777                .levels_contributed
778                .contains(&ResolutionLevel::Semantic)
779        );
780    }
781
782    #[test]
783    fn semantic_resolver_clamps_confidence() {
784        let matcher = StubMatcher(vec![("customers", 1.7, "out-of-range stub")]);
785        let binding =
786            SemanticResolver::new(matcher).resolve(&intent("anything"), &IntentBinding::default());
787        assert!((binding.packs[0].confidence.as_f64() - 1.0).abs() < f64::EPSILON);
788    }
789
790    // ── Level 4: Learned ───────────────────────────────────────────
791
792    struct StubRecall(Vec<EpisodeSummary>);
793
794    impl EpisodeRecall for StubRecall {
795        fn similar_episodes(&self, _intent: &super::super::IntentPacket) -> Vec<EpisodeSummary> {
796            self.0.clone()
797        }
798    }
799
800    fn ep(outcome: &str, packs: &[&str], passed: bool) -> EpisodeSummary {
801        EpisodeSummary {
802            outcome: outcome.into(),
803            packs_used: packs.iter().map(|p| (*p).to_string()).collect(),
804            passed,
805        }
806    }
807
808    #[test]
809    fn learned_resolver_records_episode_count_in_trace() {
810        let recall = StubRecall(vec![
811            ep("a", &["customers"], true),
812            ep("b", &["customers"], false),
813        ]);
814        let binding =
815            LearnedResolver::new(recall).resolve(&intent("anything"), &IntentBinding::default());
816        assert_eq!(binding.resolution.prior_episodes_consulted, 2);
817    }
818
819    #[test]
820    fn learned_resolver_adds_pack_used_in_passing_episode() {
821        let recall = StubRecall(vec![ep("similar", &["customers"], true)]);
822        let binding =
823            LearnedResolver::new(recall).resolve(&intent("anything"), &IntentBinding::default());
824
825        let added = binding.packs.iter().find(|p| p.pack_name == "customers");
826        assert!(added.is_some(), "passing-episode pack should be added");
827        assert_eq!(added.unwrap().source, ResolutionLevel::Learned);
828        assert!(
829            binding
830                .resolution
831                .levels_contributed
832                .contains(&ResolutionLevel::Learned)
833        );
834    }
835
836    #[test]
837    fn learned_resolver_skips_packs_only_in_failing_episodes() {
838        let recall = StubRecall(vec![ep("similar", &["risky_pack"], false)]);
839        let binding =
840            LearnedResolver::new(recall).resolve(&intent("anything"), &IntentBinding::default());
841        assert!(
842            binding.packs.is_empty(),
843            "failing-episode-only packs should not be added"
844        );
845        // Level was attempted but did not contribute.
846        assert!(
847            binding
848                .resolution
849                .levels_attempted
850                .contains(&ResolutionLevel::Learned)
851        );
852        assert!(
853            !binding
854                .resolution
855                .levels_contributed
856                .contains(&ResolutionLevel::Learned)
857        );
858    }
859
860    #[test]
861    fn learned_resolver_bumps_confidence_on_already_bound_pack() {
862        let seed = DeclarativeBinding::new()
863            .pack("customers", "explicit")
864            .build();
865        let baseline = seed.packs[0].confidence;
866        let recall = StubRecall(vec![
867            ep("a", &["customers"], true),
868            ep("b", &["customers"], true),
869        ]);
870        let binding = LearnedResolver::new(recall)
871            .with_confidence_bump(0.05)
872            .resolve(&intent("anything"), &seed);
873
874        let customers = binding
875            .packs
876            .iter()
877            .find(|p| p.pack_name == "customers")
878            .expect("customers pack still present");
879        assert_eq!(customers.source, ResolutionLevel::Declarative);
880        // Two passing episodes, weight = 1.0, count = 2, bump = 0.05*1.0*2 = 0.1
881        // baseline of 1.0 already capped, so still 1.0.
882        assert!(
883            customers.confidence >= baseline,
884            "learned recall must not lower existing confidence"
885        );
886    }
887
888    #[test]
889    fn learned_resolver_no_episodes_does_not_contribute() {
890        let recall = StubRecall(vec![]);
891        let binding =
892            LearnedResolver::new(recall).resolve(&intent("anything"), &IntentBinding::default());
893        assert!(
894            binding
895                .resolution
896                .levels_attempted
897                .contains(&ResolutionLevel::Learned)
898        );
899        assert!(
900            !binding
901                .resolution
902                .levels_contributed
903                .contains(&ResolutionLevel::Learned)
904        );
905        assert_eq!(binding.resolution.prior_episodes_consulted, 0);
906    }
907
908    // ── Ladder integration ─────────────────────────────────────────
909
910    /// Resolver that mimics the existing pack-prefix logic without depending on
911    /// `runtime::Registry` (which would create a circular dep). Sufficient to
912    /// prove ladder ordering and trace accumulation.
913    struct StubStructural;
914
915    impl IntentResolver for StubStructural {
916        fn level(&self) -> ResolutionLevel {
917            ResolutionLevel::Structural
918        }
919
920        fn resolve(
921            &self,
922            intent: &super::super::IntentPacket,
923            current: &IntentBinding,
924        ) -> IntentBinding {
925            let mut binding = current.clone();
926            let already: std::collections::HashSet<String> =
927                binding.packs.iter().map(|p| p.pack_name.clone()).collect();
928            let mut contributed = false;
929            // Trivial structural rule: outcome mentioning "vendor" → procurement pack.
930            if intent.outcome.to_lowercase().contains("vendor") && !already.contains("procurement")
931            {
932                binding.packs.push(PackRequirement {
933                    pack_name: "procurement".into(),
934                    reason: "outcome mentions 'vendor'".into(),
935                    confidence: UnitInterval::clamped(0.9),
936                    source: ResolutionLevel::Structural,
937                });
938                contributed = true;
939            }
940            update_trace(
941                &mut binding.resolution,
942                ResolutionLevel::Structural,
943                contributed,
944            );
945            binding
946        }
947    }
948
949    #[test]
950    fn ladder_runs_all_four_levels_and_records_each() {
951        let seed = DeclarativeBinding::new()
952            .pack("customers", "explicit")
953            .build();
954
955        let semantic =
956            SemanticResolver::new(StubMatcher(vec![("legal", 0.6, "compliance keyword")]));
957        let learned = LearnedResolver::new(StubRecall(vec![ep(
958            "vendor selection for ACME",
959            &["customers", "partnerships"],
960            true,
961        )]));
962
963        let ladder = LadderResolver::new()
964            .with(Box::new(StubStructural))
965            .with(Box::new(semantic))
966            .with(Box::new(learned));
967
968        let binding = ladder.resolve(&intent("vendor selection for ACME"), seed);
969
970        // Level 1 declarative seed.
971        assert!(
972            binding
973                .packs
974                .iter()
975                .any(|p| p.pack_name == "customers" && p.source == ResolutionLevel::Declarative)
976        );
977        // Level 2 added procurement (vendor → procurement).
978        assert!(
979            binding
980                .packs
981                .iter()
982                .any(|p| p.pack_name == "procurement" && p.source == ResolutionLevel::Structural)
983        );
984        // Level 3 added legal.
985        assert!(
986            binding
987                .packs
988                .iter()
989                .any(|p| p.pack_name == "legal" && p.source == ResolutionLevel::Semantic)
990        );
991        // Level 4 added partnerships from prior episode.
992        assert!(
993            binding
994                .packs
995                .iter()
996                .any(|p| p.pack_name == "partnerships" && p.source == ResolutionLevel::Learned)
997        );
998
999        // Trace shows all four levels attempted and contributing.
1000        for level in [
1001            ResolutionLevel::Declarative,
1002            ResolutionLevel::Structural,
1003            ResolutionLevel::Semantic,
1004            ResolutionLevel::Learned,
1005        ] {
1006            assert!(
1007                binding.resolution.levels_attempted.contains(&level),
1008                "level {level:?} should be in levels_attempted"
1009            );
1010            assert!(
1011                binding.resolution.levels_contributed.contains(&level),
1012                "level {level:?} should be in levels_contributed"
1013            );
1014        }
1015
1016        // Prior episodes counted.
1017        assert_eq!(binding.resolution.prior_episodes_consulted, 1);
1018
1019        // All-attempted, all-contributing → completeness = 1.0.
1020        assert!((binding.resolution.completeness_confidence.as_f64() - 1.0).abs() < f64::EPSILON);
1021    }
1022
1023    #[test]
1024    fn ladder_completeness_reflects_partial_contribution() {
1025        // No semantic matches, no recall — only the declarative seed contributes.
1026        // Trace accumulates across seed + ladder: D (seed) attempted+contributed,
1027        // S (ladder) attempted only, L (ladder) attempted only.
1028        let seed = DeclarativeBinding::new()
1029            .pack("customers", "explicit")
1030            .build();
1031        let ladder = LadderResolver::new()
1032            .with(Box::new(SemanticResolver::new(StubMatcher(vec![]))))
1033            .with(Box::new(LearnedResolver::new(StubRecall(vec![]))));
1034        let binding = ladder.resolve(&intent("anything"), seed);
1035
1036        assert_eq!(binding.resolution.levels_attempted.len(), 3);
1037        assert_eq!(binding.resolution.levels_contributed.len(), 1);
1038        let expected = 1.0 / 3.0;
1039        assert!(
1040            (binding.resolution.completeness_confidence.as_f64() - expected).abs() < f64::EPSILON,
1041            "completeness should reflect 1 of 3 levels contributing; got {}",
1042            binding.resolution.completeness_confidence.as_f64()
1043        );
1044    }
1045}