Skip to main content

mythos_skill/compiler/
evidence.rs

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, &registry)?;
32    validate_fact_records(&packet.trusted_facts, &registry)?;
33    validate_hypotheses(&packet.active_hypotheses, &registry)?;
34    validate_contradictions(&packet.contradictions, &registry)?;
35    validate_failure_patterns(&packet.recurring_failure_patterns, &registry)?;
36    validate_actions(&packet.candidate_actions, &registry)?;
37    validate_verifier_findings(&packet.verifier_findings, &registry)?;
38    validate_halt_signals(&packet.halt_signals, &registry)?;
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}