diff --git a/CHANGELOG.md b/CHANGELOG.md index df5580b..31fd4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ Entries here have landed on `main` but have not yet been cut into a tagged relea ### Added +- **Re-runnable integrity-check acceptance benchmark for the warm daemon (NRN-83).** A committed, `#[ignore]`-gated integration harness (`tests/integrity_benchmark.rs` + a deterministic synthetic-vault generator in `tests/bench_util/`) proves the founding-bug fix end-to-end at 50k-doc scale: with a live `norn serve` daemon, routed `count`/`find`/`get` reads pay **zero** per-invocation `PRAGMA integrity_check` (open-once / verify-once, ADR 0005), while direct no-daemon reads still verify every call (trust preserved). The acceptance criterion is asserted **structurally, not by timing**: `src/cache/open.rs` now emits a `norn trace: integrity_check` stderr marker per check when `NORN_TRACE_INTEGRITY_CHECK` is set (env-gated, off by default — normal output and the byte-identical routing proofs are untouched), so the harness counts exactly ONE marker in the daemon's stderr across all routed reads versus one-per-call on the direct path, cross-checked against the daemon's `served` markers so the result cannot pass vacuously. The generator is deterministic given `(N, seed)` — no wall-clock or thread RNG — and scale/seed/iterations are env-overridable (`NORN_BENCH_DOCS` default 50000, `NORN_BENCH_SEED` default 83, `NORN_BENCH_ITERS` default 5). Run it with `cargo test --release --test integrity_benchmark -- --ignored --nocapture`; it prints an evidence table (sizes, timings, marker/served counts) for the operator record. The trace hook is available in release builds (unlike the debug-gated `NORN_CACHE_LOCK_TIMEOUT_MS`, it is pure observability and alters no behavior when unset), but enabling it intentionally makes routed and direct stderr diverge — never set it in an environment that asserts on norn's stderr. Dev-facing only — no runtime behavior change on any default path. + - **`norn service` — a launchd supervisor over the warm `norn serve` daemon (macOS) (NRN-115).** `norn serve` is a plain foreground process; the new `norn service install | uninstall | start | stop | restart | status` verbs adopt it as a launchd user agent so it is always warm (`KeepAlive` + `RunAtLoad`) — the same install-once-stay-warm posture Mimir's service contract uses, with no CLI auto-spawn. `install` renders a plist at `~/Library/LaunchAgents/com.dbtlr.norn.serve.plist` and bootstraps it idempotently (bootout-first, with a retry on launchd's async-teardown race). The plist's `ProgramArguments` use the invoking binary's absolute path **with symlinks preserved** (a Homebrew-style symlink stays the stable launcher path instead of a versioned target that dies on upgrade), stdout/stderr redirect to `/norn/log/serve.log`, and an install-time `XDG_CACHE_HOME` is **baked into the unit's environment** (launchd agents inherit no shell env; without this the daemon and its clients would derive different socket paths and never route). `uninstall` boots out a loaded unit and removes the plist (config and logs kept; tolerant of not-installed — but a failed unload keeps the plist and errors, never orphaning a live KeepAlive daemon); `stop` is an honest bootout (a `KeepAlive` daemon would resurrect a merely-killed pid); `restart` is `kickstart -k`. **Exit-code contract:** `start` is idempotent — exit 0 iff the unit ended running through this call *or was already running* (a loaded-but-not-running unit is kickstarted); `stop`/`restart` exit 0 iff they acted on a loaded unit, and a no-op (not installed, or not running) exits 1 with a structured reason — so a `norn service stop && …` chain never proceeds on a no-op, while `start && …` proceeds whenever the daemon is up. `status` combines `launchctl print` (loaded / running / pid) with a live control-ping over the daemon's Unix socket, rendering the running vs on-disk build version with a **restart-pending** flag (plus uptime and the plist / log / socket paths). The JSON report's `loaded` / `running` are **tri-state** (`true` / `false` / `null`): when the launchd probe itself fails, status still renders the report — launchd state `null` with the probe error in a `launchd_error` field, and whatever the live socket pong said (version / pid / uptime) — but **exits nonzero (1)**, so a `norn service status || alert` health gate fires on unknown supervision state; every known state (running, stopped, not installed) exits 0. Every verb takes `--format text|json`; under `json` every outcome — failures included — is a machine-readable object on stdout. On non-macOS hosts the verbs print a friendly fallback (run `norn serve` under your own supervisor; systemd support is planned) and exit nonzero. ### Changed diff --git a/src/cache.rs b/src/cache.rs index 537c4b9..dbbcb9c 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -43,6 +43,25 @@ pub(crate) use identity::{ cache_dir_for, cache_tree_root, events_dir_for, hex_lower, state_dir_for, state_tree_root, vault_identity, vault_identity_hash, xdg_cache_home_env, }; + +/// Resolve a vault's on-disk cache directory under an EXPLICIT cache home, +/// with the SAME identity mapping production opens use (`identity::cache_dir_in`, +/// which `cache_dir_for` delegates to after reading `XDG_CACHE_HOME`), returning +/// just the cache dir. Cross-crate test-harness seam: the NRN-83 acceptance +/// benchmark locates `cache.db` under a private cache home through it — a pure +/// function of its arguments, no process-env read or mutation anywhere. The +/// error is stringified so the crate-private `CacheError` type does not leak +/// into public visibility. Not part of the stable public API — same posture as +/// `Cache::conn`. +#[doc(hidden)] +pub fn resolve_cache_dir_in( + cache_home: &camino::Utf8Path, + vault_root: &camino::Utf8Path, +) -> Result { + identity::cache_dir_in(cache_home, vault_root) + .map(|(_canonical, dir)| dir) + .map_err(|e| e.to_string()) +} pub(crate) use lock::acquire_flock; pub(crate) use query_show::{DocumentDeep, IncomingLink}; diff --git a/src/cache/identity.rs b/src/cache/identity.rs index a7ec03f..31d0550 100644 --- a/src/cache/identity.rs +++ b/src/cache/identity.rs @@ -51,9 +51,21 @@ pub(crate) fn hex_lower(bytes: &[u8]) -> String { /// Format: `/norn//`, defaulting /// to `~/.cache/norn//` when `XDG_CACHE_HOME` is unset. pub fn cache_dir_for(vault_root: &Utf8Path) -> Result<(Utf8PathBuf, Utf8PathBuf), CacheError> { - let (canonical, hash) = vault_identity(vault_root)?; let base = xdg_cache_home()?; - let dir = base.join("norn").join(hash); + cache_dir_in(&base, vault_root) +} + +/// The same identity mapping as [`cache_dir_for`] with the cache-home base +/// passed explicitly instead of read from the environment — a pure function of +/// its arguments (plus the vault-root canonicalization). Test harnesses that +/// manage a private cache home resolve through this without any process-env +/// mutation. +pub(crate) fn cache_dir_in( + cache_home: &Utf8Path, + vault_root: &Utf8Path, +) -> Result<(Utf8PathBuf, Utf8PathBuf), CacheError> { + let (canonical, hash) = vault_identity(vault_root)?; + let dir = cache_home.join("norn").join(hash); Ok((canonical, dir)) } diff --git a/src/cache/open.rs b/src/cache/open.rs index 9f18823..6a712bc 100644 --- a/src/cache/open.rs +++ b/src/cache/open.rs @@ -243,6 +243,32 @@ fn inspect_existing_cache( } // PRAGMA integrity_check + // + // Acceptance-benchmark trace hook (NRN-83). When `NORN_TRACE_INTEGRITY_CHECK` + // is set, emit one stable stderr marker per `integrity_check` execution. This + // gives an out-of-process harness a deterministic, cross-process count of how + // many times a code path actually pays the O(db-size) check: a live daemon + // opens-once/verifies-once, so N routed reads share ONE marker, whereas N + // direct invocations each reopen and produce N markers. + // + // Contract: + // - Opt-in diagnostic, OFF by default: with the env var unset (every normal + // environment) this emits nothing and changes no behavior. + // - When enabled, routed and direct stderr INTENTIONALLY diverge — revealing + // where the check runs is the hook's entire purpose, so the byte-identical + // routing guarantee does not hold under trace. + // - Never enable it in an environment that asserts on norn's stderr (the + // routing byte-identity proofs, log-diffing pipelines). + // + // Deliberately available in RELEASE builds, unlike the debug-gated + // `NORN_CACHE_LOCK_TIMEOUT_MS` (src/cache/lock.rs): that override CHANGES + // timing behavior, so an inherited value in a production environment must + // be impossible; this hook is pure observability — it alters no timing, no + // outcome, no output when unset — and the `--release` acceptance benchmark + // needs it in the optimized binary it measures. + if std::env::var_os("NORN_TRACE_INTEGRITY_CHECK").is_some() { + eprintln!("norn trace: integrity_check"); + } let integrity: Result = conn.query_row("PRAGMA integrity_check", [], |r| r.get(0)); match integrity { Ok(s) if s == "ok" => {} diff --git a/src/lib.rs b/src/lib.rs index 463b01c..b304fe1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,12 @@ mod apply_cmd; pub mod apply_report; mod audit; mod cache; +// The (cache-home, vault-root) → cache-dir identity mapping, re-exported for +// test harnesses (the NRN-83 acceptance benchmark) that must locate a vault's +// cache.db under a private cache home exactly the way production does — pure +// function, no env read. `#[doc(hidden)]` seam, not stable public API — see +// `cache::resolve_cache_dir_in`. +pub use cache::resolve_cache_dir_in; mod cache_cmd; mod cli; mod completions; diff --git a/tests/bench_util/mod.rs b/tests/bench_util/mod.rs new file mode 100644 index 0000000..55a68af --- /dev/null +++ b/tests/bench_util/mod.rs @@ -0,0 +1,139 @@ +//! Deterministic synthetic-vault generator for the integrity-check acceptance +//! benchmark (NRN-83) and any future scale regression harness. +//! +//! `generate_vault(dir, n, seed)` writes `n` Markdown documents whose frontmatter, +//! wikilinks, and headings are a pure function of `(index, seed)` — no +//! `Date::now`, no thread RNG, no filesystem entropy — so a run is byte-for-byte +//! reproducible and drives no flakiness into the assertions layered on top of it. +//! The docs are realistic enough that the graph/link/field index is genuinely +//! exercised: a few indexed frontmatter fields (`type`/`status`/`priority`/ +//! `title`), real inter-doc `[[wikilinks]]`, and headings + body. +//! +//! Cargo compiles this into each test binary that declares `mod bench_util;`. + +use std::fmt::Write as _; +use std::path::Path; + +/// SplitMix64 — a tiny, dependency-free deterministic PRNG. Seeded from +/// `(seed, index)` so each document draws an independent-looking but fully +/// reproducible stream. We only need cheap, well-distributed integers here, not +/// cryptographic quality. +struct SplitMix64(u64); + +impl SplitMix64 { + fn new(seed: u64) -> Self { + SplitMix64(seed) + } + + fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Uniform-ish integer in `[0, bound)`. `bound` must be non-zero. + fn below(&mut self, bound: u64) -> u64 { + self.next_u64() % bound + } +} + +const TYPES: &[&str] = &["note", "task", "log", "reference"]; +const STATUSES: &[&str] = &["active", "backlog", "done", "archived"]; + +/// Zero-padded stem for document `i` (`doc-00042`). Width 6 keeps stems sorted +/// and stable up to a million docs — comfortably past the 50k benchmark scale. +pub fn doc_stem(i: usize) -> String { + format!("doc-{i:06}") +} + +/// Write a deterministic vault of `n` documents into `dir`. Returns the number +/// of documents written (always `n`). Panics on any I/O error — this is test +/// infrastructure and a failed write is a hard stop. +pub fn generate_vault(dir: &Path, n: usize, seed: u64) -> usize { + assert!(n > 0, "generate_vault needs at least one document"); + std::fs::create_dir_all(dir).expect("create vault dir"); + + for i in 0..n { + let mut rng = SplitMix64::new(seed ^ (i as u64).wrapping_mul(0x0100_0000_01B3)); + let doc_type = TYPES[(rng.below(TYPES.len() as u64)) as usize]; + let status = STATUSES[(rng.below(STATUSES.len() as u64)) as usize]; + let priority = 1 + rng.below(5); // 1..=5 + + // Two deterministic wikilinks to other docs (never self), so the link + // graph is dense enough to exercise the links index. + let link_a = pick_other(&mut rng, i, n); + let link_b = pick_other(&mut rng, i, n); + + let mut body = String::with_capacity(256); + // Frontmatter — the indexed fields queries filter on. + write!( + body, + "---\ntype: {doc_type}\nstatus: {status}\npriority: {priority}\ntitle: Document {i}\n---\n" + ) + .unwrap(); + // Headings + prose so the headings index and body scans have real input. + write!( + body, + "# Document {i}\n\n## Overview\n\nSynthetic document {i} of type {doc_type}. \ + Links to [[{a}]] and [[{b}]].\n\n## Details\n\nStatus is {status}; priority {priority}. \ + Lorem ipsum body text for index exercise.\n", + a = doc_stem(link_a), + b = doc_stem(link_b), + ) + .unwrap(); + + let path = dir.join(format!("{}.md", doc_stem(i))); + std::fs::write(&path, body).unwrap_or_else(|e| panic!("write {}: {e}", path.display())); + } + n +} + +/// Pick a document index other than `i` in `[0, n)`. With `n == 1` there is no +/// other doc, so it links to itself (harmless for the benchmark's purposes). +fn pick_other(rng: &mut SplitMix64, i: usize, n: usize) -> usize { + if n == 1 { + return 0; + } + let mut j = rng.below(n as u64) as usize; + if j == i { + j = (j + 1) % n; + } + j +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generation_is_deterministic_for_same_seed() { + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + generate_vault(a.path(), 50, 7); + generate_vault(b.path(), 50, 7); + for i in 0..50 { + let fa = std::fs::read_to_string(a.path().join(format!("{}.md", doc_stem(i)))).unwrap(); + let fb = std::fs::read_to_string(b.path().join(format!("{}.md", doc_stem(i)))).unwrap(); + assert_eq!(fa, fb, "doc {i} must be identical across same-seed runs"); + } + } + + #[test] + fn different_seeds_diverge() { + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + generate_vault(a.path(), 50, 1); + generate_vault(b.path(), 50, 2); + let any_diff = (0..50).any(|i| { + let fa = std::fs::read_to_string(a.path().join(format!("{}.md", doc_stem(i)))).unwrap(); + let fb = std::fs::read_to_string(b.path().join(format!("{}.md", doc_stem(i)))).unwrap(); + fa != fb + }); + assert!( + any_diff, + "different seeds should produce at least one differing doc" + ); + } +} diff --git a/tests/integrity_benchmark.rs b/tests/integrity_benchmark.rs new file mode 100644 index 0000000..8fe074a --- /dev/null +++ b/tests/integrity_benchmark.rs @@ -0,0 +1,344 @@ +//! NRN-83 acceptance benchmark — the daemon initiative's founding-bug proof. +//! +//! The founding bug: `Cache::open` runs `PRAGMA integrity_check` on every open, +//! an O(db-size) cost that dwarfs the actual query at scale. ADR 0005's +//! resolution relocated the check to the warm `norn serve` daemon +//! (open-once / verify-once); routed reads inherit the already-verified state. +//! This harness proves that end-to-end at 50k-doc scale, re-runnably. +//! +//! ## The structural (non-timing) acceptance observable +//! +//! `src/cache/open.rs` emits one `norn trace: integrity_check` stderr line per +//! `PRAGMA integrity_check` execution WHEN `NORN_TRACE_INTEGRITY_CHECK` is set. +//! Because a `Cache::open` on an EXISTING db always runs the check, this marker +//! is a deterministic, cross-process count of how many times a code path pays it: +//! +//! * A **direct** invocation reopens the cache every time → one marker PER call. +//! * The **warm daemon** opens the cache once and holds it → ONE marker across +//! every routed read, no matter how many `count`/`find`/`get` calls arrive. +//! +//! So the acceptance criterion is asserted structurally, not by timing: after N +//! routed reads the daemon's stderr carries exactly ONE integrity_check marker, +//! while N direct reads carry N. Timings are collected as supporting operator +//! evidence, printed as a table under `--nocapture`. The `count_served` markers +//! (one per tools/call the daemon actually serves) prove the routed reads were +//! genuinely served warm and did not silently fall back to a direct open — so +//! the one-marker result is a real verify-once win, not a vacuous zero-traffic +//! artifact. +//! +//! Run it explicitly (it is `#[ignore]`-gated; the 50k build takes real seconds): +//! +//! ```text +//! cargo test --release --test integrity_benchmark -- --ignored --nocapture +//! ``` +//! +//! Scale/seed are env-overridable: `NORN_BENCH_DOCS` (default 50000), +//! `NORN_BENCH_SEED` (default 83), `NORN_BENCH_ITERS` (default 5). + +#![cfg(unix)] + +#[path = "bench_util/mod.rs"] +mod bench_util; +#[path = "serve_util/mod.rs"] +mod serve_util; + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use bench_util::generate_vault; +use serve_util::{count_served, norn_bin, socket_path_for, wait_for_ready, ChildGuard}; + +/// The stable marker `src/cache/open.rs` emits per integrity_check under trace. +const INTEGRITY_MARKER: &str = "norn trace: integrity_check"; + +fn env_usize(key: &str, default: usize) -> usize { + std::env::var(key) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(default) +} + +fn env_u64(key: &str, default: u64) -> u64 { + std::env::var(key) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(default) +} + +/// Outcome of one `norn` invocation: exit code plus wall-clock and the count of +/// integrity_check markers on its OWN stderr (direct runs carry them here). +struct RunResult { + code: i32, + elapsed: Duration, + integrity_markers: usize, +} + +/// Run `norn --cwd ` with a private cache/state home and the +/// integrity-check trace enabled. Returns the timed result. +fn run_norn(cache_home: &Path, state_home: &Path, vault: &Path, args: &[&str]) -> RunResult { + let start = Instant::now(); + let out = Command::new(norn_bin()) + .env("XDG_CACHE_HOME", cache_home) + .env("XDG_STATE_HOME", state_home) + .env("NORN_SERVICE_HANDSHAKE_TIMEOUT_MS", "5000") + .env("NORN_TRACE_INTEGRITY_CHECK", "1") + .arg("--cwd") + .arg(vault) + .args(args) + .output() + .expect("run norn"); + let elapsed = start.elapsed(); + let markers = String::from_utf8_lossy(&out.stderr) + .matches(INTEGRITY_MARKER) + .count(); + RunResult { + code: out.status.code().unwrap_or(-1), + elapsed, + integrity_markers: markers, + } +} + +/// Spawn a daemon on `cache_home`, with integrity-check tracing on and its +/// stderr captured to `/err`. Returns the guard, the stderr path, and the +/// tempdir root (kept alive so the socket path stays valid). +fn spawn_daemon_traced(cache_home: &Path, state_home: &Path) -> (ChildGuard, PathBuf) { + let stderr_path = cache_home + .parent() + .expect("cache_home has a parent") + .join("daemon-err"); + let stderr_file = std::fs::File::create(&stderr_path).unwrap(); + let child = Command::new(norn_bin()) + .arg("serve") + .env("XDG_CACHE_HOME", cache_home) + .env("XDG_STATE_HOME", state_home) + .env("NORN_TRACE_INTEGRITY_CHECK", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::from(stderr_file)) + .spawn() + .expect("spawn norn serve"); + let guard = ChildGuard(child); + wait_for_ready(&socket_path_for(cache_home), Duration::from_secs(10)); + (guard, stderr_path) +} + +fn daemon_integrity_markers(stderr_path: &Path) -> usize { + std::fs::read_to_string(stderr_path) + .unwrap_or_default() + .matches(INTEGRITY_MARKER) + .count() +} + +fn median(mut v: Vec) -> Duration { + v.sort(); + v[v.len() / 2] +} + +/// The three routed-read shapes exercised. `get` targets the first generated +/// doc (guaranteed to exist); `find` is filtered + limited so its JSON output +/// stays small while still driving a full indexed query. +fn read_shapes() -> Vec<(&'static str, Vec<&'static str>)> { + vec![ + ("count", vec!["count"]), + ( + "find", + vec![ + "find", + "--eq", + "type:note", + "--limit", + "5", + "--format", + "json", + ], + ), + ("get", vec!["get", "doc-000000"]), + ] +} + +#[test] +#[ignore = "50k-doc acceptance benchmark; run explicitly with --ignored --nocapture"] +fn integrity_check_acceptance_50k() { + let n = env_usize("NORN_BENCH_DOCS", 50_000); + let seed = env_u64("NORN_BENCH_SEED", 83); + let iters = env_usize("NORN_BENCH_ITERS", 5); + assert!( + iters >= 2, + "need at least 2 iterations to separate cold/warm" + ); + + // ---- Generate the synthetic vault ----------------------------------- + let vault_tmp = tempfile::Builder::new() + .prefix("nb-vault-") + .tempdir() + .unwrap(); + let gen_start = Instant::now(); + generate_vault(vault_tmp.path(), n, seed); + let gen_elapsed = gen_start.elapsed(); + let vault = vault_tmp.path(); + + // One shared cache home for direct baseline THEN daemon: no daemon socket + // exists during the direct phase (so those calls run direct); the daemon + // binds the same home afterward and routes against the already-built cache. + // Short prefix keeps the socket path under macOS's ~104-byte sun_path. + let home_tmp = tempfile::Builder::new().prefix("nb-").tempdir().unwrap(); + let cache_home = home_tmp.path().join("c"); + let state_home = home_tmp.path().join("s"); + std::fs::create_dir_all(&cache_home).unwrap(); + std::fs::create_dir_all(&state_home).unwrap(); + + // ---- Build the cache once (Fresh open: no integrity_check yet) ------- + let build_start = Instant::now(); + let build = run_norn(&cache_home, &state_home, vault, &["count"]); + let build_elapsed = build_start.elapsed(); + assert_eq!(build.code, 0, "initial cache build (count) must exit 0"); + assert_eq!( + build.integrity_markers, 0, + "the Fresh-open build must NOT run integrity_check (no db existed yet)" + ); + // Resolve the vault's cache.db with the SAME identity mapping production + // uses (the cache dir is vault-hash-derived under the home), passing the + // private cache home EXPLICITLY — a pure function of the same value the + // spawned children receive via `.env()`, with zero process-env mutation in + // this test binary. A missing db here means the build above did not do + // what this harness thinks it did — fail loudly rather than reporting a + // 0-byte size. + let vault_utf8 = camino::Utf8Path::from_path(vault).expect("utf8 vault path"); + let cache_home_utf8 = camino::Utf8Path::from_path(&cache_home).expect("utf8 cache home"); + let cache_dir = norn_run::resolve_cache_dir_in(cache_home_utf8, vault_utf8) + .expect("resolve cache dir for vault"); + let cache_db = cache_dir.join("cache.db"); + let cache_db_bytes = std::fs::metadata(cache_db.as_std_path()) + .unwrap_or_else(|e| panic!("cache.db must exist at {cache_db} after the build: {e}")) + .len(); + + // ---- Direct baseline: each call reopens → pays integrity_check ------- + // This is ALSO the no-daemon regression guard (deliverable 3): a direct read + // at 50k docs must STILL verify — exactly one integrity_check marker per call. + let mut direct_medians: Vec<(&str, Duration)> = Vec::new(); + let mut direct_integrity_total = 0usize; + for (label, args) in read_shapes() { + let mut samples = Vec::with_capacity(iters); + for _ in 0..iters { + let r = run_norn(&cache_home, &state_home, vault, &args); + assert_eq!(r.code, 0, "direct {label} must exit 0"); + assert_eq!( + r.integrity_markers, 1, + "direct {label} at {n} docs must pay EXACTLY one integrity_check \ + (trust preserved on the no-daemon path)" + ); + direct_integrity_total += r.integrity_markers; + samples.push(r.elapsed); + } + direct_medians.push((label, median(samples))); + } + + // ---- Routed: spawn the daemon on the SAME (already-built) cache home -- + let (_guard, daemon_stderr) = spawn_daemon_traced(&cache_home, &state_home); + + let mut routed_medians: Vec<(&str, Duration)> = Vec::new(); + let mut served_counts: Vec<(&str, usize)> = Vec::new(); + let mut total_routed_calls = 0usize; + for (label, args) in read_shapes() { + // Warm-only latency: skip the first call, whose cost may include the + // daemon's single warm open. Calls 2..=iters are steady-state warm. + let mut warm_samples = Vec::with_capacity(iters - 1); + for i in 0..iters { + let r = run_norn(&cache_home, &state_home, vault, &args); + assert_eq!(r.code, 0, "routed {label} must exit 0"); + // A routed call carries NO integrity marker on its OWN stderr — the + // check (if any) happens daemon-side, counted separately below. + assert_eq!( + r.integrity_markers, 0, + "routed {label} must not run integrity_check in the CLI process" + ); + if i > 0 { + warm_samples.push(r.elapsed); + } + total_routed_calls += 1; + } + routed_medians.push((label, median(warm_samples))); + + let tool = match label { + "count" => "vault.count", + "find" => "vault.find", + "get" => "vault.get", + other => panic!("unmapped shape {other}"), + }; + served_counts.push((label, count_served(&daemon_stderr, tool))); + } + + // ---- STRUCTURAL ACCEPTANCE ASSERTION -------------------------------- + // The daemon opened the cache once and held it: across ALL routed reads its + // stderr carries exactly ONE integrity_check marker. If routed reads paid the + // check per invocation (the founding bug, un-fixed), this would be + // total_routed_calls instead. + let daemon_markers = daemon_integrity_markers(&daemon_stderr); + assert_eq!( + daemon_markers, + 1, + "ACCEPTANCE: the warm daemon must pay integrity_check exactly ONCE across \ + all {total_routed_calls} routed reads (verify-once); got {daemon_markers}. \ + daemon stderr:\n{}", + std::fs::read_to_string(&daemon_stderr).unwrap_or_default() + ); + + // Routing was real, not a vacuous fall-back to direct: each shape was served + // `iters` times by the daemon. + for (label, served) in &served_counts { + assert_eq!( + *served, iters, + "routed {label} must have been SERVED {iters} times by the daemon, got {served}; \ + a lower count means it silently ran direct and the one-marker result above is vacuous" + ); + } + + // Regression guard tally: direct reads verified every time. + assert_eq!( + direct_integrity_total, + iters * read_shapes().len(), + "every direct read must have paid integrity_check (no-daemon trust guard)" + ); + + // Supporting timing evidence only — NOT an acceptance gate. The structural + // marker assertions above are the gates; wall-clock is machine- and + // load-dependent (small NORN_BENCH_DOCS overrides, loaded CI machines), so + // a slower routed median WARNs into the --nocapture record instead of + // failing the run. + let direct_count_med = direct_medians[0].1; + let routed_count_med = routed_medians[0].1; + if routed_count_med >= direct_count_med { + println!( + "WARN: routed warm count median ({routed_count_med:?}) was not faster than direct \ + ({direct_count_med:?}); timing is evidence only — the structural verify-once gates \ + above still held" + ); + } + + // ---- Evidence table -------------------------------------------------- + println!("\n==================== NRN-83 integrity_check acceptance ===================="); + println!("documents generated : {n} (seed {seed})"); + println!("vault generation : {gen_elapsed:?}"); + println!("cold cache build (count) : {build_elapsed:?}"); + println!("cache.db size : {} bytes", cache_db_bytes); + println!("iterations per shape : {iters}"); + println!("--------------------------------------------------------------------------"); + println!( + "{:<8} {:>18} {:>18} {:>10}", + "shape", "direct median", "routed(warm) med", "served" + ); + for i in 0..read_shapes().len() { + let (label, d) = direct_medians[i]; + let (_, r) = routed_medians[i]; + let (_, served) = served_counts[i]; + println!("{label:<8} {:>18?} {:>18?} {:>10}", d, r, served); + } + println!("--------------------------------------------------------------------------"); + println!( + "integrity_check markers : direct = {} (one per read), daemon = {} (verify-once)", + direct_integrity_total, daemon_markers + ); + println!("routed reads served : {total_routed_calls} total across all shapes"); + println!("==========================================================================\n"); +}