1use crate::schema::{
2 CandidateAction, CompiledFact, Contradiction, EvidenceRecord, HaltSignal, Hypothesis,
3 NextPassPacket, RecurringFailurePattern, SourceRef, VerifierFinding,
4};
5use std::collections::HashSet;
6use std::fmt::{Display, Formatter};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct PacketValidationError {
10 message: String,
11}
12
13impl PacketValidationError {
14 fn new(message: impl Into<String>) -> Self {
15 Self {
16 message: message.into(),
17 }
18 }
19}
20
21impl Display for PacketValidationError {
22 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
23 f.write_str(&self.message)
24 }
25}
26
27impl std::error::Error for PacketValidationError {}
28
29pub fn validate_packet_sources(packet: &NextPassPacket) -> Result<(), PacketValidationError> {
30 let registry = source_registry(&packet.sources, &packet.raw_drilldown_refs);
31 validate_evidence_records(&packet.evidence, ®istry)?;
32 validate_fact_records(&packet.trusted_facts, ®istry)?;
33 validate_hypotheses(&packet.active_hypotheses, ®istry)?;
34 validate_contradictions(&packet.contradictions, ®istry)?;
35 validate_failure_patterns(&packet.recurring_failure_patterns, ®istry)?;
36 validate_actions(&packet.candidate_actions, ®istry)?;
37 validate_verifier_findings(&packet.verifier_findings, ®istry)?;
38 validate_halt_signals(&packet.halt_signals, ®istry)?;
39 Ok(())
40}
41
42fn source_registry(sources: &[SourceRef], raw_drilldown_refs: &[SourceRef]) -> HashSet<String> {
43 sources
44 .iter()
45 .chain(raw_drilldown_refs.iter())
46 .map(|source| source.source_id.clone())
47 .collect()
48}
49
50fn validate_evidence_records(
51 items: &[EvidenceRecord],
52 registry: &HashSet<String>,
53) -> Result<(), PacketValidationError> {
54 for item in items {
55 validate_record_local_source_refs(
56 "evidence",
57 &item.id,
58 &item.source_ids,
59 &item.source_refs,
60 )?;
61 validate_source_ids("evidence", &item.id, &item.source_ids, registry)?;
62 }
63 Ok(())
64}
65
66fn validate_fact_records(
67 items: &[CompiledFact],
68 registry: &HashSet<String>,
69) -> Result<(), PacketValidationError> {
70 for item in items {
71 validate_source_ids("compiled_fact", &item.id, &item.source_ids, registry)?;
72 }
73 Ok(())
74}
75
76fn validate_hypotheses(
77 items: &[Hypothesis],
78 registry: &HashSet<String>,
79) -> Result<(), PacketValidationError> {
80 for item in items {
81 validate_source_ids("hypothesis", &item.id, &item.source_ids, registry)?;
82 }
83 Ok(())
84}
85
86fn validate_contradictions(
87 items: &[Contradiction],
88 registry: &HashSet<String>,
89) -> Result<(), PacketValidationError> {
90 for item in items {
91 validate_source_ids("contradiction", &item.id, &item.source_ids, registry)?;
92 }
93 Ok(())
94}
95
96fn validate_failure_patterns(
97 items: &[RecurringFailurePattern],
98 registry: &HashSet<String>,
99) -> Result<(), PacketValidationError> {
100 for item in items {
101 validate_source_ids(
102 "recurring_failure_pattern",
103 &item.id,
104 &item.source_ids,
105 registry,
106 )?;
107 }
108 Ok(())
109}
110
111fn validate_actions(
112 items: &[CandidateAction],
113 registry: &HashSet<String>,
114) -> Result<(), PacketValidationError> {
115 for item in items {
116 validate_source_ids("candidate_action", &item.id, &item.source_ids, registry)?;
117 }
118 Ok(())
119}
120
121fn validate_verifier_findings(
122 items: &[VerifierFinding],
123 registry: &HashSet<String>,
124) -> Result<(), PacketValidationError> {
125 for item in items {
126 validate_record_local_source_refs(
127 "verifier_finding",
128 &item.id,
129 &item.source_ids,
130 &item.source_refs,
131 )?;
132 validate_source_ids("verifier_finding", &item.id, &item.source_ids, registry)?;
133 }
134 Ok(())
135}
136
137fn validate_halt_signals(
138 items: &[HaltSignal],
139 registry: &HashSet<String>,
140) -> Result<(), PacketValidationError> {
141 for item in items {
142 validate_source_ids("halt_signal", &item.id, &item.source_ids, registry)?;
143 }
144 Ok(())
145}
146
147fn validate_source_ids(
148 kind: &str,
149 item_id: &str,
150 source_ids: &[String],
151 registry: &HashSet<String>,
152) -> Result<(), PacketValidationError> {
153 if source_ids.is_empty() {
154 return Err(PacketValidationError::new(format!(
155 "{kind} `{item_id}` must reference at least one source id"
156 )));
157 }
158
159 for source_id in source_ids {
160 if !registry.contains(source_id) {
161 return Err(PacketValidationError::new(format!(
162 "{kind} `{item_id}` references unknown source id `{source_id}`"
163 )));
164 }
165 }
166
167 Ok(())
168}
169
170fn validate_record_local_source_refs(
171 kind: &str,
172 item_id: &str,
173 source_ids: &[String],
174 source_refs: &[SourceRef],
175) -> Result<(), PacketValidationError> {
176 let local_refs: HashSet<&str> = source_refs
177 .iter()
178 .map(|source| source.source_id.as_str())
179 .collect();
180 let source_ids: HashSet<&str> = source_ids.iter().map(|source| source.as_str()).collect();
181
182 for source_ref in source_refs {
183 if !source_ids.contains(source_ref.source_id.as_str()) {
184 return Err(PacketValidationError::new(format!(
185 "{kind} `{item_id}` declares source_ref `{}` but does not list it in source_ids",
186 source_ref.source_id
187 )));
188 }
189 }
190
191 for source_id in source_ids {
192 if is_direct_source_id(source_id) && !local_refs.contains(source_id) {
193 return Err(PacketValidationError::new(format!(
194 "{kind} `{item_id}` uses direct source id `{source_id}` without a matching local source_ref"
195 )));
196 }
197 }
198
199 Ok(())
200}
201
202fn is_direct_source_id(source_id: &str) -> bool {
203 source_id.starts_with("file:")
204 || source_id.starts_with("command:")
205 || source_id.starts_with("test:")
206 || source_id.starts_with("log:")
207}
208
209#[cfg(test)]
210mod tests {
211 use super::validate_packet_sources;
212 use crate::schema::{
213 CandidateAction, CompiledFact, Contradiction, EvidenceRecord, HaltSignal, Hypothesis,
214 NextPassPacket, RecurringFailurePattern, SourceRef, VerifierFinding,
215 };
216
217 fn source() -> SourceRef {
218 SourceRef {
219 source_id: "src-1".to_string(),
220 path: "evidence/log.txt".to_string(),
221 kind: "log".to_string(),
222 hash: "abc".to_string(),
223 hash_alg: "fnv1a-64".to_string(),
224 span: Some("1-4".to_string()),
225 observed_at: "2026-04-21T00:00:00Z".to_string(),
226 }
227 }
228
229 fn direct_source() -> SourceRef {
230 SourceRef {
231 source_id: "file:src/main.rs:10".to_string(),
232 path: "src/main.rs".to_string(),
233 kind: "file".to_string(),
234 hash: "abc".to_string(),
235 hash_alg: "fnv1a-64".to_string(),
236 span: Some("10".to_string()),
237 observed_at: "2026-04-21T00:00:00Z".to_string(),
238 }
239 }
240
241 fn packet(source_ids: Vec<String>) -> NextPassPacket {
242 NextPassPacket {
243 schema_version: "1.1.0".to_string(),
244 objective_id: "obj-1".to_string(),
245 run_id: "run-1".to_string(),
246 branch_id: "main".to_string(),
247 pass_id: "pass-1".to_string(),
248 objective: "Solve the task".to_string(),
249 evidence: vec![EvidenceRecord {
250 id: "ev-1".to_string(),
251 kind: "observation".to_string(),
252 summary: "build output".to_string(),
253 source_ids: source_ids.clone(),
254 source_refs: vec![],
255 observed_at: "2026-04-21T00:00:00Z".to_string(),
256 agent_id: None,
257 lane: None,
258 confidence: None,
259 rationale: None,
260 diff_ref: None,
261 span_before: None,
262 span_after: None,
263 }],
264 trusted_facts: vec![CompiledFact {
265 id: "fact-1".to_string(),
266 statement: "build failed".to_string(),
267 confidence: 0.8,
268 objective_relevance: 0.9,
269 novelty_gain: 0.1,
270 needs_raw_drilldown: false,
271 source_ids: source_ids.clone(),
272 }],
273 active_hypotheses: vec![Hypothesis {
274 id: "hyp-1".to_string(),
275 statement: "schema is wrong".to_string(),
276 confidence: 0.6,
277 verifier_score: None,
278 source_ids: source_ids.clone(),
279 }],
280 contradictions: vec![Contradiction {
281 id: "con-1".to_string(),
282 summary: "two packet versions disagree".to_string(),
283 conflicting_item_ids: vec!["fact-1".to_string()],
284 severity: "medium".to_string(),
285 source_ids: source_ids.clone(),
286 source_refs: None,
287 }],
288 recurring_failure_patterns: vec![RecurringFailurePattern {
289 id: "pat-1".to_string(),
290 summary: "build keeps failing".to_string(),
291 count: 2,
292 last_seen_at: "2026-04-21T00:00:00Z".to_string(),
293 impact: "medium".to_string(),
294 source_ids: source_ids.clone(),
295 }],
296 candidate_actions: vec![CandidateAction {
297 id: "act-1".to_string(),
298 title: "fix build".to_string(),
299 rationale: "clear next step".to_string(),
300 actionability_score: 0.9,
301 decision_dependency_ids: vec![],
302 source_ids: source_ids.clone(),
303 }],
304 verifier_findings: vec![VerifierFinding {
305 id: "ver-1".to_string(),
306 summary: "build failed".to_string(),
307 status: "failed".to_string(),
308 verifier_score: 0.0,
309 source_ids: source_ids.clone(),
310 source_refs: vec![],
311 agent_id: None,
312 lane: None,
313 closure_reason: None,
314 }],
315 open_questions: vec![],
316 raw_drilldown_refs: vec![source()],
317 halt_signals: vec![HaltSignal {
318 id: "halt-1".to_string(),
319 kind: "continue".to_string(),
320 contribution: 0.1,
321 rationale: "still unresolved".to_string(),
322 source_ids,
323 }],
324 sources: vec![source()],
325 }
326 }
327
328 #[test]
329 fn accepts_packets_when_all_source_refs_resolve() {
330 let result = validate_packet_sources(&packet(vec!["src-1".to_string()]));
331 assert!(result.is_ok());
332 }
333
334 #[test]
335 fn rejects_packets_when_source_ids_are_missing() {
336 let result = validate_packet_sources(&packet(vec![]));
337 assert!(result.is_err());
338 }
339
340 #[test]
341 fn rejects_packets_with_unknown_source_ids() {
342 let result = validate_packet_sources(&packet(vec!["missing".to_string()]));
343 assert!(result.is_err());
344 }
345
346 #[test]
347 fn rejects_direct_source_ids_without_local_source_refs() {
348 let direct = direct_source();
349 let mut packet = packet(vec![direct.source_id.clone()]);
350 packet.sources.push(direct);
351
352 let result = validate_packet_sources(&packet);
353
354 assert!(result.is_err());
355 assert!(
356 result
357 .unwrap_err()
358 .to_string()
359 .contains("without a matching local source_ref")
360 );
361 }
362
363 #[test]
364 fn accepts_direct_source_ids_with_local_source_refs() {
365 let direct = direct_source();
366 let mut packet = packet(vec![direct.source_id.clone()]);
367 packet.evidence[0].source_refs = vec![direct.clone()];
368 packet.verifier_findings[0].source_refs = vec![direct.clone()];
369 packet.sources.push(direct);
370
371 let result = validate_packet_sources(&packet);
372
373 assert!(result.is_ok());
374 }
375}