Skip to content

Latest commit

 

History

History
495 lines (428 loc) · 26.3 KB

DECISIONS.md

File metadata and controls

495 lines (428 loc) · 26.3 KB

Decisions

Format for every entry: constraint, options, choice, consequence, evidence. Entries that only restate a diff will be deleted.

D001 - source repo

  • Constraint: unmodified original-suite parity is the primary success metric; this port requires a preserved C ABI.
  • Options: antirez/sds; kgabis/parson; codeplea/tinyexpr; others in the candidate pool.
  • Choice: tinyexpr.
  • Consequence: smaller LOC than a typical mid-size C library; completeness arguments (enumeration, coverage) become realistic.
  • Evidence: sds tests live inside sds.c under SDS_TEST_MAIN and cannot link against a replacement object. tinyexpr smoke.c is a separate translation unit calling only the public API (plus one ex->value read). Measured smoke 4930/4930 and smoke_pr 4928/4928 on pin 4a7456e2.

D002 - Line endings and hash integrity

  • Constraint: vendored-hash verification fails if git rewrites CRLF.
  • Options: rely on developer settings; force .gitattributes.
  • Choice: * -text plus explicit eol=lf for C/H/Zig/shell, committed first.
  • Consequence: all contributors must accept LF for those paths.
  • Evidence: vendored SHA-256 values match the fresh upstream clone; see .upstream.toml and scripts/verify-original-hashes.sh.

D003 - Zig version

  • Constraint: built-in fuzzer has known crashes on some dev builds; 0.16 changed build and I/O APIs.
  • Options: nightly; 0.15.x; 0.16.0 stable.
  • Choice: 0.16.0 stable, SHA-256 pinned.
  • Consequence: contributors must not assume 0.15 APIs (linkLibC on Compile is gone; use link_libc on the module).
  • Evidence: zig version → 0.16.0; download SHA matched 70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00; b.addLibrary + export fn ... callconv(.c) verified by building a stub static library and nm.

D004 - Bind platform libm, not std.math

  • Constraint: differential equivalence compares f64 results as u64 bit patterns; last-ULP drift counts as a divergence.
  • Options: use std.math; call glibc via extern "c"; mix both.
  • Choice: every transcendental and libm-backed builtin goes through extern "c" (see src/libm.zig). No @setFloatMode(.optimized).
  • Consequence: the port depends on libc math; Zig-only freestanding builds are out of scope.
  • Evidence: sampled glibc vs std.math on the same inputs (specials, dense grids, powers of two). Counts in docs/evidence/baseline/16-libm-vs-stdmath.txt:
    • sin 602117 samples, 0 differ
    • cos/tan/exp/log/log10/sqrt/ceil/floor/fabs: 0 differ on their samples
    • asin 26109 samples, 24441 differ (rate 0.936)
    • acos 26109 samples, 24494 differ (rate 0.938)
    • atan 600022 samples, 30803 differ (rate 0.051)
    • pow 24831 samples, 8294 differ (rate 0.334)
    • atan2 24831 samples, 3606 differ (rate 0.145)
    • fmod 24831 samples, 6000 differ (rate 0.242) Using std.math would fail bit-exact differential fuzz on asin/acos/pow alone.

D005 - Generic parser over a comptime AST builder

  • Constraint: this port needs a C-layout tree for ABI consumers; Code Quality wants an idiomatic tagged-union API. Duplicating the parser risks silent drift between the two.
  • Options: one C-shaped tree everywhere; two separate parsers; one parser generic over a builder interface (ENCRUST-style encapsulated substitution, arXiv:2604.04527).
  • Choice: shared recursive-descent parser parameterized by a builder (parser.zig). NativeBuilder builds ast.Expr. A later pass adds a C-layout builder in c_abi.zig without forking parse logic.
  • Consequence: builder interface must be rich enough for both layouts; some C-specific allocation tricks stay in the C builder.
  • Evidence: single Parser(Builder) type compiles against NativeBuilder; zig build test green in all four flag combinations.

D006 - fac overflow: C returns INF, not a wrapped finite

  • Constraint: earlier planning claimed fac(21) wraps unsigned long and returns a wrong finite. Zig ReleaseSafe would panic on wrap, so the plan was *% for bit-equivalence.
  • Options: trust the plan; measure; invent wrap on purpose.
  • Choice: measure. On pin 4a7456e2, te_interp("fac(21)") returns +inf (bits 0x7ff0000000000000). The C source checks i > ULONG_MAX / result before multiplying. True 21! via tgamma(22) is finite (0x1.6283be9b5c61fp+65). So C is wrong relative to math, but as an explicit INF sentinel, not as wraparound.
  • Consequence: the port reproduces the checked INF path (default). -Dfix-fac-overflow rejects non-integer inputs with NaN (C truncates fac(4.8) to 24, which smoke expects). We do not use *% for default fac, because that would diverge from measured C.
  • Evidence: docs/evidence/baseline/14-fac-ncr-probe.txt. smoke expects fac(4.8)==24.

D007 - ncr/npr DoS surface is small under the overflow guard

  • Constraint: we care about allocator misuse; planning also flagged algorithmic-complexity DoS in ncr/npr loops up to UINT_MAX.
  • Options: add a loop-iteration cap; leave as C; document only.
  • Choice: match C arithmetic; document. Overflow checks return INF after few iterations for large n, so the long-loop DoS does not materialise on the cases we timed.
  • Consequence: a future hardening flag could cap ur, but that would be another intentional divergence.
  • Evidence: ncr(40,20) 0.000046s; ncr(1e9,100) returns INF in 0.000001s; ncr(UINT_MAX, 200000) returns INF in 0.000001s (docs/evidence/baseline/15c-ncr-dos.txt).

D008 - Nesting bound 2048 (intentional divergence)

  • Constraint: C recurses without a depth limit and SIGSEGVs near depth 20144 on WSL x86_64 with an 8 MiB stack (issue #136; host-dependent). The port must not inherit that crash.
  • Options: raise stack; keep unbounded recursion; hard error at a bound; iterative parser with a heap stack.
  • Choice: hard error error.NestingTooDeep at depth 2048 (same number as kgabis/parson MAX_NESTING). Configurable via -Dmax-nesting. Equivalence claims are scoped to inputs where C does not invoke undefined behaviour via stack overflow.
  • Consequence: differential harnesses must treat deep nests as an expected divergence table entry, not a fuzz failure.
  • Evidence: unit test builds 1e6 nested parens and expects NestingTooDeep; C crashes at 20144 without ASan (WSL x86_64, 8 MiB stack; Darwin arm64 measured 23764 in docs/findings.md).

D009 - Two-layer public surface

  • Constraint: idiomatic Zig is preferred; this port still wants the C ABI.
  • Options: only C ABI; only Zig API; both with a clear boundary.
  • Choice: src/root.zig is the Zig-native API (allocator, error unions, tagged union, Op-enum eval). src/c_abi.zig is the ABI adapter and the only file allowed to contain escape hatches.
  • Consequence: smoke.c links the Zig .a through the C header unchanged.
  • Evidence: original suite transcripts under docs/evidence/.

D010 - Export the accidental globals

  • Constraint: early nm on the upstream object showed te_free_parameters and next_token as globally defined alongside the header API.
  • Options: header API only; exact nm set.
  • Choice: exact nm set (all seven symbols).
  • Consequence: State is public as an extern struct so next_token can be called with a C-compatible layout even though it is not in the header.
  • Evidence: nm -g --defined-only on libtinyexpr.a lists all seven.

D011 - C ABI tree ownership, then parser unification

  • Constraint: D005 promised one parser generic over a builder. Smoke needs C function pointers, closures, fake-FAM parameters[1], and ex->value reads.
  • Options: translate native AST after compile; CAbiBuilder in the shared parser; keep a dedicated C-layout grammar forever.
  • Choice: land C-layout eval/free/print/allocate in c_abi.zig, then feed that layout through Parser(CAbiBuilder) and delete the private base/power/factor/term/expr/list. D005 stands.
  • Consequence: grammar lives in one place. Builder responsibilities (callee storage, malloc shape, peel-negate) stay in CAbiBuilder. Sign collapsing in power() must match C or TE_POW_FROM_RIGHT peeling drifts.
  • Evidence: private grammar gone from c_abi.zig; four configs still 4930/4924/4934/4928; error-position and te_print fixtures gate tree shape.

D012 - Fake FAM without out-of-bounds Zig indexing

  • Constraint: C allocates (sizeof(te_expr)-sizeof(void*))+arity*sizeof(void*)+(closure?sizeof(void*):0) and indexes parameters[i] past the declared [1] (UB that works in practice). Zig must not copy that UB into safe code.
  • Options: @ptrCast the struct; store args elsewhere; form a [*]?*anyopaque from the allocation base.
  • Choice: allocate with libc malloc sized like C; expose slots via parameters(n) returning a many-pointer at @offsetOf(TeExpr,"parameters"). Document that C's indexing past element zero is UB and that Zig never indexes the [1] array past zero.
  • Consequence: C ABI trees stay layout-identical without teaching Zig to index past a declared field; all pointer arithmetic for children lives behind documented escape hatches in c_abi.zig.
  • Evidence: layout probe IDENTICAL; Dynamic/Closure suites green.

D013 - Escape-hatch operational definition (escape-hatch budget)

  • Constraint: Zig has no unsafe keyword, so escape hatches need an explicit countable definition and a CI gate. No external C-to-Zig threshold was published that we could adopt as-is.
  • Options: claim zero; count nothing; define a countable set.
  • Choice: count @ptrCast, @alignCast, @constCast, pointer @bitCast, [*c], @intFromPtr, @ptrFromInt, @volatileCast, @setRuntimeSafety with a Python tokeniser that skips comments and strings (tools/count-escape-hatches.py). Threshold: 20 (ratchet; was 80 before consolidation), confined to src/c_abi.zig. Every hatch gets an invariant comment.
  • Consequence: CI fails if any hatch appears outside c_abi.zig or total exceeds the threshold. Before parser/CAbiBuilder unification the measured count was ~49-50; after consolidation it is 16 (799 audit-surface lines, 6 exported te_* functions). Per-kloc density is misleading on a tiny FFI file; hatches-per-export and absolute audit surface are the honest denominators.
  • Evidence: docs/evidence/escape-hatches.json.

D014 - C ABI vs Rust unsafe tension

  • Constraint: this port requires a preserved C ABI. Rust extern "C" entry points that dereference raw pointers need unsafe, so a faithful C-to-Rust ABI port cannot reach zero unsafe.
  • Options: ignore; switch tracks; argue Zig's countable shim.
  • Choice: stay on Zig and argue explicitly that the ABI requirement and the escape-hatch budget goal are in tension for Rust and harmonious for Zig: escape hatches live only in the FFI file and are CI-gated.
  • Consequence: the escape-hatch story rests on confinement + threshold, not on a literal unsafe keyword count of zero.
  • Evidence: this decision; hatch gate in CI.

D015 - Anti-overclaiming control

  • Constraint: README numbers must not outrun evidence.
  • Options: manual discipline; generate README; parse and compare.
  • Choice: tools/scorecard.py writes docs/evidence/scorecard.json from artifacts only (missing required inputs fail loudly; unmeasured fields use an explicit not_measured sentinel). tools/check_readme_claims.py extracts every README numeric claim and requires each to be backed in the scorecard (README ⊆ evidence; catches overclaims).
  • Consequence: structurally impossible to claim suite fractions or hatch totals without matching artifacts (or the claim check fails).
  • Evidence: scripts/check-readme-claims.sh (includes --self-test against tools/testdata/readme-wrong.md); wired into zig build verify and CI readme-claims.

D016 - Typed function pointers by arity

  • Constraint: the shared parser must preserve C ABI function and closure calling conventions while keeping the native AST type-safe.
  • Options: carry an arity plus an opaque pointer; model each C-callconv function signature in an arity-tagged union.
  • Choice: ast.FunctionPtr and ast.ClosurePtr use typed pointers by arity; the C ABI builder erases them only when writing te_expr.data.function.
  • Consequence: invalid arity/signature combinations cannot enter the shared lexer or parser. The necessary pointer casts remain documented in src/c_abi.zig.
  • Evidence: the native AST stores Fn0-Fn7 and Cl0-Cl7; C ABI variable conversion selects the matching variant from TE_FUNCTION0-7 and TE_CLOSURE0-7.

D017 - Fragment-assembled scorecard and generated README region

  • Constraint: parallel coverage, fuzz, allocation-fault, benchmark, and upstream-review campaigns will finish independently, but the scorecard and README must remain one truthful, reviewable view.
  • Options: hand-edit the aggregate files; make each campaign rewrite the whole scorecard; compose measured base artifacts with small campaign fragments.
  • Choice: tools/scorecard.py builds a measured base from the suite, hatch, layout, cross-compile, and hash artifacts, then deep-merges docs/evidence/fragments/*.json. tools/render_readme.py owns the README region between its scorecard markers.
  • Consequence: generated files are never hand-merged. Resolve a conflict in scorecard.json or the README region by taking either side and rerunning the generators; absent campaign values are explicit not_measured, never null.
  • Evidence: tools/scorecard.py, tools/render_readme.py, and scripts/check-readme-rendered.sh.

D018 - Escape-hatch thresholds and differential-harness fallback

  • Constraint: shipping needs a concrete escape-hatch gate and a differential harness even when no external template exists.
  • Options: wait for someone else's harness; invent a claimed official threshold; keep a local operational threshold and build the missing harness.
  • Choice: keep the operational threshold of 20, confined to src/c_abi.zig, and build a bespoke differential harness in this repo.
  • Consequence: the threshold is explicit project policy, backed by CI.
  • Evidence: D013 and tools/count-escape-hatches.py.

D019 - Coverage: lcov on C, honest kcov bound on Zig

  • Constraint: coverage evidence needs exact line/branch/function percentages for the original C, every missed branch with a reason, and the same honesty for the port.
  • Options: hand-roll gcov parsers; invent port percentages when kcov stays silent; ship only what the tools attribute.
  • Choice: drive C with gcc --coverage plus lcov --rc branch_coverage=1 and genhtml (user-local lcov 2.0 when apt needs root). Expand the corpus until only allocation-failure and defensive-default arms remain. Install kcov 43 and run it; when it attributes zero src/*.zig lines despite DWARF naming those files, record not_measured rather than a guessed percent.
  • Consequence: C numbers are exact and reproducible; the port coverage claim is an explicit tooling bound, not a silent omission.
  • Evidence: docs/evidence/coverage.md, docs/evidence/fragments/coverage-c.json, docs/evidence/fragments/coverage-port.json.

D020 - In-process enumeration via objcopy symbol rename

  • Constraint: linking the C original and the Zig static library in one process collides on te_compile / te_eval / te_free / te_interp / te_print.
  • Options: two subprocesses per input; rename Zig exports; rename C symbols.
  • Choice: compile the pinned C translation unit and run objcopy --redefine-sym te_*=c_te_* (and next_token when exported) before linking with the Zig archive and tools/enumerate.c.
  • Consequence: exhaustive comparison stays in-process and fast enough for 11505 inputs in about one second on the measurement host; the technique is documented in fuzz/README.md and docs/evidence/enumeration.md.
  • Evidence: scripts/run-enumerate.sh, docs/evidence/fragments/enumeration.json (total 11505, divergences 0 at string length 3 / AST depth 1).

D021 - One malloc interposer for C and Zig ABI

  • Constraint: we want allocator-misuse evidence; both implementations must be failed at every allocation ordinal on a shared corpus.
  • Options: separate Zig GPA fault injection; ld --wrap=malloc; LD_PRELOAD over libc malloc/calloc/realloc/free.
  • Choice: tools/allocfault LD_PRELOAD interposer for the main campaign. The Zig static library allocates through libc malloc at the ABI boundary just as the C original does, so one interposer drives both. ASan builds use a preprocessor shim TU (tinyexpr_instrumented.c) because ASan owns malloc and GNU --wrap did not redirect libc malloc on this host.
  • Consequence: ordinal campaigns are directly comparable; ledger diffs are meaningful; ASan ordinal runs are C-shim-only with Zig still on LD_PRELOAD.
  • Evidence: tools/allocfault/run.sh, docs/evidence/fragments/allocfault.json (271 ordinals, 0 crashes, ledger identical on 1+2*3 with 11 events).

D022 - Differential fuzz engines and ReleaseFast link

  • Constraint: the fuzz goal needs 60 continuous seconds with zero divergences; Debug Zig fuzzing is known-crashy; linking ReleaseSafe .a into a gcc main fails on __zig_probe_stack.
  • Options: Zig std.testing fuzz only; libFuzzer only; dual harness.
  • Choice: in-process C-renamed + Zig harness at fuzz/harness.c with structured generation, plus fuzz/harness.zig Smith metamorphic tests. Build the Zig archive as ReleaseFast for C-linked drivers; provide tools/zig_probe_stub.c when Debug/ReleaseSafe archives must link into gcc.
  • Consequence: certified run publishes executions, not only wall-clock; metamorphic transforms are a separate campaign.
  • Evidence: fuzz/log.txt (48512558 execs / 60s / 0 divergences), docs/evidence/fragments/metamorphic.json (45 cases / 0 failures).

D023 - Sanitizer matrix and float flags

  • Constraint: reviewers need a glanceable matrix across C sanitizers, Zig opt modes, and flag configs; float contraction would silently break bit-exact equivalence.
  • Options: cite prompt-1 measurements; re-measure at HEAD.
  • Choice: re-measure via scripts/run-sanitizer-matrix.sh at the current commit; record Valgrind as tool_missing when absent; empirically check cc -O2 -### for fast-math flags.
  • Consequence: sixteen Zig suite cells (4 modes x 4 configs) plus C UBSan/ASan cells are first-class evidence; float claim is measured.
  • Evidence: docs/evidence/sanitizers.md, docs/evidence/fragments/sanitizers.json.

D024 - Signed-char ctype lead closed by trapping wrapper

  • Constraint: UBSan on glibc did not diagnose isalpha on high-bit bytes; editing third_party/ is forbidden.
  • Options: declare unverifiable; rely on standard citations alone; inject a trapping ctype via compiler -include.
  • Choice: ship scripts/run-buglead-ctype.sh with a trap header that aborts on negative ctype arguments; feed bytes 0x80..0xFF through te_interp.
  • Consequence: Lead B is a confirmed UB surface with a one-line stderr reproducer (ctype_trap: isalpha(-128)), ready for upstream filing.
  • Evidence: docs/findings.md Lead B, docs/evidence/fragments/buglead-ctype.json.

D025 - Locale lead: correctness yes, hang no; Zig shares strtod

  • Constraint: Lead C claimed a possible non-advancing s->next DoS under de_DE.UTF-8; the port was expected to be locale-independent.
  • Options: change Zig to locale-independent parsing now; keep strtod parity.
  • Choice: keep libm.strtod for bit-exact parity; measure both under setlocale(LC_ALL, "de_DE.UTF-8"); hard-timeout the suspected hang inputs.
  • Consequence: 1.5 fails on both sides after setlocale (correctness bug in the original, shared by the port); .5 does not hang within 3s; smoke under env-only LC_ALL is not a trigger because smoke never calls setlocale. Equivalence for decimal literals is scoped to the C locale.
  • Evidence: docs/findings.md Lead C, docs/evidence/fragments/buglead-locale.json.

D030 - Docker Zig tarball must follow TARGETARCH

  • Constraint: the recommended one-command path is docker compose up. Reviewers on Apple Silicon get a linux/arm64 engine by default. The image previously always downloaded zig-x86_64-linux-0.16.0.tar.xz.
  • Options: force platform: linux/amd64 in compose; document a manual flag; select the Zig tarball from BuildKit TARGETARCH.
  • Choice: Dockerfile switches on TARGETARCH (amd64 -> x86_64 SHA 70e49664..., arm64 -> aarch64 SHA ea4b09bf...). Both digests verified against ziglang.org downloads and recorded in .upstream.toml.
  • Consequence: cold docker compose up --build verify works on arm64 Docker without qemu-user for the Zig toolchain itself. CI on ubuntu-24.04 amd64 is unchanged.
  • Evidence: first independent run failed with invalid ELF machine type: AARCH64; after the change, VERIFY OK in 122.71s on M1 Docker (docs/REPRODUCTION.md).

D031 - README claim gate must not rewrite before checking

  • Constraint: anti-overclaiming requires a checker that can fail.
  • Options: keep auto-render-then-check; split render and check; check only.
  • Choice: scripts/check-readme-claims.sh only compares. scripts/verify.sh renders explicitly after regenerating the scorecard, then runs claims + check-readme-rendered.sh. CI readme-scorecard already used --check first; the claims script no longer undoes a planted overclaim.
  • Consequence: a committed wrong fraction fails locally and in CI instead of being silently corrected during the "check".
  • Evidence: planted 4999/4999 exited 0 before the fix and 1 after (docs/evidence/gate-failure-proofs.md).

D032 - Host script portability for cold clones

  • Constraint: second-machine / review hosts are not the original WSL box. macOS /bin/bash is old enough to reject empty-array expansion under set -u; BSD sed -i needs an extension arg; mapfile and GNU objcopy are absent without Homebrew binutils.
  • Options: require Linux only; paper over in docs; fix the scripts.
  • Choice: empty-array-safe flag expansion in run-original-suite.sh; portable sed -i detection in verify.sh; sha256sum/shasum fallback; enumeration skips cleanly when objcopy is missing, with Docker remaining the full path.
  • Consequence: host zig build test and the four original-suite configs run on Darwin; full verify still prefers Docker for enumeration and tool parity.
  • Evidence: pre-fix empty-array unbound variable and mapfile: command not found on this Mac; suites then measured at 4930/4924/4934/4928 (docs/REPRODUCTION.md).

D033 - Divergence table and scoped equivalence claim

  • Constraint: reviewers need every intentional behavioural difference in one place, and an equivalence claim that does not silently cover UB or locale traps.
  • Options: scatter notes across DECISIONS only; omit scope; one table plus one sentence.
  • Choice: add an intentional-divergences table to README.md and keep the authoritative detail in docs/findings.md. Equivalence sentence: C locale, below nesting bound, no C UB.
  • Consequence: nest bound, optional fac flag, locale/strtod, and ctype UB are explicit. Locale is shared behaviour, not a port win.
  • Evidence: nest thresholds and locale tables measured 2026-08-02 (docs/evidence/repro/run-log-*.txt); upstream #136/#105/#141/#142.

D034 - Upstream engagement for upstream findings

  • Constraint: upstream engagement is stronger when issues are filed during development, without false novelty or severity inflation.
  • Options: claim #136 as ours; file many thin issues; file only what the survey shows is unfiled and confirm the rest.
  • Choice: comment on #136 and #105 with independent measurements; open #141 (next_token) and #142 (locale); open minimal PRs #143 and #144. Record allocator criterion as not satisfied.
  • Consequence: two new issues, two PRs, two confirmation comments. allocator-misuse remains an honest negative.
  • Evidence: URLs in docs/findings.md; survey in docs/evidence/upstream-issues.md; fragment docs/evidence/fragments/upstream-findings.json.

D035 - Benchmark host and fair comparison

  • Constraint: useful benches need p99 / RSS / startup with methodology, and must not hide regressions. This machine is an Apple M1 laptop; bare-metal Linux with a locked governor was not available.
  • Options: skip benches; report only Darwin; report Docker Linux with full confounder disclosure; claim bare-metal controls we did not have.
  • Choice: primary numbers from Ubuntu 24.04 aarch64 Docker via Docker Desktop; protocol written first in bench/methodology.md; headline = C -O2 vs Zig ReleaseSafe, with ReleaseFast always shown beside it.
  • Consequence: governor/turbo unavailable; tail latency credibility reduced; ReleaseSafe interp arithmetic p99 was about 1.03% slower than C on that host and is disclosed in the README.
  • Evidence: bench/results.json, bench/logs/run-docker.txt, fragment docs/evidence/fragments/bench-linux-docker.json.

D036 - C and Zig consumability as first-class examples

  • Constraint: "exposes the same C ABI" and package consumption must be demonstrated outside assertions in prose.
  • Options: README snippets only; in-tree examples without external fetch; examples plus an out-of-tree zig fetch of a tarball of build.zig.zon paths.
  • Choice: examples/c-consumer links unmodified tinyexpr.h to Zig .a and .so. examples/zig-consumer uses a path dependency. External scratch project fetched file:///tmp/tinyexpr-pkg.tar.gz and ran successfully. build.zig exports b.addModule("tinyexpr", ...).
  • Consequence: directory file:// fetch is rejected by Zig 0.16 ("unknown file type"); consumers need a git URL or an archive of the package paths.
  • Evidence: examples/c-consumer/run-log.txt, examples/zig-consumer/run-log.txt, /tmp/te-user-run.txt on the author machine (external_fetch_ok value=10).

D037 - Reviewer-facing documentation shape

  • Constraint: reviewers have minutes, not hours, and most readers will not read Zig.
  • Options: long narrative README; scorecard only; six-minute path + FAQ + architecture diagram + regenerated scorecard region.
  • Choice: rewrite README first screen for pin / verify / CI / scorecard; add docs/quickstart.md, docs/architecture.md, docs/faq.md; keep generated numbers inside scorecard markers.
  • Consequence: claim checker still owns every digit in the README; bench headlines must land in a fragment before they may appear in prose.
  • Evidence: scripts/check-readme-claims.sh on the rewritten README; timer notes in docs/quickstart.md.