1use crate::common::{ValidationError, deserialize_milliunit, validate_milliunit};
4use serde::{Deserialize, Serialize};
5
6#[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#[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#[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 pub fn validate(&self) -> Result<(), ValidationError> {
43 Ok(())
44 }
45}
46
47#[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 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#[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 pub fn validate(&self) -> Result<(), ValidationError> {
90 validate_milliunit(self.coverage_milli, "coverage_milli")
91 }
92}
93
94#[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 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}