C6 — Standardized nornir test-matrix (nornir test)
Design + guide for the C6 epic: ONE standardized way to run every repo's tests, land the results in the warehouse, and watch them light up green/red in a viz Test pane. Mirrors the
nornir benchrun→persist→view shape and therelease_eventswarehouse-DAG pattern.
0. LAW — how an agent fixes a matrix test (no human in the loop)
This is the canonical, mandatory way a Claude agent fixes any red / missing / "looks wrong" UI / gRPC / render / CLI test in this matrix — and validates the fix with NO human ever looking at a screen. The single source is
agent-ui-test-fixing.md(a chapter of the docs book,docs/book.pdf); read it before touching a red row. Its creed: green ⟺ the shipped surface, on real data, through the server, across its whole outcome space, was proven RAN-and-CORRECT from emitted data — and the same oracle goes RED when it breaks. It is structured as eight named LAWS, each born from a real false-green this constellation shipped:
- SHIPPED ≠ TESTED — the subject is the DEPLOYED artifact (wasm bundle / installed binary), not the native build; require native↔wasm parity + a headless-wasm robot that proves each surface present AND ran.
- RAGNARÖK — empty / zero data on a data-bearing surface SHALL NOT PASS; name the populate gap; prove the RED with the empty-input inverse.
- ONE VALUE ≠ COVERED — sweep the SAMPLE SPACE per-function AND whole-chain (the
facett-map3dchain_sweep/partstep_sweepexemplar; ≥K classes).- END-TO-END = THROUGH THE SERVER — drive thin (
viz --server+ CLI--server) → gRPC → server → warehouse → back; assert the round-trip, not the local fat path.- WORKSPACE BLIND — the surface is
workspace × {tab@{fat,thin}, cli, mcp} × chainfor EVERY served workspace, clean-slate→populate→verify (not seeded).- VERIFIED BY EYEBALL is not verified — every emptiable render emits its populated-state as DATA; correctness read from the emit, never "looks right?".
- PESSIMISM — green only when proven ran-and-passed-and-swept; an unverified surface is presumed BROKEN; distrust every "done."
- WIRE ≠ HOME — the terminus is the Iceberg WAREHOUSE via the lock-owning server; a /tmp JSONL row is NOT done — reconcile + read it back.
Plus the supporting technique: validate as DATA not pixels (EMIT-DOCTRINE), the oracle-must-go-RED contract bound, scan-the-pixels, multivariate/metamorphic stress, binary-search debug, follow-the-data — and a false-green checklist an agent runs before claiming done.
The 🧪 Test pane surfaces this LAW as a "How agents fix tests" link (§5,
state_json["test"]["doctrine"]) so it's discoverable from the running viz, and it ships as a chapter of the rendered docs book. This applies to every UI / gRPC / render surface in the constellation (nornir CLI fat+thin, nornirviz,facett-*,korp-poc, and any future one).
1. What it is
nornir test <repo> runs a repo's native Rust test framework (cargo nextest run if cargo-nextest is on PATH, else cargo test), parses each test's pass/fail + duration, and appends one row per case to the warehouse test_results table under a fresh run_id. Any plain #[test] — including the inject-value + snapshot tests THE_BIG_PLAN mandates — is ingested automatically; there is no bespoke runner to register tests with.
nornir test run <repo> # run one repo's matrix, persist to test_results
nornir test all # every repo in nornir.toml, one run id per repo
nornir test history <repo> # read the matrix back (green/red, newest first)
nornir test history <repo> --json --limit N
The viz 🧪 Test tab reads the same table as a per-repo green/red board: run list on the left (newest on top), the selected run's cases coloured by status on the right. CLI parity with the GUI is total — the pane shows nothing the history CLI can't print.
stallednornir test history
2. The runner (src/test_matrix/)
nornir test run repo
└─ detect_runner
├─ [cargo-nextest on PATH] cargo nextest run --message-format libtest-json
│ └─ Parser::feed_json (one JSON event/line) ─┐
└─ [else] cargo test --no-fail-fast (default │
human grammar) │
└─ Parser::feed_cargo │
(test NAME ... ok|FAILED|ignored) ────┤
▼
Vec<TestCase>
└─ TestResultRow per case (shared run_id + ts)
└─ append_test_results → Iceberg test_results
stall watchdog (NORNIR_TEST_STALL_SECS) ⋯⋯▶ kills silent child, mints red `stalled` case → Vec<TestCase>
detect_runner()prefers nextest (structuredlibtest-json: one JSON object per test event, withexec_timedurations + captured failure stdout). Falls back to plaincargo test, parsing the default human grammartest <name> ... ok|FAILED|ignored. (We deliberately do NOT pass--format terse: it collapses each test to a single char and loses the names.)run_matrix(repo_root, runner)spawns the subprocess withrepo_rootas CWD (so each repo's own[workspace]is the scope), pipes stdout/stderr through reader threads into a channel, and folds lines intoTestCases.- Both paths yield the same
TestCase { suite, name, status, duration_ms, message }.suiteis the crate / test target; a blank suite defaults to the repo name when rows are built.
Runner capability note
- nextest: per-test durations and failure messages (from captured stdout).
- cargo test default format: pass/fail/ignored + names, but no per-test durations and no failure messages (libtest omits them in the default format). Durations land as
0.0, messages as"". Installingcargo-nextestupgrades any repo to full fidelity with zero config.
3. Stall watchdog (C6f)
The runner arms a watchdog off the output channel: if no subprocess output arrives for NORNIR_TEST_STALL_SECS seconds (default 120, 0 disables), the child is killed, a single synthetic red stalled TestCase is appended (message = "no test output for Ns — watchdog killed the run"), and the run is recorded red. A hung/crap test is therefore visible in the matrix, never silently eating the wall clock.
stalledis a red verdict (status::is_red→ true), so a stalled run is never counted green.- Manual abort (a driver-issued stop) is a documented follow-up — today the watchdog is the only stop trigger; an MCP
test_stop(C6g) is the planned manual control.
4. The test_results table (C6b)
src/warehouse/iceberg_schema.rs::test_results(), partitioned by repo (per-repo reads prune, like bench_runs). One row per (run_id, suite, test_name):
| col | type | n | notes |
| run_id | string | req | groups a run's rows; one per nornir test invocation |
| repo | string | req | partition key |
| suite | string | req | crate / test target the case lives in |
| test_name | string | req | module::path::test_fn |
| status | string | req | pass | fail | ignored | stalled |
| duration_ms | double | req | wall time (0.0 when the runner gave none) |
| ts_micros | timestamptz | req | run time (shared across a run's rows) |
| message | string | req | failure / stall detail ("" = none, never null) |
Write/read live in src/warehouse/test_results.rs:
append_test_results(wh, rows)— one Iceberg snapshot per run. Because the table isrepo-partitioned, a single batch must be one repo (a real run always is);test allwrites one batch per repo.query_test_results(wh, TestSelector::{Run|Repo|All})— sorted by(ts_micros, run_id, suite, test_name)(Iceberg gives no scan order).summarize_runs(rows)→Vec<RunSummary>(pass/fail/ignored/stalled per run, newest first);RunSummary::green()is true iff no red case.render_matrix/rows_to_jsonback thehistoryCLI.
Relation to test_outcomes
test_outcomes is bench-shaped — keyed to a bench_runs.run_id, co-written when a bench runs. test_results is the standalone green/red board: no bench, no release coupling. The two coexist; this one is what the C6 matrix + Test pane use.
5. The viz Test pane (C6c — src/viz/test_tab.rs)
A new Tab::Test (🧪 Test), added to the tab enum + toolbar in src/viz/app.rs. Local mode opens the warehouse read-only (lock-tolerant open_read_only, so it coexists with a live server holding the catalog lock — the same fallback the Release tab + release events CLI use) and calls query_test_results. Remote mode shows a note (no Test.Results RPC yet — a documented follow-up, same split as the Release tab).
- Run list (left): one selectable row per run, newest on top, green/red verdict +
P/F/I/Scounts. - Case matrix (right): the selected run's cases, each with a coloured status chip (PASS green / FAIL red / IGN grey / STALL orange), name, duration, message.
state_json()["test"](LAW #6) exposes the rendered data as structure: the per-run summaries (with green flag + counts) and the selected run's cases (with aredflag). The headless matrix + agents read that; the wgpu snapshot is the visual proof.- Doctrine link (§0 LAW). The top strip carries a 📖 "How agents fix tests" link (
test_tab::doctrine,draw_doctrine_link) that opensagent-ui-test-fixing.md/ itsdocs/book.pdfchapter. It is a discoverable surface (EMIT-DOCTRINE):state_json["test"] ["doctrine"]carries{title, doc, book, summary, opened}—openedflips when the link is clicked, so the matrix proves the link is wired, not a dead label. Covered bytests/viz_test_pane.rs::test_pane_exposes_doctrine_surface_ and_link_is_wired+doctrine_doc_exists_and_is_in_the_rendered_book.
6. Tests (LAW #1 inject-and-assert, LAW #4 snapshot)
src/warehouse/test_results.rsunit test: injects known rows across two repos / three runs, appends per-repo, queries back, and asserts the round-trip — run grouping, status counts, durations, messages, newest-first ordering, the human matrix naming the red/stalled cases, and well-formed JSON.src/test_matrix/mod.rsunit tests: feed real runner output (the cargo default grammar + nextest libtest-json events) and assert the parsedTestCases (suite/name/status/duration/message); plus the watchdog pushing a redstalledcase and the suite-from-path extractor.tests/viz_test_pane.rs: injects atest_resultsmatrix into the app, selects the Test tab, and assertsstate_json()["test"]exposes the green/red summaries (newest first) + the selected run's cases + theredflags. A wgpuegui_kittestsnapshot renders the pane totests/snapshots/viz_test_pane.png(LAW #4) and also asserts on the rendered state (functional proof).- The existing
viz_matrix/viz_buttonstab-count assertions were updated 14 → 15 (the new Test tab).
7. EPIC L — extracted crate + multi-aspect engine + strong test all
The C6 runner + pure test_results model were extracted into the reusable nornir-testmatrix crate (a workspace member; nornir is now the root crate of a 2-member workspace). nornir depends on it and re-exports every item, so nornir::test_matrix::* and nornir::warehouse::test_results::{TestResultRow, render_matrix, …} keep working. Full design: crates/nornir-testmatrix/.nornir/testmatrix-crate.md.
L1 — the crate + the TestSink seam
- Pure core (runner + model + sinks) lives in the crate,
std+serde+anyhowonly (no iceberg/arrow/eframe/tantivy/skade). pub trait TestSink { fn append(&self, rows: &[TestResultRow]) -> Result<()>; }is the durable write seam. The crate shipsJsonFileSink+NullSink; nornir shipsIcebergTestSink(warehouse::test_results) over the warehouse.
L2 — multi-aspect engine (test MANY aspects)
-
Aspect∈ {Build, Unit, Doctest, Clippy, Fmt, Audit, BenchSmoke, Coverage, FeaturePowerset, Msrv, Examples, FunnelDecompose, FunnelDemo}.run_aspect(repo_root, aspect)shells the right cargo subcommand + parses;run_full_matrix(repo_root, &[Aspect])runs a set under one run id.FunnelDemo(tagfunnel-demo, inDEFAULT) runs the funnel demo injection tests —cargo test --features viz --test funnel_demo --test funnel_viz— so the matrix has a row that proves the viz🌱 Run demobutton works end-to-end (inject the DAG → reopen the warehouse → assert the exact nodes/edges/ready set +state_json). Metric = passing test count; parserparse_funnel_demo.nornir test run --aspects funnel-demo.
-
New
TestResultRowfieldsaspect: String(defaultunit) +metric: f64(coverage %, warning/advisory count; default0.0). The Icebergtest_resultsschema gained OPTIONAL fields 9 (aspect) + 10 (metric) — migration-safe: pre-L rows read back withaspect = "unit",metric = 0.0. -
New status
skip(status::SKIP) — an absent tool (clippy/audit/llvm-cov/ hack/fmt/msrv toolchain) records a neutralskiprow, never a hard fail.is_red(skip) == false,is_green(skip) == false.
L3 — strong nornir test
nornir test run <repo> [--aspects build,unit,clippy,…] # default: build,unit,doctest,clippy,fmt,audit
nornir test all [--aspects …] # full matrix across every repo + per-repo×aspect grid
nornir test history <repo> [--json] [--limit N] # unchanged
test all runs the aspect set across every nornir.toml repo, persists every row via the IcebergTestSink, prints a per-repo×aspect terminal grid (✓ pass · ✗ red · ○ skip · · not-run), and exits red if any red.
7b. n-001 — test inventory via runner --list (the list-first phase)
Current discovery is two-tier. The PRIMARY, build-free inventory comes from the syn knowledge scan on initial workspace load (no
cargo build) — seetest-matrix-always-visible.md: every#[test]showsok | fail | Ximmediately, thetest_inventorytable LEFT JOINtest_results. The runner--listphase below still runs as part ofnornir test run(the on-request / release execution path, where a build happens anyway) to seedlistedrows for tests about to run. Initial load uses the syn inventory only;--listnever runs on load.
The results matrix only recorded tests that happened to run, so an existing #[test] that wasn't reached stayed invisible (the autonom anti-drift problem). n-001 makes nornir test run discover every test first, so the 🧪 matrix shows EVERY test that EXISTS with status NotRun / pass / fail — data-driven, the runner lists them, never a hand registry.
Storage — REUSE test_results, new listed status (no new table)
status::LISTED ("listed") joins the existing status set in crates/nornir-testmatrix/src/model.rs. A listed row is discovered-but-not- run:
is_red(listed)=is_green(listed)=is_neutral(listed)= false; a newis_listed()predicate isolates it. It is its OWN bucket — never red, never green, never counted as a skip.RunSummarygained alistedcount.- A test with a
listedrow but no pass/fail row in the latest run = NotRun. No new table, no schema change —test_results.statusis already a free-form string column, solistedrows persist through the existingIcebergTestSink.
List-first — PHASE 1 of nornir test run
runner::list_tests(repo_root, runner) discovers tests without running them:
nextest: cargo nextest list --message-format json → parse_nextest_list(json)
fallback: cargo test -- --list --format terse → parse_cargo_test_list(text)
parse_nextest_list walks the rust-suites → testcases map and emits <binary-id>::<case>; parse_cargo_test_list keeps the libtest name: test lines (dropping : benchmark). Both are PURE (fed canned output by unit tests), sorted + deduped. model::listed_rows(names, run_id, repo, ts) turns the names into listed-status TestResultRows (suite = everything before the last ::).
The run_test_one driver (src/bin/nornir.rs) seeds those listed rows first (per repo, under the run's shared run_id/ts), prints 📋 discovered N test(s), then runs the aspect matrix as before. Discovery never hard-fails the run (a launch error just skips the inventory). The crate stays lean (no iceberg); the nornir driver persists via the existing sink. Aspect columns are unchanged (from the Aspect enum).
Grid — listed renders as NotRun (src/viz/test_matrix_grid.rs)
build_cell drops listed rows before rolling up a cell: a cell of only listed seeds falls through to Cell::not_run() (ran() = false, counted in total_blank, NOT skip/neutral), while a real pass/fail/ignored row in the same run overrides the seed. So:
listedalone → NotRun (blank cell, counted blank), andlisted+ a laterpassin the same run → green cell.
Two inject-assert grid tests pin this (listed_only_cell_is_not_run, listed_then_pass_in_same_run_is_green), plus model tests asserting the exact discovered names from canned nextest list JSON, the listed_rows conversion, and the new status predicates; a runner test runs the real list phase against the crate's own dir and asserts it names this very test.
7c. utfallsrum — outcome-space coverage in the matrix (the n-005 gate's quality bar)
The matrix records pass/fail per check; utfallsrum (English math term: sample space, Ω) records the SHAPE of the check — how much of a function's OUTCOME SPACE a test swept. It is classic boundary-value + equivalence-partition analysis, made first-class and persistable. This is the machinery behind agent-ui-test-fixing.md LAW 3 (ONE VALUE ≠ COVERED): sample-space coverage of a chain f1 ∘ … ∘ fn is BOTH per-function (each fi swept across its full outcome space — the facett-map3d/tests/chain_sweep.rs shape) AND the composed chain across the same input space (partstep_sweep.rs). Keep the two layers separate: the primitive is GENERIC + UI-agnostic (works for a headless library with no viz/server/workspaces); nornir's workspace × {tab@{fat,thin}, cli, mcp} surface gate (LAW 5) is nornir applying it.
- Declare a function's outcome space as classes (partitions + boundaries) with their contracts:
nornir_testmatrix::utfallsrum::Outcome::for_fn("clamp01") .class("below-0", "input < 0 ⇒ output == 0.0").class("in-range", …).class("above-1", …). - Record which classes a test exercised, with the exact asserted output:
o.hit("below-0", "clamp01(-0.5) == 0.0"). - Score
utfallsrum_covered ∈ [0,1]= exercised-classes / declared-classes (o.covered()). A single-value test scores low even when green; an empty outcome space scores0.0(declaring nothing is a gap, not full coverage). - Emit
o.emit(component)lands one functional row per class + a roll-up<fn>/utfallsrumrow whosemetricis the score — so the warehouse carries per-function outcome-space coverage next to the functional verdict, on the existingtest_results/Aspect::Functionalpath.
The n-005 gate consumes it (DELIVERABLE — the workspace × utfallsrum extension)
The autonom completeness gate (nornir test coverage, autonom.md / aut5-surface-coverage-gate.md) gained two dimensions:
- Workspace dimension — the surface is
workspace × {cli_command, viz_tab@{fat,thin}, mcp_tool, grpc}(discover::SERVED_WORKSPACES,workspace_surface_key). The same tab in two workspaces is two distinct surfaces. - utfallsrum threshold — a surface is covered only when it BOTH ran end-to-end AND its function(s) swept ≥K outcome classes (
coverage::covered_with_utfallsrum, default K =DEFAULT_UTFALLSRUM_THRESHOLD= 2). A one-value smoke does NOT flip a surface to covered. The score persists tosurface_coverage.utfallsrum_covered(additive Iceberg field 10), and the--seed-allowlistburn-down backlog still works unchanged.
Worked slice: tests/viz_utfallsrum_slice.rs (gate viz,server,testmatrix) drives two workspaces × {Test,Bench}@{fat,thin} clean-slate→populate→verify, records the outcome class each workspace exercised (the pair sweeps one + many), scores the outcome space, and persists + reads back the utfallsrum-scored coverage rows.
8. Deferred (noted, not built here)
- C6d Bench pane —
bench_runs+bench_telemetryalready hold the data; a sibling Bench-results tab (green/red + live CPU) is a follow-up. - C6e re-run znippy / skade matrices through the standard (benchmarked + visible) — the runner is repo-agnostic, so this is a
nornir test allsweep + doc once the standard is merged. - C6g MCP tooling —
test_run/test_status/test_results/test_stop(manual abort) mirroring the CLI, for LLM/agent parity. - Server-side write path — today the fat-CLI owns the
test_resultswrite (likebench rundoes whenNORNIR_SERVERis unset). ATest.SubmitRPC so a client can ship results to the server that owns the warehouse lock is the server-XOR-client follow-up, mirroringBench.Submit.