Format for every entry: constraint, options, choice, consequence, evidence. Entries that only restate a diff will be deleted.
- 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.cunderSDS_TEST_MAINand cannot link against a replacement object. tinyexprsmoke.cis a separate translation unit calling only the public API (plus oneex->valueread). Measuredsmoke4930/4930 andsmoke_pr4928/4928 on pin4a7456e2.
- Constraint: vendored-hash verification fails if git rewrites CRLF.
- Options: rely on developer settings; force
.gitattributes. - Choice:
* -textplus expliciteol=lffor 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.tomlandscripts/verify-original-hashes.sh.
- 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 (
linkLibCon Compile is gone; uselink_libcon the module). - Evidence:
zig version→0.16.0; download SHA matched70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00;b.addLibrary+export fn ... callconv(.c)verified by building a stub static library andnm.
- Constraint: differential equivalence compares f64 results as u64 bit patterns; last-ULP drift counts as a divergence.
- Options: use
std.math; call glibc viaextern "c"; mix both. - Choice: every transcendental and libm-backed builtin goes through
extern "c"(seesrc/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.mathon the same inputs (specials, dense grids, powers of two). Counts indocs/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.
- 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).NativeBuilderbuildsast.Expr. A later pass adds a C-layout builder inc_abi.zigwithout 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 againstNativeBuilder;zig build testgreen in all four flag combinations.
- 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(bits0x7ff0000000000000). The C source checksi > ULONG_MAX / resultbefore multiplying. True 21! viatgamma(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-overflowrejects non-integer inputs with NaN (C truncatesfac(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 expectsfac(4.8)==24.
- 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).
- 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.NestingTooDeepat depth 2048 (same number as kgabis/parsonMAX_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).
- 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.zigis the Zig-native API (allocator, error unions, tagged union, Op-enum eval).src/c_abi.zigis the ABI adapter and the only file allowed to contain escape hatches. - Consequence: smoke.c links the Zig
.athrough the C header unchanged. - Evidence: original suite transcripts under
docs/evidence/.
- Constraint: early
nmon the upstream object showedte_free_parametersandnext_tokenas globally defined alongside the header API. - Options: header API only; exact nm set.
- Choice: exact nm set (all seven symbols).
- Consequence:
Stateis public as an extern struct sonext_tokencan be called with a C-compatible layout even though it is not in the header. - Evidence:
nm -g --defined-onlyonlibtinyexpr.alists all seven.
- Constraint: D005 promised one parser generic over a builder. Smoke needs C
function pointers, closures, fake-FAM
parameters[1], andex->valuereads. - 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 throughParser(CAbiBuilder)and delete the privatebase/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 inpower()must match C orTE_POW_FROM_RIGHTpeeling 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.
- Constraint: C allocates
(sizeof(te_expr)-sizeof(void*))+arity*sizeof(void*)+(closure?sizeof(void*):0)and indexesparameters[i]past the declared[1](UB that works in practice). Zig must not copy that UB into safe code. - Options:
@ptrCastthe struct; store args elsewhere; form a[*]?*anyopaquefrom the allocation base. - Choice: allocate with libc
mallocsized like C; expose slots viaparameters(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.
- Constraint: Zig has no
unsafekeyword, 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,@setRuntimeSafetywith a Python tokeniser that skips comments and strings (tools/count-escape-hatches.py). Threshold: 20 (ratchet; was 80 before consolidation), confined tosrc/c_abi.zig. Every hatch gets an invariant comment. - Consequence: CI fails if any hatch appears outside
c_abi.zigor total exceeds the threshold. Before parser/CAbiBuilder unification the measured count was ~49-50; after consolidation it is 16 (799 audit-surface lines, 6 exportedte_*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.
- Constraint: this port requires a preserved C ABI. Rust
extern "C"entry points that dereference raw pointers needunsafe, 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
unsafekeyword count of zero. - Evidence: this decision; hatch gate in CI.
- Constraint: README numbers must not outrun evidence.
- Options: manual discipline; generate README; parse and compare.
- Choice:
tools/scorecard.pywritesdocs/evidence/scorecard.jsonfrom artifacts only (missing required inputs fail loudly; unmeasured fields use an explicitnot_measuredsentinel).tools/check_readme_claims.pyextracts 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-testagainsttools/testdata/readme-wrong.md); wired intozig build verifyand CIreadme-claims.
- 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.FunctionPtrandast.ClosurePtruse typed pointers by arity; the C ABI builder erases them only when writingte_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-Fn7andCl0-Cl7; C ABI variable conversion selects the matching variant fromTE_FUNCTION0-7andTE_CLOSURE0-7.
- 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.pybuilds a measured base from the suite, hatch, layout, cross-compile, and hash artifacts, then deep-mergesdocs/evidence/fragments/*.json.tools/render_readme.pyowns the README region between its scorecard markers. - Consequence: generated files are never hand-merged. Resolve a conflict in
scorecard.jsonor the README region by taking either side and rerunning the generators; absent campaign values are explicitnot_measured, never null. - Evidence:
tools/scorecard.py,tools/render_readme.py, andscripts/check-readme-rendered.sh.
- 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.
- 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 --coveragepluslcov --rc branch_coverage=1andgenhtml(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 zerosrc/*.ziglines despite DWARF naming those files, recordnot_measuredrather 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.
- 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_*(andnext_tokenwhen exported) before linking with the Zig archive andtools/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.mdanddocs/evidence/enumeration.md. - Evidence:
scripts/run-enumerate.sh,docs/evidence/fragments/enumeration.json(total11505,divergences0 at string length 3 / AST depth 1).
- 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_PRELOADover libcmalloc/calloc/realloc/free. - Choice:
tools/allocfaultLD_PRELOAD interposer for the main campaign. The Zig static library allocates through libcmallocat 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 ownsmallocand GNU--wrapdid not redirect libcmallocon 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 on1+2*3with 11 events).
- Constraint: the fuzz goal needs 60 continuous seconds with zero divergences; Debug
Zig fuzzing is known-crashy; linking ReleaseSafe
.ainto a gcc main fails on__zig_probe_stack. - Options: Zig
std.testingfuzz only; libFuzzer only; dual harness. - Choice: in-process C-renamed + Zig harness at
fuzz/harness.cwith structured generation, plusfuzz/harness.zigSmith metamorphic tests. Build the Zig archive as ReleaseFast for C-linked drivers; providetools/zig_probe_stub.cwhen 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).
- 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.shat the current commit; record Valgrind astool_missingwhen absent; empirically checkcc -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.
- Constraint: UBSan on glibc did not diagnose
isalphaon high-bit bytes; editingthird_party/is forbidden. - Options: declare unverifiable; rely on standard citations alone; inject a
trapping ctype via compiler
-include. - Choice: ship
scripts/run-buglead-ctype.shwith a trap header that aborts on negative ctype arguments; feed bytes0x80..0xFFthroughte_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.mdLead B,docs/evidence/fragments/buglead-ctype.json.
- Constraint: Lead C claimed a possible non-advancing
s->nextDoS underde_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.strtodfor bit-exact parity; measure both undersetlocale(LC_ALL, "de_DE.UTF-8"); hard-timeout the suspected hang inputs. - Consequence:
1.5fails on both sides after setlocale (correctness bug in the original, shared by the port);.5does 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.mdLead C,docs/evidence/fragments/buglead-locale.json.
- 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 downloadedzig-x86_64-linux-0.16.0.tar.xz. - Options: force
platform: linux/amd64in compose; document a manual flag; select the Zig tarball from BuildKitTARGETARCH. - Choice: Dockerfile switches on
TARGETARCH(amd64-> x86_64 SHA70e49664...,arm64-> aarch64 SHAea4b09bf...). Both digests verified against ziglang.org downloads and recorded in.upstream.toml. - Consequence: cold
docker compose up --build verifyworks 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).
- 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.shonly compares.scripts/verify.shrenders explicitly after regenerating the scorecard, then runs claims +check-readme-rendered.sh. CIreadme-scorecardalready used--checkfirst; 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/4999exited 0 before the fix and 1 after (docs/evidence/gate-failure-proofs.md).
- Constraint: second-machine / review hosts are not the original WSL box.
macOS
/bin/bashis old enough to reject empty-array expansion underset -u; BSDsed -ineeds an extension arg;mapfileand GNUobjcopyare 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; portablesed -idetection inverify.sh;sha256sum/shasumfallback; enumeration skips cleanly whenobjcopyis missing, with Docker remaining the full path. - Consequence: host
zig build testand 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 foundon this Mac; suites then measured at 4930/4924/4934/4928 (docs/REPRODUCTION.md).
- 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.mdand keep the authoritative detail indocs/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.
- 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 indocs/evidence/upstream-issues.md; fragmentdocs/evidence/fragments/upstream-findings.json.
- 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-O2vs ZigReleaseSafe, withReleaseFastalways 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, fragmentdocs/evidence/fragments/bench-linux-docker.json.
- 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 fetchof a tarball ofbuild.zig.zonpaths. - Choice:
examples/c-consumerlinks unmodifiedtinyexpr.hto Zig.aand.so.examples/zig-consumeruses a path dependency. External scratch project fetchedfile:///tmp/tinyexpr-pkg.tar.gzand ran successfully.build.zigexportsb.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.txton the author machine (external_fetch_ok value=10).
- 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.shon the rewritten README; timer notes indocs/quickstart.md.