Skip to main content

lean_ctx_protocol/
knowledge_routing.rs

1//! Knowledge-source discovery and context-routing contracts.
2
3use crate::common::{ValidationError, deserialize_milliunit, validate_milliunit};
4use serde::{Deserialize, Serialize};
5
6/// Relative acquisition cost of a knowledge source.
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum CostClass {
10    #[default]
11    Negligible,
12    Low,
13    Medium,
14    High,
15}
16
17/// Operations supported by a knowledge source.
18#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct SourceCapabilities {
21    pub search: bool,
22    pub exact_get: bool,
23    pub delta_sync: bool,
24    pub live_query: bool,
25    pub graph_edges: bool,
26}
27
28/// Describes one source available to the knowledge router.
29#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct KnowledgeSourceManifestV1 {
32    pub source_id: String,
33    pub display_name: String,
34    pub kinds: Vec<String>,
35    pub capabilities: SourceCapabilities,
36    pub freshness_typical_ms: u64,
37    pub cost_class: CostClass,
38}
39
40impl KnowledgeSourceManifestV1 {
41    /// Validate the manifest's structural invariants.
42    pub fn validate(&self) -> Result<(), ValidationError> {
43        Ok(())
44    }
45}
46
47/// Ranked, content-addressed context candidate.
48#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct ContextCandidateV1 {
51    pub candidate_id: String,
52    pub task_id: String,
53    pub source_id: String,
54    pub kind: String,
55    #[serde(deserialize_with = "deserialize_milliunit")]
56    pub relevance_milli: u16,
57    #[serde(deserialize_with = "deserialize_milliunit")]
58    pub confidence_milli: u16,
59    #[serde(deserialize_with = "deserialize_milliunit")]
60    pub freshness_milli: u16,
61    pub estimated_tokens: u64,
62    pub content_hash: String,
63}
64
65impl ContextCandidateV1 {
66    /// Validate candidate ranking and freshness bounds.
67    pub fn validate(&self) -> Result<(), ValidationError> {
68        validate_milliunit(self.relevance_milli, "relevance_milli")?;
69        validate_milliunit(self.confidence_milli, "confidence_milli")?;
70        validate_milliunit(self.freshness_milli, "freshness_milli")
71    }
72}
73
74/// Context bundle selected for a task.
75#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct ContextBundleV1 {
78    pub bundle_id: String,
79    pub task_id: String,
80    pub candidates: Vec<String>,
81    pub total_tokens: u64,
82    #[serde(deserialize_with = "deserialize_milliunit")]
83    pub coverage_milli: u16,
84    pub strategy: String,
85}
86
87impl ContextBundleV1 {
88    /// Validate bundle coverage bounds.
89    pub fn validate(&self) -> Result<(), ValidationError> {
90        validate_milliunit(self.coverage_milli, "coverage_milli")
91    }
92}
93
94/// Accounting receipt for a context-routing decision.
95#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct ContextReceiptV1 {
98    pub receipt_id: String,
99    pub task_id: String,
100    pub bundle_id: String,
101    pub budget_tokens: u64,
102    pub materialized_tokens: u64,
103    pub candidates_considered: u32,
104    pub candidates_selected: u32,
105    pub sources_used: Vec<String>,
106    pub degraded_reasons: Vec<String>,
107}
108
109impl ContextReceiptV1 {
110    /// Validate candidate and token accounting invariants.
111    pub fn validate(&self) -> Result<(), ValidationError> {
112        if self.materialized_tokens > self.budget_tokens {
113            return Err(ValidationError::new(
114                "materialized_tokens exceeds budget_tokens",
115            ));
116        }
117        if self.candidates_selected > self.candidates_considered {
118            return Err(ValidationError::new(
119                "candidates_selected exceeds candidates_considered",
120            ));
121        }
122        Ok(())
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    fn manifest() -> KnowledgeSourceManifestV1 {
131        KnowledgeSourceManifestV1 {
132            source_id: "docs".to_owned(),
133            display_name: "Documentation".to_owned(),
134            kinds: vec!["reference".to_owned()],
135            capabilities: SourceCapabilities {
136                search: true,
137                exact_get: true,
138                delta_sync: false,
139                live_query: false,
140                graph_edges: true,
141            },
142            freshness_typical_ms: 60_000,
143            cost_class: CostClass::Low,
144        }
145    }
146
147    fn candidate() -> ContextCandidateV1 {
148        ContextCandidateV1 {
149            candidate_id: "candidate-1".to_owned(),
150            task_id: "task-1".to_owned(),
151            source_id: "docs".to_owned(),
152            kind: "reference".to_owned(),
153            relevance_milli: 950,
154            confidence_milli: 900,
155            freshness_milli: 800,
156            estimated_tokens: 500,
157            content_hash: "sha256:content".to_owned(),
158        }
159    }
160
161    fn bundle() -> ContextBundleV1 {
162        ContextBundleV1 {
163            bundle_id: "bundle-1".to_owned(),
164            task_id: "task-1".to_owned(),
165            candidates: vec!["candidate-1".to_owned()],
166            total_tokens: 500,
167            coverage_milli: 850,
168            strategy: "relevance_first".to_owned(),
169        }
170    }
171
172    fn receipt() -> ContextReceiptV1 {
173        ContextReceiptV1 {
174            receipt_id: "receipt-1".to_owned(),
175            task_id: "task-1".to_owned(),
176            bundle_id: "bundle-1".to_owned(),
177            budget_tokens: 1_000,
178            materialized_tokens: 500,
179            candidates_considered: 4,
180            candidates_selected: 1,
181            sources_used: vec!["docs".to_owned()],
182            degraded_reasons: Vec::new(),
183        }
184    }
185
186    #[test]
187    fn serialization_round_trip() {
188        let values = (manifest(), candidate(), bundle(), receipt());
189        let json = serde_json::to_string(&values).expect("routing values should serialize");
190        let decoded: (
191            KnowledgeSourceManifestV1,
192            ContextCandidateV1,
193            ContextBundleV1,
194            ContextReceiptV1,
195        ) = serde_json::from_str(&json).expect("routing values should deserialize");
196        assert_eq!(values, decoded);
197        values.0.validate().expect("manifest should be valid");
198        values.1.validate().expect("candidate should be valid");
199        values.2.validate().expect("bundle should be valid");
200        values.3.validate().expect("receipt should be valid");
201    }
202
203    #[test]
204    fn validation_rejects_invalid_milliunits_and_accounting() {
205        let mut candidate = candidate();
206        candidate.relevance_milli = 1001;
207        assert!(candidate.validate().is_err());
208
209        let mut bundle = bundle();
210        bundle.coverage_milli = 1001;
211        assert!(bundle.validate().is_err());
212
213        let mut receipt = receipt();
214        receipt.materialized_tokens = 1_001;
215        assert!(receipt.validate().is_err());
216        receipt.materialized_tokens = 500;
217        receipt.candidates_selected = 5;
218        assert!(receipt.validate().is_err());
219
220        let invalid_json = r#"{
221            "candidate_id": "candidate-1",
222            "task_id": "task-1",
223            "source_id": "docs",
224            "kind": "reference",
225            "relevance_milli": 1001,
226            "confidence_milli": 900,
227            "freshness_milli": 800,
228            "estimated_tokens": 500,
229            "content_hash": "sha256:content"
230        }"#;
231        assert!(serde_json::from_str::<ContextCandidateV1>(invalid_json).is_err());
232    }
233
234    #[test]
235    fn defaults_are_stable_and_valid() {
236        let values = (
237            KnowledgeSourceManifestV1::default(),
238            ContextCandidateV1::default(),
239            ContextBundleV1::default(),
240            ContextReceiptV1::default(),
241        );
242        assert_eq!(values.0, KnowledgeSourceManifestV1::default());
243        assert_eq!(values.1, ContextCandidateV1::default());
244        assert_eq!(values.2, ContextBundleV1::default());
245        assert_eq!(values.3, ContextReceiptV1::default());
246        values
247            .0
248            .validate()
249            .expect("default manifest should be valid");
250        values
251            .1
252            .validate()
253            .expect("default candidate should be valid");
254        values.2.validate().expect("default bundle should be valid");
255        values
256            .3
257            .validate()
258            .expect("default receipt should be valid");
259        assert_eq!(CostClass::default(), CostClass::Negligible);
260    }
261}