Skip to main content

logicaffeine_compile/
testrun.rs

1//! The `largo test` runner core.
2//!
3//! Studio-reusable: everything here is CLI-independent. The runner discovers
4//! `## Test` blocks in a parsed program, executes each in a FRESH interpreter
5//! world (fixture pollution is structurally impossible), applies the
6//! constitution's semantics — `Expect` records-and-continues, `Expect this to
7//! fail` fails on success, `Expect output:` compares byte-exact, deterministic
8//! op budgets, loud skips — and renders a DETERMINISTIC report (name-ordered,
9//! byte-identical across `-j N`; the golden in `test_runner_lock.rs`).
10
11use serde::Serialize;
12
13/// One test's verdict.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "snake_case")]
16pub enum Verdict {
17    Passed,
18    /// Failed — with every recorded expectation failure (record-and-continue:
19    /// one run reports everything, not just the first).
20    Failed,
21    /// Skipped via `Skip this test because "…"` — always loud, always counted.
22    Skipped,
23}
24
25/// Cross-tier evidence for one test under `--tiers all` (footgun F18): what
26/// the bytecode VM did with the test's computation, machine-readable in the
27/// JSON report. Absent (`None` on [`TestOutcome::tier`]) when the guard did
28/// not run — evidence is never invented.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "snake_case")]
31pub enum TierEvidence {
32    /// The VM re-ran the computation and agreed on every axis — outcome
33    /// class, FULL output byte-for-byte, and error text.
34    Agreed,
35    /// The VM cannot run this computation; `reason` says why. Reported as an
36    /// explicit `tier: refused (…)` failure — never a pass, never a silent
37    /// skip.
38    Refused { reason: String },
39    /// The VM ran and DISAGREED — always a compiler bug; `detail` carries the
40    /// divergence bundle.
41    Diverged {
42        detail: String,
43        /// The first top-level statement the tiers disagreed at, for a MOUNTED test compared
44        /// through `page_host` (`0` is the program itself, `k` its `k`-th test statement).
45        /// `None` for the plain interpreter/bytecode-VM computation comparison, which has no
46        /// page statements to number.
47        #[serde(skip_serializing_if = "Option::is_none")]
48        statement: Option<usize>,
49        /// The `.lg` source line that statement is written on. Always `None` today — no `Stmt`
50        /// carries a span yet, and a line is never guessed; the field exists so a consumer can
51        /// start reading it the day one does.
52        #[serde(skip_serializing_if = "Option::is_none")]
53        line: Option<u32>,
54    },
55}
56
57/// The finite domain a `For all` property quantified over, with the concrete
58/// bound the runner actually enumerated/sampled — the disclosure that keeps a
59/// green property from silently meaning "unbounded proof" (G13-full). Carried on
60/// the [`TestOutcome`] so the inventory can classify a property honestly.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
62#[serde(rename_all = "snake_case")]
63pub enum PropertyDomain {
64    /// `For all integers …:` — sampled over `[-1000, 1000]` (infinite domain, so
65    /// never a proof, even under `--prove`).
66    Integers,
67    /// `For all naturals …:` — sampled over `[0, 1000]` (infinite domain).
68    Naturals,
69    /// `For all booleans …:` — the only FINITE domain, exhausted under `--prove`.
70    Booleans,
71}
72
73impl PropertyDomain {
74    /// The human-readable, disclosed bound (`[-1000, 1000]` etc.) — what the
75    /// runner ACTUALLY covered, never a claim of unbounded truth.
76    pub fn bound(self) -> &'static str {
77        match self {
78            PropertyDomain::Integers => "integers sampled over [-1000, 1000]",
79            PropertyDomain::Naturals => "naturals sampled over [0, 1000]",
80            PropertyDomain::Booleans => "booleans (finite: {false, true})",
81        }
82    }
83
84    /// Whether this domain is FINITE — the only domain a property can be PROVED
85    /// over by exhaustion (booleans). Infinite domains are only ever sampled.
86    pub fn is_finite(self) -> bool {
87        matches!(self, PropertyDomain::Booleans)
88    }
89}
90
91/// The `For all` property a test exercised, if any — its domain (for bound
92/// disclosure) and how many variables it quantified (for the exhaustion bound).
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
94pub struct PropertyInfo {
95    pub domain: PropertyDomain,
96    pub vars: usize,
97}
98
99/// One executed (or skipped) test.
100#[derive(Debug, Clone, Serialize)]
101pub struct TestOutcome {
102    /// The prose name — the test's identity.
103    pub name: String,
104    pub verdict: Verdict,
105    /// Every failure recorded during the run (empty for pass/skip).
106    pub failures: Vec<String>,
107    /// The mandatory skip reason, when skipped.
108    pub skip_reason: Option<String>,
109    /// Ops executed (deterministic; identical across machines and `-j N`).
110    pub ops: u64,
111    /// True when the test ran clean but asserted NOTHING (no `Expect`/`For all`)
112    /// — the vacuous-green the non-vacuity gate fails; surfaced here so the
113    /// inventory can flag it explicitly (G13-full), not just fail it.
114    pub vacuous: bool,
115    /// Whether this test MOUNTED a page — the honest signal that it drives a document rather than
116    /// checking a value, and what makes it a `Component` claim in the inventory. Read from the
117    /// test's own body, so it says what the test DOES rather than what the run was configured to
118    /// do: a project with both kinds must tell them apart within one document.
119    pub mounted: bool,
120    /// The `For all` property this test exercised, if any — `None` for a plain
121    /// `Expect`-only example. Drives the inventory's property classification and
122    /// its bound disclosure.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub property: Option<PropertyInfo>,
125    /// Whether the run was under `--prove` (finite-domain exhaustion escalation).
126    /// Carried so the inventory can distinguish an exhausted proof-by-cases from
127    /// a sampled hold without re-reading the config.
128    pub proved_by_exhaustion: bool,
129    /// Cross-tier evidence under `--tiers all`; `None` when the guard did not
130    /// run this test (flag off, or the test already failed/skipped).
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub tier: Option<TierEvidence>,
133}
134
135/// The whole run, name-ordered (report order is part of determinism).
136#[derive(Debug, Clone, Serialize)]
137pub struct RunReport {
138    pub seed: u64,
139    pub outcomes: Vec<TestOutcome>,
140    pub passed: usize,
141    pub failed: usize,
142    pub skipped: usize,
143    /// How many tests exercised a `For all` property (adversarial evidence).
144    pub properties: usize,
145    /// How many properties were PROVED by exhaustion (finite domains, `--prove`).
146    pub proven: usize,
147    /// The page tiers this run was CONFIGURED to check a mounted page against
148    /// (`config.page_tiers`, `Ast` excluded — that one is the reference, never a "candidate"),
149    /// labeled (`PageTierKind::label()`). `render_tier_summary` names them so a green `largo test
150    /// --browser` cannot claim to have checked a tier it never launched, the way `vm=on` alone
151    /// could not distinguish "the lowered VM only" from "the lowered VM AND a real Chrome."
152    pub page_tiers: Vec<String>,
153}
154
155/// The earned rank of a run — a
156/// ladder computed from EVIDENCE, never self-declared. The higher rungs
157/// (COVERED/HARDENED/PROVEN/BATTLE-READY) require coverage, mutation, proofs,
158/// and the full inspection, which light up as those waves land; the runner
159/// computes the base rungs honestly today.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
161#[serde(rename_all = "snake_case")]
162pub enum Rank {
163    /// It compiles. Nothing is tested.
164    Untried,
165    /// A test failed — no rank is earned.
166    Failing,
167    /// All tests pass; deterministic; zero unbudgeted debts.
168    Green,
169    /// Green + at least one `For all` property held over its generated cases.
170    Hardened,
171    /// Hardened + at least one property PROVED by exhaustion (a certificate,
172    /// not a sample).
173    Proven,
174}
175
176impl Rank {
177    pub fn label(self) -> &'static str {
178        match self {
179            Rank::Untried => "UNTRIED",
180            Rank::Failing => "FAILING",
181            Rank::Green => "GREEN",
182            Rank::Hardened => "HARDENED",
183            Rank::Proven => "PROVEN",
184        }
185    }
186}
187
188impl RunReport {
189    /// True when the run is green: no failures AND no unbudgeted skips
190    /// (the caller applies the skip budget — the runner only counts).
191    pub fn all_passed(&self) -> bool {
192        self.failed == 0
193    }
194
195    /// How many tests the compiled tier REFUSED under `--tiers all` — derived
196    /// from the per-test evidence, so the count can never drift from it.
197    pub fn tier_refused(&self) -> usize {
198        self.outcomes
199            .iter()
200            .filter(|o| matches!(o.tier, Some(TierEvidence::Refused { .. })))
201            .count()
202    }
203
204    /// The earned rank, from the run's evidence.
205    pub fn rank(&self) -> Rank {
206        if self.outcomes.is_empty() {
207            Rank::Untried
208        } else if self.failed > 0 {
209            Rank::Failing
210        } else if self.proven > 0 {
211            Rank::Proven
212        } else if self.properties > 0 {
213            Rank::Hardened
214        } else {
215            Rank::Green
216        }
217    }
218}
219
220/// Render the fitness report (§1.12) — the last block of every run: the earned
221/// rank and the evidence behind it, with the next rung named.
222pub fn render_fitness(report: &RunReport) -> String {
223    let rank = report.rank();
224    let next = match rank {
225        Rank::Untried => "write a `## Test` — nothing is checked yet.",
226        Rank::Failing => "fix the failing test(s) to reach GREEN.",
227        Rank::Green => "add a `For all` property to reach HARDENED.",
228        Rank::Hardened => "run with `--prove` to certify a finite-domain property → PROVEN.",
229        Rank::Proven => "COVERED/BATTLE-READY need coverage, mutation, and the full `--inspect`.",
230    };
231    format!(
232        "════════════════════ FITNESS REPORT ════════════════════\n\
233         \x20RANK: {}\n\
234         \x20tests {} · properties {} (proven {}) · passed {} · failed {} · skipped {}\n\
235         \x20Next rank: {}\n\
236         ═════════════════════════════════════════════════════════\n",
237        rank.label(),
238        report.outcomes.len(),
239        report.properties,
240        report.proven,
241        report.passed,
242        report.failed,
243        report.skipped,
244        next,
245    )
246}
247
248/// Runner configuration.
249#[derive(Debug, Clone)]
250pub struct TestConfig {
251    /// Substring filter over prose names (None = all).
252    pub filter: Option<String>,
253    /// The suite seed (default 0 — reproducibility is the default).
254    pub seed: u64,
255    /// The default deterministic op budget per test; a test's
256    /// `This test may run up to N steps.` sentence overrides it.
257    pub default_budget: u64,
258    /// Run capability-granted (real-resource) tests too. Off by default —
259    /// live tests leave the no-flake guarantee and are counted separately.
260    pub live: bool,
261    /// `--prove`: escalate finite-domain properties to exhaustive proof.
262    pub prove: bool,
263    /// `--tiers all`: re-run each passing test's COMPUTATION on the bytecode VM
264    /// and COMPARE — outcome class, FULL output byte-for-byte, and error text
265    /// (a mismatch fails the test as a TIER DIVERGENCE); a computation the VM
266    /// cannot run is an explicit `tier: refused (…)` failure with
267    /// machine-readable [`TierEvidence`] — the user-facing F18
268    /// "works-on-my-tier" guard (Wave 13), honest by construction.
269    pub all_tiers: bool,
270    /// Which page tiers a MOUNTED test also runs on and must agree with the reference (the page's
271    /// lowered LOGOS on the tree-walker) — every candidate here beyond
272    /// [`crate::page_host::PageTierKind::LoweredHere`] runs
273    /// the test's whole trace through `page_host` and compares it against the reference axis by
274    /// axis (`page_host::PageTrace::compare`), unconditionally, on every `largo test` run: "what
275    /// ships is not what is tested" is caught for free, not only under `--tiers all`. Defaults to
276    /// `[LoweredHere, LoweredVm]` — every mounted test also runs on the page's lowered LOGOS on the
277    /// bytecode VM, the tier a browser actually runs. A `Browser` candidate here is exercised
278    /// only when the caller hands [`run_tests_with_launcher`] a live launcher; without one it is
279    /// reported as `tier: refused` rather than silently skipped.
280    pub page_tiers: Vec<crate::page_host::PageTierKind>,
281}
282
283/// The candidate page tiers `config` checks a mounted page against, labeled — `LoweredHere`
284/// excluded (it is the reference every candidate is compared to, never a candidate itself). Shared by both
285/// [`RunReport`] construction sites so the label set can never drift between them.
286fn page_tier_labels(config: &TestConfig) -> Vec<String> {
287    config
288        .page_tiers
289        .iter()
290        .filter(|k| **k != crate::page_host::PageTierKind::LoweredHere)
291        .map(|k| k.label().to_lowercase().replace(' ', "-"))
292        .collect()
293}
294
295impl Default for TestConfig {
296    fn default() -> Self {
297        TestConfig {
298            filter: None,
299            seed: 0,
300            default_budget: 500_000_000,
301            live: false,
302            prove: false,
303            all_tiers: false,
304            page_tiers: vec![
305                crate::page_host::PageTierKind::LoweredHere,
306                crate::page_host::PageTierKind::LoweredVm,
307            ],
308        }
309    }
310}
311
312/// Compare one test computation's run on the interpreter against its run on
313/// the bytecode VM — the `--tiers all` comparison, PURE so it is unit-lockable
314/// with synthetic divergent inputs. Both outputs are newline-joined lines (the
315/// shared engine convention), compared byte-for-byte in FULL; error text is
316/// compared exactly; the outcome class (clean vs errored) falls out of the
317/// error comparison. Returns `None` on agreement, or the divergence bundle.
318pub fn tier_divergence(
319    interp_output: &str,
320    interp_error: Option<&str>,
321    vm_output: &str,
322    vm_error: Option<&str>,
323) -> Option<String> {
324    let outputs_agree = interp_output == vm_output;
325    let errors_agree = interp_error == vm_error;
326    if outputs_agree && errors_agree {
327        return None;
328    }
329    let mut axes = Vec::new();
330    if interp_error.is_some() != vm_error.is_some() {
331        axes.push("outcome class (one engine errored, the other ran clean)");
332    } else if !errors_agree {
333        axes.push("error text");
334    }
335    if !outputs_agree {
336        axes.push("output");
337    }
338    Some(format!(
339        "TIER DIVERGENCE (compiler bug — F18): this test passes on the interpreter, but \
340         re-running its computation on the bytecode VM disagreed on: {}.\n\
341         interpreter: output={:?} error={:?}\n\
342         bytecode VM: output={:?} error={:?}",
343        axes.join(", "),
344        interp_output,
345        interp_error,
346        vm_output,
347        vm_error,
348    ))
349}
350
351/// Render a case-table cell for its sub-test's name (`[a=99, b=1]`): literal
352/// cells render exactly; computed cells render as an ellipsis.
353fn render_cell(
354    cell: &logicaffeine_language::ast::stmt::Expr<'_>,
355    interner: &crate::intern::Interner,
356) -> String {
357    use logicaffeine_language::ast::stmt::{Expr, Literal};
358    match cell {
359        Expr::Literal(Literal::Number(n)) => n.to_string(),
360        Expr::Literal(Literal::Float(f)) => f.to_string(),
361        Expr::Literal(Literal::Boolean(b)) => b.to_string(),
362        Expr::Literal(Literal::Text(s)) => format!("{:?}", interner.resolve(*s)),
363        _ => "…".to_string(),
364    }
365}
366
367/// What one test's authoritative tree-walker run hands back out of its session.
368struct SessionRun {
369    /// The run's own outcome — `None` when it needs an async runtime this target cannot drive.
370    result: Option<Result<(), String>>,
371    /// Every recorded expectation failure.
372    failures: Vec<String>,
373    assertions: u64,
374    /// Properties this run proved by exhaustion.
375    proven: u64,
376    ops: u64,
377}
378
379/// Discover and execute every `## Test` in `source`. Each test runs in a
380/// fresh interpreter world; the report is deterministic and name-ordered.
381///
382/// Returns `Err` only for program-level problems (parse errors); an individual
383/// test failing is a `Failed` outcome, never an `Err`.
384pub fn run_tests(source: &str, config: &TestConfig) -> Result<RunReport, String> {
385    run_tests_with_launcher(source, config, None)
386}
387
388/// [`run_tests`], with a live [`crate::page_host::RemotePageLauncher`] so a MOUNTED test's
389/// [`TestConfig::page_tiers`] can also exercise a `Browser` candidate — the seam `largo test
390/// --browser` calls through once a real one exists (`apps/logicaffeine_cli/src/browser/**`).
391/// `None` reports a `Browser` candidate as `tier: refused`, never a silent skip.
392pub fn run_tests_with_launcher(
393    source: &str,
394    config: &TestConfig,
395    launcher: Option<&dyn crate::page_host::RemotePageLauncher>,
396) -> Result<RunReport, String> {
397    use logicaffeine_language::ast::stmt::Stmt;
398
399    // `## Main` is the PROGRAM's entry point, never test fixture: strip it
400    // before parsing so no test's world ever executes it. A `Serve` entry must
401    // not bind its port (or block forever) while pure tests run, and a Main
402    // `Show` must not pollute a byte-exact `Expect output:`. Definitions
403    // (`## To`, types, theorems) all survive — only the Main block goes.
404    let source = crate::analysis::assemble::strip_main_block(source);
405    if source.trim().is_empty() {
406        // Nothing but a Main (or nothing at all): no tests can exist, and an
407        // empty unit has no parse errors to report.
408        return Ok(RunReport {
409            seed: config.seed,
410            outcomes: Vec::new(),
411            passed: 0,
412            failed: 0,
413            skipped: 0,
414            properties: 0,
415            proven: 0,
416            page_tiers: page_tier_labels(config),
417        });
418    }
419
420    crate::ui_bridge::with_parsed_program(&source, |parsed, interner| {
421        let (stmts, types, policies) = match parsed {
422            Ok(t) => t,
423            Err(advice) => return Err(advice),
424        };
425
426        // The shared world every test starts from: everything EXCEPT the
427        // tests themselves (functions, types, theorems — the program's defs).
428        let defs: Vec<Stmt> = stmts
429            .iter()
430            .filter(|s| !matches!(s, Stmt::TestDef { .. }))
431            .cloned()
432            .collect();
433
434        // Discover, filter, and name-sort the tests (report order is part of
435        // determinism — never completion order).
436        let mut tests: Vec<&Stmt> = stmts
437            .iter()
438            .filter(|s| matches!(s, Stmt::TestDef { .. }))
439            .collect();
440        tests.sort_by_key(|s| match s {
441            Stmt::TestDef { name, .. } => interner.resolve(*name).to_string(),
442            _ => unreachable!("filtered to TestDef above"),
443        });
444
445        // Properties proved by exhaustion across the whole run (`--prove`).
446        let proven_total = std::cell::Cell::new(0usize);
447
448        // Execute one program (shared defs + a body) in a FRESH interpreter
449        // AND a fresh sealed world (§1.7) — state can never leak between
450        // tests or between table rows, and no unrecorded entropy exists.
451        let execute = |program: &[Stmt],
452                       body: &[Stmt],
453                       name: String,
454                       budget: u64,
455                       caps: logicaffeine_system::testworld::Capabilities,
456                       property: Option<PropertyInfo>,
457                       mounted: bool|
458         -> TestOutcome {
459            // Identity-keyed world seed: adding or reordering tests never
460            // shifts another test's stream (constitution D1.5).
461            let mut h: u64 = 0xCBF2_9CE4_8422_2325 ^ config.seed;
462            for b in name.bytes() {
463                h ^= u64::from(b);
464                h = h.wrapping_mul(0x0000_0100_0000_01B3);
465            }
466            // A page test's world is more than the clock/entropy/env/fs `testworld` seals: the
467            // document, its route, the journal, and the browser storage areas (local/session/shared)
468            // are their own thread-locals (`semantics::dom`, `semantics::world`) that a fresh
469            // `Interpreter` does not touch. Reset them too, exactly as `page_host::World::enter`
470            // does, or a second test in the same file inherits the first one's page — and, for
471            // `shared on`, the first one's TOPIC. A `## Page` boots at its own declared URL, not the
472            // reset default `"/"` — `program` already carries the page's
473            // `ComponentDef` and its auto-inserted `Mount`, so `routePath()` reads the real one from
474            // this test's very first statement, on the tree-walker exactly as `page_host::Scenario`
475            // already seeds it for the lowered VM and browser tiers.
476            // The run is a tree-walker SESSION: its world, its program and everything read back
477            // from it happen on one thread whose stack holds the full call depth
478            // (`interpreter::on_session_stack`), and only plain data comes back.
479            let run = crate::interpreter::on_session_stack(|| {
480                crate::semantics::dom::reset();
481                crate::page_host::boot_at_declared_route(program, interner);
482                crate::semantics::drive::reset_clock();
483                crate::semantics::world::reset();
484                crate::concurrency::net_inbox::set_net_offline(true);
485                logicaffeine_system::testworld::install(
486                    logicaffeine_system::testworld::TestWorld::new(caps, h),
487                );
488                let mut interp = crate::interpreter::Interpreter::new(interner)
489                    .with_type_registry(types)
490                    .with_policies(policies.clone());
491                // Hermetic tests get the sealed in-memory filesystem (§1.7): the
492                // Vfs-backed `Read`/`Write` statements and `Given a file …` share
493                // one source of truth. A filesystem-capability test gets real disk.
494                if !caps.filesystem {
495                    interp = interp
496                        .with_vfs(std::sync::Arc::new(logicaffeine_system::fs::MemVfs::new()));
497                }
498                interp.enable_test_mode(budget);
499                interp.set_prove_mode(config.prove);
500                // Property generation is keyed by test identity (the same `h`), so a
501                // counterexample is stable across runs and independent of test order.
502                interp.set_gen_seed(h ^ 0x5DEE_CE66_1234_ABCD);
503                // wasm32 has no thread to park on: a browser cannot `block_on` a future the way
504                // the host can. Poll it once instead — a sealed world answers sleep, the clock and
505                // files synchronously, so a computation that only needs THOSE resolves on that one
506                // poll; one that genuinely needs the async runtime (real I/O, a live server) does
507                // not, and is reported Skipped rather than hung or falsely passed.
508                #[cfg(target_arch = "wasm32")]
509                let result = if crate::interpreter::needs_async(program, interner) {
510                    futures::FutureExt::now_or_never(interp.run(program))
511                } else {
512                    Some(interp.run_sync(program))
513                };
514                #[cfg(not(target_arch = "wasm32"))]
515                let result = Some(if crate::interpreter::needs_async(program, interner) {
516                    futures::executor::block_on(interp.run(program))
517                } else {
518                    interp.run_sync(program)
519                });
520                logicaffeine_system::testworld::uninstall();
521                SessionRun {
522                    result,
523                    failures: interp.take_test_failures(),
524                    assertions: interp.test_assertions(),
525                    proven: interp.properties_proven(),
526                    ops: interp.test_ops(),
527                }
528            });
529            let Some(run_result) = run.result else {
530                return TestOutcome {
531                    name,
532                    verdict: Verdict::Skipped,
533                    failures: Vec::new(),
534                    skip_reason: Some(
535                        "needs the async runtime, which the in-browser runner does not drive"
536                            .to_string(),
537                    ),
538                    ops: 0,
539                    vacuous: false,
540                    mounted,
541                    property,
542                    proved_by_exhaustion: false,
543                    tier: None,
544                };
545            };
546            proven_total.set(proven_total.get() + run.proven as usize);
547            let assertions = run.assertions;
548            let mut failures = run.failures;
549            if let Err(e) = run_result {
550                failures.push(e);
551            }
552            // G13 non-vacuity: a test that RAN CLEAN but asserted NOTHING (no
553            // `Expect`/`For all`) is vacuous — a green that checked nothing. Fail
554            // it loudly rather than count false confidence. (A run that already
555            // errored keeps its real failure; a test isn't reached here when
556            // skipped.)
557            let vacuous = assertions == 0 && failures.is_empty();
558            if vacuous {
559                failures.push(
560                    "vacuous test: it ran without a single assertion — add an `Expect …` \
561                     or a `For all …` property (a test that checks nothing cannot pass)"
562                        .to_string(),
563                );
564            }
565            // Whether THIS test's finite-domain property was proved by
566            // exhaustion (booleans under `--prove`): the interpreter's
567            // per-run counter rose. Read before the tier replay, which does
568            // not re-run properties.
569            let proved_by_exhaustion = run.proven > 0;
570            // `--tiers all` (Wave 13, footgun F18): a test that passes on the
571            // interpreter must also RUN on the bytecode VM and AGREE — outcome
572            // class, FULL output byte-for-byte, and error text. The assertions
573            // stay the interpreter's authoritative verdict; both engines re-run
574            // the COMPUTATION with the test-only statements no-op'd (the VM's
575            // `compile_tiers` profile and the interpreter's `tiers_noop`
576            // mirror) in fresh, identically-seeded sealed worlds — two engines,
577            // one program. A computation the VM cannot run is an explicit typed
578            // refusal — never a pass, never a silent skip.
579            let mut tier: Option<TierEvidence> = None;
580            if config.all_tiers && failures.is_empty() && !mounted {
581                if crate::interpreter::needs_async(program, interner) {
582                    tier = Some(TierEvidence::Refused {
583                        reason: "the computation needs the async runtime (file I/O, sleep, \
584                                 serve, or networking) — the bytecode VM is sync-only"
585                            .to_string(),
586                    });
587                } else if crate::concurrency::uses_scheduler(program) {
588                    tier = Some(TierEvidence::Refused {
589                        reason: "the computation uses tasks/channels — the plain bytecode VM \
590                                 has no scheduler driver"
591                            .to_string(),
592                    });
593                } else {
594                    // Interpreter replay of the bare computation, in a fresh
595                    // world sealed with the SAME identity-keyed seed, as a session of its own.
596                    let (replay_out, replay_err) = crate::interpreter::on_session_stack(|| {
597                        crate::concurrency::net_inbox::set_net_offline(true);
598                        logicaffeine_system::testworld::install(
599                            logicaffeine_system::testworld::TestWorld::new(caps, h),
600                        );
601                        let mut replay = crate::interpreter::Interpreter::new(interner)
602                            .with_type_registry(types)
603                            .with_policies(policies.clone());
604                        if !caps.filesystem {
605                            replay = replay.with_vfs(std::sync::Arc::new(
606                                logicaffeine_system::fs::MemVfs::new(),
607                            ));
608                        }
609                        replay.enable_test_mode(budget);
610                        replay.set_tiers_noop(true);
611                        let replay_err = replay.run_sync(program).err();
612                        logicaffeine_system::testworld::uninstall();
613                        (replay.output.join("\n"), replay_err)
614                    });
615
616                    // The VM run of the SAME computation, in its own fresh
617                    // world sealed with the same seed (entropy streams align).
618                    crate::concurrency::net_inbox::set_net_offline(true);
619                    logicaffeine_system::testworld::install(
620                        logicaffeine_system::testworld::TestWorld::new(caps, h),
621                    );
622                    let vm_result = crate::vm::run_tiers_strict(
623                        program,
624                        interner,
625                        Some(types),
626                        Some(&policies),
627                    );
628                    logicaffeine_system::testworld::uninstall();
629
630                    tier = Some(match vm_result {
631                        Err(reason) => TierEvidence::Refused { reason },
632                        Ok((vm_out, vm_err)) => match tier_divergence(
633                            &replay_out,
634                            replay_err.as_deref(),
635                            &vm_out,
636                            vm_err.as_deref(),
637                        ) {
638                            None => TierEvidence::Agreed,
639                            Some(detail) => {
640                                failures.push(detail.clone());
641                                TierEvidence::Diverged {
642                                    detail,
643                                    statement: None,
644                                    line: None,
645                                }
646                            }
647                        },
648                    });
649                }
650                if let Some(TierEvidence::Refused { reason }) = &tier {
651                    failures.push(format!("tier: refused ({reason})"));
652                }
653            }
654            // A MOUNTED test also runs, statement by statement, on every candidate of
655            // `config.page_tiers` beyond the reference (`LoweredHere`) and must trace identically — the
656            // `largo test` "what ships is not what is tested" guard, unconditional (not gated on
657            // `--tiers all`: a page test's cross-tier agreement is not opt-in).
658            if mounted && failures.is_empty() {
659                let candidates: Vec<crate::page_host::PageTierKind> = config
660                    .page_tiers
661                    .iter()
662                    .copied()
663                    .filter(|k| *k != crate::page_host::PageTierKind::LoweredHere)
664                    .collect();
665                if !candidates.is_empty() {
666                    let world = crate::page_host::World::for_test(&name, config.seed, caps, budget);
667                    let scenario = crate::page_host::Scenario::new(
668                        interner, types, &policies, &defs, body, world,
669                    );
670                    let reference = scenario.reference_trace();
671                    for kind in candidates {
672                        let evidence = match scenario.run(kind, launcher, &source) {
673                            crate::page_host::TierRun::Refused { reason, .. } => {
674                                TierEvidence::Refused { reason }
675                            }
676                            crate::page_host::TierRun::Traced(t) => {
677                                // The journal axis is excluded. R3 has
678                                // landed: an ordinary turn — an action, a real-listener `When …:`,
679                                // a bound write — notes itself on the lowered tier exactly where
680                                // the reference commits it, and agrees. What is left is narrower
681                                // but still real: a TRAPPING turn never reaches its own write-back
682                                // (rollback by construction skips everything after the trap, and
683                                // `noteTurn` is placed there, matching the reference's own
684                                // post-body `journal_push`), so the reference's `failed: Some(…)`
685                                // entry has nothing to agree with on the lowered tier. Closing that
686                                // needs whoever catches the trap (`page_host::lowered`'s guarded
687                                // door, not this LOGOS-compiled body) to note the turn itself — a
688                                // known, already-ledgered gap (`lgx_known_divergences.txt`'s
689                                // remaining `journal @ …` rows), not a per-project regression to
690                                // punish here.
691                                let axes: Vec<_> = reference
692                                    .compare(&t)
693                                    .axes
694                                    .into_iter()
695                                    .filter(|a| a.axis != crate::page_host::Axis::Journal)
696                                    .collect();
697                                if axes.is_empty() {
698                                    TierEvidence::Agreed
699                                } else {
700                                    let divergence = crate::page_host::Divergence { axes };
701                                    TierEvidence::Diverged {
702                                        detail: divergence.render(),
703                                        statement: divergence.first_statement(),
704                                        line: None,
705                                    }
706                                }
707                            }
708                        };
709                        match &evidence {
710                            TierEvidence::Refused { reason } => {
711                                failures
712                                    .push(format!("tier: refused on {} ({reason})", kind.label()));
713                            }
714                            TierEvidence::Diverged { detail, .. } => {
715                                failures
716                                    .push(format!("TIER DIVERGENCE on {}: {detail}", kind.label()));
717                            }
718                            TierEvidence::Agreed => {}
719                        }
720                        let agreed = matches!(evidence, TierEvidence::Agreed);
721                        tier = Some(evidence);
722                        if !agreed {
723                            break;
724                        }
725                    }
726                }
727            }
728            let verdict = if failures.is_empty() {
729                Verdict::Passed
730            } else {
731                Verdict::Failed
732            };
733            TestOutcome {
734                name,
735                verdict,
736                failures,
737                skip_reason: None,
738                ops: run.ops,
739                vacuous,
740                mounted,
741                property,
742                proved_by_exhaustion,
743                tier,
744            }
745        };
746
747        let mut outcomes: Vec<TestOutcome> = Vec::new();
748        let mut properties = 0usize;
749        for test in tests {
750            let Stmt::TestDef {
751                name,
752                skip,
753                budget,
754                cases,
755                body,
756                capabilities,
757            } = test
758            else {
759                unreachable!("filtered to TestDef above")
760            };
761            // The first `For all` property in the body classifies the test as a
762            // property (for the inventory) and discloses its finite bound. A test
763            // body carries at most one property in practice; the first is its
764            // identity.
765            // Whether this test MOUNTS a page — the honest signal that it drives a document
766            // rather than checking a value: an explicit `Mount` in the body (how a bare
767            // `## Component` test is driven), OR `defs` auto-mounting a `## Page` (how every
768            // page's OWN test is driven — a page "mounts itself", so nothing in the body ever
769            // spells `Mount`, but the cross-tier check below is exactly as owed to it).
770            let mounted = body
771                .iter()
772                .any(|s| matches!(s, Stmt::Ui(crate::ast::stmt::UiStmt::Mount { .. })))
773                || crate::page_host::defs_auto_mount_a_page(&defs);
774            let property: Option<PropertyInfo> = body.iter().find_map(|s| match s {
775                Stmt::ForAll { vars, domain, .. } => Some(PropertyInfo {
776                    domain: match domain {
777                        crate::ast::stmt::PropDomain::Integers => PropertyDomain::Integers,
778                        crate::ast::stmt::PropDomain::Naturals => PropertyDomain::Naturals,
779                        crate::ast::stmt::PropDomain::Booleans => PropertyDomain::Booleans,
780                    },
781                    vars: vars.len(),
782                }),
783                _ => None,
784            });
785            if property.is_some() {
786                properties += 1;
787            }
788            let caps = logicaffeine_system::testworld::Capabilities {
789                filesystem: capabilities.filesystem,
790                network: capabilities.network,
791                environment: capabilities.environment,
792                clock: capabilities.clock,
793            };
794            let is_live = caps.filesystem || caps.network || caps.environment || caps.clock;
795            let name_s = interner.resolve(*name).to_string();
796            if let Some(f) = &config.filter {
797                if !name_s.contains(f.as_str()) {
798                    continue;
799                }
800            }
801            if let Some(reason) = skip {
802                outcomes.push(TestOutcome {
803                    name: name_s,
804                    verdict: Verdict::Skipped,
805                    failures: Vec::new(),
806                    skip_reason: Some(interner.resolve(*reason).to_string()),
807                    ops: 0,
808                    vacuous: false,
809                    mounted,
810                    property,
811                    proved_by_exhaustion: false,
812                    tier: None,
813                });
814                continue;
815            }
816            if is_live && !config.live {
817                // Capability-granted tests leave the hermetic guarantee and
818                // run only under `--live` — reported loudly, never silently.
819                outcomes.push(TestOutcome {
820                    name: name_s,
821                    verdict: Verdict::Skipped,
822                    failures: Vec::new(),
823                    skip_reason: Some(
824                        "uses a real resource — runs only under `largo test --live`".to_string(),
825                    ),
826                    ops: 0,
827                    vacuous: false,
828                    mounted,
829                    property,
830                    proved_by_exhaustion: false,
831                    tier: None,
832                });
833                continue;
834            }
835            let budget = budget.unwrap_or(config.default_budget);
836
837            // A `## Page` mounts itself, which the parser writes as a literal `Mount` among
838            // `defs` — placed there for the PROGRAM, which runs before any test statement. A test
839            // that arranges the scene (`Given that … answers`, `Given a file`, `Serve fetches with
840            // routes`) needs that mount moved to just after the scene, exactly as `page_host`
841            // drives it (`page_scene_lock`): otherwise a page that fetches `When the page loads`
842            // already asked before its test could answer. (`Reload the page.` mounts the page
843            // again by itself.) A no-op for anything that is not a page (nothing to extract, so
844            // `arrange` returns `defs`/`body` unchanged).
845            let arranged = crate::page_host::arrange(&defs, body);
846            match cases {
847                None => {
848                    let mut program: Vec<Stmt> = arranged.defs.clone();
849                    program.extend(arranged.body.iter().cloned());
850                    outcomes.push(execute(
851                        &program, body, name_s, budget, caps, property, mounted,
852                    ));
853                }
854                Some(table) => {
855                    // Each data row is a NAMED sub-test in table order: the
856                    // columns bind as fresh `Let`s ahead of the body.
857                    for row in &table.rows {
858                        let mut program: Vec<Stmt> = arranged.defs.clone();
859                        let mut bindings: Vec<String> = Vec::new();
860                        for (column, cell) in table.columns.iter().zip(row.iter()) {
861                            bindings.push(format!(
862                                "{}={}",
863                                interner.resolve(*column),
864                                render_cell(cell, interner)
865                            ));
866                            program.push(Stmt::Let {
867                                var: *column,
868                                ty: None,
869                                value: cell,
870                                mutable: false,
871                            });
872                        }
873                        program.extend(arranged.body.iter().cloned());
874                        let sub_name = format!("{name_s} [{}]", bindings.join(", "));
875                        outcomes.push(execute(
876                            &program, body, sub_name, budget, caps, property, mounted,
877                        ));
878                    }
879                }
880            }
881        }
882
883        // G13: a `--filter` that matched ZERO tests is a mistake (a typo'd name
884        // silently "passing" is exactly the false-green we forbid), never a green
885        // empty run. Zero tests WITHOUT a filter is a legitimate no-test file and
886        // stays an empty report (the CLI decides its own policy there).
887        if let Some(f) = &config.filter {
888            if outcomes.is_empty() {
889                // Wording aligns with the CLI's own zero-match message so the
890                // `closure_lock` gate (which asserts "matched none") passes
891                // whichever layer surfaces it — one message, no drift.
892                return Err(format!("filter {f:?} matched none of the discovered tests"));
893            }
894        }
895
896        let passed = outcomes
897            .iter()
898            .filter(|o| o.verdict == Verdict::Passed)
899            .count();
900        let failed = outcomes
901            .iter()
902            .filter(|o| o.verdict == Verdict::Failed)
903            .count();
904        let skipped = outcomes
905            .iter()
906            .filter(|o| o.verdict == Verdict::Skipped)
907            .count();
908        Ok(RunReport {
909            seed: config.seed,
910            outcomes,
911            passed,
912            failed,
913            skipped,
914            properties,
915            proven: proven_total.get(),
916            page_tiers: page_tier_labels(config),
917        })
918    })
919}
920
921/// Render the human report — deterministic, golden-locked (`test_runner_lock`).
922pub fn render_report(report: &RunReport) -> String {
923    let mut out = String::new();
924    for o in &report.outcomes {
925        match o.verdict {
926            Verdict::Passed => {
927                out.push_str(&format!("PASS  {}\n", o.name));
928            }
929            Verdict::Skipped => {
930                let reason = o.skip_reason.as_deref().unwrap_or("");
931                out.push_str(&format!("SKIP  {} — {}\n", o.name, reason));
932            }
933            Verdict::Failed => {
934                out.push_str(&format!("FAIL  {}\n", o.name));
935                for f in &o.failures {
936                    // A failure may be a multi-line power-assert; indent every
937                    // line by 6 so the block sits cleanly under its test.
938                    for line in f.lines() {
939                        out.push_str(&format!("      {line}\n"));
940                    }
941                }
942            }
943        }
944    }
945    let refused = report.tier_refused();
946    if refused > 0 {
947        out.push_str(&format!(
948            "\n{} passed · {} failed · {} skipped · {} tier-refused · seed {}\n",
949            report.passed, report.failed, report.skipped, refused, report.seed
950        ));
951    } else {
952        out.push_str(&format!(
953            "\n{} passed · {} failed · {} skipped · seed {}\n",
954            report.passed, report.failed, report.skipped, report.seed
955        ));
956    }
957    out
958}
959
960/// A machine-readable census of which EXECUTION TIERS a `largo test` run actually
961/// exercised — the runner's answer to G14's "a green run must be legible about what
962/// it proved". The tree-walking interpreter is the always-on reference tier (every
963/// `## Test` runs on it); the bytecode VM tier is a cross-tier VALIDATOR that only
964/// runs under `--tiers all`, and when it does, each test's [`TierEvidence`] says
965/// whether the VM agreed, refused, or diverged. Derived purely from the report, so
966/// the census can never drift from the evidence it summarizes.
967#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
968pub struct TierSummary {
969    /// Tests that ran on the tree-walking interpreter (the reference oracle) — the
970    /// whole non-skipped suite, always.
971    pub interpreter: usize,
972    /// Was the bytecode-VM cross-tier validator turned on for this run?
973    pub vm_validator_ran: bool,
974    /// VM agreed byte-for-byte (`--tiers all`).
975    pub vm_agreed: usize,
976    /// VM could not run the computation and REFUSED (`--tiers all`) — reported as a
977    /// `tier: refused (…)` failure, never a silent skip.
978    pub vm_refused: usize,
979    /// VM ran and DISAGREED (`--tiers all`) — always a compiler bug.
980    pub vm_diverged: usize,
981}
982
983impl RunReport {
984    /// The execution-tier census for this run (see [`TierSummary`]).
985    pub fn tier_summary(&self) -> TierSummary {
986        let ran = |pred: fn(&TierEvidence) -> bool| {
987            self.outcomes
988                .iter()
989                .filter(|o| o.tier.as_ref().is_some_and(pred))
990                .count()
991        };
992        let vm_agreed = ran(|e| matches!(e, TierEvidence::Agreed));
993        let vm_refused = ran(|e| matches!(e, TierEvidence::Refused { .. }));
994        let vm_diverged = ran(|e| matches!(e, TierEvidence::Diverged { .. }));
995        TierSummary {
996            // Every executed (non-skipped) test ran on the interpreter oracle.
997            interpreter: self
998                .outcomes
999                .iter()
1000                .filter(|o| o.verdict != Verdict::Skipped)
1001                .count(),
1002            vm_validator_ran: self.outcomes.iter().any(|o| o.tier.is_some()),
1003            vm_agreed,
1004            vm_refused,
1005            vm_diverged,
1006        }
1007    }
1008}
1009
1010/// Render the machine-readable tier census as ONE greppable line, mirroring the
1011/// `LOGOS_VALIDATORS_RAN:` convention the Rust suite uses for external validators.
1012/// A CI/release gate greps `LOGOS_TIERS_RAN:` to see, at a glance, which execution
1013/// tiers a green `largo test` actually exercised — the interpreter always, the VM
1014/// cross-tier validator only under `--tiers all`. When the VM validator was OFF, the
1015/// line says so explicitly (`vm=off`), so a fast default run never masquerades as
1016/// having proven cross-tier agreement it never ran.
1017///
1018/// A MOUNTED page test's own cross-tier check (G1: this is not gated on `--tiers
1019/// all`, and since `mounted` now recognizes a `## Page`'s own auto-mount and not
1020/// only a literal `Mount` statement, this line covers it too) additionally names
1021/// WHICH candidate page tiers actually ran, appending `page_tiers=<a,b,…>` — so a
1022/// green `largo test --browser` cannot read the same as a green plain `largo test`
1023/// when the difference is exactly whether a real Chrome was ever launched.
1024pub fn render_tier_summary(report: &RunReport) -> String {
1025    let s = report.tier_summary();
1026    let mut line = if s.vm_validator_ran {
1027        format!(
1028            "LOGOS_TIERS_RAN: interpreter={} vm=on vm_agreed={} vm_refused={} vm_diverged={}",
1029            s.interpreter, s.vm_agreed, s.vm_refused, s.vm_diverged
1030        )
1031    } else {
1032        format!("LOGOS_TIERS_RAN: interpreter={} vm=off", s.interpreter)
1033    };
1034    let page_tiers_ran = report
1035        .outcomes
1036        .iter()
1037        .any(|o| o.mounted && o.tier.is_some());
1038    if page_tiers_ran && !report.page_tiers.is_empty() {
1039        line.push_str(&format!(" page_tiers={}", report.page_tiers.join(",")));
1040    }
1041    line.push('\n');
1042    line
1043}
1044
1045/// Render the machine report (`--format json`).
1046pub fn render_report_json(report: &RunReport) -> String {
1047    serde_json::to_string_pretty(report).expect("a run report always serializes")
1048}
1049
1050// ============================================================
1051// G13-full — the machine-readable proof/test INVENTORY.
1052// ============================================================
1053//
1054// ONE structured document classifies EVERY claim a project makes by KIND, with an
1055// honest per-item status. It is a REPORTING layer over the existing outcomes — the
1056// runner's [`RunReport`] (examples + finite-enumeration properties) and the CLI's
1057// prove report (theorems + admitted axioms) — never a re-prover. The honesty
1058// invariants it enforces:
1059//
1060//   * A finite-enumeration property or theorem is marked `finite_enumeration` and
1061//     carries its disclosed `domain_bound`; it is NEVER `proof: true` (an
1062//     unrestricted mathematical proof). A green enumeration never silently reads
1063//     as an unbounded proof.
1064//   * An axiom / admitted assumption is `proof: false`, status `assumption` — a
1065//     premise, never a proven fact.
1066//   * A theorem/axiom NAME COLLISION is rejected upstream (see
1067//     [`Inventory::from_prove`]), so an axiom can never masquerade as a theorem.
1068//   * The counts are internally consistent:
1069//     examples + properties + theorems + axioms + admitted == the item total, and
1070//     the `proven` set ⊆ properties + theorems.
1071
1072/// The KIND of a claim — the top-level partition the inventory reports over.
1073#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1074#[serde(rename_all = "snake_case")]
1075pub enum ClaimKind {
1076    /// A `## Test`/`## Example` block asserted with `Expect`/`ExpectOutput`/
1077    /// `ExpectFail` — finite testing of concrete cases (never a proof).
1078    Example,
1079    /// A `For all <domain> …:` property test — finite enumeration/sampling over
1080    /// a disclosed domain (a proof ONLY when the domain is finite AND exhausted).
1081    Property,
1082    /// A `## Theorem`/`Theorem …` claim discharged by the proof engine.
1083    Theorem,
1084    /// A `## Axiom`/`Axiom …` — an ASSUMPTION admitted into the premise base,
1085    /// never proved.
1086    Axiom,
1087    /// Anything the proof engine ADMITS rather than proves (an admitted lemma).
1088    AdmittedAssumption,
1089    /// ★ A COMPONENT TEST — one that MOUNTS a page, drives it, and asserts about the rendered
1090    /// document.
1091    ///
1092    /// Not an `Example`, and the difference is what a claim inventory is for: an example checks a
1093    /// VALUE, this checks a PAGE. They fail in different ways and cover different risk, and
1094    /// reporting both as `example` told a reader that a run of twenty examples had checked twenty
1095    /// values when some of them had checked a document. It is what lets somebody ask "does this
1096    /// project test its UI at all?" and get an answer.
1097    ///
1098    /// Finite testing like an example, and never a proof: mounting a page and clicking it checks
1099    /// the cases you drove and nothing else.
1100    Component,
1101}
1102
1103/// The honest per-item status.
1104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1105#[serde(rename_all = "snake_case")]
1106pub enum ClaimStatus {
1107    /// An example whose assertions all held.
1108    Passed,
1109    /// An example/property whose check failed (a property failure carries a
1110    /// concrete counterexample; see [`InventoryItem::counterexample`]).
1111    Failed,
1112    /// A `## Test` that RAN CLEAN but asserted NOTHING — a green that checked
1113    /// nothing (the non-vacuity gate fails it; the inventory surfaces it too).
1114    Vacuous,
1115    /// A property that held over every case of a FINITE domain exhausted under
1116    /// `--prove` — a certificate over that finite domain, NOT an unrestricted
1117    /// proof (the item stays `finite_enumeration: true`, `proof: false`).
1118    ProvedByExhaustion,
1119    /// A property that held over its SAMPLED cases (an infinite domain, or the
1120    /// finite domain without `--prove`) — evidence, not a proof.
1121    Held,
1122    /// A property with a concrete counterexample — disproved.
1123    Falsified,
1124    /// A theorem the proof engine kernel-certified — an actual proof.
1125    Proved,
1126    /// A theorem the proof engine could not discharge.
1127    Unproved,
1128    /// An axiom / admitted assumption — a premise, reported as an assumption,
1129    /// explicitly not a proof.
1130    Assumption,
1131}
1132
1133/// ONE classified claim in the inventory.
1134#[derive(Debug, Clone, Serialize)]
1135pub struct InventoryItem {
1136    pub kind: ClaimKind,
1137    /// The claim's identity (the test's prose name, or the theorem/axiom name).
1138    pub name: String,
1139    pub status: ClaimStatus,
1140    /// True when the claim was established by FINITE testing (an example, or a
1141    /// finite-enumeration property/bounded theorem) rather than an unbounded
1142    /// mathematical argument. The disclosure that keeps green from meaning
1143    /// "unbounded proof".
1144    pub finite_enumeration: bool,
1145    /// The disclosed finite bound (`integers sampled over [-1000, 1000]`, the
1146    /// exhausted boolean space, …) — present whenever a bound was actually
1147    /// covered, so a bounded claim can never masquerade as unbounded.
1148    #[serde(skip_serializing_if = "Option::is_none")]
1149    pub domain_bound: Option<String>,
1150    /// A concrete counterexample, when the claim was falsified.
1151    #[serde(skip_serializing_if = "Option::is_none")]
1152    pub counterexample: Option<String>,
1153    /// True only for the vacuous example — a green that asserted nothing.
1154    pub vacuous: bool,
1155    /// Whether this item is an actual PROOF: true ONLY for a kernel-certified
1156    /// theorem. An example, a finite-enumeration property (even one exhausted
1157    /// over booleans), an axiom, and an admitted assumption are all `false` —
1158    /// finite testing and admitted premises are never unrestricted proofs.
1159    pub proof: bool,
1160    /// The independently re-checkable proof certificate — the kernel proof term,
1161    /// the goal it proves, and the theorem's disclosed premises. Present ONLY on a
1162    /// `proof: true` theorem: a third party can re-verify it against the kernel
1163    /// WITHOUT trusting the prover. Never present on an axiom/assumption (an
1164    /// assumption is not a proof), a finite-enumeration item, or an unproved
1165    /// theorem.
1166    #[serde(skip_serializing_if = "Option::is_none")]
1167    pub certificate: Option<crate::kernel::certificate::TheoremCertificate>,
1168    /// Whether the carried certificate RE-CHECKS via the independent kernel checker
1169    /// (type-checking the term to the claimed goal, not re-running the prover).
1170    /// `Some(true)` on a certificate that re-verifies; `None` when there is no
1171    /// certificate to check. A `proof: true` theorem whose certificate does NOT
1172    /// re-check is a soundness alarm the consumer must treat as a failure.
1173    #[serde(skip_serializing_if = "Option::is_none")]
1174    pub certificate_rechecks: Option<bool>,
1175}
1176
1177/// The rolled-up census the inventory carries alongside its items — the
1178/// internally-consistent counts (examples + properties + theorems + axioms +
1179/// admitted == claims; proven ⊆ properties + theorems).
1180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1181pub struct InventorySummary {
1182    pub claims: usize,
1183    pub examples: usize,
1184    pub properties: usize,
1185    pub theorems: usize,
1186    pub axioms: usize,
1187    pub admitted_assumptions: usize,
1188    /// Items that are ACTUAL proofs (kernel-certified theorems) — never counts an
1189    /// axiom, an example, or a finite-enumeration property.
1190    pub proven: usize,
1191    /// Falsified examples/properties + unproved theorems + vacuous examples.
1192    pub failing: usize,
1193}
1194
1195/// The whole inventory: every classified claim plus its rolled-up summary.
1196#[derive(Debug, Clone, Serialize)]
1197pub struct Inventory {
1198    pub items: Vec<InventoryItem>,
1199    pub summary: InventorySummary,
1200}
1201
1202impl Inventory {
1203    /// Build the summary from the items, so the counts can never drift from the
1204    /// classification they summarize.
1205    fn summarize(items: &[InventoryItem]) -> InventorySummary {
1206        let count = |k: ClaimKind| items.iter().filter(|i| i.kind == k).count();
1207        InventorySummary {
1208            claims: items.len(),
1209            examples: count(ClaimKind::Example),
1210            properties: count(ClaimKind::Property),
1211            theorems: count(ClaimKind::Theorem),
1212            axioms: count(ClaimKind::Axiom),
1213            admitted_assumptions: count(ClaimKind::AdmittedAssumption),
1214            proven: items.iter().filter(|i| i.proof).count(),
1215            failing: items
1216                .iter()
1217                .filter(|i| {
1218                    matches!(
1219                        i.status,
1220                        ClaimStatus::Failed | ClaimStatus::Unproved | ClaimStatus::Vacuous
1221                    )
1222                })
1223                .count(),
1224        }
1225    }
1226
1227    fn from_items(items: Vec<InventoryItem>) -> Inventory {
1228        let summary = Self::summarize(&items);
1229        Inventory { items, summary }
1230    }
1231
1232    /// Classify every claim in a `largo test` [`RunReport`] — examples and
1233    /// finite-enumeration properties. Skipped tests carry no verified claim, so
1234    /// they are not inventoried as passing or failing evidence.
1235    pub fn from_run_report(report: &RunReport) -> Inventory {
1236        let mut items: Vec<InventoryItem> = Vec::new();
1237        for o in &report.outcomes {
1238            if o.verdict == Verdict::Skipped {
1239                continue;
1240            }
1241            match o.property {
1242                Some(info) => items.push(classify_property(o, info)),
1243                None => items.push(classify_example(o)),
1244            }
1245        }
1246        Self::from_items(items)
1247    }
1248}
1249
1250/// Extract the concrete counterexample from a property's recorded failure — the
1251/// runner records it as `Property failed. Counterexample: <binding>`.
1252fn extract_counterexample(failures: &[String]) -> Option<String> {
1253    const MARK: &str = "Counterexample: ";
1254    failures
1255        .iter()
1256        .find_map(|f| f.split_once(MARK).map(|(_, cx)| cx.trim().to_string()))
1257}
1258
1259/// Classify a plain `Expect`-only test as an EXAMPLE — or as a COMPONENT claim when it mounted a
1260/// page, since driving a document is a different kind of evidence from checking a value.
1261fn classify_example(o: &TestOutcome) -> InventoryItem {
1262    let status = if o.vacuous {
1263        ClaimStatus::Vacuous
1264    } else if o.verdict == Verdict::Passed {
1265        ClaimStatus::Passed
1266    } else {
1267        ClaimStatus::Failed
1268    };
1269    InventoryItem {
1270        kind: if o.mounted {
1271            ClaimKind::Component
1272        } else {
1273            ClaimKind::Example
1274        },
1275        name: o.name.clone(),
1276        status,
1277        // Examples check concrete cases — finite testing, always.
1278        finite_enumeration: true,
1279        domain_bound: None,
1280        counterexample: None,
1281        vacuous: o.vacuous,
1282        // An example is never a proof.
1283        proof: false,
1284        certificate: None,
1285        certificate_rechecks: None,
1286    }
1287}
1288
1289/// Classify a `For all` test as a PROPERTY, disclosing its finite bound and —
1290/// honestly — never marking it an unrestricted proof.
1291fn classify_property(o: &TestOutcome, info: PropertyInfo) -> InventoryItem {
1292    let counterexample = extract_counterexample(&o.failures);
1293    let status = if counterexample.is_some() {
1294        // A concrete disproving witness — the honest falsification.
1295        ClaimStatus::Falsified
1296    } else if o.verdict == Verdict::Failed {
1297        // The property's body failed WITHOUT yielding a counterexample (a
1298        // runtime error or a budget hit) — a failure, but not a disproof.
1299        ClaimStatus::Failed
1300    } else if o.proved_by_exhaustion {
1301        ClaimStatus::ProvedByExhaustion
1302    } else {
1303        ClaimStatus::Held
1304    };
1305    InventoryItem {
1306        kind: ClaimKind::Property,
1307        name: o.name.clone(),
1308        status,
1309        // A property is checked by finite enumeration/sampling — ALWAYS, even a
1310        // boolean exhaustion. This is the line that stops a green property from
1311        // reading as an unbounded mathematical proof.
1312        finite_enumeration: true,
1313        domain_bound: Some(info.domain.bound().to_string()),
1314        counterexample,
1315        vacuous: false,
1316        // NEVER a proof: exhaustion over a finite domain is a certificate over
1317        // THAT domain, not an unrestricted proof; sampling is only evidence.
1318        proof: false,
1319        certificate: None,
1320        certificate_rechecks: None,
1321    }
1322}
1323
1324/// A theorem/axiom claim handed to the inventory by the prove path. Kept tiny so
1325/// the CLI's `Outcome`/prove report can map onto it without a dependency cycle.
1326#[derive(Debug, Clone)]
1327pub struct ProvenClaim {
1328    pub name: String,
1329    /// True when the proof engine kernel-certified it.
1330    pub verified: bool,
1331    /// The disclosed finite bound when the theorem was checked over a bounded
1332    /// (finite) domain rather than proved unrestricted — `None` for an
1333    /// unbounded kernel proof.
1334    pub finite_bound: Option<String>,
1335    /// The independently re-checkable proof certificate — the kernel proof term,
1336    /// the goal it proves, and the theorem's disclosed premises — present ONLY for
1337    /// a genuine unbounded kernel proof. `None` for an unproved theorem, a
1338    /// finite-enumeration (bounded) theorem, or any surface that does not yet
1339    /// expose a kernel term. An axiom/assumption NEVER carries one.
1340    pub certificate: Option<crate::kernel::certificate::TheoremCertificate>,
1341}
1342
1343impl Inventory {
1344    /// Build the inventory for a `largo prove` pass from its classified claims:
1345    /// theorems, axioms (assumptions), and admitted assumptions. REJECTS a
1346    /// name that appears both as a theorem and as an axiom/admitted assumption —
1347    /// an axiom must never be able to masquerade as a proved theorem.
1348    ///
1349    /// `axioms` and `admitted` are `(name)` lists; `theorems` are
1350    /// [`ProvenClaim`]s. Returns `Err(collision_message)` naming every colliding
1351    /// name, so the run can fail loud.
1352    pub fn from_prove(
1353        theorems: &[ProvenClaim],
1354        axioms: &[String],
1355        admitted: &[String],
1356    ) -> Result<Inventory, String> {
1357        use std::collections::BTreeSet;
1358        let theorem_names: BTreeSet<&str> = theorems.iter().map(|t| t.name.as_str()).collect();
1359        let assumption_names: BTreeSet<&str> = axioms
1360            .iter()
1361            .chain(admitted.iter())
1362            .map(String::as_str)
1363            .collect();
1364        let collisions: Vec<&str> = theorem_names
1365            .intersection(&assumption_names)
1366            .copied()
1367            .collect();
1368        if !collisions.is_empty() {
1369            return Err(format!(
1370                "name collision: {} appears as BOTH a theorem and an axiom/admitted assumption — \
1371                 an axiom is an ASSUMPTION and must never be able to masquerade as a proved \
1372                 theorem. Rename one so every claim has exactly one kind.",
1373                collisions
1374                    .iter()
1375                    .map(|n| format!("`{n}`"))
1376                    .collect::<Vec<_>>()
1377                    .join(", ")
1378            ));
1379        }
1380
1381        let mut items: Vec<InventoryItem> = Vec::new();
1382        for t in theorems {
1383            let bounded = t.finite_bound.is_some();
1384            // A PROOF only when kernel-certified AND unbounded. A bounded check is a
1385            // certificate over its finite domain, not an unrestricted proof.
1386            let proof = t.verified && !bounded;
1387            // The certificate rides ONLY on a genuine unbounded kernel proof — never on
1388            // a bounded (finite-enumeration) theorem, which has no unrestricted proof
1389            // term. When present, re-check it INDEPENDENTLY of the prover here, so the
1390            // inventory carries a verdict a consumer can trust without re-running search.
1391            let certificate = if proof { t.certificate.clone() } else { None };
1392            let certificate_rechecks = certificate
1393                .as_ref()
1394                .map(|c| crate::kernel::certificate::recheck_theorem(c).rechecks);
1395            items.push(InventoryItem {
1396                kind: ClaimKind::Theorem,
1397                name: t.name.clone(),
1398                status: if t.verified {
1399                    ClaimStatus::Proved
1400                } else {
1401                    ClaimStatus::Unproved
1402                },
1403                // A bounded (finite-domain) theorem is finite enumeration; an
1404                // unbounded kernel proof is not.
1405                finite_enumeration: bounded,
1406                domain_bound: t.finite_bound.clone(),
1407                counterexample: None,
1408                vacuous: false,
1409                proof,
1410                certificate,
1411                certificate_rechecks,
1412            });
1413        }
1414        for a in axioms {
1415            items.push(InventoryItem {
1416                kind: ClaimKind::Axiom,
1417                name: a.clone(),
1418                status: ClaimStatus::Assumption,
1419                finite_enumeration: false,
1420                domain_bound: None,
1421                counterexample: None,
1422                vacuous: false,
1423                proof: false,
1424                // An axiom is an ASSUMPTION, not a proof — it never gets a certificate.
1425                certificate: None,
1426                certificate_rechecks: None,
1427            });
1428        }
1429        for a in admitted {
1430            items.push(InventoryItem {
1431                kind: ClaimKind::AdmittedAssumption,
1432                name: a.clone(),
1433                status: ClaimStatus::Assumption,
1434                finite_enumeration: false,
1435                domain_bound: None,
1436                counterexample: None,
1437                vacuous: false,
1438                proof: false,
1439                // An admitted assumption is not a proof — it never gets a certificate.
1440                certificate: None,
1441                certificate_rechecks: None,
1442            });
1443        }
1444        Ok(Self::from_items(items))
1445    }
1446}
1447
1448/// Render the `largo test --json` inventory document: the classified claims plus
1449/// their summary, the seed, and the raw outcomes (so a consumer keeps both the
1450/// classification and the underlying detail from one call).
1451pub fn render_test_inventory_json(report: &RunReport) -> String {
1452    let inventory = Inventory::from_run_report(report);
1453    let doc = serde_json::json!({
1454        "seed": report.seed,
1455        "inventory": inventory.items,
1456        "summary": inventory.summary,
1457        "outcomes": report.outcomes,
1458    });
1459    serde_json::to_string_pretty(&doc).expect("the inventory always serializes")
1460}
1461
1462#[cfg(test)]
1463#[path = "../../logicaffeine_tests/unit/logicaffeine_compile/testrun/inventory_tests.rs"]
1464mod inventory_tests;