Skip to main content

devscout_rs/truth/
report.rs

1//! Case evaluation and the `TruthReport` artifact.
2//!
3//! `evaluate_case` is the one comparator every case, fault control, and
4//! freshness pair in this harness runs through: it fails an empty result
5//! against a positive obligation, fails a case that satisfies only its
6//! absent-facts list, and never reports a failed or partial context as
7//! complete. The two lanes (`SyntaxOnly`/`Enriched`) are separate
8//! top-level artifacts and are never summed or averaged into one figure
9//! by anything in this file.
10
11use 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/// One fact an analyzer run actually produced for a case, in the same
22/// shape a manifest's expected-present fact takes.
23#[derive(Debug, Clone, PartialEq)]
24pub struct ObservedFact {
25    /// The observed symbol identity.
26    pub identity: SymbolIdentity,
27    /// The observed occurrence span.
28    pub occurrence: OccurrenceSpan,
29    /// The observed confidence state.
30    pub state: Uncertainty,
31}
32
33/// One diagnostic an analyzer run actually emitted: its severity and code,
34/// reduced to the pair a case's expected-diagnostic obligation checks for.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ObservedDiagnostic {
37    /// The diagnostic's severity.
38    pub severity: String,
39    /// The diagnostic's code.
40    pub code: String,
41}
42
43/// What an analyzer run actually produced for one case: its facts,
44/// diagnostics, and the context health it reports for the run.
45///
46/// Never upgraded -- there is no function anywhere in this crate that
47/// turns a `Failed`/`Partial` health into `Complete`.
48#[derive(Debug, Clone, PartialEq)]
49pub struct ObservedCase {
50    /// The context health this run reports.
51    pub context: ContextHealth,
52    /// The facts this run actually produced.
53    pub facts: Vec<ObservedFact>,
54    /// The diagnostics this run actually emitted.
55    pub diagnostics: Vec<ObservedDiagnostic>,
56}
57
58/// The result of comparing an `ObservedCase` against its manifest case.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum CaseVerdict {
61    /// Every obligation the case declares was satisfied.
62    Pass,
63    /// At least one obligation was missed; `reasons` names every one.
64    Fail {
65        /// Every obligation this case missed, one line per reason.
66        reasons: Vec<String>,
67    },
68}
69
70impl CaseVerdict {
71    /// Whether this verdict is a pass.
72    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
95/// Grades one case's observed run against its manifest expectation.
96/// Reasons accumulate rather than short-circuit, so a single failing run
97/// names every obligation it missed, not just the first.
98pub 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
185/// Whether a report's own denominator has shrunk below the manifest's
186/// declared count.
187///
188/// This is the "quality figure improves by omission" failure mode a
189/// dropped case produces even though its own ratio can look fine, or
190/// better, without it.
191pub fn denominator_shrunk(observed_denominator: u32, declared_case_count: u32) -> bool {
192    observed_denominator < declared_case_count
193}
194
195/// A ratio that reports undefined (`None`) rather than a perfect score
196/// when its denominator is zero.
197pub 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/// Which of the two persisted result lanes a report belongs to.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum Lane {
208    /// Graded from syntax-only facts, no compiler enrichment.
209    SyntaxOnly,
210    /// Graded from compiler-enriched facts.
211    Enriched,
212}
213
214impl Lane {
215    /// The wire label for this lane.
216    pub fn label(self) -> &'static str {
217        match self {
218            Lane::SyntaxOnly => "syntax-only",
219            Lane::Enriched => "enriched",
220        }
221    }
222}
223
224/// The pinned-input header every report carries: enough to replay the run
225/// from the record alone.
226#[derive(Debug, Clone)]
227pub struct PinnedInputHeader {
228    /// The contract version this report was produced under.
229    pub contract: String,
230    /// The fixture corpus revision the run was graded against.
231    pub corpus_revision: String,
232    /// Every producer/version pair the run consulted.
233    pub producer_versions: Vec<(String, String)>,
234    /// The resolved context health the run recorded.
235    pub resolved_context: String,
236    /// The exact commands the run issued, in order.
237    pub commands: Vec<String>,
238    /// The pinned foundation-candidate digest this run was graded against --
239    /// a consumer reads this straight off the report header rather than
240    /// recomputing it from a side channel.
241    pub pin: Pin,
242}
243
244/// One case's verdict, ready to be sorted into a report.
245#[derive(Debug, Clone)]
246pub struct CaseOutcome {
247    /// The case this outcome is for.
248    pub case_id: String,
249    /// What the comparator decided.
250    pub verdict: CaseVerdict,
251}
252
253/// The persisted, two-lane truth-and-capability report.
254#[derive(Debug, Clone)]
255pub struct TruthReport {
256    /// Which lane this report belongs to.
257    pub lane: Lane,
258    /// The pinned-input header.
259    pub header: PinnedInputHeader,
260    /// Every graded case's outcome.
261    pub outcomes: Vec<CaseOutcome>,
262}
263
264impl TruthReport {
265    /// How many cases passed.
266    pub fn passed(&self) -> u32 {
267        self.outcomes.iter().filter(|o| o.verdict.is_pass()).count() as u32
268    }
269
270    /// How many cases this report graded.
271    pub fn denominator(&self) -> u32 {
272        self.outcomes.len() as u32
273    }
274}
275
276/// Deterministic JSON for a report: outcomes sorted by case id, so two
277/// in-process runs from the same pinned inputs are byte-identical.
278pub 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
366/// The committed file name for one lane's persisted artifact, inside
367/// `fixtures/csharp-truth/reports/`.
368pub 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
375/// Writes a report's JSON to its lane's persisted-artifact path under
376/// `dir`, so the run is a committed file a consumer can read rather than a
377/// string that only ever lived inside a test process.
378pub 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        // No facts at all: the absent obligation is trivially satisfied, but
456        // the positive obligation is not -- this must still fail.
457        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        // Sorted by case id regardless of insertion order.
565        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}