1use std::io;
12use std::path::Path;
13
14use crate::query::json::J;
15
16use super::identity::{OccurrenceSpan, SymbolIdentity};
17use super::manifest::Case;
18use super::pin::Pin;
19use super::uncertainty::{ContextHealth, Uncertainty};
20
21#[derive(Debug, Clone, PartialEq)]
24pub struct ObservedFact {
25 pub identity: SymbolIdentity,
27 pub occurrence: OccurrenceSpan,
29 pub state: Uncertainty,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ObservedDiagnostic {
37 pub severity: String,
39 pub code: String,
41}
42
43#[derive(Debug, Clone, PartialEq)]
49pub struct ObservedCase {
50 pub context: ContextHealth,
52 pub facts: Vec<ObservedFact>,
54 pub diagnostics: Vec<ObservedDiagnostic>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum CaseVerdict {
61 Pass,
63 Fail {
65 reasons: Vec<String>,
67 },
68}
69
70impl CaseVerdict {
71 pub fn is_pass(&self) -> bool {
73 matches!(self, CaseVerdict::Pass)
74 }
75}
76
77fn fact_matches(
78 expected_identity: &SymbolIdentity,
79 expected_occurrence: &OccurrenceSpan,
80 observed: &ObservedFact,
81) -> bool {
82 &observed.identity == expected_identity && &observed.occurrence == expected_occurrence
83}
84
85fn identity_asserted(identity: &SymbolIdentity, observed: &ObservedCase) -> bool {
86 observed.facts.iter().any(|f| {
87 &f.identity == identity
88 && matches!(
89 f.state,
90 Uncertainty::Confirmed | Uncertainty::Candidate { .. }
91 )
92 })
93}
94
95pub fn evaluate_case(case: &Case, observed: &ObservedCase) -> CaseVerdict {
99 let mut reasons = Vec::new();
100
101 if !case.present.is_empty() && observed.facts.is_empty() {
102 reasons.push(format!(
103 "case '{}': empty analyzer result against a positive obligation",
104 case.id
105 ));
106 }
107
108 for expected in &case.present {
109 let matched = observed.facts.iter().any(|f| {
110 fact_matches(&expected.identity, &expected.occurrence, f)
111 && f.state.label() == expected.state
112 });
113 if !matched {
114 reasons.push(format!(
115 "case '{}': expected present fact '{}' at {}:{} not observed",
116 case.id,
117 expected.identity.member,
118 expected.occurrence.file,
119 expected.occurrence.start_line
120 ));
121 }
122 }
123
124 for expected in &case.absent {
125 if identity_asserted(&expected.identity, observed) {
126 reasons.push(format!(
127 "case '{}': expected-absent fact '{}' was asserted",
128 case.id, expected.identity.member
129 ));
130 }
131 }
132
133 for expected in &case.unresolved {
134 let observed_state = observed
135 .facts
136 .iter()
137 .find(|f| fact_matches(&expected.identity, &expected.occurrence, f))
138 .map(|f| f.state.label());
139 match observed_state {
140 Some(label) if label == expected.state => {}
141 Some("confirmed") => reasons.push(format!(
142 "case '{}': fact '{}' expected {} but was promoted to confirmed",
143 case.id, expected.identity.member, expected.state
144 )),
145 Some(other) => reasons.push(format!(
146 "case '{}': fact '{}' expected {} but observed {}",
147 case.id, expected.identity.member, expected.state, other
148 )),
149 None => reasons.push(format!(
150 "case '{}': expected unresolved fact '{}' not observed at all",
151 case.id, expected.identity.member
152 )),
153 }
154 }
155
156 for expected in &case.diagnostics {
157 let matched = observed
158 .diagnostics
159 .iter()
160 .any(|d| d.severity == expected.severity && d.code == expected.code);
161 if !matched {
162 reasons.push(format!(
163 "case '{}': expected diagnostic '{}' ({}) not observed",
164 case.id, expected.code, expected.severity
165 ));
166 }
167 }
168
169 if observed.context.label() != case.context {
170 reasons.push(format!(
171 "case '{}': expected context '{}', observed '{}'",
172 case.id,
173 case.context,
174 observed.context.label()
175 ));
176 }
177
178 if reasons.is_empty() {
179 CaseVerdict::Pass
180 } else {
181 CaseVerdict::Fail { reasons }
182 }
183}
184
185pub fn denominator_shrunk(observed_denominator: u32, declared_case_count: u32) -> bool {
192 observed_denominator < declared_case_count
193}
194
195pub fn ratio_or_undefined(passed: u32, denominator: u32) -> Option<f64> {
198 if denominator == 0 {
199 None
200 } else {
201 Some(f64::from(passed) / f64::from(denominator))
202 }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum Lane {
208 SyntaxOnly,
210 Enriched,
212}
213
214impl Lane {
215 pub fn label(self) -> &'static str {
217 match self {
218 Lane::SyntaxOnly => "syntax-only",
219 Lane::Enriched => "enriched",
220 }
221 }
222}
223
224#[derive(Debug, Clone)]
227pub struct PinnedInputHeader {
228 pub contract: String,
230 pub corpus_revision: String,
232 pub producer_versions: Vec<(String, String)>,
234 pub resolved_context: String,
236 pub commands: Vec<String>,
238 pub pin: Pin,
242}
243
244#[derive(Debug, Clone)]
246pub struct CaseOutcome {
247 pub case_id: String,
249 pub verdict: CaseVerdict,
251}
252
253#[derive(Debug, Clone)]
255pub struct TruthReport {
256 pub lane: Lane,
258 pub header: PinnedInputHeader,
260 pub outcomes: Vec<CaseOutcome>,
262}
263
264impl TruthReport {
265 pub fn passed(&self) -> u32 {
267 self.outcomes.iter().filter(|o| o.verdict.is_pass()).count() as u32
268 }
269
270 pub fn denominator(&self) -> u32 {
272 self.outcomes.len() as u32
273 }
274}
275
276pub fn report_to_json(report: &TruthReport) -> String {
279 let mut outcomes = report.outcomes.clone();
280 outcomes.sort_by(|a, b| a.case_id.cmp(&b.case_id));
281
282 let ratio = ratio_or_undefined(report.passed(), report.denominator());
283
284 J::Obj(vec![
285 ("contract", J::Str(report.header.contract.clone())),
286 ("lane", J::Str(report.lane.label().to_string())),
287 (
288 "pinnedInputs",
289 J::Obj(vec![
290 (
291 "corpusRevision",
292 J::Str(report.header.corpus_revision.clone()),
293 ),
294 (
295 "producerVersions",
296 J::Arr(
297 report
298 .header
299 .producer_versions
300 .iter()
301 .map(|(name, version)| {
302 J::Obj(vec![
303 ("producer", J::Str(name.clone())),
304 ("version", J::Str(version.clone())),
305 ])
306 })
307 .collect(),
308 ),
309 ),
310 (
311 "resolvedContext",
312 J::Str(report.header.resolved_context.clone()),
313 ),
314 (
315 "commands",
316 J::Arr(
317 report
318 .header
319 .commands
320 .iter()
321 .map(|c| J::Str(c.clone()))
322 .collect(),
323 ),
324 ),
325 (
326 "pin",
327 J::Obj(vec![
328 ("contract", J::Str(report.header.pin.contract.clone())),
329 ("digestHex", J::Str(report.header.pin.digest_hex.clone())),
330 ]),
331 ),
332 ]),
333 ),
334 (
335 "outcomes",
336 J::Arr(
337 outcomes
338 .iter()
339 .map(|o| {
340 let reasons = match &o.verdict {
341 CaseVerdict::Pass => Vec::new(),
342 CaseVerdict::Fail { reasons } => reasons.clone(),
343 };
344 J::Obj(vec![
345 ("caseId", J::Str(o.case_id.clone())),
346 ("pass", J::Bool(o.verdict.is_pass())),
347 ("reasons", J::Arr(reasons.into_iter().map(J::Str).collect())),
348 ])
349 })
350 .collect(),
351 ),
352 ),
353 ("passed", J::UInt(u64::from(report.passed()))),
354 ("denominator", J::UInt(u64::from(report.denominator()))),
355 (
356 "ratio",
357 match ratio {
358 Some(r) => J::RawNum(format!("{r:.6}")),
359 None => J::Str("undefined".to_string()),
360 },
361 ),
362 ])
363 .to_json_string()
364}
365
366pub fn lane_artifact_filename(lane: Lane) -> &'static str {
369 match lane {
370 Lane::SyntaxOnly => "syntax-only.json",
371 Lane::Enriched => "enriched.json",
372 }
373}
374
375pub fn write_lane_artifact(report: &TruthReport, dir: &Path) -> io::Result<()> {
379 let path = dir.join(lane_artifact_filename(report.lane));
380 std::fs::write(path, report_to_json(report))
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use crate::truth::identity::CompilationIdentity;
387 use crate::truth::manifest::{ExpectedPresentFact, Provenance};
388
389 fn identity(member: &str) -> SymbolIdentity {
390 SymbolIdentity {
391 assembly: "Fixture".to_string(),
392 declaring_type: "Fixture.Cases".to_string(),
393 member: member.to_string(),
394 generic_arity: 0,
395 overload_signature: format!("{member}()"),
396 compilation: CompilationIdentity {
397 project: "Cases".to_string(),
398 tfm: "net8.0".to_string(),
399 configuration: "Release".to_string(),
400 },
401 }
402 }
403
404 fn occurrence(line: u32) -> OccurrenceSpan {
405 OccurrenceSpan {
406 file: "src/Cases.cs".to_string(),
407 start_line: line,
408 start_col: 1,
409 end_line: line,
410 end_col: 20,
411 }
412 }
413
414 fn positive_case() -> Case {
415 Case {
416 id: "positive-a".to_string(),
417 scenario_family: "shared-language-semantics".to_string(),
418 language: "csharp".to_string(),
419 profiles: vec!["csharp-net8.0-sdk".to_string()],
420 prerequisites: vec![],
421 fact_contract: "semantic-truth-v1".to_string(),
422 source: vec!["src/Cases.cs".to_string()],
423 context: "complete".to_string(),
424 present: vec![ExpectedPresentFact {
425 identity: identity("Load"),
426 occurrence: occurrence(10),
427 state: "confirmed".to_string(),
428 }],
429 absent: vec![],
430 unresolved: vec![],
431 diagnostics: vec![],
432 provenance: Provenance::Reviewed,
433 }
434 }
435
436 #[test]
437 fn empty_output_fails_a_positive_obligation() {
438 let observed = ObservedCase {
439 context: ContextHealth::Complete,
440 facts: vec![],
441 diagnostics: vec![],
442 };
443 let verdict = evaluate_case(&positive_case(), &observed);
444 assert!(!verdict.is_pass());
445 }
446
447 #[test]
448 fn satisfying_only_the_absent_list_cannot_pass_a_case() {
449 let case = Case {
450 absent: vec![super::super::manifest::ExpectedAbsentFact {
451 identity: identity("NeverCalled"),
452 }],
453 ..positive_case()
454 };
455 let observed = ObservedCase {
458 context: ContextHealth::Complete,
459 facts: vec![],
460 diagnostics: vec![],
461 };
462 assert!(!evaluate_case(&case, &observed).is_pass());
463 }
464
465 #[test]
466 fn a_matching_observation_passes() {
467 let observed = ObservedCase {
468 context: ContextHealth::Complete,
469 facts: vec![ObservedFact {
470 identity: identity("Load"),
471 occurrence: occurrence(10),
472 state: Uncertainty::Confirmed,
473 }],
474 diagnostics: vec![],
475 };
476 assert!(evaluate_case(&positive_case(), &observed).is_pass());
477 }
478
479 #[test]
480 fn a_partial_context_can_never_be_reported_complete() {
481 let observed = ObservedCase {
482 context: ContextHealth::Partial {
483 reason: "one project missing".to_string(),
484 },
485 facts: vec![ObservedFact {
486 identity: identity("Load"),
487 occurrence: occurrence(10),
488 state: Uncertainty::Confirmed,
489 }],
490 diagnostics: vec![],
491 };
492 let verdict = evaluate_case(&positive_case(), &observed);
493 assert!(
494 !verdict.is_pass(),
495 "a partial context must not satisfy a case expecting complete"
496 );
497 }
498
499 #[test]
500 fn zero_denominator_ratio_is_undefined_not_perfect() {
501 assert_eq!(ratio_or_undefined(0, 0), None);
502 assert_eq!(ratio_or_undefined(3, 4), Some(0.75));
503 }
504
505 fn header() -> PinnedInputHeader {
506 PinnedInputHeader {
507 contract: "semantic-truth-v1".to_string(),
508 corpus_revision: "fixtures/csharp-truth@test".to_string(),
509 producer_versions: vec![("scout-semantic".to_string(), "0.6.0".to_string())],
510 resolved_context: "complete".to_string(),
511 commands: vec!["devscout map".to_string()],
512 pin: crate::truth::pin::compute_pin("semantic-truth-v1", b"test", &[]),
513 }
514 }
515
516 #[test]
517 fn the_report_header_carries_the_pin() {
518 let json = report_to_json(&TruthReport {
519 lane: Lane::SyntaxOnly,
520 header: header(),
521 outcomes: vec![],
522 });
523 assert!(json.contains("\"pin\":{"));
524 assert!(json.contains(&format!("\"digestHex\":\"{}\"", header().pin.digest_hex)));
525 }
526
527 #[test]
528 fn a_failing_outcomes_reasons_are_in_the_record() {
529 let report = TruthReport {
530 lane: Lane::SyntaxOnly,
531 header: header(),
532 outcomes: vec![CaseOutcome {
533 case_id: "a-case".to_string(),
534 verdict: CaseVerdict::Fail {
535 reasons: vec!["expected present fact 'Load' not observed".to_string()],
536 },
537 }],
538 };
539 let json = report_to_json(&report);
540 assert!(json.contains("expected present fact 'Load' not observed"));
541 }
542
543 #[test]
544 fn two_runs_from_the_same_inputs_are_byte_identical() {
545 let build = || TruthReport {
546 lane: Lane::SyntaxOnly,
547 header: header(),
548 outcomes: vec![
549 CaseOutcome {
550 case_id: "b-case".to_string(),
551 verdict: CaseVerdict::Pass,
552 },
553 CaseOutcome {
554 case_id: "a-case".to_string(),
555 verdict: CaseVerdict::Fail {
556 reasons: vec!["missing".to_string()],
557 },
558 },
559 ],
560 };
561 let first = report_to_json(&build());
562 let second = report_to_json(&build());
563 assert_eq!(first, second);
564 assert!(first.find("\"a-case\"").unwrap() < first.find("\"b-case\"").unwrap());
566 }
567
568 #[test]
569 fn the_two_lanes_are_never_summed() {
570 let syntax = TruthReport {
571 lane: Lane::SyntaxOnly,
572 header: header(),
573 outcomes: vec![CaseOutcome {
574 case_id: "a".to_string(),
575 verdict: CaseVerdict::Pass,
576 }],
577 };
578 let enriched = TruthReport {
579 lane: Lane::Enriched,
580 header: header(),
581 outcomes: vec![CaseOutcome {
582 case_id: "a".to_string(),
583 verdict: CaseVerdict::Fail { reasons: vec![] },
584 }],
585 };
586 let syntax_json = report_to_json(&syntax);
587 let enriched_json = report_to_json(&enriched);
588 assert_ne!(syntax_json, enriched_json);
589 assert!(syntax_json.contains("\"lane\":\"syntax-only\""));
590 assert!(enriched_json.contains("\"lane\":\"enriched\""));
591 }
592}