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;