1use converge_pack::UnitInterval;
16use serde::{Deserialize, Serialize};
17
18macro_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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
132pub struct IntentBinding {
133 pub packs: Vec<PackRequirement>,
135 pub capabilities: Vec<CapabilityRequirement>,
137 pub invariants: Vec<InvariantId>,
139 pub resolution: ResolutionTrace,
141}
142
143#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum ResolutionLevel {
165 Declarative,
167 Structural,
169 Semantic,
171 Learned,
173}
174
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
177pub struct ResolutionTrace {
178 pub levels_attempted: Vec<ResolutionLevel>,
179 pub levels_contributed: Vec<ResolutionLevel>,
180 pub prior_episodes_consulted: usize,
182 pub completeness_confidence: UnitInterval,
184}
185
186#[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
260pub trait IntentResolver: Send + Sync {
268 fn level(&self) -> ResolutionLevel;
269 fn resolve(&self, intent: &super::IntentPacket, current: &IntentBinding) -> IntentBinding;
270}
271
272pub trait SemanticMatcher: Send + Sync {
284 fn match_packs(&self, outcome: &str) -> Vec<(String, f64, String)>;
285}
286
287pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct EpisodeSummary {
344 pub outcome: String,
346 pub packs_used: Vec<String>,
348 pub passed: bool,
350}
351
352pub trait EpisodeRecall: Send + Sync {
356 fn similar_episodes(&self, intent: &super::IntentPacket) -> Vec<EpisodeSummary>;
357}
358
359pub struct LearnedResolver<R: EpisodeRecall> {
366 recall: R,
367 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 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
446pub 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 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
489fn 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 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 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 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 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 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 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 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 assert!(
972 binding
973 .packs
974 .iter()
975 .any(|p| p.pack_name == "customers" && p.source == ResolutionLevel::Declarative)
976 );
977 assert!(
979 binding
980 .packs
981 .iter()
982 .any(|p| p.pack_name == "procurement" && p.source == ResolutionLevel::Structural)
983 );
984 assert!(
986 binding
987 .packs
988 .iter()
989 .any(|p| p.pack_name == "legal" && p.source == ResolutionLevel::Semantic)
990 );
991 assert!(
993 binding
994 .packs
995 .iter()
996 .any(|p| p.pack_name == "partnerships" && p.source == ResolutionLevel::Learned)
997 );
998
999 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 assert_eq!(binding.resolution.prior_episodes_consulted, 1);
1018
1019 assert!((binding.resolution.completeness_confidence.as_f64() - 1.0).abs() < f64::EPSILON);
1021 }
1022
1023 #[test]
1024 fn ladder_completeness_reflects_partial_contribution() {
1025 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}