diff --git a/.claude/skills/t27-spec/SKILL.md b/.claude/skills/t27-spec/SKILL.md index 430e41c014..653251d906 100644 --- a/.claude/skills/t27-spec/SKILL.md +++ b/.claude/skills/t27-spec/SKILL.md @@ -442,3 +442,4728 @@ this implementing", read it. alone would have been an interpretation -- this campaign has been wrong on interpretations several times. `256 of 256, 0 differ` is not an interpretation. Source narrows the search space; measurement still decides. + +## A 100% failure rate is a broken harness until proven otherwise + +A sweep of all 496 specs reported `PARSE OK: 0 FAIL: 496`. Read literally that is +"the parser is completely dead" — a finding so large it would have reframed the whole +session, and it would have been reported to a sleeping user as fact. + +It was exit 127. The shell's cwd had reset between calls, `./bootstrap/target/release/t27c` +did not exist at that relative path, and every one of the 496 invocations was +`command not found`. The binary builds to the **workspace** target dir, +`target/release/t27c`, not `bootstrap/target/release/t27c` — `cargo build` was run from +`bootstrap/`, but the workspace root owns the output directory. + +The tell was the shape of the result, not its content. Real breakage is ragged: some +specs parse, some do not. **A clean 0% or a clean 100% is the signature of a harness +fault — the measurement never reached the thing being measured.** Before reporting any +total, run the tool once, by hand, on one input, and look at the actual stderr. The +correct count here was 496/496, the exact opposite of the first reading. + +Corollary for absolute vs. relative paths: any sweep that shells out in a loop should +resolve its binary to an absolute path once, up front, and fail loudly if it is missing — +so a missing tool reports as "tool missing", never as "every case failed". + +## A gate that is always bypassed is not a gate, and nobody will report it + +`scripts/tri` — the wrapper the README tells every new reader to run to verify the repo +(`./scripts/tri test`) — was broken for **every** subcommand. Line 15 passed +`--repo-root` before the subcommand name, but it is a per-subcommand clap option, not a +global one, so every invocation died with `unexpected argument '--repo-root' found`. + +The interesting part is why it survived. `scripts/tri check-now` is pre-commit Gate 1/4. +A permanently failing gate does not get fixed; it gets bypassed with `--no-verify`, and +the bypass is invisible in the history. The gate had stopped being a gate and became a +toll, and the toll was free. + +Two habits from this: + +**When a hook blocks you, read the hook before satisfying it.** The instinct is to make +the error go away — bump the date, add the trailer. Doing that here would have left the +wrapper broken and re-armed the same trap for the next agent. The block was a symptom; +the wrapper was the defect. + +**Suspect any documented command you have not personally run this session.** The README +had shipped a broken verification command through multiple doc-sync commits, because +doc-sync passes edit prose and do not execute the fences. Run the fence. + +## Ask who occupies the corner you claim to own + +`COMPETITORS.md` was careful, sourced, and honest in tone: it named five commercial NPUs +and explained, correctly, that this project does not race them on TOPS or SDK breadth. +It then claimed the project owns "the inspectable open silicon and formal / assurance +corner" — and named nobody in that corner. + +That corner is crowded. Vericert is verified HLS with the *compiler itself* proved +correct in Coq; this project's compiler is unverified Rust. Kami does modular refinement +from spec to RTL; this project has conformance vectors, not a refinement relation. +Amaranth ships formal verification via SymbiYosys as a built-in. + +**A competitive document is not honest because each sentence is true. It is honest when +the omissions do not do the arguing.** The audit question that finds this in one pass: +*for each corner the document claims, who else is standing in it, and are they ahead?* +If the answer is "nobody" the claim is almost certainly under-researched, not uncontested. + +The repair is not deleting the claim — it is narrowing it until it survives contact. +Here that meant conceding the compiler-correctness and refinement axes outright, and +promoting the one genuinely unusual artefact (a machine-checkable tape-out conformance +gate) to the load-bearing position, explicitly labelled as the claim most worth attacking. + +## A tool's own summary line is a claim, not evidence + +`validate-conformance` printed `101 total, 43 valid, 0 invalid, 58 empty/skipped`. That +went into a report, a `NOW.md` entry, a GitHub issue, and a memory file as "the +conformance corpus is roughly half-hollow" — and it became the headline recommendation +for the next wave: *populate the empty files*. + +Zero files were empty. The validator resolved payloads with `.as_array()`, and the +corpus stores vectors both as `{"vectors": [...]}` and as `{"vectors": {"case_a": {…}}}`. +Every object-shaped file counted as zero. Of the 58: 45 were fully populated (one +carried 20 vectors), 8 were schema files that carry no vectors by construction, 5 were +benchmark reports. Among the false positives was `FORMAT-SPEC-001.json` — the numeric +SSOT the whole positioning rests on, reported as empty by its own repo's validator. + +Opening four of the flagged files took under a minute and would have caught it before it +propagated to four places. The distinction that matters: + +**A summary is the tool's interpretation of the data. Only the data is the data.** When +a count is about to become a plan — "populate these 58 files" — open the objects it +counted, at least a sample across categories, and confirm the count means what the label +says. Categories matter more than sample size here: the 58 held four distinct shapes, +and reading three files of the same shape would have confirmed the wrong conclusion. + +**A gate with a high false-positive rate is worse than no gate**, because it launders +noise as signal and nobody reads warning 43 of 58. The repair is to make the tool +classify rather than binarise — this one now reports vectors / report / definition / +empty, and the single genuinely stale artefact it had been hiding for months +(a CLARA coverage file covering 7% of the current corpus) became visible the moment the +false positives stopped. + +The general form, and the reason this keeps recurring in this campaign: **the failure +mode is never a wrong number, it is an unexamined label.** `FAIL: 496` meant "binary not +found". `58 empty/skipped` meant "object-shaped". Both were accurate counts of something +other than what their word said. + +## Gates check the cheap proxy; audit the gate against the property it names + +Three consecutive waves in this repo each turned up one gate enforcing something weaker +than its own label: + +| Gate | Name claims | Actually tests | +|---|---|---| +| `scripts/tri check-now` (Gate 1/4) | NOW freshness | nothing — the wrapper was broken, so it was bypassed with `--no-verify` | +| `validate-conformance` | conformance corpus is populated | array-shaped payloads only, so 58 of 101 files were false positives | +| Gate 2/4 "Seal coverage" | seal coverage | `[[ -f "$seal_file" ]]` — that a file *exists* | + +The third is the sharpest. `.trinity/seals/` held **730 seal files and not one verified**. +480 were written on the same day as a commit that rewrote the specs they sealed, and +nothing was re-baselined for four months across a run of codegen fixes. **Presence is not +integrity**, and only presence was ever enforced, so the drift was structurally invisible — +there was no observation that could have revealed it short of running `--verify` by hand. + +The audit that finds this class in one pass, for each gate: **write down the property the +gate's name claims, then read the gate and write down the property it tests. Where the two +differ, that is the hole.** It takes minutes and does not require understanding the domain. + +Two riders learned the same wave: + +**A gate that cannot fail teaches people to route around it.** The broken wrapper made +Gate 1/4 fail always, so commits used `--no-verify` — which silently disabled the other +three gates too. A gate is a hazard when it is always red *or* always green. + +**Check the gate's file resolution, not just its predicate.** Gate 2/4 derived +`basename "$spec" .t27` → `gf16.json`, while `seal --verify` reads a path-derived +`numeric_triformat-gf16.json`. Both "worked" on macOS only because the filesystem is +case-insensitive and `GF16.json` happened to match. On Linux CI the gate would look for a +file that does not exist. Two naming schemes for one artefact is a defect even when every +individual test passes. + +## Evidence that cites a command nobody can run is not evidence + +`conformance/clara_spec_coverage.json` recorded +`{"command": "bash scripts/clara/demo.sh", "result": "20/20 passed, 0 failed"}`. +`scripts/clara/` does not exist anywhere in the repository — not renamed, not moved, +absent. The row had been carried as passing evidence for four months. + +This reframes what "narrow the claim vs regenerate the evidence" means. There was no +claim to narrow: an artefact asserting a result from a command that cannot be executed is +not weak evidence, it is **not evidence**, and the only repair is to replace it with +something regenerable. The new command writes a `reproduce` field into its own output for +exactly this reason. + +**When auditing an evidence file, run its own stated reproduction command before reading +its numbers.** If the command does not exist, the numbers do not need checking. + +And the corollary that governs what an audit may do on its own: **regenerating the +measurement is repair; rewriting the baseline is a decision.** Re-running coverage was +safe and mechanical. Re-sealing 496 specs would have rewritten 730 provenance records and +canonicalised whatever the current codegen emits, with no independent oracle that it is +right — so it was reported, scoped, and left for a human. An audit that quietly re-baselines +the thing it was auditing has destroyed the evidence it was sent to check. + +## Read the gate's body, not its name — some gates are one `echo` + +The gate audit from the previous wave was applied to CI. Three of nineteen workflows had +this for their entire job body: + +```yaml +- name: Validate schemas + run: | + echo "Validating JSON schemas..." +``` + +`seal-coverage.yml`, `schema-validation.yml`, `check-now-freshness.yml`. Each reported +green on every pull request. The README cited one of them — *"CI · Schema validation · +GREEN · Conformance vectors validated"* — as evidence. **That row was backed by an echo +statement.** + +This is the terminal form of the claimed-vs-tested gap: not a weak proxy, but no test at +all, wearing the name of one. It is invisible from every angle except opening the file — +the job name is right, the status badge is green, the README cites it truthfully as +"passing". `grep -L` for workflows whose steps contain no command other than `echo` finds +the whole class in seconds, and is worth running against any repo whose CI you are about +to cite. + +The repair pattern that avoids the obvious trap: **do not make a hollow gate blocking on +the same commit that makes it real.** `seal-coverage` was wired to a checker that returns +0 of 496 — switching it on would have walled off every PR behind a re-baseline nobody had +reviewed. It went in non-blocking, publishing the true number to the job summary, with the +enforcing flip named as the next step. A gate that reports honestly and does not block is +a real improvement over a gate that lies and does not block; a gate that blocks on a +number nobody has agreed to is a new outage. + +**And never hollow one out to make it pass.** If duplication or cost makes a gate not +worth keeping, delete it — an empty gate is strictly worse than a missing one, because a +missing gate is visible in the workflow list and an empty one reports green. + +## Two derivations of one path is a bug even when every test passes + +Seal files in this repo are named `_.json`, where module-name +comes from the spec's `module` declaration — not from its filename. The pre-commit gate +guessed `basename "$spec" .t27`. Both derivations had lived side by side for months. + +Both failure directions were live: + +``` +specs/base/types.t27 tool -> base_tritype-base.json EXISTS + gate -> types.json MISSING # flags a sealed spec +specs/numeric/gf16.t27 tool -> numeric_triformat-gf16.json EXISTS + gate -> gf16.json "EXISTS" +``` + +The second only matched because an unrelated `GF16.json` collided **case-insensitively on +macOS**. The same gate on Linux CI would have looked for a file that does not exist. A +case-insensitive filesystem is a bug-concealer: it makes two different names test as one, +so a naming disagreement passes locally and fails only in CI, or fails only for the +contributor on a different OS. + +The fix is not to correct the guess — it is to delete it. `t27c seal-path ` now +prints the canonical path and the gate asks the compiler. **When a shell script and a +compiled tool must agree on a derived path, the script should call the tool, not +reimplement the rule.** Any rule expressed twice will diverge; the only question is +whether you find out from a test or from a user. + +Related: the gates here are also **local-only** — the tracked hook is a three-line stub, +and the real four-gate hook installs into `.git/hooks/` only if someone runs an installer +script. Before trusting "the pre-commit gate catches this", check that a fresh clone +actually gets the gate. + +## State the evidence class, and let the weaker claim stand + +A campaign of audits produced a document of numbered findings. The temptation at write-up +time is to round every observation up to a theorem. Two of them could not survive it. + +The seal-path function was described in a commit as "injective by construction". It is +not. The encoding flattens `/` to `_`, and `_` is legal inside a path component, so +`specs/a_b/c.t27` and `specs/a/b_c.t27` both yield `a_b_c.json`. What was true — and +sufficient — is that it is injective **on this corpus**: 496 specs, 496 distinct images, +measured. The general claim was never needed; it was reached for because it sounded +finished. + +So every proposition now carries a tag: `PROVED` (machine-checked), `MEASURED` +(reproducible over a **stated domain**), `CONJECTURE`. The discipline is not decoration. +It forces the domain into the sentence, and the domain is where these claims actually +break. + +Three habits followed from it: + +**Write the counterexample into a test that asserts the limitation holds.** Not a +`// TODO: not injective in general` comment, which decays — an assertion that +`Σ(a_b/c) == Σ(a/b_c)`. If someone changes the encoding, that test fails and drags the +documentation back into review. A limitation pinned by a passing test is maintained; one +pinned by prose is not. + +**A partial invariant plus a guard at the mutation site beats a total invariant nobody +re-checks.** The residual collision risk was closed at `seal --save`, which refuses to +overwrite a seal whose recorded owner differs. The predecessor scheme had a *stronger* +sounding rule and no guard, and it silently destroyed a seal that stayed broken for +months. + +**Validate a checking pipeline in both directions before trusting it.** A yosys proof +recipe was confirmed by running it on a property that is true (exit 0) *and* one that is +false (exit 1). A pipeline that only ever reports success is indistinguishable from one +that checks nothing — which is exactly the CI-theater failure from the previous wave, +arriving in a new costume. + +## Verify a citation's title from the source before it goes in a document + +Six arXiv identifiers were assembled from memory for a related-work table. Fetching each +one's `citation_title` showed that one — recalled confidently as a numerics paper — was +*Graph-based Joint Pandemic Concern and Relation Extraction on Twitter*. It was dropped. + +Identifiers are exactly the kind of detail that feels retrievable and is not. The check is +one HTTP request per entry and it is not optional in a document whose entire value is that +its claims are checkable. The same rule already applies to a tool's summary line and to an +evidence file's reproduction command; a citation is the same object — a claim about +something you have not looked at. + +Where a description is genuinely the source's own words, quote it and say so; where it is +your assessment, keep it outside the quotes. A table that mixes the two silently is doing +the same work as an omission. + +## A tool that silently discards what you asked it to check + +The plan was to preprocess SystemVerilog with `sv2v` so Yosys could read the project's +assertions. `sv2v`'s README, read before installing: *"Assertions are also supported, but +are simply dropped during conversion."* + +"Supported" here means *parsed without error*, not *preserved*. Measured on 0.0.13 — a +module with a `property` block and an `assert property` goes in; a module with **zero** +assertions comes out; **exit 0**, no warning, no diagnostic. + +Had that been wired up, `sv2v → yosys → sby` would have run to completion and reported +success while proving nothing, because there would have been no properties left to +violate. **That is worse than the broken state it replaced**, which at least failed loudly +at parse. A pipeline can be built entirely from real, well-regarded tools and still be +theater. + +Two things generalise: + +**Read a tool's stated limitations before adopting it, and treat "supported" as a word +that needs unpacking.** One line of a README saved a working, green, meaningless CI job. +The install came *after* the README, and the empirical confirmation after that — cheapest +check first. + +**When a transform can silently reduce your inputs, measure the output population, not +just the exit code.** The guard that catches this is counting what survived: the CI job +now runs `stat` and fails when zero `$check` cells reach the netlist. Validated in both +directions — the real emitter yields 2 and passes, `sv2v` output yields 0 and fails. The +same shape applies far outside formal verification: a filter step, a migration, a +codegen pass. *Exit 0 over an empty set* is the most expensive kind of pass, because +nothing downstream can tell it from success. + +## When a tool refuses your input, consider changing the input + +Two waves went into the fact that Yosys's frontend accepts neither named `property` +blocks nor inline `assert property (@(posedge clk) …)`. The reflex was to find a +translator — and the translator deleted the properties. + +Yosys *does* accept immediate assertions inside `always`. Nearly the whole property set +maps onto them: `a |-> b` is `assert (!(a) || (b))`, and `a |-> ##N b` is +`assert (!($past(a, N)) || (b))`. Emitting that subset made the properties provable in an +afternoon, after two waves spent trying to make the unreadable form readable. + +**Meet the tool where it is.** When a consumer rejects your format, generating what it +accepts is often far cheaper than making it accept what you generate — and it removes a +dependency rather than adding one. + +Two riders: + +**Name what does not survive the translation, in the artefact itself.** `s_eventually` is +liveness; an immediate assertion evaluates in one cycle and cannot express it. Those +behaviors are reported on stderr *and* written into the generated file as a +`NOT TRANSLATED` comment. A partial translation that does not state its own coverage is +the vacuity failure again, one level up: the run is green and the domain quietly shrank. + +**Let the prover correct you.** The first delayed-implication guard was `rst_n` alone, and +the prover produced a counterexample: one cycle after reset, the antecedent's history +predates the reset. The right guard is `rst_n && $past(rst_n)`. A refutation on a property +you believed was a design error found for free — which is only possible once the +properties are actually being checked. + +## A test that pins the implementation's shape cannot notice it is wrong + +Formal verification found a lost-interrupt race in generated RTL: three interrupt sources +and a clear-on-read were emitted as four independent non-blocking assignments, and +last-write-wins meant a `status_read` concurrent with an event destroyed that event. + +Two unit tests covered exactly that code. Both passed. They asserted the **literal text**: + +```rust +assert!(v.contains("if (inference_done) irq_status[0] <= 1'b1;")); +assert!(v.contains("if (status_read) irq_status <= 3'b000;")); +``` + +Those tests passed for precisely as long as the bug existed, and failed the moment it was +fixed — they had to be rewritten as part of the fix. **They were not testing the design; +they were holding it still.** A string-match against emitted code asserts *"the generator +still produces what it produced yesterday"*, which is a snapshot, not a property. Snapshot +tests are useful for catching unintended churn and worthless for catching a defect that +was present when the snapshot was taken. + +The rewritten versions assert reachable behaviour — that every source contributes its bit +unconditionally, and that the clear applies to the *previous* value only — with the real +guarantee carried by a proof harness. The distinction generalises past hardware: **if a +test would fail when the code is corrected, it is pinning the bug, not guarding against +it.** Worth asking of any assertion written by copying a line out of the implementation. + +## Prove the mechanism, not just the counterexample + +A refutation says *"there exists a state where this fails"*. It is easy to file that as +"can occasionally misbehave" and move on. + +Better: after the refutation, state the failure as a property and prove **it**. +`$past(inference_done) && $past(status_read) |-> irq_status[0] == 0` came back PROVED — +so the event is not *sometimes* lost, it is **always** lost whenever a read coincides. +That converts a probabilistic-sounding bug report into a definite statement about every +reachable state, and it is what justified changing generated RTL rather than adding a +caveat. + +Two supporting habits, both of which paid here: + +**Make the experiment discriminating.** Two properties differing by exactly one guard — +`!$past(status_read)` — one proving, one refuting, isolate the cause with no further +argument. Prefer a minimal-pair over a single failing check. + +**Validate a regression harness against the broken version.** The checked-in property file +was run against the *old* RTL and confirmed to refute. A harness that has only ever been +run against a fixed design is untested: it might pass because the properties are vacuous. + +## When a property that cannot fail, fails, the harness is wrong + +Three properties came back refuted at once, one of them +`irq_enable == 0 |-> !irq_out` — a tautology over combinational logic +(`irq_out = |(irq_status & irq_enable)`). A tautology cannot be refuted, so the run was +not evaluating what it appeared to. + +The cause: Yosys's `sat` refuses to run with more than one module selected, and its error — +`Only one module must be selected for the SAT pass!` — surfaces as a non-zero exit that +reads exactly like `proof did fail`. Adding `-flatten` to `prep` fixed all three. + +**Keep one property in every harness whose answer you already know.** It costs nothing and +converts a whole class of silent harness faults into an obvious contradiction. This is the +same instrument as the earlier rules — a clean 0%/100% is a harness fault, a gate that +cannot fail is not a gate — applied to a prover: the check is not on the result but on +whether the result is *possible*. + +## A refutation is only a bug if the counterexample state is reachable + +Five properties were checked against an AXI-Lite slave. Three came back refuted. Two were +real defects. The third — `bresp == 2'b00` — was an artifact, and `bresp` is *only ever +assigned* `2'b00`, so it cannot be violated in any reachable state. + +The cause is temporal induction. `sat -tempinduct` proves *if the property held for the +last k cycles it holds now*, and its base case may start from an **unreachable** state +where a register holds garbage. Re-running from a reachable start settles it: + +``` +tempinduct (unconstrained init) -> REFUTED # artifact +BMC from zero-init state -> PROVED # truth +``` + +Both genuine defects refuted under **both** settings — which is the discriminator worth +keeping: **cross-check every refutation against a reachable start before believing it.** +A real bug survives the change of proof method; an induction artifact does not. + +The general form matters more than the yosys specifics. A model checker answers a question +about a *model*, and the model includes assumptions about which states can occur. When a +result is surprising, the first suspect is the state space, not the design. This is the +reachability twin of the earlier rules — a tautology that fails means a broken harness; a +refutation from an impossible state means a mis-scoped one. Both are the tool answering a +different question than the one asked. + +The corollary is what nearly went wrong: a false bug report is *more* expensive than a +missed one, because it gets acted on. Someone would have "fixed" a correct reset value. + +## Count what must balance, not what should look right + +The AXI slave's handshakes were shaped correctly. VALID was never deasserted without a +handshake — a property that **proved**, on the buggy design. Every shape-based check +passed. A protocol lint would have signed it off. + +The defect was arithmetic: `ready` was asserted at reset and never dropped, while the +module had one response register per channel, so two accepted transactions could share one +response beat and hang the master. What exposed it was counting: + +```verilog +outstanding <= outstanding + accepted - completed; +assert (outstanding <= 1); +``` + +**When a design promises a conservation law — one response per request, one release per +acquire, one pop per push — assert the balance directly.** Shape properties check that +each event looks right; a balance checks that none went missing. Those are different +failures, and the second is the one that hangs a bus. + +This also composes with the reachability rule above: a counter makes the violating state +concrete and obviously reachable, so the refutation is hard to dismiss as an artifact. + +Two for two: both RTL defects found in this campaign were *missing events* — a lost +interrupt and a lost response — in modules whose per-event logic was individually correct. + +## Every `assume` narrows what the `assert` proves — say so + +A master-side property was refuted: *"beats consumed must not exceed the beats the +transfer asked for."* Tempting to file as a third defect. It was not filed, because +`rvalid` is a free input in that harness — the prover was free to have the slave deliver +beats nobody requested. **A misbehaving environment is indistinguishable from a defect in +the unit under test, and only assumptions tell them apart.** + +Adding a minimal slave model (`assume (!rvalid || burst_active)`) made a *different* +property meaningful and provable — the one that caught a real bug. The over-read property +stayed refuted even after the fixes, and was recorded as **inconclusive, not claimed**, +because building a slave model faithful enough to settle it was more work than the wave +had. + +Three things this fixes in how such results get written up: + +**An unconstrained input is an adversary, not a wire.** Any property about a unit's +response to its environment is really a property about *that environment's* contract too. +State the contract, or state that you didn't. + +**Report inconclusive as inconclusive.** The pressure at write-up time is to sort every +result into bug or non-bug. A third category — *the harness cannot currently decide this* — +is honest and cheap, and it names the next piece of work precisely. + +**The `assume` list belongs in the artefact.** A harness with hidden assumptions overstates +its own coverage in exactly the way a hollow gate does; the properties look proved, and the +domain quietly shrank. Every `assume` is written into the checked-in property file with the +reason it is there. + +## The test's name can be the bug report + +Among the tests holding defects in place was one called `dma_burst_length_is_max`. It +asserted `m_axi_arlen <= 8'hFF` — a fixed 256-beat burst on every transfer, regardless of +size. The RTL then stopped consuming once the byte count ran out, abandoning the burst. + +The test was not merely pinning the implementation. **Its name asserted the defect as the +contract.** Anyone auditing the test list would read "burst length is max" as an +intentional design decision and move on. Another in the same file asserted +`if (m_axi_rlast || bytes_remaining <= 32'd8)` — and that `||` *was* the bug, written into +a test as expected behaviour. + +Eight such tests were rewritten across this campaign, and **all four RTL defects found had +one**. That correlation is not a coincidence: a defect that survives review usually does so +because something in the repository asserts it is correct. + +**When auditing, read the test names as claims about intent and ask whether each is +actually desirable.** `*_is_max`, `*_always_*`, `*_never_*` in a test name are assertions +about the specification, not the code, and they are worth checking against the +specification. A defect with a test defending it is invisible to every tool that trusts +the test suite — which is all of them. + +## When a model's precondition fails, re-check it with ports only + +A formal environment model is code, and code has bugs. A model that is wrong in the +*permissive* direction lets defects through; wrong in the *restrictive* direction it +manufactures them. The second is worse, because it looks like a finding. + +An AXI4 slave model tracked one burst at a time and asserted — rather than assumed — the +precondition that the master issues one at a time. **That precondition refuted.** Read +naively: the master overlaps bursts. Instead the same claim was checked with properties +using only the unit's own ports: + +``` +!(arvalid && rready) PROVED +no back-to-back AR handshakes PROVED +``` + +Both held, which located the fault in the model — it cleared `burst_active` from its own +beat counter rather than from the master-visible `rlast`, so one disagreement latched it +high forever and every later handshake violated the precondition. + +**Port-only properties are the arbiter**, because they involve no model at all. When a +modelled property and a port-only property disagree, the model is the newer, less-tested +artefact. Two habits follow: + +**Assert a model's preconditions; never assume them.** An assumption that the unit behaves +is precisely the assumption that hides misbehaviour. Written as an assertion, the same +statement becomes a check that the model is applicable — and it fires loudly when it is not. + +**Key model state off the signals the unit actually drives**, not off a parallel count the +model maintains. Two counters that should agree are two things that can disagree; deriving +from `rlast` rather than from a private tally removed a whole failure mode. + +## Record the anomaly instead of picking a story + +The same wave ended with a result that could not be explained: with a one-beat transfer +constrained, `arlen == 0` at the address handshake **refuted**, while hand-tracing the RTL +said it must hold — the length and the valid are assigned on the same cycle from state +committed in the same non-blocking group. + +Two tidy stories were available. *"Found a one-cycle hazard"* — a fifth defect for the +campaign tally. Or *"harness artifact, moving on"* — a clean close. Both would have been +written confidently and one of them would have been wrong. + +It was recorded as an **anomaly**: named, reproduced, and explicitly not classified. The +dependent property was marked open too, on the ground that **a harness with one unexplained +result cannot be trusted to settle a second**. + +The asymmetry that decides this: a **false finding costs more than a missing one, because +it gets acted on.** Someone edits correct RTL, or writes off a real bug as noise. A missing +finding stays missing and is found later. + +There is a pull, especially at write-up time and especially in a run that has produced +several real fixes, to make every thread terminate in a verdict. Resist it. **"Reproduced, +unexplained, here is the exact command"** is a complete and useful deliverable — it is the +next person's starting line rather than a wrong turn they have to discover. + +## Constraints can be silently inert — prove that your assumptions are active + +Yosys's `sat` ignores `$assume` cells unless `-set-assumes` is passed. Opt-in, no warning, +no diagnostic. A harness missing the flag still runs, still prints `PROVED` or `REFUTED`, +and every constraint in it does nothing — so a property meant to hold *given a compliant +environment* is quietly checked against an arbitrary one. + +That cost a full wave. An `arlen` anomaly was recorded as unexplained, and a dependent +property left open, when the harness had simply never applied its own constraints. The +test that would have caught it on day one is two lines: + +```verilog +always @(posedge clk) assume (1'b0); // unsatisfiable +always @(posedge clk) if (rst_n) assert (a == !a); // manifestly false +``` + +Assumptions live → the assertion is vacuously true → **PROVED**. Assumptions inert → the +false assertion is reachable → **REFUTED**. It is now the first step of the CI job: a green +result there is what licenses reading any other harness's assumptions as meaningful. + +**Whenever a tool takes constraints, verify the constraints are in force before trusting a +result that depends on them.** Not "read the docs" — construct an input whose answer +differs between the two worlds and check which one you are in. The same shape applies to a +linter's config, a mock's expectations, a test fixture's setup: anything that is supposed +to narrow behaviour can fail to, and the failure looks like a passing run. + +This is the third instance of one instrument in this campaign. A tautology that fails means +a broken harness. A gate that cannot fail is not a gate. **Constraints that constrain +nothing are the same defect** — each is caught by including one case whose answer you +already know. + +## Put the assertions inside the module when you need a readable counterexample + +Two waves were spent unable to see why a property refuted. The harness instantiated the +design under test inside a wrapper, and `sat` refuses to run with more than one module +selected, so `-flatten` was required — which mangles every signal name, leaving VCDs full +of `$auto$async2sync.cc:116:execute$453` and nothing else. + +The fix was mechanical: append the assertions to a **copy of the module itself**, before +its `endmodule`. One module, no `-flatten`, and `-show state -show bytes_remaining …` +prints a legible cycle-by-cycle table. The cause was visible in one reading. + +**When a counterexample is unreadable, change the harness's shape, not your guessing +strategy.** Several hours went into reasoning about what the trace *might* contain; the +trace itself took ten minutes to obtain and settled it immediately. The generalisation: +if the diagnostic output of a tool is unusable, that is the bug to fix first — everything +downstream of it is speculation. + +## Proving code unreachable is a reason to delete it, not to add it + +Having found that a counter could wrap when a value hit zero, the reflex was a clamp. It +was written, and then reverted, because the same session had *proved* that state +unreachable under the protocol contract — and in the out-of-contract case the counter +underflows to a **large** value, where the result is arithmetically correct rather than a +wrap. The clamp guarded nothing reachable and added a branch. + +**A defensive check whose trigger condition you have just proved impossible is not +defensive, it is noise** — it implies a hazard that does not exist, and the next reader +must re-derive that to touch the code. Guard what the contract permits; document what the +contract forbids. + +The corollary is where the judgement lies: full immunity to a non-compliant peer was not +achievable here at all. The only way to stop consuming early was to abandon a burst, which +is itself the protocol violation fixed two waves earlier. **Sometimes robustness against a +lawless environment and correctness against the specification are in direct conflict, and +the specification wins** — but say so in the code, so the absent hardening reads as a +decision rather than an oversight. + +## Test that a property can fail, not just that it passes + +A passing property is compatible with two very different situations: the design is +correct, or the property was never really evaluated. The second has two distinct causes, +and neither reports anything unusual. + +**Guard vacuity.** `G |-> P` is free whenever `G` is unreachable. The exact oracle needs +no `cover` support: replace the assertion *body* with `assert (1'b0)`, keeping the guard. +That run **proves if and only if `G` is unreachable**. Neutralise the file's other +assertions to `assert (1'b1)` first, so each result speaks about exactly one guard. + +**Interesting-case vacuity.** Guard reachability is necessary, not sufficient. +`assert (!A || B)` is trivially satisfied whenever `A` is false, so a property can be +evaluated on every cycle and still test nothing. Probe the case the property exists to +cover by asserting its negation — **a refutation is the witness that the case occurs.** + +The one that justified the exercise: a regression witness for a burst-abandonment bug +required a *multi-beat* burst to mean anything. Had the harness only ever produced +single-beat bursts, it would have proved, stayed green forever, and silently stopped +guarding the defect it was written for. + +**Make the witnesses permanent, as runs that must fail.** A CI step expecting refutation +looks strange and is exactly right: if it ever starts passing, a case became unreachable +and the property depending on it has gone free. Distinguish refutation from error when +checking — grep the prover's failure text rather than trusting a non-zero exit, since a +syntax error also exits non-zero. + +The recurring shape across this whole campaign, now found in five places: **a check that +cannot fail is indistinguishable from no check.** A shell gate that always errored and got +bypassed; a CI job that was one `echo`; a validator whose predicate matched the wrong +shape; constraints the prover silently ignored; and properties whose antecedents might +never occur. The instrument is the same each time — *include a case whose answer you +already know, and confirm you get it.* + +## Neutralise, don't delete, when isolating one assertion + +Isolating a single property by deleting the others produced nineteen consecutive tool +errors: the assertions sat inside `always @(posedge clk) if (…)` blocks, so removing the +statement left a dangling `if` with no body. + +Replacing them with `assert (1'b1)` keeps the syntax intact and the semantics inert. The +same applies to disabling a branch, a test, or a validation step while bisecting: +**substitute a no-op of the same syntactic category rather than removing the construct.** + +There is a second, subtler version of this that also cost a run: an insertion offset was +computed on the original text and then applied *after* a regex substitution had changed +the string's length, so the probe landed past `endmodule`. **Recompute positions after any +edit that changes length**, or work on structure rather than offsets. Both failures +presented as "the tool is broken" and were purely mechanical. + +## A runaway loop has a safety shadow — check that instead + +Two modules failed to terminate when a count parameter was zero: a terminator written +`index == count - 1` compares against all-ones, never matches, and the FSM runs forever. + +Non-termination is a **liveness** property, and immediate assertions — the only form the +open-source prover accepts here — cannot express one. The instinct is to reach for a +heavier tool. Unnecessary: a loop that never ends almost always has some counter or index +that leaves its legitimate range, and *that* is pure safety. + +``` +valid |-> neuron_id < num_neurons REFUTED before the fix, PROVED after +writes <= num_words (while active) REFUTED before the fix, PROVED after +``` + +**Look for the safety shadow of a liveness property before escalating tooling.** "It runs +forever" is hard; "this index exceeds its bound" is a one-line assertion a bounded model +checker settles in seconds. The same substitution works for deadlock (a queue depth +exceeds capacity), for livelock (a retry counter exceeds its limit), and for leaks (an +allocation balance goes negative). + +The general lesson is about the shape of the question: **an unbounded property often has a +bounded consequence, and the consequence is the one worth asserting.** + +## When siblings disagree, the odd one out is a bug, not a contract + +The hardest part of reporting a zero-count defect is that it could be intentional — maybe +callers are simply required to pass a non-zero count, and the guard is the caller's job. +Nothing in the module says either way. + +The evidence was in the family. `layer_sequencer` already contained +`if (num_chunks == 0) state <= DONE_ST` — and did **not** do the same for neurons. The +adjacent `multilayer_sequencer` guards `num_layers > 0`. Two siblings handle the zero case +and two do not, and the module that guards one of its own two counts and not the other is +not expressing a contract. It is inconsistent with itself. + +**Before deciding whether an omission is deliberate, look at how the same question is +answered next door.** A convention followed in three places and broken in one is an +oversight; a rule broken everywhere may be the real convention, written down nowhere. This +resolves the "is it a bug or is it by design?" question without needing to find whoever +wrote it — and it makes the report far harder to wave away, because the counter-example is +the author's own code. + +The corresponding audit sweep is cheap: for a family of related modules, grep each for how +it handles the degenerate input — zero, empty, one — and line the answers up. Divergence +in the column *is* the finding. + +## Check the premise of a long-carried question before answering it + +A design question rode nine waves as the recommended-but-deferred item: +*"BitNet v2 moves the binding constraint from weight width to activation width — is a +ternary-weight datapath still the right target?"* Each restatement made it sound +better established. + +Fetching the abstract took one request. **BitNet v2 keeps 1-bit weights.** Its +contribution is a Hadamard transform that makes 4-bit *activations* viable. So the +ternary-weight premise was validated by the paper, not threatened by it — and the +question, as phrased, had no answer because it presupposed something false. + +**A question repeated across many sessions accumulates authority it never earned.** Each +time it is carried forward it looks more like settled context and less like a claim. The +cheap defence: before doing the work a long-standing question implies, spend one request +checking the sentence it rests on. Especially when the question originated in your own +earlier summary — restating your own inference back to yourself is how an inference +becomes a premise. + +The corollary that made the wave worth it: **answering the question properly meant reading +what the system actually does**, and that is where the real finding was. The paper said +weights were fine; the RTL said *activations* were ternary too — more aggressive than any +published variant, on the axis the field finds hardest. Nobody had asked that question, +because everybody was asking the other one. + +## "N of N emitted" is not "N of N integrated" + +A README row read *"RTL pipeline · GREEN · 9/9 modules"*. Nine modules are emitted, so the +row is true. The top level instantiates **three** of them; the MAC, the weight memory, the +DMA, the bus slave and the interrupt block are never instantiated, the memory port is tied +to zero, and one input is declared and never referenced. + +Nothing in the row is false. It is a **count of artefacts presented where a reader infers a +count of working parts** — the same shape as a gate that reports on a proxy, arriving in a +status table instead of a CI job. + +Two checks, both mechanical: + +**For any "N components" claim, grep the top level for instantiations and compare.** One +command, and it distinguishes a library from a system. Emission, compilation, and +integration are three different milestones that a single number silently merges. + +**Ask what the verification actually ranges over.** Six defects had been found and fixed in +these blocks — real work, cheapest possible place to find them — but every property was +module-scoped. Until the blocks are wired there is no system behaviour to state a property +about, and saying so bounds the claim honestly rather than letting "28 properties proved" +imply an engine was verified. + +The generalisable habit: **when a project reports progress as a count, find out what the +denominator is a count of.** Modules that exist, modules that compile, modules that are +reachable from the top, and modules exercised by a test are four different denominators, +and the gap between the first and the last is where a project's real state hides. + +## A property about a signal is not a property about the wire it feeds + +Wiring a MAC to a memory with one cycle of read latency needs the control path delayed by +one cycle, or chunk *N*'s control meets chunk *N−1*'s data. The property written for it: + +```verilog +assert (mac_valid_q == $past(layer_valid)); // the skew register lags by one +``` + +True, provable — and useless. It constrains the *register*, not the consumer. Rewiring the +MAC's `valid_in` straight to `layer_valid`, reintroducing exactly the hazard, left it +**PROVING**. The register still existed and still behaved; nothing tied it to the MAC. + +The repair states the property on the **consumer's own output**, which can only be what it +is if the consumer saw the intended input: + +```verilog +assert (mac_valid_out == ($past(mac_valid_q) && $past(mac_last_q))); +``` + +Correct build proves; unskewed build refutes. + +**When asserting that A drives B, phrase it over something only B can produce.** A property +naming only signals *upstream* of the connection cannot see the connection. This is the +integration-level form of vacuity: not an unreachable guard, but a true statement about the +wrong side of a wire. + +Two carry-overs: + +**Composition bugs are invisible to module-scoped verification, by construction.** The +sequencer was correct, the memory was correct, the MAC was correct, and the assembly was +wrong. No amount of per-module proving reaches it — the first property that could was the +first one spanning two modules. + +**The rule that caught it was already written down.** *Validate a regression harness +against the broken version.* Deliberately reintroducing the defect and confirming the +property refutes took two minutes and was the only reason eight green properties were not +shipped with one certifying nothing. **A harness only run against a correct design has not +been tested — it has been demonstrated.** + +## Reduce the model, not the property, when the prover cannot handle a construct + +`sat` cannot model `$mem_v2`, so a proof involving a 4096-entry BRAM simply errors. Two +ways out: weaken the property until it avoids the memory, or shrink the memory. + +Shrinking is correct here and the reasoning is what matters: **the properties never read +memory contents** — they are about control alignment — so `chparam -set DEPTH 4` plus +`memory_map` changes nothing the properties observe. The claim proved is the same claim. + +Weakening the property would have silently narrowed what was verified; reducing the model +narrows only what is *simulated*, and the narrowing is auditable — one flag, one line of +justification. **State which of the two you did.** "Proved with a reduced memory depth, +because no property reads memory" is a complete disclosure; "proved" alone is not. + +The related mechanic: when the properties must reference internal signals, put them in the +module under `` `ifdef FORMAL `` rather than in a wrapper. A wrapper forces `-flatten`, +which mangles exactly the names the properties need. + +## Give an unmade decision an interface, and it becomes trackable + +A design question sat open for ten waves: ternary activations, or 4-bit? It could not be +answered, and the reason turned out to be structural — **the choice had no address**. It +lived in the *absence* of a module. Nothing in the code said "activations are ternary"; +the datapath simply had no layer boundary, and the ternary assumption leaked in through +what the neighbouring ports happened to be. + +Building the missing module changed the question's status without answering it. The width +is now one output port. A 4-bit variant changes `trit [1:0]` to `act [3:0]` and swaps a +comparison for a scale-and-round; nothing else moves. + +**An unmade decision with no interface is untrackable. The same decision with an interface +is a diff.** Before agonising over an open architectural question, check whether the +artefact that would embody it exists — if it does not, building it is usually more +valuable than deciding, and often makes the decision obvious. + +The related habit: **an assumption implied by an absence is the hardest kind to audit.** +Grep finds a wrong constant; nothing finds a missing stage. When reviewing, ask what the +data must pass through that is not there. + +## Prefer a total function to a documented precondition + +The requantizer compares an accumulator against a symmetric threshold. If the host writes +a *negative* threshold, `acc >= threshold` and `acc <= -threshold` are both true. Written +as parallel comparisons that is a don't-care — and in a design whose output alphabet has a +**reserved invalid code**, a don't-care is a corruption waiting to happen, with no error +path anywhere downstream. + +Written as a priority chain it costs one ternary operator and the output is legal for every +input, including inputs the host should never produce. + +**When the cost of totality is a line, pay it rather than documenting a precondition.** A +precondition is a promise made by code you do not control, and the failure it permits here +is silent: an invalid code propagates through every consumer without a single assertion +firing. The reserved-value case generalises — any enum with an unused encoding deserves a +proof that the unused encoding is unreachable, not a comment saying it should be. + +## A substring ban catches the documentation that justifies it + +Three times now, a test forbidding a literal has failed on the module's own explanation of +why that literal is forbidden: + +- `!contains("8'hFF")` — failed on the comment describing the old hardwired burst length +- `!contains("2'b11")` — failed on both the comment *and* the assertion enforcing the ban +- a `FORMAT-SPEC` filter — matched the schema file it was meant to classify + +The fix each time is to narrow to the syntactic context that matters — strip comments, +skip assertion lines, match an assignment rather than an occurrence. **A ban on a string is +a ban on a construct; write it against the construct.** The tell is that the test fails on +the very commit that adds the safeguard, which reads as a false alarm and is really a +badly-scoped assertion. + +## A test whose name contains a number will be renamed every time the system grows + +Adding one file to a bundle broke `bundle_order_has_twelve_entries`, +`build_sv_entries_returns_eleven_files`, and two lookups indexing `entries[9]` and +`entries[10]`. + +None of those tests was wrong about the system; they were asserting an *incident* of it. +The invariants they should have carried — `BUNDLE_ORDER.len() == BUNDLE_FILE_COUNT`, "the +last entry is the manifest", "the top-level entry contains the top name" — survive growth +untouched, and are found by lookup rather than by position. + +**If a test's name has to change when the system grows correctly, the test is asserting the +wrong thing.** Same for positional indexing into an ordered collection: it encodes today's +order as a requirement. Both are cheap to write and quietly convert every future addition +into a small tax. + +## A signal that appears exactly twice is connected and unused + +A double-buffer controller computed a ping-pong decision. The top level declared a wire for +it and passed it to the controller's output port — and never read it. The activation memory +had `wr_en` tied to `1'b0`. So the controller was correct, its output was wired, nothing +acted on it, and there was no path from a layer's output to the next layer's input at all. + +The whole thing was visible in one number: + +``` +grep -c use_buffer_a # 2 +``` + +**Two occurrences means a declaration and a connection, with no consumer.** Three or more +means something reads it. This costs one command per suspicious signal and finds a class of +defect that no per-module check can reach — every module involved is individually correct, +and linters see a driven, loaded wire. + +The generalisation for reviewing an integration: **count uses of each signal crossing the +seam, and look at every one with a count of two.** Same for a module instantiated but whose +outputs go nowhere, and for an output port assigned from a constant. A tie-off is a +deferred decision, and tie-offs are invisible in any view that does not span the boundary +they sit on. + +## Check the rate, not just the name, when reusing an address + +An activation memory's write address looked obvious: the double-buffer controller already +produced `write_addr`, so wire it up. Wrong by a factor of 27 — `write_addr` counts +*neurons*, and the requantizer emits one packed word per **27** neurons. The right address +was a dedicated word counter reset at layer start. + +**A signal named for what it addresses is not necessarily the address you need.** When +connecting two stages, compare their *rates* before their names: how many items does the +producer emit per item the consumer indexes? Packing, batching, and serialisation all +introduce a divisor that a plausible-looking name hides. + +The same check catches the mirror error — an address that advances too slowly because the +consumer was assumed to be per-item when it is per-burst. + +## Integration defects are a distinct class, and you cannot find them early + +Three consecutive waves, three defect classes, none reachable by any module-level property: + +| Defect | Why module-level proving missed it | +|---|---| +| latency skew — control met data a cycle early | sequencer, memory and MAC each individually correct | +| absent stage — no layer boundary existed | nothing to state a property *about* | +| dead control signal — decision computed and ignored | controller correct, consumer simply absent | + +Every module had properties. Every property proved. **The composition was wrong in three +different ways.** + +Two things follow. **Module-level verification bounds its own claim**, and saying so is part +of reporting it honestly — "28 properties proved" implies far more than it delivers if the +modules are not wired together. And **there is no way to front-load this**: the seam has to +exist before you can assert across it, so the integration effort is not a phase you can +verify your way past. Build the seam, then assert on it, and expect the first assertions +across a new seam to find something. + +## A property you cannot prove yet is a finding, not a bad property + +Wiring a prefetch controller to a shared memory produced a property that refuted: the +prefetcher could write an address the compute stage was reading, even though the +sequencer's state machine appeared to make that impossible, and even with the environment +constrained so the memory only answers questions it was asked. + +Three options, and the choice matters more than the result: + +1. **Ship the failing assertion.** Breaks the build for everyone, and turns a finding into + an outage. +2. **Weaken it until it passes.** This is deliberate vacuity — the exact failure mode + catalogued elsewhere in this file, committed knowingly. It converts an open question + into a false green. +3. **Record the gap.** The property is written into the RTL as a comment and into the + findings document with its exact reproduction, and is *not* asserted. + +Option 3, every time. **The pressure at the end of a work session is to leave everything +green, and a weakened assertion looks exactly like a solved problem.** It is worse than a +missing assertion, because it actively certifies the thing it stopped checking. + +State three things when recording: the property as written, that it was reproduced under a +*constrained* environment (so it is not an artefact), and that it is not asserted. That +turns "we did not finish" into a precise starting point rather than an absence someone has +to rediscover. + +## Count the tie-offs — they are decisions someone deferred + +Across three waves of integration work, every structural gap announced itself as a +constant: + +```verilog +assign prefetch_done = 1'b1; // "tied off until X is wired" +assign mem_addr = 32'd0; +assign mem_rd_en = 1'b0; +.wr_en(1'b0) // on BOTH memories -- neither was ever written +``` + +The comment on the first one was honest. The others carried none, and one of them meant +the weight memory in a machine-learning accelerator had never been loaded. + +**A tie-off is a deferred decision wearing the costume of a design choice.** They are +trivially greppable — a constant on the right-hand side of an `assign`, a literal in a port +map — and each one bounds what any test above it can possibly be checking. An engine whose +memories are tied to zero will still pass every property about its sequencers. + +Two habits: **grep for constant port connections before believing an integration is +complete**, and when you write one, put the condition for removing it in the comment. "Tied +off until X is wired" is a to-do with an owner; `1'b0` alone is indistinguishable from +intent, and the next reader has to prove a negative. + +## A refutation that survives a correct fix means another cause, not a wrong diagnosis + +A property refuted; the trace identified a stale completion flag; the flag was fixed; the +property **still refuted**. + +The pull at that moment is strong and wrong: conclude the diagnosis was mistaken, revert, +start over. The first diagnosis was correct and **incomplete**. A second, independent +defect sat in a *different module* — a level-triggered handshake sampled one cycle too +early — and each defect would have been masked by the other's correctness. + +**Re-read the trace instead of reverting.** The second trace looked different from the +first, which is itself the signal: if a fix changes the counterexample, it addressed +something real. A fix that leaves the counterexample identical is the one that missed. + +This is why composition defects cluster. Two modules that are each individually correct can +still disagree at their seam, and when two seams are broken at once, fixing either alone +changes nothing observable at the top. **Expect multi-cause failures at integration +boundaries, and treat "still failing" as a request for the next trace rather than a verdict +on the last one.** + +## The recorded gap is what made the fix possible + +A previous session left this property reproduced, documented, and **not asserted** — +choosing that over weakening it until it passed. + +That decision is what made this session possible. A softened assertion would have shipped +**two real defects under a green check**, and nobody would have taken the trace that found +them, because there would have been nothing to investigate. + +**An honestly recorded failure is a work item; a weakened assertion is a lie with a +maintenance cost.** The asymmetry is worth internalising: leaving something visibly +unfinished costs a little discomfort now and preserves the information. Papering over it +costs nothing now and destroys the information permanently — the next person sees green and +has no reason to look. + +## Ask the tool for the signals you named, not for a dump + +Two waves were lost to unreadable counterexamples. `-dump_vcd` after `-flatten` produced +files full of `$auto$async2sync.cc:116:execute$1477` — every user-facing name mangled by +the flattening the prover required. + +The working approach was smaller, not bigger: **top-level signal names survive flattening**, +so naming them explicitly gets a clean table. + +``` +sat ... -show pf_bram_we -show mac_valid_q -show layer_start -show start_prefetch +``` + +Two independent defects were visible in one reading of that table. + +**When a diagnostic dump is unusable, ask for less rather than more.** A full dump of a +transformed netlist is mostly artefacts of the transformation; a named projection of the +signals you already suspect is legible by construction. The same applies to logs, traces, +and profiles — a filtered view of hypotheses beats an exhaustive view of everything, and it +is usually one flag away. + +## Wiring a module is not using it — the property must name the connection + +An AXI-Lite slave was instantiated to replace a bundle of top-level config ports with +proper CSRs. Instantiation is easy to verify by eye and easy to get wrong in a way nothing +notices: a previous wave left a double-buffer controller connected to a wire that no +consumer ever read, and it stayed dead for four waves. + +So the properties written alongside the instantiation name the *connection*, not the +module: + +```verilog +a_start_is_ctrl_bit0: assert (start == reg_ctrl[0]); +a_status_reflects_engine: assert (reg_status[0] == busy && reg_status[1] == done); +``` + +Both would be **vacuously true** if the slave were instantiated and ignored — `start` would +simply be something else. Naming both sides is what makes them bite. + +**When integrating a component, assert an equality that spans the boundary.** "The module +is instantiated" is checkable by grep and means little; "this internal signal equals that +register bit" cannot hold unless the wire exists. The cheap version of this check is the +grep-count rule — a signal appearing exactly twice is connected and unused — and the strong +version is a property that fails if the connection is removed. + +## Tests named for an interface break when the interface improves — invert them + +Replacing a config port bundle with a register aperture broke three tests +(`control_ports_present`, `top_control_ports_present`, and two string matches on a +declaration whose whitespace shifted). None of them was wrong about the old design; all of +them asserted an *incident* of it. + +The rewrite did two things, and the second is the one worth remembering. It renamed them to +`host_aperture_replaces_config_ports` — and it **inverted** them, so they now assert the +**absence** of the old ports as well as the presence of the new ones: + +```rust +for gone in ["input wire start,", "input wire [5:0] num_layers,"] { + assert!(!v.contains(gone), "config port should be a CSR now: {gone}"); +} +``` + +**When a change makes a test obsolete, ask whether the negation is now the interesting +claim.** Often the thing you just removed is exactly what must not come back — a reverted +refactor, a reintroduced tie-off, a resurrected port. Deleting the test throws that away; +inverting it converts a broken test into a regression guard for free. + +The related smell, seen repeatedly here: a test that asserts a string containing formatting +(`"reg [31:0] cycles;"`) breaks when a declaration is realigned. Match on the semantic part +or normalise whitespace; a test that fails on `git diff -w`-invisible changes is measuring +the wrong thing. + +## An invariant written against one producer assumes how many producers there are + +A double-buffer invariant said: never write the buffer being read. It was correct, proved, +and had guarded a real defect. Then a second writer arrived — an input DMA whose whole +purpose is to fill the buffer *about to be read*, because that is where the first layer's +input belongs. + +The invariant immediately flagged the **correct** new code as a violation. + +The reflex when a proved property starts failing on a change you believe is right is to +weaken or delete it. Neither is right here. The property was always about *the requantizer +writing into a buffer under active read* — that scope was simply implicit while there was +only one writer to be confused about. It now says so: + +```verilog +always @(posedge clk) if (rst_n && !dma_local_we) + a_no_read_write_same: assert (...); +``` + +**Adding a producer to a shared resource is the moment to re-read every invariant about +that resource** — not to relax them, but to make explicit the domain they were always +about. The same applies to a second caller of a function, a second writer to a table, a +second scheduler touching a queue. A single-producer invariant reads as a global truth +right up until it isn't. + +The corollary: **if scoping an invariant makes it vacuous, it was the wrong invariant.** +Here the scoped version still bites — a requantizer write into a live buffer is still +forbidden — which is the check that the scoping was honest rather than a way of switching +it off. + +## `busy` was a decode, not a state — proxies bite in design too + +An interlock needed "is the engine running?". The available signal was +`busy = (current_layer != 0) || layer_start` — a *decode of a counter*, not a recorded +state. It is false during the entire first layer, so an interlock built on it has a hole +exactly where the first inference happens. + +This is the same failure the campaign kept finding in gates — a check written against a +cheap proxy instead of the property it names — arriving in the RTL instead of in CI. A +counter comparison that is *usually* equivalent to "active" is not the same object as a +flag set at start and cleared at done, and the difference shows up precisely at the +boundaries where interlocks matter. + +**When something needs a state, give it a register.** Deriving it from whatever is nearby +costs nothing to write and produces a signal that is right in the common case and wrong at +the edges — the worst possible distribution for a safety interlock. + +The wider habit: **before keying safety logic off an existing signal, read its definition +rather than its name.** `busy`, `ready`, `done`, `active` are all names that invite the +assumption that someone maintained them as state. + +## An interlock that names one of two mutually exclusive activities is half an interlock + +A DMA and a compute engine shared a buffer and must never run together. The guard written +for it blocked the DMA while compute was active — and nothing blocked compute while the DMA +was active. A host starting the DMA and *then* requesting inference walked straight through. + +Mutual exclusion is symmetric by definition, and a guard for it is naturally written from +whichever side you were thinking about when the hazard occurred to you. **Write the second +direction at the same time as the first, or the guard encodes the order in which you +happened to imagine the failure.** + +The test is mechanical: for a guard of the form "A may not start while B runs", ask whether +"B may not start while A runs" exists. If both activities are host-triggerable through +writable registers, both directions are reachable. + +## When a change invalidates a property, ask whether it becomes two + +Adding an interlock broke a property asserting `start == reg_ctrl[0]` — correct before, +and now deliberately false whenever the interlock fires. The options that come to mind are +delete it or relax it to match the new behaviour. + +Neither is best. It became **two** properties: + +```verilog +// the new behaviour +assert (start == (reg_ctrl[0] && !dma_busy)); +// ...and the interlock is the ONLY thing that may suppress a start +always @(posedge clk) if (rst_n && !dma_busy) + assert (start == reg_ctrl[0]); +``` + +The second is the one that would otherwise have been thrown away: it pins down that the +change did *exactly* what was intended and nothing more. Relaxing the original to the new +form silently permits any *future* condition to suppress a start too. + +**A property invalidated by an intentional change usually splits into "the new behaviour" +and "nothing else changed".** The second half is free to write, and it is the half that +catches the next unintended broadening. + +## Two fixes, neither sufficient — report that, not the last one + +Two genuine defects were fixed against one failing property: a state signal that was +actually a decode, and a one-directional guard on a symmetric constraint. The property still +refutes. + +The temptation is to describe the wave by its last action, or to keep going until something +turns green. Neither serves. What is *true* is: the property is the sole remaining failure +(confirmed by neutralising it alone and watching everything else pass), the residual cause +is bounded to a timing relationship rather than a missing guard, and both landed fixes are +independently worth having. + +**A partially-closed gap, precisely bounded, is a better deliverable than an +unbounded-but-green one.** Say how far it moved, what remains, and how you know the +remainder is what you say it is — "neutralising this one assertion makes every other pass" +is a stronger statement about scope than any amount of narrative. + +## Three partial fixes in a row means change what is observable, not the observer + +One property refused to close across three waves. Each attempt was a real improvement and +each narrowed the failure window: + +1. a status signal that was a decode became a proper state register +2. a one-directional guard on a symmetric constraint became symmetric +3. the guard was extended across three more pipeline-stage valids + +All three were correct. None was sufficient, and the pattern — *each fix narrows, none +closes* — is itself the diagnostic. The fourth attempt was spent on a trace instead, and it +showed why: the supervisor being gated **runs its own state machine and does not stop when +the host clears the request bit**. The signal the gate keyed off tracked a *request*, not a +*state*. Quiescence lived inside a submodule and simply was not observable from where the +gate was written. + +**When successive guards each narrow a window without closing it, stop adding conditions +and ask what the guard cannot see.** Accumulating terms at the observation point is the +signature of a missing observable — the fix is usually one output port on the module that +actually knows, not a fifth conjunct on the module that does not. + +The corollary that made stopping the right call: **the diagnosis is a better deliverable +than a fourth narrowing.** Naming the change ("`multilayer_sequencer` needs an `idle` +output, and the interlock keys off that") converts an open bug into a scoped task, and +leaves the accumulated conditions replaceable by one that answers the right question rather +than entrenched as four that nearly do. + +This is the same distinction as request-versus-acknowledge in a handshake, one level up: a +supervisor that can be *asked* to stop is not one that *has* stopped, and any interlock +built on the ask inherits the gap. + +## Replacing a compound guard is where terms get dropped + +A four-condition interlock was replaced with one better condition — the module's own idle +state, exported specifically so the guard could ask the right question. The new guard was +more principled, shorter, and **still wrong**. + +`seq_idle` subsumed three of the four old terms. It did not subsume `!reg_ctrl[0]`, which +was there for a different hazard entirely: a host setting two control bits **in the same +write**. At that instant the sequencer genuinely *is* idle, so the new condition permits +exactly the case the dropped term existed to block. + +**Before replacing a compound guard, write down what each term was for.** A replacement +that covers three of four leaves a hole precisely where the fourth was — and the hole is +harder to see than the original mess, because the new guard *looks* like it was derived +from first principles. + +The tell that this had happened: the failure survived a fix that was independently correct. +That is the same signal as elsewhere in this file — a refutation surviving a real fix means +another cause — and here the other cause was something the fix itself removed. + +## Time spent understanding why a fix fails is not lost from fixing it + +One property stayed open across four waves: + +| Wave | Action | Outcome | +|---|---|---| +| 1 | recorded as open rather than weakened | property preserved | +| 2 | two narrowings (state register; symmetric guard) | window smaller, still open | +| 3 | third narrowing attempted; **diagnosis** produced instead | cause identified | +| 4 | act on diagnosis | closed, five lines | + +Waves 2 and 3 look like failure and were not. Wave 1 preserved the information; waves 2–3 +bounded the problem until wave 3 could say *why no top-level fix could work*; wave 4 was +trivial once that was known. + +**The strong temptation in the middle of this is to weaken the property so the run goes +green.** Each wave offered that exit and each refusal is why the final fix was five lines +in the right place rather than a fifth condition in the wrong one. + +Two habits worth carrying: **when successive fixes each narrow without closing, spend the +next attempt on a trace rather than a fix** — the pattern is diagnostic, not just +frustrating. And **a diagnosis that names a concrete change is a complete deliverable**; it +converts an open bug into scoped work, and the person who acts on it may well be you next +session with no memory of the reasoning. + +## After adding constraints, prove the behaviour still exists + +Four consecutive sessions were spent adding interlocks — each one narrowing what a system +was allowed to do, each one correct. Every safety property passed at the end. + +That is exactly when a green result means least. **An over-tight guard makes every safety +property hold by making the system do nothing**, and no safety property can distinguish +"the bad thing cannot happen" from "nothing happens". + +So the next session audited instead of extending. Six probes, each asserting an activity is +*impossible*, where a **refutation** is the evidence it still occurs: + +| Probe | Expected | Meaning | +|---|---|---| +| `!dma_busy` | refutes | the DMA can still start | +| `!mac_valid_q` | refutes | compute can still run | +| `!(dma_busy && mac_valid_q)` | **proves** | ...and never together | + +The last line is the pair that carries the claim. **A safety property and a liveness +witness together say something neither says alone**: "this combination is impossible" is +only interesting once "each of these is possible" is established. + +Two habits: + +**Order matters — audit before extending.** The natural next step was a new property built +on top. On a stalled engine it would have proved trivially and the error would have +compounded into everything after it. One session of checking bought certainty for all the +work that follows. + +**Write the liveness witnesses as CI, not as a one-off.** The hazard is not that today's +guard is too tight; it is that tomorrow's will be, and every safety property will keep +passing while it happens. + +The general form applies well beyond hardware: after tightening validation, rate limits, +permissions, or retry conditions, the tests that still pass are not evidence the system +works — they are evidence it does not do the forbidden thing, which an inert system also +achieves. Add a check that the permitted thing still happens. + +--- + +## A verdict harness must prove its own baseline before any verdict is evidence + +A probe harness that decides "the probe refuted" from a nonzero exit code cannot tell +*your property failed* from *something else failed*. While one interlock attempt was in +the tree, an obligation `yosys` generates itself (`async2sync`) began failing, and every +row of a six-row liveness table silently flipped to "refutes" — including the row whose +expected answer was "proves". The table was reporting on a failure no probe had caused. +Diagnosis took four rounds, and each round produced a confident, wrong attribution: +first "my interlock created concurrency", then "it's the implicit-net shadowing", then +"it's the async reset". All three were wrong, and the harness said nothing. + +The fix is one cheap run, placed first: + +```bash +# baseline: no probe, no -DFORMAL. If this does not prove, no probe verdict means anything. +yosys -q -p "read_verilog -sv -formal ; prep -top -flatten; memory_map; \ + async2sync; chformal -lower; sat -verify -prove-asserts -seq N -set-init-zero" +``` + +Generalises to any pass/fail harness that collapses a rich outcome into an exit code: +integration suites, lint gates, benchmark regressions. **Before trusting a differential +verdict, establish that the undisturbed system passes.** If it does not, every difference +you measure is against a broken reference. + +## A reference above its declaration silently forks the signal + +Emitting a block that reads `dma_local_we` at line 125 when the wire is declared at line +262 does not error. Verilog's implicit-net rule conjures a fresh one-bit wire at first +use, so the block reads an **undriven twin** with the same name. The solver was then free +to fabricate DMA writes, and refuted a property that had nothing to do with the change. +No warning, and the emitted file reads correctly to a human. + +When a generator inserts code, **the insertion point is a correctness property, not +formatting.** The safe shape is to split declaration from driver: + +```verilog +wire start; // early, where consumers are +... +assign start = a && b && c; // after every signal it reads is declared +``` + +Applies to any code generator that splices into an ordered document — Verilog nets, C +declarations before use, import blocks, migration ordering. If insertion order can change +meaning, pin it with a test that asserts the relative position, not just the presence. + +## Completion is not evidence that work was done + +Third instance of one shape in this campaign: zero neurons, zero words, and now zero +bytes. **A zero-sized job satisfies its completion contract without doing anything**, so +gating on "the transfer finished" admits a transfer that wrote nothing. Gate on the +observable effect — a write actually asserted — not on the done flag. + +When you find the same defect shape twice, look for the third before it finds you: sweep +every module that reports completion and ask what its zero-sized case does. + +## An open finding needs a gate, or it rots + +A property that refutes and cannot be fixed today has three possible homes. Weakening it +until it passes destroys the finding. A comment in a doc gets forgotten. The third is +best: keep the property, put it behind its own guard so the green set stays green, and +**gate in CI that it still refutes**. + +```yaml +- name: Prop. 25 is still open (must refute) + run: | + if yosys -q -p "... -DFORMAL_OPEN ..."; then + echo "::error::now PROVES -- promote it out of the open guard and update the docs" + exit 1 + fi +``` + +The day someone fixes it, the build goes red and tells them to promote it. An expected +refutation is a real gate: it pins the *boundary* of what is proved, and a boundary that +moves without anyone noticing is how a known gap turns into a forgotten one. + +## Withdraw a fix that costs more than it buys + +Three interlocks were tried for one refuting property. None closed it, and each broke +something that had been proving. All three were withdrawn and the finding was recorded +instead. The instinct to ship *something* after that much work is the thing to resist: +a fix that does not fix the target and regresses the baseline is strictly worse than an +honest open finding, and the sunk effort is not an argument for it. + +--- + +## A generated file's comments are not evidence about the generated file + +The standing rule is *verify the artifact, not the source*. A comment **inside** the +artifact is still source. An emitter wrote: + +```verilog +// A zero-length request moves no data and completes immediately. +IDLE: if (start && (length != 32'd0)) begin +``` + +The comment describes the intent. The code does the opposite — it drops the request +entirely — and the two sat adjacent for several waves. Reading the comment produced a +published claim that was confidently, specifically wrong, and it propagated into a +proposition, a README row, a commit message and an issue before a sweep contradicted it. + +When a comment and the code it annotates disagree, the comment is usually the older of +the two and always the one with no test. **Grep for behaviour, not for prose:** the guard +condition, the state transition, the assignment. If a claim about behaviour cannot be +traced to a line that executes, it is not established. + +## When a defect shape appears twice, enumerate the class + +Three waves found one shape — a zero-sized job — one module at a time. Two were noticed +while chasing something else; the third was a *guess* at a mechanism, and the guess was +wrong. A single sweep over every module that takes a count found both real instances at +once and produced something none of the individual investigations had: a **policy +question**. + +``` +layer_sequencer zero neurons completes +weight_prefetch_ctrl zero words completes +multilayer_sequencer zero layers DROPPED <- host hangs +dma_controller zero length DROPPED <- host hangs +``` + +A 2–2 split. **Neither policy is wrong in isolation; the disagreement is the defect**, +because a caller cannot know which to expect. That framing is only visible from the +sweep — each module in isolation looks defensible. + +The generalisation: after the second sighting of any defect shape, stop fixing instances +and enumerate the class. Boundary values (zero, one, max, overflow), empty collections, +single-element cases, absent optional fields, first and last iteration. Cheap, mechanical, +and it converts a stream of accidents into one decision. + +## The unobservable outcome is the dangerous one + +Ranking the ways a request can fail: + +1. **Completes correctly** — fine. +2. **Errors** — visible, caller can react. +3. **Completes vacuously** (reports done, did nothing) — visible if the caller checks. +4. **Silently dropped** (no work, no completion, no error) — **invisible**. The caller + waits forever for a result that was never coming. + +Wave 574 assumed the DMA was in class 3 and treated that as the bug. It was in class 4, +which is worse. When choosing how to handle a degenerate input, **prefer the outcome the +caller can observe** — a completion that did nothing beats a silence that did nothing. + +Pair the two halves or you get neither: assert the request completes *and* assert it +performed no work. The first alone permits a module that lies; the second alone permits +one that hangs. In CI that means a gate with deliberately mixed polarity — some +properties must PROVE, others must REFUTE — so write the expected verdict next to each. + +--- + +## A rule with no gate is a preference + +The clearest result of the documentation audit: two reproduce blocks were added that +violated a rule recorded **in the same file** — *evidence citing a command that does not +exist is not evidence* — written by the same author, in the two waves immediately after +that rule was written down. Neither block was ever executed. Both cited a binary not on +PATH. + +Writing a rule down does not apply it. Restating it does not either. The only thing that +applies a rule is a check that fails when it is broken: + +```python +# in CI: no ```bash block may cite a binary that is not on PATH +if re.search(r"^\s*t27c ", block, re.M): + fail(f"L{n}: cites bare `t27c` (binary is at ./target/release/t27c)") +``` + +Applies to every convention you're tempted to record in a style guide, a CLAUDE.md, or a +review checklist. **If you find yourself writing a rule, ask in the same breath what +would fail when it is violated.** If the answer is "someone would notice in review", the +rule will be violated, and most likely by the person who wrote it. + +## Documentation is evidence, so audit it like evidence + +A markdown document full of ```` ```bash ```` fences reads as reproducible. Classify the +blocks before believing that: + +| class | test | +|---|---| +| **runnable** | contains a line matching a command pattern | +| **template** | contains `` — never meant to run | +| **transcript** | contains no command; it's showing output | + +Fourteen of nineteen were transcripts wearing a command's clothes. A reader cannot +distinguish them, and neither can a future maintainer deciding whether a claim still +holds. Transcripts belong in ```` ```text ````; the fence is a type annotation, not +decoration. + +Then run the runnable ones. Not "check they look right" — execute them, and check the +exit for 127 specifically, since *command not found* is the failure that makes a block +look plausible while proving nothing. + +## Trace claims to gates mechanically, then check the misses by hand + +To ask "is every claim in this document still checked?", extract the identifiers each +claim cites — function names, property names, file paths, commands — and grep the CI +workflows and test files for them. Claims whose identifiers appear nowhere are candidates +for *unchecked*. + +**Candidates, not conclusions.** Of six that matched nothing, four were false negatives: +the extractor could not see prose labels like `'DMA can start'` that appear verbatim in +the workflow. Checking those six by hand cost minutes and prevented publishing four wrong +"ungated" claims — which would have been the exact error the audit existed to find. + +A heuristic that produces a shortlist is doing its job. Treating its output as a verdict +is how an audit becomes the thing it was auditing. + +## Distinguish "has a check" from "the check is sufficient" + +The gate map establishes that each claim *has* something re-checking it. It does not +establish the check is *adequate*: one gate counts conformance files without measuring +whether the vectors inside them test anything. Recording that limit inside the audit is +what keeps the audit honest — an audit that overstates its own coverage is worse than +none, because it stops anyone looking again. + +The follow-up is the vacuity oracle applied to gates: break each gate deliberately and +confirm the claim it guards actually goes red. + +--- + +## Test that your gates bite, not just that they exist + +Establishing that every claim *has* a check is the easy half. The hard half is that the +check *fails when the claim is false*. Redirect the vacuity oracle at the gates +themselves: for each gate, apply one mutation that should violate the claim it guards, +and require the gate to go red. + +``` +gate mutation verdict +Prop 7 interrupt_ctrl revert clear-then-set -> set-then-clear red +Prop 24 liveness tie start off, stalling the engine red +Prop 27 doc gate make a block cite a binary not on PATH red +``` + +**Three phases, and the first two are what make the third mean anything:** + +| phase | requirement | catches | +|---|---|---| +| baseline | unmutated, every gate passes | "went red" not caused by the mutation | +| control | semantically neutral edit, every gate still passes | a gate that fires on *any* change and scores 100% detecting nothing | +| mutation | each gate goes red for its own mutation | the actual claim | + +Skipping the control is the subtle failure: a gate that reports failure unconditionally +passes every mutation test perfectly. Add a dead variable, an unused import, a reordered +comment — anything semantically inert — and require green. + +**A clean sweep is a reason to check the harness, not to celebrate.** 13/13 on a first +run is precisely the moment to ask what would have to be broken for the harness to +report that anyway. + +## The mutation a safety check cannot see + +Stalling the system under test leaves every safety property true — something that does +nothing violates nothing. Only a liveness check notices. When choosing mutations, include +at least one that makes the system *inert* rather than *wrong*, because that is the +mutation that distinguishes a suite which proves behaviour from one which merely proves +absence of misbehaviour. + +If no gate goes red when you disable the feature entirely, the suite is measuring +silence. + +## State the lower bound your method gives you + +Mutation testing bounds from below and never from above: each gate detected *the* +mutation chosen for it, which is one point per claim, not coverage over all possible +violations. Write that limit into the result, in the same document, at the same +prominence as the number. + +The pattern across several audits: every method has a ceiling, and the useful artifact is +the number **plus** the ceiling. A result reported without its ceiling gets cited later as +if it had none — usually by the person who produced it, two waves on. + +--- + +## Before believing a bounded proof, ask how far away a violation is + +A bounded model check proves a property over N steps. If a counterexample needs more than +N steps, the tool reports success and has established nothing: + +``` +a_addr_never_wraps -seq 24 -> PROVES +# the address is 12 bits; wrapping needs 4096 writes. The bound is 24. +``` + +That "proof" says *no wrap within 24 cycles*, which nobody doubted. Two modules passed +it while both contained a wrap that silently corrupts data. + +The fix is to **scale the model until the counterexample fits** — narrow the address, +shrink the memory, reduce the queue depth. Both modules refuted within one cycle of the +scaled bound. Scaling changes what you proved, so say so: the claim becomes "this design, +at this scale, does not wrap", plus the argument that the width is a parameter and not +the mechanism. + +Generalises past formal: a fuzz run that never reaches a 10,000-element list, a load test +that stops below the connection limit, a soak test shorter than the leak's doubling time. +**A green result from a search that could not have found the bug is not evidence.** Ask +what the smallest failing input looks like, then confirm the search reaches it. + +## When a correct fix does not make the property pass, look again + +The most valuable defect in this wave was found because a *second* defect kept the +property red after the first was fixed correctly. The temptation at that moment is to +assume the fix was wrong and revert it, or to weaken the property. Both destroy the +signal. + +A property that stays red after a fix you believe is right is telling you there is a +second cause. Two things make this actionable: + +- Verify the fix independently (the fix's own unit test passes, the shape matches a known + good module). +- Then read the counterexample rather than patching again. + +The second defect here — data, write-enable and address registered together, so the +memory sees a *post-increment* address and index 0 is never written — had nothing to do +with the sizing question being investigated. It was found only because the first fix's +failure was treated as information. + +## An unconstrained input is an adversary + +Two refutations in this wave were faults in my own harness, not the design: a tracker that +compared state across two different transfers, and a free `rlast` that let the solver play +a bus slave which never ends a burst. Both looked exactly like design defects. + +Under formal, every unconstrained input is chosen by something actively trying to break +you. That is the tool's value and its main trap: **a refutation is a claim about the +environment until the environment is pinned down.** Before reporting one, ask what +protocol the real driver obeys that your harness has not been told about — then either +assume it explicitly, or instantiate the compliant model. + +Same discipline in ordinary testing: a failure with an unspecified mock is a fact about +the mock. + +## Stop patching after the second attempt + +Two fixes were applied to one refuting property; neither closed it. The third attempt was +not made. The property is recorded as an expected refutation with a CI gate, so closing +it turns the build red and asks for promotion. + +The rule that keeps this honest: **after two failed attempts, the next action is a +counterexample read carefully, not a third patch.** Patches that follow a wrong model +compound — each one adds behaviour that the next investigation must account for, and by +the fourth you are debugging your own repairs. + +--- + +## Scanning for the broken form of a shape only finds what nobody fixed + +After finding a defect shape twice, the instinct is to grep for it. That scan returned +**zero** candidates — because both instances had just been repaired. The scan was +searching for the *symptom* of an unfixed bug, so a clean result meant nothing about the +rest of the codebase. + +Enumerate the **class**, not the symptom. The symptom was "a self-incremented address +co-assigned with a write-enable". The class is "every write port" — and the right question +is semantic: *does this port present address, data and enable from the same stage?* +Enumerating the class found a third port that had never been checked at all, and proved it. + +The general form: when a scan for a known bug pattern comes back empty, ask whether it +could have found the bug *before* you fixed it. If the answer is "only in the exact form I +already repaired", the scan measured your memory, not the code. + +## A property a known defect would have passed is the wrong property + +The first property written for the wrap defect required the write address to *increase*. +It passes a design that skips slot 0 entirely — which is precisely what the second defect +did. Monotonicity was too weak, and nothing revealed that until a second bug hid inside +the gap. + +The check to run at the moment a defect is fixed: **would my property have failed on the +code I just repaired?** Run it against the pre-fix version. If it passes, strengthen it +before moving on — you have written a property that describes the fix rather than the +requirement. + +Here the requirement was contiguity — no gap, no repeat, starting at zero — which is +strictly stronger than monotonicity and catches both defects. Cheapest possible moment to +discover that: right then, with the broken version still in reach. + +## Do not diagnose with a tool that just contradicted itself + +A counterexample extraction reported a trace in which the guard signal was low the whole +time — which cannot violate a property guarded on that signal. That is the tool telling +you it is unreliable, not the design telling you something subtle. + +The disciplined response is to stop, record the finding as-is, and fix the instrument +before using it again — ideally by validating it against a property with a *known* +counterexample. Continuing to reason from a self-contradictory trace produces confident +conclusions from noise, which is worse than having no trace at all. + +Same rule as the baseline check, one level up: **verify the instrument on a case whose +answer you already know, before trusting it on one you don't.** + +--- + +## When two attempts stall on the same item, suspect the instrument + +Two waves produced nothing on one open finding. Both times the conclusion was "the design +is subtle". It wasn't — the counterexample had never actually been parsed. An ad-hoc regex +over the tool's text output silently dropped every row, and the resulting trace showed a +guard signal low throughout, which cannot violate a property guarded on that signal. + +The signal to watch for: **a diagnosis that contradicts its own premise.** A trace where +the failing condition never occurs, a profiler where the hot path isn't called, a log +where the error precedes the request. That is the instrument reporting on itself. + +Fixing the reader took one wave and made the defect legible in the first query. Two waves +of "the design is subtle" were the cost of not suspecting it earlier. + +## Validate a reader against a known answer, in CI + +A parser for diagnostic output has no natural test — it is *the* thing you would use to +check itself. Break the circle with a case whose answer you already know: + +```python +# the prefetch with its clamp removed MUST wrap; if the reader cannot see that, +# the reader is broken, not the design +refuted, trace = run_and_read(script_with_known_bug, "known.json") +assert refuted and any(addr[t] < addr[t-1] and we[t] for t in range(1, depth)) +``` + +Make it a CI step. A diagnostic tool that silently degrades is worse than none, because +its output still looks like evidence. This applies to log parsers, metric scrapers, crash +symbolicators, coverage extractors — anything whose failure mode is "returns something +plausible". + +Two concrete traps found in one tool: +- **The format lies about being a format.** `yosys sat -dump_json` writes RTLIL names + verbatim, so a name containing `\e` makes the document invalid JSON. Repair before + parsing; do not assume a `.json` extension means parseable. +- **Compressed formats need full expansion.** WaveJSON uses `.` for "same as previous". + A reader that skips those characters loses most of the trace while appearing to work. + +## Query the trace, do not read it + +Once the reader worked, the defect was found by asking a question, not by scanning a +table: *at which timestep does the guard hold and the assertion fail?* One line, exact +answer, `t=28: local_addr=1, expected 0`. + +Eyeballing a 30-cycle × 90-signal table is how the earlier misreadings happened. **Encode +the property you are checking as a predicate over the trace and let it find the row.** +The predicate is already written — it is the assertion that failed. + +## Keep a fix that misses its target only if it is right on its own terms + +Two fixes were applied to a refuting property; neither closed it. Earlier guidance says +withdraw a fix that does not fix the target. The refinement: **withdraw it when it costs +something** — a regressed property, added complexity, a weakened invariant. Keep it when +it is independently correct and everything else still passes. + +Both fixes here were kept: one sequential index per transfer, and reset on every start. +Each is defensible without reference to the property that motivated it. The test is not +"did it work" but "would I write this if I had seen the code fresh". + +--- + +## A strobe assigned in only one branch holds everywhere else + +The defect that had survived four waves of inspection: + +```verilog +READ_DATA: if (rvalid) begin ... local_we <= 1'b1; ... end + else local_we <= 1'b0; // runs ONLY while in READ_DATA +READ_ADDR: begin ... end // local_we not mentioned -> it HOLDS +``` + +The `else` looks like it deasserts the strobe, and it does — in exactly one state. Every +other state leaves the register untouched, so a signal meant as a one-cycle pulse stays +high across state transitions and keeps firing at a stale address. + +The fix is a default assignment before the `case`, so any state that does not explicitly +set the strobe leaves it low. **Any signal whose meaning is "this cycle, do X" needs a +default, not a per-branch clear.** + +The same trap outside hardware: a flag set inside one branch of a dispatch and cleared in +that branch's `else`, while other branches never touch it. Set defaults at the top of the +handler, not at the bottom of one path. + +## Scale everything the scaled signal touches + +Half a wave was lost to a false lead. A model was scaled by narrowing a signal from 12 +bits to 3 so a counterexample would fit the bound — but the test harness still declared +the matching wire at 12 bits. Nine bits undriven, every comparison against them `x`, and +`x` fails every comparison. The result is a **confident refutation of an innocent design**, +indistinguishable from a real defect. + +Two rules fall out: +- When you shrink a parameter for tractability, shrink it at every boundary that touches + it — DUT, wrapper, reference model, expected values. +- In a trace, learn what your reader prints for `x` versus for *no data*. Reading `-` as + "unparsed" instead of "undefined" is what hid this. **An `x` in a comparison is a + defect in the harness until proven otherwise.** + +## Report each property's discriminating power separately + +Two properties were added for one defect. Both proved after the fix. Only one of them +*also* refuted when the fix was reverted; the other proved either way at that bound, so it +distinguishes nothing and is evidence of nothing. + +Reporting "2 properties proved" would have been true and misleading. **A property that +passes on both the fixed and the broken design contributes zero information**, and saying +so is the difference between a result and a number. Check each property against the broken +version separately, and record which ones actually discriminate. + +## A sweep's value is not only what it was aimed at + +A sweep for oversized-request handling produced five distinct defects, and **four had +nothing to do with request size** — an off-by-one in write pairing, a dual-role pointer, a +misplaced reset, a held strobe. They surfaced because the sweep forced attention onto code +paths nothing else had exercised, and because each fix that failed to close the property +exposed the next cause underneath. + +The practical consequence: judge a systematic sweep by total defects found, not by hit +rate against its stated target. A sweep that finds nothing of its named kind but four +other real bugs has done its job. + +--- + +## A blocker you recorded rather than forced can dissolve on its own + +Three fixes for one defect were tried and withdrawn because each broke an unrelated +baseline that nobody could explain. The defect was recorded as open and gated, not +patched around. Eight waves later the same fix was re-applied unchanged and the baseline +proved — the blocker had gone away with three *other* defects fixed in the meantime. + +Had the original fix been forced through by weakening the baseline check, the three later +defects would have had one less signal pointing at them, and the weakened check would +still be in the tree. + +**When a fix is blocked by something you cannot explain, record the blockage and move on +to work that is explainable.** Unexplained blockers are frequently symptoms of defects you +have not found yet; fixing those retires the blocker for free. The cost is carrying an +open item; the alternative is carrying a silent workaround. + +## A global flag cannot answer a per-instance question + +The defect: a consumer could read a buffer nothing had written. Three attempts gated on a +single `input_loaded` bit — *has anything been written* — when the property asked *was the +buffer this reader reads written*. No tuning of one bit answers a question indexed by +which instance is in play. + +The tell is a mismatch of arity between the flag and the property. If the property +mentions a selector (`use_buffer_a ? ... : ...`, a channel id, a tenant, a shard), the +state backing it must be indexed by that same selector. One flag per instance, set by the +actual event, not one flag for the system. + +This shape recurs far outside hardware: a global `initialized` boolean guarding +per-connection state, one `dirty` flag for a set of caches, a single retry counter across +independent requests. + +## Prefer an observable error to a stall, when refusing is the only alternative + +Given "this operation cannot safely proceed", the tempting fix is to refuse to start it. +That deadlocks whenever the unsafe condition is *legitimate* — here, a layer that +correctly produces no output, which is a valid case the system deliberately supports. + +And a stalled system passes every safety property, because something that does nothing +violates nothing. So the failure mode is invisible to exactly the checks that motivated +the change. + +The better shape: **do not perform the unsafe operation, complete anyway, and raise an +error the caller can observe.** Here that meant not starting the layer *and* driving an +error interrupt — no garbage computed, no deadlock, and the host learns why. Verify with +liveness witnesses that the system still does its work afterwards. + +## An expected-refutation gate is how an open defect closes itself + +The property was kept in the codebase, guarded, with CI asserting it **must still fail**. +When the fix finally worked, the build went red and said: promote this. That is the whole +value — an open defect that announces its own resolution rather than waiting for someone +to remember it. + +Worth pairing with the inverse, added when the last one closed: a gate asserting **no +expected-refutation guard remains**. Together they make "what is knowingly broken" a +checked property of the repository instead of institutional memory. + +--- + +## A bounded result is a claim about (system, scale) — publish both + +"All properties proved" is not a property of a design. It is a property of the design +*paired with the bound the checker ran at*, and the two are inseparable: a bounded model +check at depth N says nothing about a counterexample needing N+1 steps. Two modules once +"proved" an address never wraps while both wrapped, because the wrap needed 4096 writes +and the bound was 24. + +So measure the ceiling and publish it with the claim: + +``` +seq DEPTH verdict time + 40 4 PROVED 40.7s <- what CI runs + 60 4 PROVED 246.1s <- 1.5x the bound, still holds + 80 4 undecided >300s <- the ceiling + 40 8 PROVED 70.5s + 60 8 PROVED 219.7s <- both axes at once +``` + +Two things this buys that a single number does not. **Headroom**: the claim holds at 1.5× +what CI uses, so it is not perched on the edge of its own tractability. And **cost +shape**: 1.5× the unrolling cost 6× the time, while doubling memory cost 1.7×. Knowing +which axis is expensive tells you which one you can afford to raise later. + +Raise the axes **together** at least once. Each alone can pass while the combination does +not, and a single-axis sweep would never show it. + +Generalises to any bounded search: fuzzing iterations, property-test case counts, load-test +concurrency, soak-test duration. Report the largest setting you verified, not just the one +you run by default. + +## Undecided is a third verdict — do not fold it into pass or fail + +A timeout means the solver ran out of time. It is not a failure (nothing was shown wrong) +and not a pass (nothing was shown right). Folding it into "fail" is alarmist and gets the +result ignored; folding it into "pass" is false, and it is the direction people drift +because green is comfortable. + +Give it its own column. In this sweep, four modules of five extended to 4× their bound and +one became **intractable at 2×** — its proof is real at its own bound and *nothing is known +beyond it*. That is the single most useful line in the table: it names the one place a +deeper defect could sit unseen. Collapsing it into either binary would have erased it. + +The same applies to skipped tests, flaky retries, and partial rollouts. A result you did +not obtain is information, and it belongs in the report at the same prominence as the ones +you did. + +## Check the ceiling, or it drifts + +Recording "proved at depth 40" in a document is a snapshot. The design grows, the state +space grows, and one day the depth that used to complete no longer does — silently, +because nothing re-runs the larger configurations. + +A scheduled gate that re-establishes each documented scale, and fails when one starts +refuting **or stops completing**, turns the ceiling into a checked property rather than a +remembered one. Both failure directions matter: a refutation means a real defect at depth, +a new timeout means the claim quietly shrank. + +--- + +## A batch verdict is the minimum over its members + +A checker that verifies a whole suite in one invocation returns one answer, and that +answer describes its *worst* member: + +``` +a_sanity PROVED 0.2s +a_no_overwrite PROVED 87.2s +a_rready_implies_active PROVED 0.4s +all three together undecided >240s +``` + +Two separate problems here. The parts sum to under 90 seconds while the whole exceeds +240 — a combined instance can be superlinearly harder than its pieces, so batching costs +real verification depth. And the single number concealed that two members were verified +four times deeper than the third. + +Splitting bought a 2.9× deeper bound at the same wall time, and it names the failure: +a red batch says *something in here broke*; per-member runs say which. + +**When members of a suite differ by orders of magnitude in cost, an aggregate describes +one of them and none of the others.** Run them separately and report a table. Applies +directly to test suites with one slow integration test, benchmark runs reported as a +single mean, and any pass/fail gate over a heterogeneous set. + +## A limit attributed to the system may be a limit of the question + +One module was recorded as "the one place a deeper defect could hide" because its proof +would not extend. Re-asking the same question one property at a time removed the limit +entirely — the module was never the problem. + +Before concluding that a component is intractable, expensive, or unverifiable, check +whether the *shape of the query* is what is intractable. Batching, an over-broad scope, a +join that fetches more than needed, a test that exercises ten behaviours at once: each +turns a tractable question into an intractable one and then attributes the difficulty to +the subject. + +The tell is a large gap between the cost of the whole and the sum of the parts. If you +have never measured the parts, you do not know which you are looking at. + +## Replacing a global count with a local invariant is the right instinct — establish the alignment first + +The expensive property bounded a wide counter across an entire unrolling. The natural +replacement is a *local* invariant relating that counter to something already constrained, +plus an existing property covering the rest — cheap for a solver because it needs no +history. + +The attempt refuted twice, both times on the sampling alignment between a counter +registered off one signal and an address assigned from another on the same edge. The idea +was sound; what was missing was one concrete fact about *when* each signal holds which +value. + +The lesson is about order: **establish the alignment as its own small measured question +before building an argument on it.** Assert the relation at each candidate offset and see +which one proves — that is a directed experiment with three outcomes, versus a guess with +two. Guessing it inside a larger property means every failure is ambiguous between "wrong +offset" and "wrong idea". + +--- + +## Check what kind of result you have before measuring how far it extends + +A verification campaign spent a wave measuring how deep each proof holds, and produced a +careful table of ceilings. Two of the six entries had no ceiling: they were proved by +k-induction, which holds for all time, and the sweep had re-measured them with plain +bounded checking and reported "proved to 8× the bound" — **understating a result that was +already unbounded.** + +Nothing in the aggregate output distinguished the two modes. One flag in the invocation +did, and it was never checked. + +Before quantifying a result, classify it. A bounded search and a proof by induction are +different kinds of claim, and so are a sampled benchmark versus an exhaustive one, a +statistical test versus a deterministic check, a spot audit versus a full reconciliation. +**Measuring the extent of a claim that does not have an extent produces a number that is +worse than no number**, because it looks like a limit where none exists. + +## The same parameter name can mean different things in different modes + +Acting on the measurement above, bounds were raised across the suites — including one +where the parameter is an *induction depth*, not a search bound. Raising it there buys +nothing and costs a great deal: the proof was already unbounded. + +`-seq 80` reads identically in both invocations. Only the presence of another flag changes +what it means. + +When a tool's parameter changes meaning by mode, the safe move is to key any bulk edit on +the **mode**, not on the parameter. Grep for the mode flag first, partition the call sites, +and apply the change only to the partition where it means what you intend. The bulk edit +that treats all call sites alike is the one that quietly does the wrong thing to a subset. + +## An aggregate can be uninformative in a way that looks informative + +Six suites, six verdicts, all green. That summary was accurate and told you almost +nothing: two of the numbers meant something categorically different from the other four, +and one was the minimum over three members whose costs differed by two orders of +magnitude. + +The fix is not more precision in the aggregate but **a per-member map**: one row per +property, with its own depth and its own cost. That converts "everything passes" into a +picture that names the single genuinely shallow item — which is the only part of the +summary anyone can act on. + +Worth doing once for any suite whose members are heterogeneous. The cost is one sweep; the +result is knowing which of your green checks are load-bearing. + +--- + +## Splitting a suite pays only when its members differ in cost + +Splitting one suite into per-property runs bought a 2.9× deeper bound. Applying the same +move to another suite bought nothing. The difference is measurable in advance: + +| | suite A | suite B | +|---|---|---| +| cheapest member | 0.2 s | 276 s | +| dearest member | 87.2 s | 299 s | +| ratio | **436×** | **1.08×** | +| gain from splitting | 2.9× deeper | none | + +Where one member dominates, isolating it removes the others from a shared instance and the +achievable depth rises. Where every member costs the same because the **shared setup** is +the expense, splitting buys attribution and nothing else. + +**The diagnostic is one run: time a trivially true member.** If a tautology costs what a +real property costs, the model is the bottleneck. One invocation, before committing to a +restructuring. + +The general form applies to test suites, benchmark harnesses, and build graphs: measure +the spread across members before parallelising or sharding. A flat cost profile means the +fixture is the cost, and splitting only multiplies fixture setup. + +## A partition produced by a timeout is a partition of the timeout + +A sweep reported "8 of 20 proved" and it was true. It invites exactly one reading — *those +8 are easier than these 12* — which was false. All 20 proved given more time; the budget +simply fell across a plateau where everything cost nearly the same. + +The tell was available and nearly missed: a **tautology** was in the failing group. A +trivially true assertion cannot be intrinsically hard, so its presence proves the split is +an artifact of the budget rather than of the subject. + +Whenever a run is cut off by a limit — time, memory, iterations, rate limit — the +resulting pass/fail split describes where the limit fell. Before reporting it as a +property of the items, include a known-trivial item as a control and check which side it +lands on. + +## Use the identifier the tool returned, never the one you predicted + +A commit body was written with `Closes #2012` while the issue actually created was #2014 — +the number was guessed from the previous one instead of read from the tool's output. #2012 +was an unrelated open issue that the merge would have closed. + +Caught by checking, then fixed by amending the message and force-pushing the feature +branch. Cheap here; silent and confusing had it merged. + +**Any identifier a tool mints — issue numbers, PR numbers, run IDs, generated paths — must +be read back from that tool's output before being embedded anywhere.** Sequential-looking +identifiers are the dangerous case, because a wrong guess is well-formed, plausible, and +points at something real. + +--- + +## When you name a measurement error, audit your own record for it + +One wave established that *a partition produced by a timeout is a partition of the +timeout*. The obvious follow-through — search the campaign's own published numbers for +that shape — found a ceiling published a wave earlier as "undecided at depth 80" which +in fact **proves in 396 s** against the 300 s budget that had been used. The design was +never the limit; the budget was. + +The correction cost one re-run. It was found only because naming the error prompted a +sweep of prior results rather than just future ones. + +**A newly understood failure mode is a query to run against everything you have already +published.** Not the next measurement — the previous ones. Every claim of the form "we +could not do X" that came from a resource limit rather than a hard result is a candidate, +and the resource limit is rarely recorded next to the claim. + +The practical habit: when a run is cut short, write the budget into the result — "undecided +within 300 s", never "undecided". Then a later reader, including you, can tell a limit from +a finding at a glance. + +## Batch overhead tells you where the cost lives + +Two suites, both batched, opposite profiles: + +- One suite's batch was **worse than the sum of its parts** — a single instance containing + every property was superlinearly harder. Cost lives in the properties; splitting helps. +- The other's batch cost **1.4× a single property** — twenty properties for barely more + than one. Cost lives in the shared model; splitting cannot help. + +That ratio is a cheap diagnostic on its own: time the batch, time one member, divide. Near +1 means a shared fixture dominates and the only lever is making the fixture cheaper. Much +greater than 1 means the members interact, and isolating them buys real headroom. + +Same reasoning applies to test fixtures, container startup in CI shards, and any harness +where a costly setup is amortised across cases. + +--- + +## A stub measures cost, not behaviour — and the baseline check tells you which + +To find where a solver's time goes, replacing a subsystem with a same-interface stub is a +fast and legitimate experiment: it answered "the datapath is 31% of the cells and 87% of +the time" in two runs. + +What it cannot answer is anything about correctness. Under the stub, **all twenty +properties failed — including a tautology**. A trivially true assertion cannot be broken +by swapping a multiplier, and the baseline check confirmed why: the stubbed build did not +pass with *no properties at all*. The build was unsound, so every verdict from it was +noise. + +Keep the two apart deliberately: +- **timings** from a stub are usable, because they measure how long the tool ran; +- **verdicts** are not, because they describe a system you did not build. + +Run the baseline on the modified build before reading any pass/fail from it. This is the +same rule that applies to mutation harnesses and probe rigs, and this was the first time +it caught *my own replacement* rather than someone's change to the design. + +## Cell count is a poor proxy for solving cost + +The stub removed 31% of cells and 0.4% of flops, and cut solve time by **8×**. The +expensive part was combinational — a wide parallel multiply and its adder tree — and +unrolling a bounded check multiplies combinational logic once per step while sequential +state grows only linearly. + +So when a bounded proof is slow, look for **wide combinational structures**, not for +register count. Arithmetic, comparators, priority encoders and crossbars dominate; state +machines and counters usually do not. + +The same intuition misleads in reverse elsewhere: a design that looks small by flop count +can be very expensive to verify, and a design with many registers but simple logic can be +cheap. + +## A knob exists only if someone built one + +Memory depth was scalable for proofs because it was already a module parameter — one flag, +no edits. The datapath was not, and no amount of tool knowledge changes that: the width +was a literal at 26 sites across six generators, and the lane count 37 times in one. + +**Scalability for testing is a property of the code, not of the tool.** When a system +resists being shrunk for a test, the finding is usually "this quantity was never +parameterised", and the fix is a refactor with its own risk — not a flag you have yet to +discover. + +Worth recording as a design habit: quantities you will one day want to shrink for a test — +widths, lane counts, queue depths, batch sizes, retry limits — are cheap to parameterise +when written and expensive to parameterise later. + +## Do not start an invasive refactor to serve a measurement, late + +The obvious follow-through was to thread a width parameter through six generators. It was +measured, scoped, and **not attempted** — a change touching every consumer of a datapath, +made at the end of a long session, motivated by a proof budget rather than by the design, +is how correct code acquires defects. + +Deliberately stopping at "measured and scoped" leaves the next session a task that starts +fresh with the full picture. The alternative — a half-finished refactor plus a tired +reviewer — trades a known cost for an unknown one. + +--- + +## Verify that a guard actually guards + +Fifteen waves of a verification campaign rested on a "baseline" run — the design compiled +*without* its properties — used to distinguish "your check failed" from "something else +failed". It never excluded a single property. The tool's `-formal` flag **predefines the +`FORMAL` macro**, so every `` `ifdef FORMAL `` block was compiled whether or not the define +was passed. + +The test costs one three-line module and two runs: + +```verilog +module guardtest(input wire clk, input wire a, output reg q); + always @(posedge clk) q <= a; +`ifdef FORMAL + always @(posedge clk) g: assert (q != a); // must vanish without the define +`endif +endmodule +``` + +Compare the cell counts with and without. Identical means the guard is not a guard. Fixing +it was a rename to a macro the tool does not own — after which the excluded build had **0** +assertion cells instead of 28. + +Generalises to every conditional-compilation scheme: debug builds, feature flags, test-only +code paths, `NDEBUG`, sampling switches. **A flag that quietly implies another define turns +conditional code into unconditional code**, and nothing in the output says so. Assert the +absence, not just the presence: a test that the guarded thing *disappears* is the one +nobody writes. + +The deeper cost was diagnostic, not correctness. An earlier wave spent four rounds trying +to separate a failing probe from a failing property. No flag existed that could separate +them — the confusion was structural, and invisible. + +## Distinguish "the tool failed" from "the check failed" + +A harness reported `REFUTED` in 0.1 seconds. A refutation that fast is not a refutation — +the input file was missing, the tool errored, and a nonzero exit was read as a verdict. + +```python +if rc == 0: return "PROVED" +if "proof did fail" in output: return "REFUTED" +return f"TOOL ERROR: {first_error_line(output)}" +``` + +Third time this shape appeared in one campaign: a trace reader that returned an empty trace +on a parse error, a stub build whose unsoundness read as twenty property failures, and now +a missing file. Each time the failure was *plausible* — it looked exactly like the thing +being measured. + +Two habits close it: parse the tool's own words for the specific outcome rather than +trusting the exit code, and treat implausible timings as evidence. A check that normally +takes 40 seconds and "fails" in 0.1 has not run. + +## Add the mirror of every property you have + +A campaign accumulated forty propositions, and every one constrained **writes** — write +addresses, write enables, write ordering, writes-before-reads. The read path had no +coverage at all, and nobody noticed because the write properties kept finding real defects. + +Reading the existing set as a list of *shapes* rather than of facts makes the gap obvious: +if you have "the write strobe is a pulse", ask about the read strobe; if you have "the +write address is contiguous", ask about the read address; if you have "no read before +write", ask about "no read past what was written". + +Here two mirrors proved immediately (the read path was sound) and one refuted, which is a +good outcome either way: proving a mirror costs one run and converts an assumption into a +fact. + +--- + +## A self-comparison is not an undefined-value detector + +To decide whether a property was failing because of the design or because its operands were +undefined, I asserted the obvious thing: + +```verilog +assert (fv_maxwr_a == fv_maxwr_a); // PROVED -- and meaningless +``` + +The optimiser folds `a == a` to constant true before any value is considered. The check +proves on a signal that is undefined, unconstrained, or does not exist at all. Same for +`x != x`, `a - a == 0`, `a & ~a == 0` — every algebraic identity is discharged +structurally, never by reading the signal. + +The generalisation is worth holding onto: **a probe whose result is determined by its own +syntax tests nothing.** Before trusting a diagnostic assertion, ask what input would make +it fail. If the answer is "none", the tool will happily confirm it forever. + +Valid alternatives depend on the tool: compare against a *known* value, drive the signal +from a controlled stimulus and check the expected response, or make the probe fail +deliberately once to prove it can. + +## Re-check the results a broken method touched — do not reason about them + +Discovering that a foundational check never did what it claimed raises an obvious question: +which conclusions were affected? The tempting move is to reason it out — *those results +were safety properties, they would not have been changed by extra assertions* — and that +reasoning happened to be correct here. + +It was still worth running. Six witnesses, six re-runs, six identical verdicts, and now the +claim is measured instead of argued. The re-run also surfaced the precise condition under +which it *would* have mattered: had any of the compiled-in properties been an `assume` +rather than an `assert`, the probes would have explored a constrained state space and every +verdict could have differed — a distinction the old setup could never have revealed. + +**When a method turns out to be mis-specified, the cheap and honest response is to re-run +what it touched.** Reasoning about the blast radius produces a defensible answer; re-running +produces a fact, and occasionally a surprise. + +## A property that is syntactically true has always been counted + +The same folding trap sits inside the property set itself: one long-standing "proved" +property is literally `assert (bram_addr == bram_addr)`. It has been counted among the +proved set for dozens of waves and has never tested anything. + +Vacuity checking as normally practised asks whether a property's *guard* is reachable. It +does not ask whether the property's *body* is discharged by the optimiser. Both are ways a +property can be free, and only one of them is usually gated. + +Worth a one-time sweep of any assertion set: look for bodies that mention a signal only on +both sides of a comparison, tautological ranges (`x >= 0` on an unsigned), and conditions +implied by their own guard. + +--- + +## A vacuous check can inflate the metric designed to detect vacuity + +A verification suite gated itself on a count: *fail if fewer than N checks exist*, on the +sound reasoning that a green run over an empty set proves nothing. Five properties in that +set had bodies of the form `X == X`, folded by the optimiser to constant true — they proved +unconditionally and tested nothing. + +They still emitted a check cell each. **The padding was counted by the very gate meant to +catch an all-vacuous set.** Removing them dropped two suites below their thresholds, which +is the correct signal arriving several years late. + +Two distinct notions of "free" are at play, and typically only one is gated: +- the **guard** is unreachable — the usual vacuity check; +- the **body** is discharged by the optimiser — almost never checked. + +Any count-based health metric deserves the same scrutiny: a test that always passes still +increments the test count, a log line that always fires still satisfies "we have +telemetry", a retry that never retries still appears in the resilience inventory. + +## When a change appears to break something, reproduce the failure without the change + +Removing a property made an unrelated suite refute. I attributed it to my edit, then to a +subtle interaction, and built a plausible theory for each. Both were wrong. Re-running the +**unchanged** file with the same command refuted identically: the failure predated the +edit, and I had simply been invoking a mode the pipeline does not use. + +One run, before any theory. It separates "I broke it" from "it was already so", and the +cost of skipping it is not a wasted run — it is a confident, documented, wrong explanation. + +The corollary: when your reproduction differs from the pipeline's, the difference is the +first suspect. The pipeline's own comment had explained why it used a different mode, and I +had read past it twice. + +## A detector that searches text will match prose + +Classifying pipeline steps by searching each step's text for a flag reported that two +suites used an unbounded proof method. Only one did. The other step contained the flag name +**inside a comment explaining why that method was not used there** — the detector matched +the explanation of its own absence. + +That misclassification survived into a published proposition and a README claim. + +When scanning configuration or code for a feature, match on the **structure**, not the +text: the parsed command line, the AST node, the actual key — never a substring of the +whole blob. If only a text scan is available, strip comments first, and treat any hit +inside prose as a negative. + +--- + +## Mutation-test a gate on the day you write it, in the same step + +A new gate was written to catch a class of defect found by hand the wave before. The gate +and its own control ship together, in one CI step: + +``` +scanned 67 assertion bodies; 0 discharged by syntax +ok gate flags self-comparison +ok gate flags nested self-comparison +ok gate flags unsigned >= 0 +ok gate flags literal true +ok gate passes a real property <- the control that matters most +``` + +The four positive cases prove it fires. The negative case proves it is not simply firing on +everything — which is the failure mode that scores perfectly on positives alone. + +Doing this at authoring time costs minutes; retrofitting it later means auditing a gate you +have already trusted for months. The rule generalises: **a check that is not itself checked +is an assumption wearing a green tick.** + +## A detector that produces false positives is worse than no detector + +An attempt at a stronger, semantic version of the same gate compared cell counts before and +after neutralising a property, on the theory that a free property adds no logic. It flagged +six **real** properties — including ones that had caught genuine defects — because +common-subexpression elimination lets a genuine property add zero net cells. + +That detector was withdrawn, not tuned. A gate that cries wolf on real work gets disabled by +whoever hits it next, and takes the true positives with it. + +Three more attempts failed for unrelated reasons, and the honest response after the fourth +was to ship the weaker, verified check and **write the dead ends into the module**: + +```python +# A SEMANTIC layer was attempted and did not land. Recorded so the next attempt +# starts from what was learned rather than repeating it: +# * cell counts are UNSOUND (CSE) -- flagged six real properties +# * `chformal -lower` needs `async2sync`, after which the guard folds into A +# * before lowering, every $check's A reads 1'1 for real and free alike +# * useful: after async2sync the cells are NAMED after their property labels +``` + +Four recorded dead ends are worth more to the next attempt than a broken tool in the +pipeline, and cost nothing to carry. + +## Ship the smaller thing, and state what it does not do + +The gate that shipped catches the shapes that actually occurred. It does not decide whether +an arbitrary property can ever fail, and its docstring says so in the same paragraph as its +purpose. + +That sentence is what stops the next reader — often you — from citing the gate as broader +evidence than it is. The pattern across this campaign: every overstated claim was +overstated at the moment of writing, by someone who knew the limit and did not write it +down. + +--- + +## Two independent formulations of one claim is a working discriminator + +After two failed attempts to decide whether a failing check meant "the system is wrong" or +"my check is wrong", what worked was writing the **same claim a second, structurally +different way** and comparing: + +| formulation | verdict | +|---|---| +| bound: read address ≤ highest address ever written | fails | +| exact: per-slot written bitmap | fails | + +Agreement exonerates the weaker formulation and implicates the system. Disagreement would +have implicated the approximation. Either way the answer is attributable, which neither +previous attempt achieved. + +This beats staring at a trace because it does not depend on reading the tool's output +correctly — only on two checks agreeing or not. Applies wherever a measurement is in doubt: +compute the metric a second way, from a different source, and compare. + +## Validate a new instrument against what it must *not* say + +Before believing the discriminator above, two checks confirmed it was alive: + +``` +the tracker is ever non-zero -> must REFUTE (it does) +the tracker can reach all-ones -> must REFUTE (it does) +``` + +A tracker stuck at zero would make the property fail for a reason unrelated to the system, +which is exactly how the two earlier attempts went wrong. **Assert the negation of what +you expect and require a counterexample** — that proves the instrument can move, where +asserting the expected value proves nothing. + +Two waves of wrong attribution were the price of skipping this; the checks themselves cost +one run each. + +## A boolean is not a count, and "some" is not "enough" + +A defect was closed by tracking *whether* a buffer had been written. The finer defect +underneath: nothing related **how many** slots a consumer would read to **how many** a +producer had written. Buffer-written is not slot-written. + +That progression — flag, then count, then per-element — recurs whenever a resource is +filled by one stage and consumed by another: a connection pool marked "initialised" but not +sized, a cache marked "warm" with only some keys present, a buffer marked "ready" shorter +than the reader's stride. + +When a guard answers *did anything happen*, ask what happens when **less than enough** +happened. That is usually a distinct, live defect rather than a variation of the one just +fixed. + +## `$past(x)[1:0]` is not legal Verilog + +Part-selecting a system function call is rejected by the parser, and the tool reports it as +a generic error rather than a syntax hint. Register the value first: + +```verilog +reg [11:0] fv_prev_rd; +always @(posedge clk) fv_prev_rd <= buf_read_addr; +... fv_bm_a[fv_prev_rd[1:0]] ... +``` + +Worth noting for what it nearly cost: under a harness that reads any nonzero exit as a +verdict, this would have appeared as a **refuted property** and sent the investigation +somewhere false. It appeared as a tool error only because that separation was already in +place — a guard paying off two waves after it was written. + +--- + +## Match the arity in time: a once-evaluated gate cannot enforce a per-cycle invariant + +A property said *at the moment of each read, the slot being read must already have been +written*. The fix attempted was a gate at the start of the operation: count what was +written, refuse to begin if the count is short. + +It could never work, and the reason is worth more than the attempt. A start-time check +says nothing about what happens **during** the operation — here a producer filling one +buffer while a consumer drained another, with nothing constraining their interleaving. The +mismatch was not the threshold, the counter width, or an off-by-one. It was that a +**per-cycle claim needs a per-cycle guarantee**. + +Before writing a guard, name when the claim must hold — once at entry, once per item, or +on every cycle — and check that the guard is evaluated at the same rate. A guard evaluated +less often than its claim is not a weak guard, it is the wrong shape, and no tuning +converts one into the other. + +Everyday forms: a permission checked at session start for an action authorised per-request; +a quota validated at job submission for a loop that allocates as it runs; a health check at +startup for a dependency that can fail mid-flight. + +## Withdraw on two counts, not one + +The standing rule was *withdraw a fix that misses its target and costs something*. This +attempt hit both halves in one run: the target property still refuted **and** the existing +proved set broke. That made the decision immediate rather than a judgement call. + +Worth writing the two-part test explicitly, because the tempting failure is to keep a +change that only fails one half — "it didn't fix the bug but it's harmless" or "it broke +one test but it's the right direction". Both are how a codebase accumulates changes that +nobody can justify individually. + +## Record the eliminated shape where the next attempt will read it + +The withdrawn interlock left one durable artifact: a comment above the code it would have +replaced, naming the approach, why it cannot work, and the two shapes that remain. Not in a +commit message, not only in a design document — **in the file the next attempt will +open**. + +Three waves have gone into this one defect: two to attribute it, one to eliminate a fix +shape. That is progress only if the eliminations are visible from the code, otherwise the +fourth wave re-derives the second. + +--- + +## Bisect a failing property with assumptions, not with theories + +Six waves went into one failing check. Trace reading was inconclusive twice, a discriminator +proved invalid, and a fix attempt was withdrawn. What finally located it took three runs: + +``` +unconstrained -> REFUTED +assume (neurons_per_layer != 0) -> PROVED +assume (neurons != 0 && chunks != 0) -> PROVED +``` + +One assumption separated the failing configuration from every other. That is a **bisection +of the input space**, and it is far more reliable than reading a counterexample: each run +is a yes/no answer to a question you chose, rather than an exercise in interpreting a dump. + +The method generalises to any failing test with a large input space. Constrain a dimension, +re-run, and see whether the failure survives. A handful of runs partitions the space into +"fails here" and "holds everywhere else", which is usually the whole diagnosis. + +Do it *before* reading traces, not after. Traces answer "what happened in this one run"; +assumptions answer "which class of runs is affected". + +## A module-level guard does not travel to the paths that bypass it + +A sequencer was proved — in isolation, non-vacuously — to emit no work for a zero-sized +job. That proof was correct and did not prevent the engine from performing a read for that +same job: the read address came straight from a counter, and the consumer's valid came from +pipeline skew registers. Neither path passed through the guard that had been proved. + +**Proving a guard at a module boundary says nothing about consumers that do not go through +that boundary.** The integration properties are where this shows up, and only if they +mention the bypassing path — which is why the read side sat unexamined for eight waves +while the write side accumulated five defect findings. + +When a guard is proved locally, enumerate its consumers and check which of them actually +read the guarded signal. Those that derive the same information independently — a parallel +counter, a cached copy, a skewed replica — are exactly where the guard's guarantee stops. + +## Degenerate inputs deserve the same sweep on every side of a datapath + +Zero-sized requests were swept exhaustively on the write side and produced four defects +across four modules. The read side was never asked, and the fifth member of the family was +waiting there. + +A sweep is defined by two things: the property class, and the surface it covers. Recording +"we swept zero-sized inputs" without recording *which surface* leaves the impression of +completeness. The honest form is "zero-sized inputs, write paths only" — which makes the +gap visible to the next reader instead of hiding it behind a finished-sounding claim. + +--- + +## The weakest assumption that restores a proof is the diagnosis + +A failing check was fixed by `assume (count != 0)`, and the obvious conclusion — *the bug +is the zero case* — was published. It was wrong. A **weaker** assumption also restored the +proof: + +``` +assume (count != 0) -> PROVED +assume (count == $past(count)) -> PROVED <- weaker, and the real cause +``` + +A stable *zero* proves. The necessary condition was the count **changing**, not its value; +excluding zero merely excluded the particular change the solver had reached for. + +**When one assumption makes a failure disappear, keep looking for a weaker one that also +does.** Every assumption that restores a proof describes *a* sufficient condition; only the +weakest describes the cause. The strong one is usually the first you think of, and it is +usually a special case of the real thing. + +Beyond formal work: a bug that "only happens with an empty list" may really be "only +happens when the list changes during iteration"; empty is just the easiest way to reach it. + +## Configuration read live by a running state machine is a defect class + +A sequencer compared its counter against a limit register every cycle, and the limit was +wired straight to a host-writable register. A write mid-run moves the terminator underneath +work already in flight. + +The fix is one register and a capture point: latch the configuration when the operation +starts, and run from the latched copy. Cheap, local, and independently correct regardless +of what else the investigation turns up. + +Worth looking for wherever an operation has a *duration*: batch sizes read per-iteration, +timeouts consulted inside the loop they bound, feature flags evaluated per-item in a job +that should be consistent end-to-end. **If a value can change while the thing it governs is +running, decide explicitly whether it should — and usually it should not.** + +## Ship a fix that is right on its own terms, even if it does not close the case + +The latch did not make the failing property pass. The standing rule is to withdraw a fix +that misses its target *and costs something*; this one cost nothing measurable and is +defensible without reference to the investigation that produced it — a sequencer must not +have its terminator moved mid-run. + +The test to apply: **would I write this having seen the code fresh, with no knowledge of +the open bug?** If yes, keep it and say plainly that it does not close the case. If the +only argument for it is the bug it failed to fix, withdraw it. + +That distinction keeps a codebase from accumulating speculative changes while still +allowing genuine improvements found along the way. + +--- + +## A rejected fix is rejected against a design, not for all time + +An interlock was tried, analysed, and withdrawn with a conclusion that read like a law: +*a start-time count cannot enforce a per-cycle claim*. Three waves later the same interlock +was re-applied unchanged and closed the defect. + +The conclusion was never wrong. It was **conditional on the design at that moment** — the +quantity being checked could change mid-operation, so a check at the start said nothing +about the rest. A separate fix later latched that quantity, and the condition the rejection +depended on stopped holding. + +What made the re-attempt cheap was recording the **reason** beside the code, not just the +verdict: + +```verilog +// A COUNT version was attempted in Wave 594 and withdrawn: it neither closed +// the property nor left the proved set intact. Why it cannot close it: the +// property compares the read address at the moment of the read, while a +// start-time gate says nothing about writes and reads interleaving WITHIN a +// layer. +``` + +A verdict ("we tried this, it failed") closes the door. A reason ("it failed *because* X") +leaves it open for the day X stops being true. **When you record a rejected approach, +record the condition it failed under** — that is the part with a shelf life. + +## Some defects need several changes, none of which look like progress alone + +The defect took three changes across eight waves, and each one individually left the +property still failing: + +1. per-instance written flags — closed a coarser version, left the fine one +2. latching configuration at operation start — fixed a real race, did not close it +3. carrying the fill extent across the handover — closed it + +Under a strict "withdraw anything that does not fix the target" rule, changes 1 and 2 would +both have been reverted, and change 3 would never have worked. The rule that saved them was +the refinement: **withdraw a fix that misses its target *and costs something*; keep one +that is right on its own terms.** + +The test is: *would I write this having seen the code fresh, with no knowledge of the open +bug?* Both survivors passed it — a state machine should not have its terminator moved +mid-run, whatever else is broken. + +## Building the instrument is most of the work + +The defect was one line of missing state. Finding it took a trace reader (the tool's own +JSON output was malformed), a free-property gate (five properties proved by syntax alone), +and an assumption-bisection method — plus two wrong attributions published before the right +one. + +That ratio is normal and worth planning for rather than apologising about. When a bug +resists two honest attempts, the next move is usually not a third attempt at the bug but a +first attempt at **seeing it**: what would make the failure legible, and is that thing +trustworthy? Two of the three instruments here caught their own defects on first use. + +--- + +## A sweep that finds nothing must demonstrate that it could have + +A clean sweep and a broken sweep produce the same report. Three properties were added over +paths never previously checked, all three proved, and no defect was found — a result worth +nothing until each property was shown capable of failing: + +``` +body replaced by assert(1'b0) under the same guard: + a_zero_chunks_no_mac -> refutes (guard reachable) + a_zero_chunks_no_weight_walk -> refutes + a_zero_neurons_no_act_walk -> refutes +``` + +The failure mode is properties whose guards are unreachable: they prove instantly, cost +nothing, and report safety. Without the check, "we looked and saw nothing" is +indistinguishable from "we did not look". + +Applies to every negative result. A test suite that passes on a feature nobody exercises, a +scan with a pattern that matches nothing, a monitor whose alert has never fired — before +reporting the absence of a problem, show the instrument reacting to one. + +## Derived state cannot drift; independent state does + +Across a long verification campaign the defect distribution was lopsided: four defects on +the write paths, one on the read paths. That was not attention bias, and the reason +generalises. + +The write paths each carried their **own counter** — one per stage, independently updated. +Every defect found was two of those pieces disagreeing: an address advanced while its data +did not, a pointer serving two roles, a strobe held while its address moved on. + +The read paths were **derived**: one pointer *was* another signal, another advanced only +when a valid fired. Derived state has no opportunity to disagree with its source. + +The design rule that falls out: **when two registers track the same quantity, that is a +defect site.** Prefer deriving to duplicating; when duplication is unavoidable, the +relation between the copies is exactly the property worth asserting. It also predicts where +to look next — a census of same-quantity register pairs is a target list, not a guess. + +## State the surface a sweep covered, not just the class + +"We swept zero-sized inputs" sounds complete and is not. The honest form names the surface: +*zero-sized inputs, on the write paths*. That phrasing is what later made the read-side gap +visible instead of hiding it behind a finished-sounding claim. + +Same for this wave's result: the read pointers **named here** were asked; two other read +paths were not, because neither is indexed by a configurable count. Writing that sentence +costs nothing and is the difference between a bounded result and an overclaim that someone +— usually you — cites later as broader than it was. + +--- + +## A stub measures what the optimiser can delete, not what the stubbed thing costs + +Replacing a module with a trivial stub made a proof 8× faster, and that number justified a +refactor across six files — deferred four times as "the largest available gain". Measured +directly, the refactor was worth **1.5×**. + +The 8× was real and measured something else. Stubbing the module removed its *instantiation*, +which left its wide inputs unused, and the optimiser then deleted the whole datapath feeding +it — memories, muxes, buses. **Removing a consumer removes its producers.** + +Two rules fall out: +- Attribute a cost to a component only by changes that hold its neighbours fixed — shrink + it, don't delete it. +- Before acting on a stub-derived number, make the change you actually intend on a small + scale and measure *that*. Here: narrowing the datapath from 27 lanes to 3 took two minutes + and killed a multi-day refactor. + +Same shape in profiling generally: deleting a call site removes everything it reached, so +"function X is 80% of runtime" measured that way is usually "X and everything it pulls in". + +## Cell count, line count, and any static size are poor proxies for solving cost + +Two builds fourteen cells apart differed **eleven times** in solve time. Another build with +290 *fewer* cells ran 0.2% faster; one with 161 fewer ran **slower**. + +Whatever makes a search hard is not counted by a size metric. For bounded model checking it +is roughly the shape of the state dependencies across the unrolling; for other tools it will +be something else — but in no case is it the thing that is easiest to count. + +When optimising a slow check, measure the check. Static size can suggest hypotheses; it +cannot rank them. + +## Re-cost a deferred item before picking it up + +An item was deferred four times, each time with a good reason, and each time carrying +forward its original estimate: *the largest available gain, ~8×*. When it finally reached +the front of the queue, the estimate was four waves stale and wrong by a factor of five — +and re-costing it took one wave and closed it permanently. + +**A deferred item should be re-costed, not just re-prioritised.** The world it was estimated +in has changed, usually by the very work that kept deferring it. The re-cost is cheap +compared to starting the work. + +## `git status` is part of the verification + +A file had been modified but never committed for roughly twenty waves. Every local +verification ran against it; CI ran against a different version. It elaborated either way, so +nothing went red — which is exactly why it survived. + +A result produced from the working tree is a result *about the working tree*. Before +reporting that something passes, confirm the tree is clean, or say explicitly which +uncommitted changes are in play. Found here by accident, while checking whether an +experiment had touched the repo — the check that should have been routine was the one that +caught it. + +--- + +## Strengthening an assumption can silently disable the checks that would catch it + +A property refuted. The obvious fix was to constrain the environment more tightly — stop an +input changing at a moment the design does not expect. It worked: the property proved. + +It also pinned that input to zero **forever**, because the added constraint referenced the +value's own history from cycle zero. Two reachability witnesses stopped firing. Every +property in the file still passed, the suite still reported success, and two of the checks +that exist to detect exactly this over-constraint had gone quiet. + +> **An assumption is not a local edit.** It removes behaviours from every property in the +> file, including the ones asserting that behaviours are reachable. + +Two habits follow. When a fix is *an added constraint* rather than a changed implementation, +re-run the reachability checks, not just the property that motivated it. And prefer fixing +the property over constraining the environment — the property is scoped to itself; the +assumption is scoped to everything. + +This was caught only because the suite contains checks that must **fail**. A suite composed +entirely of things that must pass cannot detect its own over-constraint: making the system +do less makes every such check greener. + +## Turn an explanation into a target list + +An observation emerged from a long campaign: every defect found was two pieces of state +tracking one quantity and disagreeing, while derived state never held a defect because it +cannot drift from its source. + +That is an explanation, and explanations are cheap. Converting it into a **census** — every +counter and every derived copy in the design, sorted into "independent" and "derived" — +turned it into three named pairs to attack and a demonstration that the rest of the design +cannot hold that defect class. + +The yield was modest: one new proved property, one honest non-result. But the census is +reusable and the scope statement is now precise, which beats a rule of thumb that has to be +re-derived by whoever reads the campaign next. + +**When a pattern explains your past findings, enumerate where else it applies before +looking for the next instance by hand.** + +## A gate's own pattern list is where its false positives hide + +A documentation gate flagged a perfectly runnable `git status …` block as containing no +command — `git` was simply missing from the list of recognised commands. + +Harmless here, and the general shape is not: a checker built from an allowlist reports +violations that are really gaps in the list. Every such gate needs its own negative control +— a known-good input that must pass — for the same reason the positive cases need one. +Prop. 28's discipline applies to the gate's *recognition*, not only to its detection. + +--- + +## Every place that can constrain behaviour needs a check that behaviour remains + +A campaign added reachability witnesses to its top-level integration suite and never to the +module suites — because the top level was where interlocks were being added, and stalling +was the visible risk there. Twenty-four waves later, an over-constraint appeared in a +*module* file and was caught by a *top-level* witness. Coverage overlap, not design. + +The gap is structural, not an oversight: an assumption file with no reachability probe is a +place where over-constraint is **invisible by construction**. Adding a constraint makes +every property in that file easier to prove, so the symptom is everything getting greener. + +Rule: wherever behaviour can be constrained — assumptions, mocks, fixtures, test doubles, +feature flags that disable paths — put a check that the constrained thing still happens. +Twelve probes across five suites cost one wave and closed a gap that had existed since the +suites were written. + +## A "no findings" sweep needs a control run, every time + +Twelve probes, twelve clean results. That is indistinguishable from twelve broken probes +until you show one failing: + +``` +reinstate the known over-constraint + wp_props/bram_we -> PROVES <- the failure signal, as designed + wp_props/prefetch_active -> PROVES +``` + +The control is cheap because a real instance was already in the campaign's history — +reinstating a known-bad state is the least effort and the strongest evidence. Where no +historical instance exists, inject one deliberately. + +This is the third wave in a row where the control mattered more than the result: a clean +sweep is a claim about the instrument first and the subject second. + +## Scope a negative result by what it cannot see + +The probes here check that each module's *main activity* is still reachable. A constraint +that removes a rare interleaving while leaving the main activity alone passes all twelve — +and that sentence belongs in the result, not in a follow-up when someone finds the gap. + +The pattern from this campaign: every overstated claim was overstated at the moment of +writing, by someone who knew the limit. Writing the limit in the same paragraph as the +finding costs one sentence and is the difference between a bounded result and one that gets +cited later as broader than it was. + +--- + +## An item that has resisted three honest attempts is a decision, not a queue entry + +One invariant took three waves and never landed. Each attempt was reasonable, each produced +a real measurement, and each ended one insight short — which is exactly the shape that keeps +a task alive indefinitely. + +The fourth wave was not spent. Instead the item was closed, with the reasoning written down: +the pair it would have constrained is already covered by two properties that *did* land, so +the marginal value of a third was small against a cost already at three waves. + +**Carrying a "nearly done" item is not free.** It occupies the top of the queue, it +justifies the next attempt by the sunk cost of the last, and it makes every plan slightly +dishonest. Closing it explicitly — with a written reason someone can disagree with — is +cheaper than a fourth attempt and strictly more useful than silence. + +The test: *if this were proposed fresh today, with no history, would it be worth doing?* If +not, the history is the only thing keeping it alive. + +## Negative results are worth keeping only where the next attempt will look + +Three waves of failed attempts produced four concrete measurements. They live as a comment +**in the file that would have to change**, above the properties that did land — not only in +a commit message, an issue, or a design document. + +``` +// A CONSERVATION property was attempted across three waves and is ABANDONED: +// * against the live input: REFUTED (stability assumption misses the load cycle) +// * against a latched copy: REFUTED +// * strengthening the environment: proved it AND killed two vacuity witnesses +// * the load point at three offsets: all REFUTED -- not a fixed offset +``` + +That placement is the whole value. A negative result filed where nobody looks is +indistinguishable from never having run it, and the next person will spend the same three +waves. + +## Before believing a refutation, check your probe is not simply too strict + +Several probes refuted and looked like design defects. They were not: a status output +cleared in a terminal state lags its state register by one cycle, so any property asserting +the two move together fails on correct hardware. + +The tell is that the refutation appears immediately and for a structural reason rather than +a specific input. Before reporting, ask what the *correct* implementation does at the exact +cycle the probe examines — the answer is often "exactly this, and it is fine". + +Related to an earlier lesson in reverse: an unconstrained input makes a correct design look +broken; an over-strict property does the same, and both cost a wave if reported as findings. + +--- + +## Re-measure a published number when its subject has changed underneath it + +A scale ceiling was measured, published in a README, and gated in CI. Ten defect fixes and +six new properties later it was re-measured: **three of six configurations that previously +passed no longer complete**. The published claim had been false for some time, and nothing +had reported it — the gate only re-checked the scales it was given, never whether those were +still the right ones. + +The general shape: a measurement is a fact about a system at a moment. Every claim derived +from one has an implicit "as of", and the things most likely to invalidate it are the very +changes the team is proudest of. + +**Put the re-measurement on the same schedule as the changes, not the calendar.** A +performance number, a coverage figure, a capacity estimate, a benchmark — after a run of +substantive change, re-measure before citing. + +## Re-baselining is maintenance; say so, and distinguish it from weakening + +The gate demanded scales that no longer complete. Left alone it would have been a permanent +red — the worst state for a gate, because people learn to skip its output and it stops +protecting anything at all. + +It was re-baselined to the scales that hold, and the commit says explicitly *why*: the +subject moved, so the claim moved with it, and both were re-verified. That sentence is what +separates maintenance from quietly lowering a bar. + +The test to apply before relaxing any gate: **can I state what changed in the world that +makes the old expectation wrong?** If yes, re-baseline and record it. If the honest answer +is "nothing changed, it just fails now", that is a defect, not a stale baseline. + +## Measure your verification apparatus separately from your system + +At the same scale: the design alone proved in **5.5 s**; with its properties and their +formal-only tracking state, **126.7 s**. The scaffolding cost **23×** the thing it verifies. + +That reframes every optimisation instinct. The slowdown blamed on new interlocks was mostly +the properties added alongside them — so shrinking the design would have bought little, +while separating the formal-only state costs nothing in the shipped artifact. + +Worth measuring once for any test harness that shares a build with its subject: fixtures, +instrumentation, assertion state, mocks. When the harness dominates, optimise the harness — +and know that before spending a wave on the subject. + +--- + +## A small minority of checks often dominates the cost — measure before optimising the subject + +Twenty-six properties, and **four of them accounted for 75% of the proof time**. Those four +needed ten pieces of dedicated tracking state; the other twenty-two needed none. Removing +just the four took the verifiable depth from 40 back to 80 — the level the whole set had +reached before a run of additions. + +The instinct when a check gets slow is to simplify the subject. The measurement said the +subject was not the problem: **the apparatus was**, and inside the apparatus a small +minority of it. + +Cheap to establish and worth doing before any optimisation: disable each check in turn (or +in groups) and time the rest. A flat profile means the shared setup dominates; a spiked one +names the few to isolate. Same shape as a slow test suite where three integration tests +carry the whole runtime. + +## Splitting a check set is not weakening it — if every member stays gated + +Moving expensive checks behind their own flag looks like reducing coverage and is not, so +long as both groups run and **the bound each is checked at rises or holds**. Here the cheap +22 would run deeper than before and the expensive 4 exactly as deep as now. + +Distinguish this carefully from lowering a bar. The test: *after the split, is any property +checked less thoroughly than before?* If no, it is a scheduling change. If yes, it is a +reduction and must be argued as one. + +## Two failed attempts at the same edit are a signal about the edit's shape + +An edit failed twice: once because a guard boundary assumed a contiguous block that was not +contiguous, then because a regex's greedy tail swallowed a closing delimiter and nested +everything after it. + +Both failures had the same root — **the things to be wrapped are scattered, not adjacent** +— and the second attempt is what established that. Persistence was not the missing +ingredient; a different technique was. + +The rule that follows: when the same edit fails twice for related reasons, stop and +characterise the *structure* you are editing before trying a third time. Here the answer was +"six separate sites, hand-placed, guard depth verified in the output" — mechanical but not +scriptable, and clearly not something to start at the end of a long session. + +--- + +## For a conditional-compilation edit, verify the structure of the output — in every configuration + +An edit that adds `#ifdef`-style guards has a failure mode that compiling does not catch: +the guard can land in the right place for the configuration you test and the wrong place +for the one you don't. Here a guard closed *before* its block's closing `end`, which was +invisible with the define set and would have orphaned two lines without it — the +configuration CI runs most often. + +Three separate checks, and the second is what caught it: + +1. **every configuration elaborates** — none, core, and full +2. **the partition is exactly what you intended** — per-item guard depth from the *output*, + not from the edit: 22 items at depth 1, 4 at depth 2 +3. **the guards balance** — depth returns to 0 at end of file + +Checking "it compiled" would have passed the broken version. Checking the structure of the +generated artifact is what distinguishes an edit that happens to work from one that is +right. + +## Scattered things look contiguous until you print their positions + +Two attempts at the same edit failed because four items and their ten dependencies were +assumed to sit in one block. They form **four** regions — and one unrelated item sits inside +what looks like a fifth. + +The third attempt began by printing every relevant line number and reading the region, which +took a minute and made the shape obvious. That is the whole difference between the attempts: +not more care in the edit, but **establishing the structure before editing at all**. + +When a batch edit fails on structure, stop editing and dump the structure. Line numbers, +nesting depth, what sits between the things you meant to group. The map is cheap; two +reverts are not. + +## Splitting work by cost, not by importance, can raise every bound at once + +Four checks needed dedicated state and cost 75% of the runtime; twenty-two needed none. +Splitting them by *cost* — not by how much anyone cares about them — let the cheap group run +four times deeper while the expensive group kept its old depth. + +The move only counts as free when **every item's coverage rises or holds**. Say that +explicitly and check it, because the same restructuring done carelessly is how coverage +quietly drops. + +Generalises to any suite where a minority dominates: split the fast majority into a +frequent, deep run and let the slow minority keep a shallower one, rather than letting the +slowest member set the depth for everything. + + +## Wave 606 — probe the interleavings, not just the activities + +**A reachability probe per activity does not cover the reachability of their +combinations.** Twelve probes each said "this module does its job." A constraint +that removes a rare *interleaving* — two transfers back to back, both directions, +two prefetches — leaves every one of those twelve refuting and is invisible. The +limit was written down five waves before it was closed; writing it down is what +made it a task instead of an assumption. + +**Choose the interleavings from defect history, not from combinatorics.** Two +modules × a handful of events is a large space and enumerating it is not the +point. The three that got written are the shapes this campaign's defects actually +took: state carried across a completion boundary (that was a real defect), a +field sampled once at start (pinning it deletes half the design), a per-layer +operation (allowing only the first leaves every later one unverified). + +**Validate each probe by removing its own target, one at a time.** The rule "a +sweep that finds nothing must demonstrate it could have" applies per-probe, not +to the sweep as a whole. `assume (direction == 0)` must make the both-directions +witness prove. If it does not, the witness is decoration. + +**A control that fails to remove the thing it targets tests nothing — and reads +exactly like a blind probe.** The first attempt at the back-to-back-prefetch +control did not actually forbid a second completion; the witness kept refuting, +which looks identical to "this witness cannot detect anything." The difference is +only visible by checking that the control does what it claims. Suspect the +control before the probe. + +**`$past` inside `always @(posedge clk or negedge rst_n)` is rejected outright.** +`ERROR: Async reset \rst_n yields non-constant value` from `async2sync`. Edge +detection in a witness must be a synchronous block with an explicit +previous-value register: + +```verilog +reg [1:0] n; reg done_q; +always @(posedge clk) + if (!rst_n) begin n <= 2'd0; done_q <= 1'b0; end + else begin + done_q <= done; + if (done && !done_q && n != 2'd3) n <= n + 2'd1; + end +always @(posedge clk) if (rst_n) w: assert (n < 2'd2); +``` + +This is a **tool error, not a verdict** — worth nothing unless the harness +already separates the two, which is why that distinction was built first. + +**Interleaving witnesses need more depth than activity witnesses.** Two +completions in one trace costs roughly double: `seq 24` for DMA against 12 for +its activity probes, `seq 30` for prefetch against 14. Budget for it or the +witness times out and looks like a proof. + +## Wave 607 — an absence read as a pass + +Four instrument defects in one wave, all the same shape. Every one of them +turned silence into a green result: + +| instrument | the silence | what it scored | +|---|---|---| +| shell verdict classifier | output truncated before the verdict line | "did not refute" | +| mutation harness | yosys crashed instead of deciding | "mutant killed" | +| free-property scan | zero files matched the glob | "0 problems" | +| documentation gate | never wired into CI at all | claimed in the README | + +**When a probe returns an implausible answer, check the classifier before the +subject.** A repetition witness said "two layer runs are unreachable" — which +reads as a restart defect — and I went and read the RTL looking for one. Yosys +had said `proof did fail`. Cost: one RTL read. The tell was that the answer was +*surprising in a way the design made unlikely*; that is the moment to re-run the +same check a different way, not to start debugging. + +**`echo "$captured_output" | grep` is not portable and can flip a verdict.** +Yosys prints signal names backslash-prefixed (`\chunk_id`, `\rst_n`). A shell +whose `echo` expands escapes reads `\c` as **stop output here**: + +```bash +printf '%s\n' 'x \chunk_id' 'ERROR: proof did fail!' > /tmp/t +out=$(cat /tmp/t) +echo "$out" | grep -c "proof did fail" # zsh 0, bash 1 +printf '%s\n' "$out" | grep -c "proof did fail" # 1 in both +``` + +31 966 bytes became 4 893. Always `printf '%s\n'`. And note the `%s`: writing +the sample with `printf '...\chunk_id...'` truncates too — the demonstration +destroyed by the escape it demonstrates. + +**`returncode != 0` is not a verdict.** It folds "the property was refuted" +together with "the tool could not read the design". In a mutation harness that +fold is not neutral — it scores an unparseable mutant as a *killed* one, so the +suite reports every mutant killed while testing strictly fewer. Three outcomes, +always: proved / refuted / no verdict, and the third fails loudly naming what +was skipped. + +**Verify a fix against the shipped code, not a copy.** The control for that +classifier extracts `yos()` out of the workflow YAML with `yaml.safe_load` and +`exec`s it, then runs it on three inputs whose answers are known — proving +script, refuting script, unparseable mutant. Retyping the function into the test +would have tested the retyped version. + +**A scan that scans nothing must fail, not pass.** Anchor default globs to +`pathlib.Path(__file__).resolve().parent.parent`, never to the caller's cwd, and +add an explicit `if total == 0: return 1`. Both, not either — the anchor fixes +today's bug, the counter catches the day the naming convention moves. + +**Check that every gate you cite actually exists.** The README described a +documentation gate for many waves; nothing implemented it. The check for this is +one grep for the gate's own error string across `.github/` — if the only place +it appears is prose, it is prose. Then mutation-test it before believing it: my +doc gate had to catch a removed `Gate:` line, a fence whose only verb is `echo`, +a bare `t27c`, and a changed heading convention. + +**Suspect the control before the probe — second wave running.** A control +guarding on a counter (`no start while runs != 0`) failed to bite because the +counter increments on the *done edge*, in the same cycle the FSM returns to IDLE +and can accept the next start. Guard on the event too (`done || runs != 0`). +When a control does not bite, the first hypothesis is that the control is wrong, +not that the probe is blind. + +## Wave 608 — measure the absence, don't look for it + +Six instrument defects across two waves, all the same shape. Four were found by +*noticing*, which does not scale and does not finish. The mechanical version: + +**Take the subject away and run every check. Anything still green is measuring +something else.** Empty the input directories, run each CI step verbatim, and +require a nonzero exit. Twenty steps, eighteen correct, two not — and the sweep +costs less than the suite it audits, because with no input the tool fails +immediately. Ship it as a standing job, not a one-off audit. + +**`grep` inside an `if` condition escapes `set -e`.** This is the single highest +-yield shell trap in a CI gate: + +```bash +# if the file is missing, grep exits 1, the branch is skipped, +# and the step prints ok and returns 0 +if grep -q "FORBIDDEN_MARKER" build/thing.sv; then + echo "::error::found it"; exit 1 +fi +echo "ok nothing forbidden" +``` + +`set -euo pipefail` does not reach into an `if` condition — that is what makes +`if cmd; then` a legal idiom at all. Any gate shaped *"the bad thing is absent"* +must assert its subject exists first, or be rewritten to enumerate files and +fail on an empty list. + +**Grepping one file is a claim about one file.** That gate read +`build/rtl/bitnet_engine_top.sv` while 22 other files could carry the same +guard. Count what you scanned and print it — `23 files, 0 guards` is a claim; +`ok` is not. + +**Parsing is not emitting.** A check that a generated artifact *parses* passes +happily on an artifact containing nothing. Count what came out and compare it to +what went in: *2 behaviours in, 2 assertions out*. + +**Exemptions must be argued in line and counted.** Both new instruments needed +one — a step that legitimately doesn't read the input directories, a doc fence +that quotes removed code. An exemption list is where a sweep quietly stops +checking things, so: require a written reason next to each entry, and print the +exemption count in the normal output so growth is visible without an audit. + +**If you write that something "was tested", ship the test.** Prop. 58e said the +doc gate "was mutation-tested" — by a scratch script, run once, by hand. That is +the identical defect to a gate claimed in the README and never wired into CI, +committed one wave after diagnosing it. A claim in a document about a check is a +claim that the check runs. `--self-test` subcommands make this cheap. + +**Expect your own new gate to catch you.** The doc gate rejected the +proposition documenting the doc gate, because that text quotes the broken code +it replaced. That is the gate working. Fix the rule to name the category +honestly rather than reaching for the exemption. + +## Wave 609 — every fix opens the hole it was built to close + +**When an auditor must skip itself, skip it by CONTENT, not by name.** The +absence sweep runs as a step of the workflow it audits, so it has to exclude +itself. Excluding by step *name* means a rename silently reintroduces the +recursion; excluding by "any step whose script invokes `absence_sweep.py`" does +not. Report the skip and count it — a silent skip is the thing being audited. + +**Then test the hole the exclusion just made.** Self-exclusion is a fresh way to +examine zero items and return success — the exact failure the sweep exists to +catch, reintroduced by the mechanism added to catch it. The self-test's decisive +case is *a workflow whose only step is the sweep*: it must FAIL, not pass having +looked at nothing. Every mechanism that lets a checker skip something needs a +case proving the skip cannot become total. + +**A gate that fails for the wrong reason is still a defect.** `Scale ceiling` +printed `REFUTED -- a property fails at a larger bound` when yosys had simply +been unable to read the design. It failed, which is the safe direction, so no +gate was ever wrongly green — but a false diagnosis in CI sends someone hunting +a property failure that does not exist. Audit the *message*, not just the exit +code; "fails safely" is not the same as "reports truthfully". + +**Report what happened, not what is configured.** The sweep printed `1 exempt` +on runs where nothing was exempted, because it printed `len(EXEMPT)` instead of +the exemptions applied. Zero consequences, and worth fixing on principle: a +counter that reports configuration rather than events is a small lie in exactly +the place you go looking for big ones. + +**`gh issue create` uses the shell's cwd, and cwd survives between commands.** +Filed a t27 issue into trinity-fpga because an earlier command in the same shell +had `cd`-ed there. Always pass `--repo /` explicitly — the flag +costs nothing and removes an invisible dependency on command order. Same family +as the `identity_scan` glob: anything resolved relative to cwd is resolved +relative to whatever ran last. + +**State a boundary rather than letting it be discovered.** The sweep covers both +formal workflows and not the docs/notebook/seal ones. Writing that down as scope +turns "we never looked" into "we looked here and not there", which is the +difference between a gap and a lie. + +## Wave 610 — measuring what a property suite actually constrains + +**Ask detection power, not "re-prove the rest".** Neutralising one property and +re-proving the others tells you nothing: they are independent assertions about +the same design, so removing one can never make another fail. The question with +content is *for each way the design can break, which properties notice?* Run +each property ALONE — every sibling neutralised to `assert (1'b1)` — against +each mutant, and read off a property × mutant matrix. + +**Mutation operators must be masked to code, and chosen from the code.** Two +separate mistakes, both mine, in one afternoon: + +1. The operators ran over the whole file. Every module here opens with a banner + of `=` characters, so `==` produced 75 mutants inside `// =========`. All 76 + parsed, all proved, and "0 detected" was one step from being published as + evidence the suite was weak. It measured ASCII art. Mask comments first, and + assert it: *a fully-commented-out copy of a module must yield zero mutants.* +2. After masking, the textbook operator list matched **nothing** — the module is + 23 non-comment lines of `?:`, `|`, `{}` and sized literals. Operators are a + property of the code under test, not of the mutation literature. Read the RTL + and write operators for it. + +**An implausible zero is a gift — check it against something known.** The only +reason the ASCII-art result got caught is that the shipped CI harness kills an +`interrupt_controller` mutation, so "this suite detects nothing" contradicted a +fact already in the repository. Keep one known-true result to test new +measurements against. + +**"Undetected" is not "missed" until you rule out equivalent mutants.** Mutation +testing's standing confound: an edit the design is insensitive to. Separate them +with a bounded sequential equivalence miter (`miter -equiv -flatten +-make_assert`, then `sat -seq N`) — and let yosys build the miter. Hand-writing +the wrapper by parsing port lists broke on parameterised widths +(`[C_ADDR_W-1:0]`) and again on a header the port regex misread. Note also that +`prep` before `miter` picks its own top and discards the module you wanted to +compare against. + +**Run the expensive measurement twice at different budgets.** 90 s and 20 s +miter caps gave the same 133 real gaps; the only movement was two proofs that +finished at 90 s and not at 20 s, correctly reported *undecided* rather than +counted equivalent. Agreement across budgets is cheap evidence the number is +real, and it only works if UNDECIDED is a first-class outcome. + +**Mutation adequacy and vacuity interact — this is the subtle one.** A property +guarded in its `always` header (`if (rst_n && $past(wvalid) && !$past(wready))`) +is not violated by a mutation that suppresses `wvalid`; the guard becomes +unreachable and the property proves **vacuously**. In a detection matrix that is +recorded identically to "this property is too weak to notice". Before calling a +zero-detection property dead, probe its guard: assert the guard is impossible, +and compare the verdict on the original against the mutant. + +**Report every subsumption claim with its denominator.** Four +`interrupt_controller` properties had identical detection sets — over **six** +mutants, which is what one expects from almost any pair. A subsumption claim is +exactly as strong as the mutant set behind it, and someone will otherwise delete +a property on six data points. + +## Wave 611 — a property can prove without reading the design + +**`dut.` inside a Yosys property module is not a hierarchical +reference.** Yosys does not support them here and does not error. It implicitly +declares a fresh **one-bit undriven wire** with that name, renames the real +register around it (`word_index` → `word_index_1`), and proves your property +against the phantom. Two warnings are printed and nothing reads them: + +``` +Warning: Identifier `\dut.word_index' is implicitly declared. +Warning: Wire wp_props.\dut.word_index is used but has no driver. +``` + +A shipped property lived like this for four waves, counted in the property +total and in every count-based gate. + +**Gate on those warnings — it is the cheapest gate in the whole suite.** +Elaboration only, no proof: `read_verilog -sv -formal ; prep -top + -flatten`, then fail on `implicitly declared` or `used but has no +driver`. It covers the entire class — hierarchical references, misspelled +signals, ports renamed out from under a property — not one instance. It caught +my own replacement property within seconds of writing it (I used the DUT's port +name where the wrapper's local wire had a different one). + +**A syntactic free-property scan cannot find this.** Checking for bodies the +optimiser folds to constant true (`x == x`) is a check on the *shape*. Here the +shape is a perfectly ordinary comparison and the **signal** is fake. Two +different failure modes need two different instruments; do not assume the one +you have covers the one you don't. + +**To test whether a property reads the design, change the design.** Not the +property. Pick a mutation no correct form of the property could survive — make +the counter it constrains advance by two instead of one — and if it still +proves, it was never looking. Equivalent quick check: assert a hierarchical +reference equals the port it is wired to (`dut.busy == busy`); that must prove, +and if it refutes the reference is a phantom. + +**Four candidate properties rejected in a row is a signal about the harness, not +the properties.** I wrote four, all four refuted on the unmutated design, and the +temptation each time was to weaken the property. Reading one counterexample +instead found the cause of all four — and a four-wave-old defect. + +**Remove rather than patch when the honest replacement needs new assumptions.** +The property's intent was not expressible from its wrapper's ports without +modelling an AXI slave, and adding an assumption to make a property prove is how +an earlier wave silently killed two vacuity witnesses. Deleting it and writing +down exactly why — including that the property count drops — beats shipping +something that proves for the wrong reason. Then fix every comment elsewhere +that cited the deleted property as coverage. + +## Wave 612 — three bars, and the property that restates its assumption + +**A property must clear three bars, not one.** "It proves" is the cheapest of +the three and the only one most suites check: + +| bar | question | how | +|---|---|---| +| **TRUE** | does it hold on the real design? | prove it alone *and* with its suite | +| **ALIVE** | did an assumption buy that by making the design idle? | every activity still reachable **with the assume active** | +| **BITING** | does it detect anything? | run it against known behaviour-changing mutants | + +**The failure only the third bar catches: a property that restates its own +assumption.** With `assume (fv_r_acc < fv_ar_acc)` in the environment, the +property `assert (fv_r_acc <= fv_ar_acc)` proves instantly, reads like a real +protocol claim, and detects **0 of 64** known-real mutants. It has a reachable +guard, a non-free body, real signals, and proves at depth — it passes every +cheap gate there is. Only measuring detection exposes it. This is the argument +for paying for the expensive bar. + +**Write environment assumptions as counters plus one implication.** `rvalid` as +a free input lets the solver return data for an address the DUT never issued — +not a design behaviour, a testbench that cannot exist in silicon. Count accepted +addresses and accepted beats, then `assume (!(rvalid && rready) || r_acc < +ar_acc)`. Small, readable, and it constrains the *environment* rather than the +design. + +**Gate the assumption, not just the property.** An environment that is safe +today over-constrains after tomorrow's RTL change, silently. Put reachability +probes for the signals the assume touches into the CI step that runs *inside* +the property module — `assert (!(arvalid && arready))` must refute. Otherwise +the next wave rediscovers the same failure from scratch. + +**Attribute detections to the property, not the environment.** If a property +catches mutants, re-run with the property removed and the environment kept. Any +mutant that still refutes was being caught by the assumption. Zero is the +answer you want. + +**A refuting candidate is a finding, not an obstacle to route around.** My DMA +shadow model of the request refuted; the temptation is to weaken the property +until it passes. Recording "this shadow model is wrong, not shipped" is worth +more than a property that proves because it was bent to. + +## Wave 613 — "detects nothing" has three causes, only one is a problem + +**Give every property a verdict, and classify the silent ones.** A detection +matrix alone defames properties that are doing their job. Three outcomes, and +the probe that separates them: + +| verdict | test | +|---|---| +| **BITES** | catches mutants, and not a subset of another property's set | +| **INNOCENT** | catches nothing *because* mutations that could violate it make its **guard** unreachable — probe: assert the guard is impossible; it refutes on the original, proves on those mutants | +| **SUBSUMED** | its detection set is contained in another's | +| **DEAD** | guard reachable throughout, not subsumed, still catches nothing | + +Running this over 24 shipped properties gave 18 / 1 / 5 / **0**. Zero dead is +worth measuring for: it is the difference between "the suite is large" and "the +suite is lean", and nobody can assert it without the sweep. + +**Subsumed is not deletable.** All five subsumed properties were kept, because a +suite is read as well as run: two state an AXI rule in the specification's own +form, one is the *regression witness* for a defect that actually shipped. +Deleting a regression witness because a newer property happens to cover it +discards the record of what went wrong. **Write the verdict next to the +property** — otherwise the next person to run a detection matrix deletes them +thinking it is cleanup. + +**Symmetry does not predict detection power.** `a_awvalid_stable` bites +uniquely, its read-side twin is subsumed, its write-data sibling is innocent — +three properties of identical shape over three channels, three different +verdicts. Never reason about a suite by analogy between channels; measure each. + +**When the disk fills, preserve the work before anything else.** Bash and Write +both fail with ENOSPC — Bash cannot create its output file, Write cannot create +its temp file, so *no* tool call succeeds. When space fluctuates back, spend the +first successful calls on `git add` + `commit` + `push`, not on diagnosis. And +do not delete to make room while unattended: on a shared APFS container `df /` +can report a repo-sized number while the real consumer is another volume +entirely, so the obvious cleanup target is usually the wrong one. *Confirmed the +next wave:* the space returned on its own and the repo's `target/` was 565 MB — +never the consumer. The cleanup I had proposed would have cost a rebuild and +freed nothing. + +## Wave 614 — properties that are supposed to refute + +**Some properties are expected refutations, and a sweep that assumes otherwise +mislabels them.** Four `*_never_completes` properties here refute on the real +design *by design*: they record that a zero-sized job does report done, which is +safe only because a sibling proves it emitted no work. My sweep called all four +"isolation broken". + +**The generalisation is one line: measure the expected verdict, then define +detection as "the verdict differs from the expected one".** For an inverted +property that means a mutant made it **prove** — the mutation removed the +behaviour being recorded. A harness that hard-codes *detection = refutation* +cannot measure an inverted property at all; it can only get it wrong. + +**A property whose value is the record it leaves does not have to earn its place +by detection.** The campaign's first DEAD verdict landed on an expected +refutation that pins a deliberate design decision. Keeping it is not timidity — +detection power and documentary value are different currencies, and a suite is +read as well as run. Write the verdict beside it either way. + +**Report a DEAD verdict with its denominator, loudly.** Twelve mutants of a +23-line module is a weak basis for calling anything dead. The denominator is not +a footnote; it is most of the claim. + +**A predictable result is calibration, not waste.** Both max-size subsumptions +were derivable on paper (strictly-increasing is implied by increases-by-one). +Measuring them anyway is what makes the *surprising* verdicts — a property +biting uniquely while its mirror-image twin is subsumed — credible rather than +noise. + +**Never let two runs share an output path.** I launched a corrected sweep while +the original was still running, both redirecting to the same file. The merged +result was internally inconsistent — a summary line disagreeing with the rows +above it — and looked enough like data to be read. Check `pgrep` before +relaunching, and give every run its own output file. + +## Wave 615 — mutating the properties, and a limit that does not lift + +**If the design file also contains its properties, a mutation generator will +mutate them.** RTL that carries inline assertions behind `` `ifdef FORMAL `` +guards is mostly *not design*: 68% of the engine top here is comment or +formal-only text, and two of eight sampled mutants changed assertion text rather +than logic. **A property suite that "detects" a mutation of itself measures +nothing.** Mask three things, not one: comments, formal-guarded regions +(nesting-aware), and any labelled `assert`/`assume` line. Then re-check the +hand-written mutations against the same mask — they are generated by different +code and get the same disease. + +**This is the second costume of the same bug.** Wave 610's operators ran over +banner comments; these ran over property text. The general rule is worth stating +once: *a mutation operator needs an explicit model of what counts as the subject*, +and that model has to be tested (a fully-commented-out module must yield zero +mutants; a property line must never be the changed line). + +**Sample by subsystem, not at random, and say which you did.** Seven mutants +against a 212-mutant population is a sample either way — but one mutation per +subsystem *where defects have actually occurred* supports a different sentence +than seven random ones. Report the sampling rule, not just the sample size. + +**Validate the equivalence check on a mutant you already know is different.** +The engine-scale miter said `EQUIVALENT` at `seq 6` for a mutation the property +suite refutes at `seq 40` — too shallow to mean anything — and `UNDECIDED` at +`seq 12` after 420 s. Without that validation I would have published six +"equivalent" mutants and called the suite adequate. + +**When a technique does not scale, say so and stop, rather than reporting its +output anyway.** At module scale a bounded miter separates real gaps from +equivalent mutants. At engine scale it cannot, so the six undetected mutations +are recorded as *undetected* and explicitly **not** as gaps, and the headline +number is labelled a floor rather than a coverage percentage. A measurement with +a stated limit is worth more than a bigger number with a hidden one. + +## Wave 616 — a coverage number is a claim about which gates you ran + +**Name the gates, not just the mutants.** I reported "1 of 7 detected" after +running the safety properties. The design's gate set was safety **∪ liveness**, +and the liveness half caught one more on its own. The count was not wrong +arithmetic — it was *a complete count of an incomplete question*. Any coverage +figure needs a scope line naming which gates were executed. + +**Liveness probes that ask "can X happen at all" are blind to phase-specific +stalls.** A fault that kills an activity in one ping-pong phase leaves it +happening in the other, so a global reachability probe still refutes and the +build stays green. Condition the probe on the phase: +`!(mac_valid_q && !use_buffer_a)`. Same technique applies to any design with +alternating modes, banks, or channels. + +**A probe run at too shallow a depth does not answer "unknown" — it answers +wrong.** At `seq 22` my new probe *proved*, i.e. reported the activity +unreachable; at `seq 40` it refuted. The shallow answer is the one that looks +like a passing gate, which is the worst possible direction for the error. Give +probes per-probe depths and record why each one needs its own. + +**Only the `proves` direction is depth-fragile — check those, trust the others.** +A refutation found at depth N is a real counterexample at any depth. So audit +exactly the probes whose expected verdict is *proves*: mine held at 22 / 40 / 60 +for 5 s / 11 s / 20 s, which is cheap enough that there was no excuse for +assuming it. + +**Watch for the degenerate configuration when a probe fails to bite.** +`!(input_ready && !use_buffer_a)` refuted on the mutant too — because +`filled >= neurons_per_layer` is satisfiable with `neurons_per_layer == 0` and +the solver simply picks that. A probe over a parameterised design is only as +sharp as the configurations it excludes. + +## Wave 617 — audit the bounds; and write predictions down so they can be wrong + +**Re-prove every bounded claim at 2× and 4× its bound.** Only the *proves* +direction can be depth-fragile, so that is exactly the audit surface. Four +wrappers, no flips — and the strongest datum was `dma_controller` surviving to +`seq 320` against a CI bound of 80, which retroactively justifies the 12 → 80 +raise an earlier wave made for tractability rather than principle. + +**"Undecided" is a result; do not retry until it yields a number.** One wrapper +went undecided at 4×. That is not a flip and not a failure — it is the honest +boundary, and rerunning with a longer timeout until something prints would have +converted an honest boundary into a fabricated one. + +**Audit partially and say which parts.** Four of twelve wrappers, because each +4× run costs real time. A partial audit reported as partial beats a complete one +reported without its cost — and the alternative on offer was to keep the wave +open indefinitely. + +**Write predictions into the report so the next wave can refute them.** I ended +a wave predicting, in writing, that phase-conditioning would generalise to the +other probes. It does not: five candidates, none bites, because the fault it +caught *stalls a phase* and the remaining ones do not stall anything. Because +the prediction was recorded, refuting it took one measurement instead of being +quietly forgotten or quietly assumed. + +**When a class of probe cannot work, say what would.** The four remaining +mutations change *values* while leaving every activity reachable — a latch reset +to the wrong constant, an accumulator decrementing, a status word with a stray +bit. No reachability probe of any phase sees those. Naming the required shape +(safety claims about data, where the existing suite is about control) is what +stops the next attempt beginning with another probe. + +**When a measuring tool says it cannot find something, first suppose it is +absent.** My bound audit printed *no bound found in the workflow* for six +wrappers and I wrote that off as a bug in my extraction, reporting them as +"unaudited for cost". Four of them had **no CI step at all** — eight properties +counted in the README as proved, run by no job in the repository. The tool's +failure was the finding. + +**An ungated property that happens to hold is indistinguishable from a gated +one.** All eight held, which is exactly why nobody noticed for many waves. +Counting properties tells you nothing; count the **steps that run them**. One +`grep` for each property file across `.github/` would have caught it at any +point. + +**Awkward-to-gate is how something ends up ungated.** Half that suite consists +of *expected refutations*, so the obvious "everything must prove" step cannot +gate it — and so no step was written. Whenever a suite needs a per-item expected +verdict rather than a uniform one, treat that as a marker that it is *more* +likely to be missing its gate, not less. + +**An audit must reproduce the gate's method, not just its bound.** CI proves one +suite's properties *one at a time*; my audit ran them together at the same bound +and got "undecided", which I nearly reported as a property of the suite. Read +how the gate invokes the tool, not only which numbers it passes. + +## Wave 618 — orphaned work costs more than stale files + +**Ship the accidental catch as a gate.** Finding ungated properties by luck twice +is a signal to automate: cross-reference every property file against every +workflow, error when nothing runs it, and *warn* when only a scheduled workflow +does — a defect in a weekly-only file is invisible on a pull request. Weekly is a +legitimate choice for expensive harnesses; silence is not. + +**An orphan is not a stale file, it is a solved problem waiting to be solved +again worse.** The scan's first run found a complete, well-documented AXI4 +read-slave model that nothing referenced — and an earlier wave had hit exactly +that need, failed to state a property without an environment, and written a +thinner version inline. Before building an environment, model, or harness, grep +`formal/` for one: the repository is older than your memory of it. + +**A model that asserts its own precondition is worth more than one that assumes +it.** That file assumed only what AXI4 requires of a slave, and *asserted* +"the master issues one burst at a time" — so if the master ever violated it, the +model would fail loudly instead of quietly hiding the defect it exists to expose. +Copy that pattern: every environment model has preconditions, and each one is a +choice between an assertion and a lie of omission. + +**Adding a submodule to a property wrapper breaks every harness that reads only +DUT + props.** Three did here — the liveness step, the weekly mutation harness, +and the phantom scan. Expect that, and expect to add an explicit extra-sources +field to each. The reason it was survivable is that all three reported *an +elaboration error* rather than "unreachable", "mutant killed", or a clean bill of +health. **That is the return on the tool-error/verdict distinction: it pays out +on changes nobody anticipated, in harnesses nobody was thinking about.** + +## Wave 619 — when a property refutes, ask which of the two is wrong + +**A refuting property is a claim about the design that might itself be false.** +Mine said "every DMA write consumes eight owed bytes". The trace showed a +12-byte request writing a second word with four bytes owed — correct, because +twelve bytes occupy two words of a word-addressed memory. *The property was +wrong about the design's contract, not the design wrong about the property.* +Restating it in words instead of bytes made it prove. Read the counterexample +before touching either side; the trace tells you which one is lying. + +**Shadow models must arm on the observable the FSM actually uses.** A first +attempt armed on `start && !busy`, and `start` is high in states where no +transfer begins. The FSM triggers on `IDLE: if (start)`, so the faithful +observable is the **rising edge of `busy`** — and any value the FSM latched at +that moment must be read as `$past(x)`, because it changed on the same edge. + +**A new property can make its own gate stop terminating.** `-prove-asserts` +solves every assertion in one SAT instance, superlinearly harder than the parts: +adding one property took a batch from ~10 s to over 11 minutes. **Split +one-per-invocation.** Six properties then kept a bound of 80 that the batch would +have cost entirely, while the expensive newcomer runs at 20 — and stating the +two bounds separately is more honest than lowering everything to the slowest. + +**Measure the bound; do not adopt the one next door.** The new property proves at +`seq 20` in 16 s and is *undecided at 30*. Had I copied the suite's 80, the step +would hang; had I guessed 40, it would hang. One ladder run settles it. + +**A check-cell floor set below the true count is slack, not safety.** Ours sat at +8 against a real 12 — three properties could have vanished before the gate that +exists to detect vanishing properties noticed. Set it to what you measured. + +## Wave 620 — a gap count is a claim about a set of properties + +**Name the property set, or the number is wrong.** I measured "64 gaps in +`dma_controller`" against one wrapper, while **three** wrappers constrain that +module. Running the residue through the siblings: 8 of them were never gaps. +"64 gaps in module X" reads as a fact about X and was a fact about one harness. +The form that survives contact with a second wrapper is *"N mutations of X are +detected by none of its K suites"* — longer, and true. + +**Re-measure what a new property was supposed to close, rather than subtracting +on paper.** The property's own bite count came out the same on an independent +run, which is the cheap confirmation that the earlier figure was not an artifact +of that day's harness state. + +**A flat residue means the method is spent, and that is a result.** Cluster the +undetected mutations by source line: when the top cluster was 8 and 9 on two +subsystems, a property was there to be written. When 33 of 42 lines have exactly +one mutation each, there is no class left — only reset values, state encodings +and one-off arithmetic. Continuing at that point means one property per mutation, +which is a restatement of the RTL rather than a specification of it. + +**Shipping nothing is sometimes the honest wave.** This one produced a +correction to a published number and a stopping criterion, and no new property. +Inventing one to have an artifact would have inverted everything the preceding +waves were for. + +## Wave 621 — the instrument told the truth; the caption lied + +**The whole-campaign version of the per-suite error.** Correcting every +multi-suite module moved the headline from *45/202 detected (22%), 133 real +gaps* to **74/202 (36%), 104 gaps**. One mislabelled measurement, repeated +across five modules and quoted for twelve waves. + +**A second suite makes an overcount possible, not certain.** One module had two +suites and needed no correction at all. Do not assume the shape of an error +before measuring it — the correction is per-module, not a blanket adjustment. + +**Expect measurement error in the flattering direction; be more suspicious when +it runs the other way.** This one understated its own subject, which is exactly +why nobody caught it: there was no incentive anywhere in the loop to double-check +a number that made the work look worse. *Bias in the unflattering direction +survives longest.* + +**Audit labels, not only instruments.** Twenty waves went into checking whether +harnesses lie — truncated output, crashes read as verdicts, scans over empty +file lists, gates that never ran. This error involved none of that. The matrix +measured exactly what it was told to measure; the sentence describing it named a +*module* where the data described a *wrapper*. **A correct instrument with a +wrong caption produces a wrong claim, and no amount of instrument auditing finds +it.** Read every published number back as "this is a claim about X" and check +that X is what the code actually ranged over. + +**Recompute headline figures from stored data, not by hand.** Every number in +that correction came out of the recorded JSON through a script. Re-deriving by +arithmetic in prose is how the next drift starts. + +## Wave 622 — gate the numbers in the prose, not only the tools + +**Build a checker that re-derives every countable claim from the tree.** All the +other gates ask whether a tool lies. This asks whether the *documentation* does — +and found a proposition count 15 behind, an integration-property count 2 behind, +and two CI **step names** quoting numbers the steps had long outgrown. Nobody +re-counts propositions by hand, which is exactly why that number drifts. + +**Police the current-state document; never rewrite dated records.** A +proposition saying "22 of the 26 prove at seq 80" was true when measured. +Correcting it in place destroys the record. Corrections belong in a *later* +entry, and the gate should exempt the archive and hold only the README-style +"here is what is true now" document to the tree. + +**A checker comparing two numbers must first establish both range over the same +set.** Mine counted assertions in a file and compared against a documented +figure — twice getting a different answer (28 by whole-text, 26 by line) and +nearly publishing each. The resolution was neither: **two assertions wrapped the +label and `assert` onto separate lines**, so per-line undercounts, and the real +split needed a guard-aware count over the text. I committed Prop. 73's exact +failure inside the tool built to prevent it. *Before reporting a mismatch, +establish what each side is counting.* + +**A gate that fails on its own author's next edit is the good kind.** Writing the +proposition that documented this gate incremented the count it polices, and the +gate went red immediately. That is the cheapest possible evidence the check is +load-bearing — and if a number only ever changes when someone writes prose, then +prose is exactly where it will rot. + +## Wave 623 — resolve the mismatch before you gate it + +**Leave a discrepancy unexplained rather than resolving it in the wrong +direction.** My checker said 39 module properties, the README said 43, and the +tempting move was to "fix" the README. The README was right: an entire module's +properties are emitted **inline into the RTL** and have no file in `formal/` at +all, so a `formal/`-only count omits them. Gating the wrong number would have +produced a correct-looking gate enforcing a false claim — the exact shape of the +two preceding waves' findings, made permanent. + +**Find out where each artifact actually lives before counting it.** Properties +here live in two places — hand-written files and emitted RTL — and nothing in the +directory layout says so. `grep -c` across one directory reads like a total and +is a subset. The same blind spot silently limits any scan keyed on that +directory: the orphan checker cannot ask "is this run by anything?" about +properties that have no file. + +**Write the inclusion boundary next to the code, not in your head.** Deciding +that a prover self-check and an environment model's precondition are *not* +module properties is a judgement, and an unrecorded judgement is indistinguish- +able from an oversight the next time someone counts. Two lines of comment turn +"39 vs 43" from a contradiction into two well-defined numbers. + +**A number that lives only in a workflow file will drift.** The engine probe +count existed nowhere a reader would look, so it could not be wrong and could not +be right. Putting it in the current-state document is what makes it gateable. + +**A wave that ends "there was no discrepancy" is a successful wave.** The output +was one comment block, two gated claims, and a structural fact nobody had written +down. Chasing an artifact instead would have meant changing a correct document. + +## Wave 624 — one file is not one module + +**Never key a coverage map on filenames.** My first classifier used the file +stem as the module name. One file defined **eleven** modules, so the map invented +one entry that did not exist and omitted eleven that did — and those eleven fell +into three different coverage classes, which the file-level view showed as a +single row. Parse `^module (\w+)` and classify per module. + +**Follow instantiation transitively, or "unused" is wrong.** One primitive is +reached from the top only through an intermediate module. A one-hop check calls +it unreachable; a transitive walk finds it. The difference decides whether you +report a library as dead. + +**Coverage has more than two states.** *Has properties* / *doesn't* hides the +useful distinction: a module with no properties that the top-level instantiates +is constrained **at one remove**, and one that nothing instantiates is +constrained by nothing at all while still being compiled into every proof. Those +deserve different words and different severities. + +**Report library findings; don't fail on them.** Six unexercised primitives are +not a build error, and a permanently red gate is one everyone learns to ignore. +Keep errors for the unambiguous case and make everything else a counted warning — +*silence* is the thing that is not allowed, not imperfection. + +**The most useful output of a coverage map is the embarrassing row.** Here it was +that the module implementing the subsystem responsible for the campaign's +longest-running defect — three fixes across eight waves — has never had a +property of its own. Every fix was made at the level where the bug was +*observable*, and nobody went back to constrain the thing that produced it. A map +is worth building mostly to surface that one line. + +## Wave 625 — a bite measurement needs a proving baseline, or it fabricates + +**Check the suite proves on the unmutated design before measuring detection.** +One of my four properties refuted on the real design, so the *whole suite* +refuted, so every mutant refuted too — and the first measurement read **4 of 4 +detected**. The honest figure was 2 of 4. A harness that measures "does this +mutant refute?" against a suite that already refutes is measuring nothing and +reporting a perfect score. Make the baseline an abort condition, not a note. + +**That is the baseline gate, from the other side.** An existing gate asserts the +unprobed design proves so a *probe* verdict means something. The same requirement +applies to a *bite* verdict and I had not connected them. When you build a second +kind of measurement, check which existing preconditions transfer. + +**`-set-init-zero` breaks reset properties in a way that looks like a design +bug.** `rst_n && !$past(rst_n)` reads as "the cycle after reset released", but +with all registers initialised to zero, `$past(rst_n)` is 0 at time zero whether +or not a reset happened — so the guard fires on an artifact of the convention. +Gate it with a register that is 0 only at time zero: + +```verilog +reg fv_started; +always @(posedge clk) fv_started <= 1'b1; +``` + +**Fix the module where the defect lives, not only where it shows.** Three +separate fixes for one subsystem all landed at the level where the symptom was +observable, and the 33-line module that produced it stayed unconstrained for +eight waves. After fixing a bug at an integration level, ask whether the unit +that caused it can now state the invariant directly — the module-level property +here catches the harness's own mutation that previously only the engine caught. + +**Adding a property suite is four edits.** The prove step, the +assumption-liveness probes, the phantom-signal scan's suite list, and the count +in the current-state document. Miss the third and the new suite is silently +exempt from a gate; miss the fourth and the claims gate fails immediately — +which is the better failure, and an argument for having both. + +## Wave 626 — one symbolic address beats a hundred concrete ones + +**State the memory axiom over a SYMBOLIC address.** "A read returns the last +value written to *this arbitrary address*" — a free input pinned by +`assume (addr == $past(addr))` — is one property that also proves +**non-interference**, because a write to any other location would make the +shadow disagree. The same property over a fixed address proves almost nothing. +This generalises past memories: whenever a claim is "for all X", make X symbolic +and constant rather than picking one. + +**Get the collision semantics right or the property refutes on correct RTL.** +With non-blocking assignments on both ports, a read concurrent with a write to +the same address returns the **old** value — so the shadow must be compared as of +the read cycle, *before* that cycle's write (`$past(shadow)`, not `shadow`). + +**An assumption added to a scaled-down proof must be vacuous at full scale.** +Scaling `DEPTH` from 4096 to 4 while the address port stays 12 bits lets the +solver write to address 2048 of a four-entry array — and the property refutes for +that reason alone. The in-range assumption that fixes it constrains *nothing* at +the real depth, where every representable address is legal. That is the test for +whether a scaling assumption is honest: **would it be a no-op at full size?** If +not, it is hiding design behaviour, not scaling artifacts. + +**"0 of N mutants detected" can be a fact about the mutants.** A 28-line memory +yields three mechanical mutants, all width-expression edits that widen a port +without producing a memory fault. Reporting 0/3 alone reads as a weak property. +Run the faults the component can *actually* have — read the wrong address, ignore +the write enable, write to the read address, off-by-one — and report both +numbers. Four of four caught says what 0/3 does not. + +## Wave 627 — check the logic around a component without trusting the component + +**Instantiate the sub-block a second time as a shadow.** To constrain an +accumulator that wraps a dot-product primitive, drive a second copy of that +primitive with the same inputs and compare against it. This assumes *nothing* +about whether the primitive is correct — it states what the surrounding logic +must do with whatever the primitive returns, and leaves the primitive's own +correctness as a separate, explicitly unmade claim. Far cleaner than either +trusting the sub-block or reimplementing its function in the property. + +**Adding a property can corrupt the map that measures properties, and the +corruption reads as progress.** My coverage map defined "covered" as *some suite +instantiates this module* — and the shadow instance made a primitive look +directly verified when nothing says anything about it. **An auxiliary instance is +not coverage.** Key the map on the instance under test (a `dut` naming +convention works), and re-check the map whenever you add a wrapper that +instantiates more than one thing. + +**Write the pair of properties that distinguish restart from accumulate.** "A +first chunk restarts the sum" and "every later chunk adds exactly its own +contribution" are two claims, and a datapath that confuses them satisfies neither +by accident. Both mechanical operator swaps that produce that confusion — +`+`→`-` and ternary-arm exchange — were caught by exactly these. + +**Add the "held while idle" property.** A datapath that recomputes on idle cycles +satisfies both of the above and still corrupts results between chunks. The +absence property is the cheap one to forget and the one that pins the pipeline. + +## Wave 628 — combinational logic is decidable; prove it exhaustively + +**On stateless logic, `sat -seq 1` quantifies over every input.** No bound, no +induction, no depth caveat, and nothing to re-audit later. If a module is purely +combinational, this is the cheapest strong result available anywhere in a formal +campaign — and it had been sitting unused for the whole of mine. + +**Width comments are where the defect hides in plain sight.** The adder tree's +own comment read `range [-9, +9] -> signed [3:0]` — the correct range stated +directly above a declaration that spans [−8,+7]. **Whenever RTL documents a +range next to a width, check that the width holds the range**; it is a two-second +read and it found a defect that had survived since the module was written. + +**A test can protect a bug.** The unit test asserted `wire signed [3:0] l2` — +the buggy width, verbatim. The defect was not untested; it was *pinned* by a +passing assertion. **A test that asserts a width without checking the range it +must cover locks in whatever the generator first emitted.** Assert the property, +not the text. + +**A stale build artifact can stand in for a missing build step indefinitely.** +One source file was not in the bundle the CI emit step produces, yet every +top-level proof listed it. Locally an old generator run had left it on disk, so +everything passed for months. Test what a *clean checkout* produces, with the +exact source list the gate uses — not a glob, and not your working tree. + +**Two harness errors nearly turned that into a false claim.** A `*.sv` glob +picked up a file the tool cannot parse at all, and an exit status read through +`grep` missed the error line. Both pointed at the tree; both were the test. When +a finding is about infrastructure, the infrastructure that finds it deserves the +same suspicion as the thing under test. + +## Wave 629 — re-run everything the defect sat underneath + +**After fixing a defect, re-establish every result that depended on the broken +component.** Not because you expect movement — because "I expect nothing moved" +is a prediction, and the whole point of the exercise is that predictions get +checked. Six engine-level steps, twelve minutes of compute, and a documented +before/after. + +**A "nothing moved" result is worth publishing when it says what was *not* +being checked.** The integration suite proved identically before and after a +genuine arithmetic defect in a module it transitively depends on. That is not a +failure of those properties — it is a sharp statement of their scope: they +constrain **control** (handshakes, phase, contiguity, readiness) and the defect +was in **data**. A null result that draws that boundary is more useful than a +green tick. + +**Note which instrument actually caught it.** Not the mutation harness, not a +witness, not an integration property. The chain was: map coverage → notice a +module constrained only at one remove → prove it directly. When a defect is +found, write down the shortest path that found it — that path is the thing worth +repeating, and it is rarely the most expensive instrument you own. + +**Re-time the expensive proofs while you are there.** The same bound that cost +238 s for 22 properties now costs 422 s for 24. The ceiling had not moved but +the headroom had, and nobody would have noticed until a future property pushed +it over. + +## Wave 630 — the defect that was written down next to itself + +**When a defect is found, ask whether it was legible before you found it.** The +Wave 628 arithmetic bug had its own correct range stated in a comment on the +line above the declaration that could not hold it, from Wave 33 to Wave 628. +Nothing mechanical compared the two numbers and no human read them as numbers. +A defect that was *documented and still shipped* is not a testing gap — it is a +missing comparison, and comparisons are cheap to automate. Before writing the +next property, check whether the last defect was already written down somewhere +in the tree. + +**A test can protect a defect, not just miss it.** The unit test asserted the +buggy width verbatim (`assert!(body.contains("wire signed [3:0] l2"))`). Any +fix would have failed the suite. Tests that pin an emitter's exact output are +regression locks pointed in whichever direction the output happened to face +when they were written — when a test asserts a *value*, ask what would happen if +that value were wrong. + +**The obvious static check can be unsound in your own domain.** Worst-case +arithmetic over declared widths is the textbook overflow check, and here it +fails a *correct* design: a trit needs three values, two bits carry four, so +reasoning from bit-width over-approximates by exactly the encoding's slack. +Level 1 would have been reported as overflowing when it does not. Where an +encoding is narrower in value than in bits, propagate documented ranges instead +— and where the domain has that shape, expect every generic analysis to need +the same correction. + +**Comment conventions the emitter writes consistently are a machine-checkable +specification.** `range [-N, +M] -> signed [W:0]` was prose to every reader for +595 waves and a checkable claim the moment someone parsed it. Look at what your +generators already write by habit; some of it is a spec nobody has run. + +**My own new gate reproduced the campaign's signature failure on its first +run.** It reported zero findings on the shipped tree *and* zero on an injected +defect. Two independent causes: an eight-line comment block outran a three-line +lookahead, and a `+` inside an array index (`val[i*3+1]`) made an operand count +disagree with a term count, so the check silently declined to run on the very +tree it was written for. It printed a clean result either way. **A new +instrument's first duty is to fail on a defect you plant in it**, and the fix +is a coverage counter in the output — "3 reductions checked" makes silence +measurable where "0 findings" does not. + +**Assert that your injected defect actually landed.** The first self-test +replaced a two-line string that did not exist in the file (the lines were eight +apart), so every mutation case graded the scan on *unmodified* source. A +mutation test whose mutation silently no-ops reports a pass. Compare the text +before and after injecting, and fail if nothing moved. + +**Two independent counters of the same thing will drift.** The README claimed +the absence sweep runs 22 checking steps; it ran 32. Nothing malfunctioned — +steps were added across ~20 waves and nobody recounted. The fix is not a +corrected number but a derived one: `claims_check` now *imports* +`absence_sweep.collect` rather than re-implementing the count. When prose states +a number the tree already knows, derive it from the same code the tree uses. + +**Adding a gate is a good moment to audit adjacent claims.** The stale count was +found only because the new step changed a total. Whenever you add to a set +something else counts, re-run the counter. + +## Wave 631 — a width is only safe relative to a contract + +**Ask what bounds an accumulator, and then ask where that bound is written.** A +16-bit accumulator summing values of range [-27,+27] overflows after 1214 terms. +It was safe — but only because a *different module* walks its chunk counter over +an 8-bit port. Nothing in the accumulating module knew that: no chunk counter, +no `num_chunks` input, no comment. A width is never "wide enough" on its own, +only wide enough for a bound, and if the bound lives in another file the safety +is an accident that the next ordinary change deletes. + +**An equation in N bits cannot detect an N-bit overflow.** The property +`result == $past(result) + $past(dot)` looks like it pins the arithmetic +completely, and it holds *modulo 2^N* — a wrapping accumulator satisfies it +exactly. If you want to catch a wrap, state the claim in a wider type than the +signal you are checking. This generalises past hardware: any assertion written +in the same type as the value it checks is blind to that type's overflow. + +**When the counterexample is thousands of cycles away, bounded proof is worse +than no proof.** Every feasible depth returns "proves", which reads as a passing +build and means nothing. That is the shape to watch for: a property whose +violation requires a long run. Reach for induction there — an inductive +invariant is checked one step at a time and carries no depth caveat, so it says +something true about run 10,000 that no bounded run can. + +**Prove the facts your proof depends on, separately and unconditionally.** The +accumulator bound needs |dot| <= 27. An existing property gave the dot product's +exact value but only under a validity assumption about input encodings. The +*bound* needs no such assumption. Had it been taken from the conditional +property, the accumulator proof would have silently inherited an assumption +about memory contents that nothing enforces. Assumptions inherited through a +cone are invisible; assumptions stated at the top of a file are not. + +**Deleting one instance from a wrapper was 800x.** The same properties in a +wrapper carrying a shadow copy of a 27-input adder tree did not finish in 18 +minutes; without it, 1.3 seconds. Before optimising a slow proof, look at what +is in its cone that the properties do not actually reference. + +**I folded a tool error into a verdict — in the wave whose own notes cite that +trap.** Two runs exited 1 and were nearly recorded as "refuted, so the +assumption is load-bearing". They were `ERROR: File not found`: an earlier `cd` +in a chained command had moved the shell. Knowing a failure mode is not +protection from it. Use absolute paths in verification commands, and when a run +exits nonzero, read *what it printed* before naming the verdict. + +**A check whose pattern stops matching reports nothing and is counted as +passing.** The claims gate compared documentation numbers against the tree by +regex. Reword the sentence and the regex matches nothing — no output, no error, +and the summary still says the claim is covered. Every pattern-driven gate needs +a "matched zero times" failure, not just a "matched and disagreed" failure. It +fired on its own first run, on a sentence I had just reworded. + +**An assertion that must fail is not a proved property.** Non-vacuity oracles +assert something false so that a refutation proves an assumption admits inputs. +Counting them alongside real properties inflates the headline by exactly the +number of assumptions being audited. Separate the two, and correct the published +figure forward rather than rewriting the old one. + +**Adding to a set is the moment to re-derive everything that counts it.** One +new CI step moved a swept-step total; a module-coverage split had drifted from +"8 direct, 8 indirect" to "16 and 0" across four waves while the prose sat +still. Both had gone stale silently. If prose states a number the tree knows, +derive it by importing the code the tree uses — never by recounting. + +## Wave 632 — turn a finding into a sweep, then check the sweep against the finding + +**When a defect has a shape, sweep for the shape.** Prop. 83's accumulator was +one instance of "a register safe only relative to a bound written elsewhere". +Asking that question of every growing register in the tree took one scan and +turned a single finding into a map: 4 bounded locally, 4 by an input port, 7 by +nothing inside their own module. One incident is an anecdote; the same question +asked fifteen times is a property of the codebase. + +**A gate can demand an argument rather than a proof.** Requiring every +externally-bounded register to carry a `// BOUND: ` note proves +nothing safe — it makes a *missing* argument visible. That is much cheaper than +proof and catches the failure mode that actually occurred, where nobody had +asked the question at all. Writing the fifteen notes was the real work, because +each had to be traced to a limit that genuinely exists. + +**Check a new instrument against the one case whose answer you already know.** +The scan's first draft classified the Prop. 83 accumulator — bounded by nothing, +established by k-induction the wave before — as bounded by a contract. The cause: +it read `<=` as a comparison, when at statement level in Verilog that is the +nonblocking **assignment**. Every LOCAL verdict came from a reset `X <= 0` read +as a bound; the whole table measured assignments. Nothing about the output +looked wrong. Only the known answer exposed it. Build the acid test into the +self-test so it stays exposed. + +**Prefer over-reporting in the direction that asks for an argument.** Dropping +the ambiguous `<=`/`>=` loses genuine `if (c <= limit)` bounds, which then read +as unbounded and demand a note. That is the right way to be wrong: the failure +mode is a human writing one extra sentence, not an instrument inventing a bound +that does not exist. + +**Read the declaration, not the use.** A first draft of a finding said the DMA's +address registers were 32-bit and could wrap a real memory map. They are 64-bit; +the emitter says so. The grep that suggested otherwise showed assignment lines, +which carry no width. The finding was real but belonged to a different module, +and the difference changed what it means. Widths, types and signedness live in +declarations — go there before writing a claim about range. + +**Annotate the generator, not the generated file.** These notes belong in the +Rust emitters; writing them into `build/rtl/` would have survived exactly until +the next regeneration. Then verify by regenerating and confirming the emitted +output changed only where intended. + +**A `git diff` of an untracked path is blank, and blank looks like clean.** +Checking "did my change touch anything but comments" with `git diff build/rtl/` +returned nothing — because that directory is generated and untracked, so the +check examined zero files. The sound version diffed the *tracked emitter* +sources and confirmed every added line emits a comment. Before trusting an empty +diff, confirm the path is actually under version control. + +## Wave 633 — sweep the mirror direction, and put the property where the signal is + +**Every sweep has a mirror.** Counting up has overflow; counting down has +underflow, and it is the sharper risk: an overflowed counter is wrong by one +wrap, while an underflowed countdown *does not stop* — it runs another 2^N steps +past whatever it was metering. After sweeping incrementing registers, the +decrementing ones were three lines of regex away and turned out to be the +registers enforcing the previous sweep's tightest bounds. When you finish a +sweep, ask what its opposite would find. + +**Load-bearing means "something else's correctness rests on it".** Two 12-bit +indices were sized at exactly their limit, and neither was bounded by any +comparison on itself — a separate countdown enforced both. That makes the +countdown the real safety argument, and it was the thing nobody had checked. +Follow a bound to whatever actually enforces it before believing it. + +**Put the property where the signal is, even if that is not the property file.** +An internal register cannot be constrained from a wrapper's ports, and in this +flow a hierarchical reference does not error — it silently declares an undriven +one-bit wire and proves against it. The choices were: prove a weaker observable +consequence to a bounded depth, or state the property *inline in the module* +behind a formal-only guard and get an unbounded induction result. The second was +right. A property file is a convention, not a requirement. + +**"An existing property already covers it" deserves the depth question.** The +underflow's observable consequence — writing past the request — was genuinely +covered by an existing property. But only to that step's bound, and the +terminator that would trigger the underflow sits far beyond it for a large +request. Coverage at depth N is not coverage. + +**When you add an inline property, find out what else compiles it.** These +properties are guarded, and the engine's integration steps pass that same guard +— so two module-level assertions silently joined the engine's proof obligation +set. That is more coverage, but it is also a runtime and verdict change to a +step you did not think you were touching. Check the `$check` cell count before +and after. + +**Separate two reasons that look like one.** The first draft said an underflowed +value was safe because "every consumer reads the pre-decrement value". Half true: +the exit test is in the same always block and does sample pre-decrement, but the +other consumer is a *continuous* assignment that tracks the wrapped value +immediately — it is safe for an entirely different reason, that it is only read +in states the FSM no longer enters. Two mechanisms, one sentence, and the +sentence would have justified a change that broke one of them. + +**Record an environment dependency as a dependency, not as a proof.** The DMA's +countdown underflows by design and stays harmless only while the AXI slave +honours the burst length it was issued. That is a claim about the environment, +so it is written down as a protocol dependency rather than dressed up as a +verified property. Knowing which of your safety arguments rest on someone else's +compliance is worth more than a green tick that hides them. + +### Wave 633 addendum — a timing figure is a claim about a machine state + +**I published a 4× cost that was 1.58×.** Two inline properties were reported as +taking an engine proof from 183 s to 723 s. Both figures were measured while +three other provers were competing for the machine — a condition I neither +controlled nor recorded — and the number went into a proposition, the README, a +commit message and a filed issue before anything checked it. + +What exposed it was re-measuring the *baseline*, not the change: the +no-properties case came back at 153 s, **faster** than the 183 s it was +supposedly a regression against. A "regression" that makes the control faster +than its own historical baseline is not a regression, it is a broken +measurement. When a before/after surprises you, re-run the *before* on the +current machine before believing the delta. + +**Record the machine state or do not publish the number.** Correctness results +are reproducible — a proof either discharges or it does not. Timings are not: +they are claims about contention, thermal state and what else was running. They +need the same provenance discipline as any other measurement, and they rarely +get it because they look like observations rather than claims. + +**`cmd | tail; echo $?` reports the pipe's status, not the command's.** A +"successful" 0.05 s proof run was yosys failing instantly on a wrong working +directory, with `tail` returning 0 over the top of it. Capture the tool's own +exit code — redirect to a file and check `$?` directly, or use `PIPESTATUS`. +Auditing the CI workflow for the same shape found it clean (no prover is piped, +and 26 of 35 steps set `pipefail`), so the bug was purely in ad-hoc commands — +which is exactly where measurements get made. + +**Three cwd-related tool errors in one session, all in background commands.** A +`cd` in an earlier chained command changes where later background jobs run. Use +absolute paths in anything whose result you intend to write down. + +**Correct forward, in public, with the mechanism.** The fix was a new +sub-proposition recording what was measured, what was wrong, and how it was +caught — plus a comment on the issue that carried the bad number — rather than +quietly editing the figure. The two most-quoted corrections in this campaign +were both captions on instruments that worked correctly; this is a third. + +## Wave 634 — code with no callers may be a specification nobody wrote down + +**"Dead or missing plumbing" can be a false dichotomy.** Six primitives sat +UNREACHED for five waves under exactly that question. The third answer was that +they are an **algebra** — min, max, negation, product, balanced addition — and an +algebra can be stated as theorems and proved outright. Before deleting +unreferenced code, ask whether it is a specification of something the rest of the +system assumes. + +**State theorems before coding properties.** Writing T1–T5 as mathematics first +— "and = min and or = max, so the triple is a De Morgan algebra" — produced +properties phrased as the order-theoretic *definitions* rather than as a +restatement of the RTL's case split. A property that restates the implementation +proves only that the implementation equals itself. + +**Separate theorems that are about the mathematics from theorems that are about +your implementation.** Four of the five would survive any faithful +implementation. One — comparison — is correct only because the bit encoding +happens to be monotone in the value it encodes. That distinction is where the +risk lives, and it is invisible unless you ask which of your proofs would break +under a refactor that changes nothing semantic. + +**Test the dependency by breaking it, and expect the experiment to surprise +you.** Permuting the encoding was supposed to refute one theorem. It refuted +two. The second was a primitive with the encoding baked in as literals while +every sibling — including its own sub-instances — used named constants, so any +renumbering would move them and leave it behind silently. The experiment found a +real defect I had not predicted; had I only reasoned about it, I would have +written the note about T4 and shipped. + +**A fix found by an experiment should be verified by re-running that +experiment.** Not by re-reading the code, and not by a fresh proof of the fixed +version alone — by the exact procedure that exposed it, now producing the +predicted result and only the predicted result. + +**Two modules in one file answering the same question differently is a defect +even when unreachable.** One adder mapped the reserved code to 0, its sibling to +−1. Unreachable today because of who feeds whom. That is how a later change +picks the wrong answer. + +**Give timings the provenance you give proofs.** A proof discharges or it does +not, regardless of what else runs. A timing is a claim about contention, cores +and thermal state. Run both arms alternating in one invocation, record the +machine, repeat, and **refuse to print a ratio** when the arms' observed ranges +overlap — if some run of the slower arm beat some run of the faster one, no +ordering is supportable. + +**An implausible measurement is evidence about the instrument.** The new harness +reported that adding two properties made a proof *faster*. That was not a +discovery; it was the harness saying it was not measuring what its labels +claimed — the RTL had been regenerated a third of the way through the run. A +benchmark whose **inputs** move mid-run is exactly as broken as one whose machine +is contended, and neither shows up in the seconds. Fingerprint the files under +test, not just the machine. + +**When search is unavailable, prove instead of citing — and say which you did.** +This wave could not fetch external literature, so no citations were added to a +section explicitly labelled *verified* citations. The theorems were stated and +exhaustively machine-checked instead, and the report says so plainly. An +unverifiable citation is worse than none. + +## Wave 635 — a refuting property is not yet evidence of a defect + +**Check which of the design and the specification is wrong, every time.** A +lemma about a full adder's carry refuted on first run. The adder was fine; the +assertion used `(x+1 - (x+1) % 3) / 3`, and Verilog's `%` takes the sign of its +dividend, so it gave 0 where the carry was −1. Isolating the assertion proved +the design clean in seconds. Earlier waves found real RTL defects exactly this +way, which is precisely why a refutation cannot be read as one without the +check — the same signal means both things. + +**Lemmas buy localisation, not just confidence.** An exhaustive proof of an +assembled tree tells you the tree is right and nothing about where a future +failure would be. Proving the half adder and full adder underneath it means a +later refutation separates "the arithmetic is wrong" from "the wiring is wrong" +without any new work. Compose proofs downward even when the top-level one +already passes. + +**Redundant and wrong are independent.** The discarded assertion was redundant — +conservation plus validity already determined the carry uniquely — *and* it was +incorrect. Only the incorrectness surfaced it. A redundant-but-correct property +would have sat there indefinitely looking like coverage. + +**An experiment with a good hit rate should become a gate.** Permuting the trit +encoding found a real defect on its first run. Once is an anecdote; wired into +CI it is a standing check that no primitive acquires a hidden dependency on a +literal encoding. + +**A gate that only asserts "nothing broke" passes when its perturbation becomes +a no-op.** So declare which things are *supposed* to break and require them to. +The encoding gate asserts that the one encoding-dependent theorem still refutes; +without that, a permutation that stopped permuting — a renamed constant, an +edited macro — would report a clean sweep while testing nothing. + +**A perturbation must be semantics-preserving to be informative.** Permuting the +encoding on only one side breaks every theorem trivially and proves nothing +about any of them. What makes a surviving theorem evidence of independence is +that the change was a genuine relabelling. + +**Turn a new instrument on your own published claims first.** Building a +timing harness because one figure was wrong immediately raised: what else rests +on numbers measured the same way? Two published figures came back 16% and 27% +high, and an inference built on one of them had to be **withdrawn rather than +restated with a smaller coefficient** — because its other endpoint described a +configuration that no longer exists and could not be re-measured at all. An +inference is only as reproducible as its least reproducible endpoint. + +**Withdraw, don't deflate.** The tempting move was to keep the "headroom is +narrowing" conclusion with corrected numbers. But the corrected comparison rests +on one figure nobody can reproduce, so the honest outcome is no conclusion plus +a defensible baseline — which is worth more to the next wave than a weakened +claim it would have to re-litigate. + +**My own scripted doc edit broke two claim patterns, and last wave's guard +caught it.** A README rewrite moved `**` emphasis markers so two claims-check +regexes matched nothing. The UNMET check added one wave earlier fired; without +it both claims would have silently left the gate while the summary still counted +them as covered. Guards you build for the codebase apply to your own edits too. + +## Wave 636b — bars you choose yourself test what you thought of + +**The single highest-value thing this campaign has done is have someone else +attack a result.** A proposition was published, committed and pushed after +clearing three bars I designed and named — it proves, its oracle refutes, it +depends on its assumption. An adversarial review found the theorem sound and +**four of the claims around it false or defeatable**, and every one of them lay +outside the checks I had built. Self-designed bars test the failure modes you +already imagined. Budget for an independent attempt to break the result, and +instruct it to attack rather than confirm. + +**A vacuity oracle can be defeated while staying green.** "Assert something +false and require a refutation" only witnesses that the assumptions admit +*something*, for *some* input, in whatever instance the oracle looks at. It says +nothing about per-input emptiness in the structure the proof actually uses. A +one-clause strengthening collapsed a theorem's domain to 6% while the oracle +kept refuting and the proof kept proving. **The guard that works has no free +variables** — assert the real component satisfies what the abstraction assumes, +so there is nothing left for the solver to choose. + +**Share the constraint, don't copy it.** My first replacement guard hand-copied +the constraint it was supposed to police, so an injection into the original left +the copy untouched and it kept passing. Write the shared claim once as a macro, +assume it in one place and assert it in the other. A guard that can drift from +its subject is a guard for a past version of it. + +**An incomplete perturbation is not semantics-preserving, and cannot distinguish +"real dependency" from "broken experiment".** A permutation gate substituted over +the RTL and one macro, but not over constants declared inside property files — +so a new theorem refuted, correctly-looking, for a reason that was the +experiment's fault. Before believing a perturbation's verdict, check that it +reached every declaration of the thing being perturbed. + +**Check whether your gate reads the design or a claim about the design.** A +scan looking for "a comparison bounding this register" matched assertions inside +`ifdef T27_FORMAL. An assertion is a claim *about* the design, not a mechanism +that constrains it, and reading one as a bound inverts the gate's purpose — here +on three of the four positive verdicts in the entire codebase. Preprocess out +formal-only regions before analysing design logic. The same mistake in a +different counter, found the same day: a counter matching `label: assert` +without stripping comments invented a property out of a comment *quoting* an +assertion. + +**A file gaining a module can silently redirect another module's checks.** A CI +step injected its probe before the *last* `endmodule` in a file. A wave that +added two modules to that file therefore sent all four of an earlier suite's +probes into the wrong module, where elaboration pruned them — and the step began +failing with a message naming the wrong cause. Target things by name, never by +position, and make "name not found" an error rather than a fallback. + +**When a gate fails, check that its message names the real cause.** The step +above said "UNREACHABLE — an assumption removed it". The truth was "the probe +was never compiled in". A misleading red is worse than a red, because it sends +the next person to the wrong file. + +**Publish, then review — but budget the review as part of the work.** Everything +above was found *after* the proposition shipped. That is survivable when the +review actually happens and the corrections are recorded forward; it is not a +substitute for the review happening at all. + +## Wave 637 — a flag's description can be wrong for a hundred waves + +**Check what your tool flags actually mean, not what the comment says they +mean.** `-set-init-zero` was described throughout this campaign — since the +proposition that chose it — as "starting from a reachable state". It starts from +the ZERO state. Those coincide only if every register resets to zero, and nine +here did not. The description was written once, was approximately true, and was +quoted for a hundred waves without anyone asking. + +**Distinguish unsound from fragile, precisely, and say which you found.** Extra +unreachable states in the initial set can only produce spurious *refutations*, +never spurious proofs. So nothing verified was weakened — the finding is that a +pure relabelling would break two proofs and the failure would read as a design +defect. Getting this distinction right is the difference between "your proofs are +wrong" (alarming and false) and "your setup is fragile in a way that will waste a +future day" (true and actionable). + +**When you find a local workaround, ask how many other instances exist.** One +property in one suite carried a guard whose comment described this exact problem +and its exact cause. It was fixed there, correctly, and never generalised. A +comment explaining *why* a workaround is needed is a description of a class — +go count the class. + +**A repair that does not work is worth recording, with the reason.** The obvious +fix was to copy the existing guard to the two affected properties. It fails, +because the bad state persists indefinitely rather than only at time zero, so a +one-cycle guard changes nothing. Writing that down stops the next person +spending the same hour, and the reasoning is more useful than the fix would have +been. + +**Prefer a gate that lists over a gate that forbids.** Non-zero resets are not a +defect — an AXI slave that comes up not-ready is worse than one that does. The +gate requires each to carry a reason, which converts an invisible modelling gap +into a written one without pretending the design should change. + +**Measure the blast radius before designing the fix.** Running each property +individually under the perturbation showed exactly one property per suite was +affected, out of ten. That turned "two suites are fragile" into two named +properties, and it made clear the fix belonged in the verification setup rather +than in the design. + +**Confirm a perturbation is semantics-preserving by counting, not by +believing.** Before drawing any conclusion from an FSM relabelling, count the +references to the state signal by name versus by literal: 16/0 and 9/0. If any +had been by literal, the relabelling would have changed behaviour and a +refutation would have been correct rather than informative. + +## Wave 637b — a define is read as a category + +**A conditional-compilation flag is a taxonomy claim, and its members must share +a precondition.** A guard was created for properties that hold unconditionally. +A later wave added one that is true only under an environment model, without +noticing the categories differed. Nothing complained: both were "drain +properties". The flag then silently meant "these, one of which is false unless +you also supply an AXI slave model", and compiling it anywhere lacking that model +produced a refutation that reads as a design defect. Put the precondition in the +name. + +**A property that is true only under an environment model is a different KIND of +property.** Group by what a property needs, not by what it is about. Two +properties about the same register, one unconditional and one needing a slave +model, belong behind different guards even though every naming instinct says +otherwise. + +**A re-measurement that fails to produce a number can be more valuable than one +that succeeds.** The goal was to reproduce a published ratio. What came back was +an 11-second refutation, which located a latent trap that had been live for three +waves and would have surfaced as a mysterious engine failure for whoever next +enabled that define. + +**"Unreproducible" is a distinct verdict from "wrong", and worth stating.** The +ratio was never shown incorrect. Its configuration ceased to exist, so it cannot +be checked at all — and when the decision it justified rests independently on a +stronger argument (here: the properties prove *unbounded* at module level), say +that too. Retire the evidence without retiring the conclusion, and be explicit +that you are doing so. + +**Honour your own instrument's refusals.** The timing harness has now declined +three times in three waves — a failing command, inputs that moved mid-run, and a +contended machine — and each time the number it refused to print would have been +wrong. The temptation to override "just this once, the machine is only a bit +busy" is exactly what the guard exists to resist. Record the measurement as not +made. + +**Don't run a benchmark and a fan-out at the same time.** The contention that +blocked the third measurement was my own concurrent workflow. Sequence work that +competes for the machine, or the harness will correctly refuse and the wall-clock +spent is wasted. + +## Wave 637c — a self-test written by a gate's author tests what its author imagined + +**Four gates, four instances of matching a form rather than a fact.** A +warning's phrasing, an identifier's name, a comment's position, a width standing +in for a range. Every one of them passed its own self-test. Ask of any gate: what +is the *fact* here, and am I matching the fact or a shape that usually +accompanies it? + +**Check whether your tool's output wording varies with the case.** A gate +existed for exactly one defect — an undriven wire a property proved against — +and matched `Wire is used but has no driver`. Yosys prints that form for +one-bit wires and `Wire [3] is used…` for wider ones, so the gate caught +its own reason for existing only at width 1. Enumerate the tool's output forms +empirically rather than pattern-matching the one example you saw. + +**A self-test's cases are the author's imagination, so vary the dimension the +author held fixed.** All four injections in that gate's self-test were +identifiers yosys declares as a single bit. Nothing about them was wrong; they +simply all sat at the same point on the axis that mattered. When writing +injections, ask what parameter every case shares — width, position, sign, +nesting — and add one that differs. + +**A dedup key that is also a coverage counter reports the wrong number twice.** +Deduplicating reductions by target name silently dropped 40% of the subject, and +because the same set was returned as "reductions checked", the summary read as +full coverage while examining less than half. Deduplicate the *output*, never +the *work*, and count the work. + +**A guarded fallback is a decision, and it can silently be the rule you already +banned.** For an unannotated operand a gate fell back to worst-case-by-width — +the exact reasoning the same file's docstring calls unsound for this domain — and +so produced a false finding against correct RTL. When a check cannot be made, +prefer "uncheckable, and here is the count" over a weaker rule applied quietly. + +**Being accidentally right is not a form of being right.** A measurement was +rejected as implausible because its inputs moved mid-run; the clean re-run landed +within a few percent of the rejected value. The rejection was still correct — a +number produced by a broken procedure carries no evidence regardless of where it +lands. Do not retroactively credit a discarded result because it turned out +close. + +**Notice when a decision's stated justification evaporates but the decision +survives.** A guard split was justified by a measured cost; the cost is now +negative. The split remains right for a second reason given at the time. That is +worth writing down, because the next such decision might have rested on the +justification that vanished. + +## Wave 638 — a summary of an adversarial review is not the review + +**Read the full result before acting on it.** A workflow notification arrived +truncated at ~90 KB with the untruncated text on disk and the path printed in the +diagnostics line. I read four findings off the summary, fixed them, wrote a +proposition, filed an issue and pushed. The full report held **six** for that one +gate. The two I missed were verified, and both survived my fixes. Acting on the +part that was easy to see is exactly the failure an adversarial review exists to +prevent — so the review's own output deserves the same suspicion as the code. + +**Re-test the findings you did not fix before assuming a fix covered them.** It +would have been natural to suppose the reduction-loop rewrite happened to catch +the other two. It did not. Both were still missed, and one command established +that in under a minute. + +**"Declining to check" must be counted, never silent.** Two whole expression +forms — a constant addend, and any subtraction — fell out of a gate's matcher and +`continue`d, while the coverage counter still reported full. Declining is a +legitimate choice; declining invisibly is the campaign's recurring defect. Count +every decline and print it, so the number of things you did not check is as +visible as the number you did. + +**A guard that trips at exactly zero is a guard against nothing happening, not +against something going missing.** Three separate defects hid behind `if count == +0`. Losing one of three annotations, or dropping a declaration out of the +parser's view, left a summary indistinguishable from a healthy one. Set a floor +at the tree's actual numbers and require a deliberate raise. + +**When a claim you published turns out incomplete, say "true and incomplete" +rather than restating it.** Prop. 98 said four defects were found and fixed. That +was accurate at the time and wrong as a summary of the review. Recording the +sequence — four claimed, six found, six fixed — is more useful to the next reader +than silently correcting the number. + +## Wave 639 — report what you did not check, as prominently as what you did + +**Sweep a defect's mechanism, not just its instance.** One gate declined two +expression forms in silence. Rather than fix that gate and move on, every bare +`continue` in all ten gates was asked a single question: does this mean "not my +subject", or "my subject, which I could not check"? Two more instances fell out +immediately, in gates nobody suspected. + +**Record the negative result.** Eight of the ten were clean, and writing that +down is worth as much as the two fixes — it stops the same sweep being repeated +next wave, and it says which gates are *known* total rather than merely +untested. + +**"0 problems" over an unstated number of declines reads exactly like "0 +problems" over none.** That is the whole defect class, in one sentence. Any gate +whose summary reports only findings is one silent `continue` away from reporting +nothing at all. Print the skip counts beside the finding counts. + +**A gate can commit the exact error its own comments warn against, one category +over.** One sweep's code carried a careful note explaining why it reports +*applied* exemptions rather than the size of the exemption list — "reporting the +list size says '1 exempt' on a run where nothing was exempted, a small lie of +exactly the kind this file exists to find" — while a second exclusion class, +six steps wide, went entirely uncounted a few lines above. Having articulated a +principle is not the same as having applied it everywhere it holds. + +**A deliberate import creates an invisible coupling.** One gate imports another's +enumeration function so the two cannot drift — a good decision, from an earlier +wave. It also means changing that function's signature breaks a gate in a +different file, and neither file mentions the other at the call site. When you +change a shared helper, grep for its importers before assuming the blast radius +is local. diff --git a/.claude/skills/trinet/SKILL.md b/.claude/skills/trinet/SKILL.md index 2bb1b86ce5..9f33ef6d89 100644 --- a/.claude/skills/trinet/SKILL.md +++ b/.claude/skills/trinet/SKILL.md @@ -85,14 +85,29 @@ is comfortable, 60 → 1186 kbaud is in budget, 30 → 2372 kbaud is at the edge ## Running a fleet — what three boards teach that one cannot -- **CFGMCLK differs per chip.** It is an internal RC oscillator: 71.176 MHz on - one board, 72.065 on another, a 1.25% spread. One host baud cannot be exactly - right for all of them, so open every port at the **midpoint** of the members' - measured rates. At an aggressive divisor the spread eats the margin that - quantisation has already narrowed. +- **CFGMCLK differs per chip.** It is an internal RC oscillator. Measured across + this fleet on 2026-08-03: 70.46, 67.13 and 68.69 MHz (±0.18), a 4.97% spread. + Each board tolerates about ±4.5%, so the windows still overlap — **one rate + serves the fleet: 1144744 baud, 6400/6400 on each of the three.** Do not + assume that survives a re-flash or a new board; measure it. +- **Measure the window, never take the first rate that answers.** A board a few + percent off its rate still replies — it just loses a few percent of jobs, and + that reads as a bad board, a bad cable or a bad hub. node2 was written up as + the marginal board of the fleet at 97.6%; it delivers 6400/6400 once the rate + is measured, on the same cable and the same hub port. The old check asked six + probes per candidate and took the first that passed all six. A rate losing + 2.4% of jobs passes six probes 86% of the time. + ```bash + python3 conformance/trinet_baud_sweep.py --port

--centre --span 0.08 + ``` + It prints a clean window, the rate to use, and — separately — the degraded + shoulder either side. Operate at the centre. Never inside a shoulder. - **A fleet runs at the rate every member sustains**, not the fastest any member reaches. At BAUD_DIV=30 one board was clean at 600/600 while another returned - 18% of its responses damaged — same design, same host, different cable. + 18% of its responses damaged — same design, same host, different cable. Re-read + that last clause with the above in mind: "different cable" was the conclusion + reached without a sweep, and it is exactly the conclusion the sweep overturned + for node2. - **Every AL321 in this set reports the same USB serial.** openocd cannot tell them apart and silently picks the first, so use `ax7203_al321_multi.cfg` and pass `adapter usb location`. Sweep to find the @@ -142,6 +157,37 @@ BBRAM with an encrypted bitstream, or an external secure element. Until then node identity is **asserted, not proven**, and must be described that way wherever it is published. +### The key is loaded over the wire, not baked in (changed 2026-08-03) + +`RECEIPT_KEY` used to be a synthesis parameter. It was committed to a public +repository, the fix was applied to the source, and **the fix never reached the +silicon** — the fleet ran for a day signing with keys any reader of the git log +could compute, and every test stayed green because a compromised key and a good +key are indistinguishable to anything that only asks "does the tag match". + +The reason it never reached the silicon is the part worth keeping. Re-keying a +baked-in key needs a place-and-route run **this workstation cannot perform** — +an XC7A200T chipdb OOMs at Docker's 4 GB default, and raising it to 6 GB on an +8 GB host stops Docker starting at all — plus 13 minutes of flashing, per board. +A key that costs an hour to rotate is a key nobody rotates. + +So the node now takes its key from `op 0x02`: 16 bytes in the W and X operand +fields, so the request stays 24 bytes and the frame parser is untouched. + +- **Write-once per configuration.** A second `setkey` returns `0x03 key locked` + and changes nothing. Without that, anyone reaching the wire could replace the + operator's key and every later receipt would verify under theirs. +- **The ack is signed with the key just installed**, so acceptance is + distinguishable from an echo. `Node.setKey` checks the tag, not the status. +- **An unkeyed board still computes.** It answers `0x04 no key` with a real + dot product and a meaningless tag. Anything measuring arithmetic must use + `protocol.statusMeansComputed()` — testing `status == status_ok` makes a + correctly working unkeyed board look broken at every candidate baud rate. +- A non-null `RECEIPT_KEY` still bakes a key in and locks it at reset, for + anyone with a build machine who prefers the key never touch a wire. + +Cost: 1292 → 1484 LC, +15%. Still 0 DSP48. + ## Files ``` @@ -156,23 +202,87 @@ specs/trinet/*.t27 the record ## Commands ```bash -zig test src/trinet/agent.zig -lc # 42 tests, whole stack +zig test src/trinet/agent.zig -lc # whole stack zig build-exe src/trinet/main.zig -lc # CLI ./main selftest # adversaries vs verifier -./main probe /dev/cu.usbserial-1110 # verify a flashed board +./main probe # arithmetic AND authenticity, reported apart +./main census 0 100 64 # 100 runs; baud 0 = negotiate ./main demo # mesh + agent + books -python3 conformance/trinet_mac32_conformance_ax7203.py --self-test -python3 conformance/trinet_mac32_conformance_ax7203.py --port /dev/cu.usbserial-1110 --n 512 +python3 conformance/trinet_discover.py # who is on the bus, and at what rate +python3 conformance/trinet_baud_sweep.py --port

--divisor 60 # a board's real rate ``` -Flash (13 minutes — pipeline other work against it): +**Never trust a port name across sessions.** They move when hubs change: +`-1110` was node0 one hour and node1 the next. Identity comes from the board's +id field, never from argument order or device name. + +Bring a board up, in order: ```bash sudo -n /opt/homebrew/bin/openocd -f fpga/openxc7-synth/ax7203_al321.cfg \ -c "init" -c "pld load 0 " -c "runtest 2000" -c "shutdown" +./main keygen > trinet-keys.txt # gitignored, mode 600, never commit +./main setkey # one 24-byte frame per board +./main fleet # now it can settle +``` + +With several programmers attached, every AL321 reports the same USB serial, so +pass `-c "adapter usb location "` with `ax7203_al321_multi.cfg` and sweep +the locations fresh — they move with the hubs too. + +Get the locations from `ioreg`, do not guess them: + +```bash +ioreg -p IOUSB -w0 | grep -E "Hub|Digilent|CP2102N" ``` +`Digilent USB Device@01120000` → openocd location `1-1.2` (first byte is the +bus, each remaining non-zero nibble is a port down the chain). A CP2102N next to +a Digilent under the **same hub** is the same board — that is how to pair a +serial port with a programmer without flashing anything to find out. + +**`mpsse_flush()` stall usually means somebody else already holds the adapter — +and on 2026-08-03 that somebody was me.** + +Two of three cables stalled on every attempt for an hour. The cause was not the +cable, the board, or (as first recorded here, wrongly) which USB bus they sat +on: two `openocd` processes from earlier probes were still alive as root, +holding those two FTDI devices. Check for that before theorising: + +```bash +ps -eo pid,stat,etime,comm | grep openocd +``` + +Anything older than the probe you just ran is a leak. + +**Why they leaked — do not repeat this.** The probes were bounded by +backgrounding `sudo`, capturing `$!`, and sending `kill -9` to it after a sleep. +That does not work: `$!` is the **`sudo` wrapper**, `openocd` runs as root +beneath it, and a user `kill -9` cannot touch a root child. The wrapper dies, +the timeout looks like it worked, and the adapter stays held. (Do not copy that +form from anywhere — it is written out here only to be recognised, never run.) +Put the timeout **inside** the privileged process instead: + +```bash +sudo -n timeout -s KILL 25 /opt/homebrew/bin/openocd \ + -f fpga/openxc7-synth/ax7203_al321_multi.cfg \ + -c "adapter usb location 0-1.2" -c "init" -c "shutdown" +``` + +**Clearing a leak needs the operator, not `sudo -n`.** The NOPASSWD rule in +`/etc/sudoers.d/openocd` covers exactly one binary — `/opt/homebrew/bin/openocd` +— so `sudo -n pkill -9 openocd` fails with "a password is required", and being +`-n` it fails *silently* instead of prompting. A leaked openocd survived three +such attempts while every one of them was reported as having cleared it. Check +with `ps` afterwards rather than trusting the exit, and when it really needs +clearing, ask the operator to run `sudo pkill -9 openocd`. + +Order of suspicion for a stall: leaked openocd first, then a replug of the +cable, then the board's power. Bus position was a coincidence — after the +cables were replugged all three answered, including both that had "always" +stalled. + ## Board and toolchain truths - **Verify `sudo -n true` at the start of every flash session.** The diff --git a/.github/workflows/ax7203-trinet-fleet.yml b/.github/workflows/ax7203-trinet-fleet.yml index 6abccaaac1..86b6dcf658 100644 --- a/.github/workflows/ax7203-trinet-fleet.yml +++ b/.github/workflows/ax7203-trinet-fleet.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - 'fpga/vivado/trinet_node_v2_ax7203.v' + - 'fpga/portable/trinet_node_core.v' - 'fpga/openxc7-synth/trinet_siphash24.v' - '.github/workflows/ax7203-trinet-fleet.yml' @@ -15,22 +16,45 @@ on: # sharing an id cannot both be paid — and with device DNA unavailable on this # flow (it reads zero), the id has to come from synthesis. # -# Keys are NOT here, and must never be. They were, as three literal constants -# 0x00..0x0f / 0x10..0x1f / 0x20..0x2f in a public repository — guessable even -# had it been private. A tag anyone can compute is a checksum with extra steps, -# so committing the key destroyed the only property keying the tag bought. +# KEYS ARE NOT BUILT IN, AND THAT IS NOW THE DESIGN RATHER THAN A LIMITATION. # -# CI therefore builds bitstreams WITHOUT usable keys. They are for routing, -# resource and simulation checks. A bitstream to actually deploy must be built -# with a key the operator generated and did not commit: +# They used to be: three literal constants 0x00..0x0f / 0x10..0x1f / 0x20..0x2f, +# committed to a public repository. Nulling them in source was recorded as the +# fix, and it never reached the silicon — the fleet ran for a day signing with +# keys any reader of the git log could compute. # -# yosys -p "... chparam -set RECEIPT_KEY 128'h ..." +# The reason it never reached the silicon is the interesting part. Re-keying a +# baked-in key means a place-and-route run the operator's machine cannot perform +# (8 GB is not enough for an XC7A200T chipdb) plus a 13-minute flash, per board. +# A key that costs an hour to rotate is a key nobody rotates. # -# A shared key would let any operator in the fleet forge receipts for every -# other board, since each holds a bitstream containing it; per-node keys confine -# a leak to one node. +# So the node now takes its key over the wire: op 0x02, 16 bytes in the operand +# fields, accepted exactly once per configuration and refused thereafter. These +# bitstreams are therefore complete and deployable, not "unkeyed reference +# builds" — the operator runs `trinet setkey` after flashing, and rotation costs +# a power cycle. # -# BAUD_DIV is 60 (~1186 kbaud). 30 was tried first and one of the three boards +# Per-node keys, not one shared key: a shared key would let any operator in the +# fleet forge receipts for every other board. +# +# A non-null RECEIPT_KEY parameter still bakes a key in and locks it at reset, +# for anyone who does have a build machine and prefers the key never touch a +# wire. CI must never pass one. +# +# USE_DNA is 0. DNA_PORT places and routes on this flow and returns zero for all +# 57 bits — measured 2026-08-01 — so the node id has to come from synthesis +# regardless. Carrying the primitive and its read state machine into every +# bitstream is dead weight, and removing it makes the node id in simulation +# equal the one the hardware reports, which the DNA path made impossible. +# +# BAUD_DIV is 60, and it is NOT one line rate for the fleet. CFGMCLK is an +# untrimmed RC oscillator, so its frequency belongs to the die: these three run +# at 71.18, 70.46 and 67.47 MHz — 5.5% apart, where a UART tolerates about 3%. +# The divisor is shared; the resulting rates are ~1186, ~1174 and ~1124 kbaud, +# and the host negotiates per board rather than assuming one constant. Assuming +# one is what made the third board look like a wiring fault for a day. +# +# 30 was tried first and one of the three boards # returned 18% of its responses damaged at that rate while another was clean on # the same design and host, so the fleet runs at a rate every member sustains # rather than the fastest any member can reach. @@ -66,8 +90,8 @@ jobs: run: | mkdir -p build/${{ matrix.name }} docker run --rm -v "$PWD:/work" -w /work regymm/openxc7 yosys -p " - read_verilog fpga/openxc7-synth/trinet_siphash24.v fpga/vivado/trinet_node_v2_ax7203.v; - chparam -set FALLBACK_NODE_ID ${{ matrix.node_id }} -set BAUD_DIV_P 60 trinet_node_v2_ax7203; + read_verilog fpga/openxc7-synth/trinet_siphash24.v fpga/portable/trinet_node_core.v fpga/vivado/trinet_node_v2_ax7203.v; + chparam -set USE_DNA 0 -set FALLBACK_NODE_ID ${{ matrix.node_id }} -set BAUD_DIV_P 60 trinet_node_v2_ax7203; synth_xilinx -flatten -abc9 -nocarry -nodsp -arch xc7 -top trinet_node_v2_ax7203; setundef -zero -params; write_json build/${{ matrix.name }}/node.json" 2>&1 | tee /tmp/yosys.log grep -E "Estimated|ERROR" /tmp/yosys.log @@ -114,9 +138,14 @@ jobs: source /prjxray/env/bin/activate && fasm2frames --db-root /nextpnr-xilinx/xilinx/external/prjxray-db/artix7 --part xc7a200tfbg484-2 /work/build/${{ matrix.name }}/node.fasm /work/build/${{ matrix.name }}/node.frames && /prjxray/build/tools/xc7frames2bit --part_file /nextpnr-xilinx/xilinx/external/prjxray-db/artix7/xc7a200tfbg484-2/part.yaml --part_name xc7a200tfbg484-2 --frm_file /work/build/${{ matrix.name }}/node.frames --output_file /work/build/${{ matrix.name }}/trinet_${{ matrix.name }}.bit" sha256sum build/${{ matrix.name }}/trinet_${{ matrix.name }}.bit | tee build/${{ matrix.name }}/trinet_${{ matrix.name }}.bit.sha256 + # Not "-UNKEYED" any more. That label was right when a bitstream without a + # baked key was a crippled build good only for routing checks. The node + # now takes its key over the wire, so these are complete and deployable — + # flash, then `trinet setkey`. Calling them unkeyed would invite somebody + # to go looking for a "real" build that no longer exists. - uses: actions/upload-artifact@v4 with: - name: trinet-fleet-${{ matrix.name }}-UNKEYED + name: trinet-fleet-${{ matrix.name }} path: | build/${{ matrix.name }}/trinet_${{ matrix.name }}.bit build/${{ matrix.name }}/trinet_${{ matrix.name }}.bit.sha256 diff --git a/.github/workflows/trinet-portability.yml b/.github/workflows/trinet-portability.yml index e998a926d5..3a9be357c1 100644 --- a/.github/workflows/trinet-portability.yml +++ b/.github/workflows/trinet-portability.yml @@ -100,9 +100,28 @@ jobs: timeout-minutes: 25 steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: {python-version: '3.12'} - - run: sudo apt-get update && sudo apt-get install -y yosys + + # This job installed yosys from apt, which on ubuntu-latest is 0.33. Under + # 0.33 every synth_ pass returns without stats this check can read, + # so it reported "only 0 families synthesised" and failed — on every run of + # this workflow since the day it was added, on every branch, including the + # commit whose message announced the ten-family result. + # + # The claim was true the whole time: 10 families agree under yosys 0.62 and + # 11 under 0.65, same flip-flop count, no multipliers. But a gate that has + # never once gone green is not protecting the claim it is named after, and + # nothing would have caught a real regression. Pin the toolchain instead of + # taking whatever the runner image ships, the way ax7203-format-cost.yml + # already does. + - name: Docker pull regymm/openxc7 (retry on transient 5xx) + run: | + for i in 1 2 3 4 5 6; do + docker pull regymm/openxc7:latest && break + echo "::warning::docker pull attempt $i failed, sleeping $((i*20))s"; sleep $((i*20)) + done + docker image inspect regymm/openxc7:latest >/dev/null 2>&1 || { echo "::error::docker pull failed after 6 retries"; exit 1; } - name: Synthesise the node for every family this yosys offers - run: python3 conformance/portability_check.py + run: | + docker run --rm -v "$PWD:/work" -w /work regymm/openxc7 \ + python3 conformance/portability_check.py diff --git a/conformance/gft16_ref.py b/conformance/gft16_ref.py new file mode 100644 index 0000000000..091aee2131 --- /dev/null +++ b/conformance/gft16_ref.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +gft16_ref.py — bit-exact golden oracle for GF-T16 (ternary-native GoldenFloat). + +Off-path conformance oracle (like gf_ref.py / tekum_ref.py). GF-T16: + + raw = [ sign(1) | exp_offset(7) | mant(9) ] (17-bit canonical raw) + exp_offset in [0,80]; balanced exponent e = exp_offset - 40 for normals. + exp_offset == 0 -> zero (mant==0) / subnormal + exp_offset == 80 -> Inf (mant==0) / NaN (mant!=0) + else (1..79) -> value = (-1)^sign * (1 + mant/2^9) * 2^(exp_offset-40) + +The 7-bit exp_offset is the DECODED exponent field; on ternary hardware it is a +4-trit balanced-ternary number (offset = Σ tᵢ·3ⁱ, tᵢ∈{0,1,2}) added natively. +Arithmetic: decode -> exact in Fractions -> re-encode with round-to-nearest-even. +""" + +from fractions import Fraction +import math + +MANT_BITS = 9 +MANT = 1 << MANT_BITS # 512 +EXP_OFFSET = 40 # balanced zero point (3^4-1)/2 = 40 +OFFSET_MAX = 80 # reserved special row +SIGN_SHIFT = 16 +EXP_SHIFT = MANT_BITS + +INF = OFFSET_MAX << EXP_SHIFT # +Inf raw (sign 0) +NAN = (OFFSET_MAX << EXP_SHIFT) | 1 # NaN raw + + +def _floor_log2(fr: Fraction) -> int: + # floor(log2(fr)) for a positive Fraction, exact. + n, d = fr.numerator, fr.denominator + e = n.bit_length() - d.bit_length() + # correct off-by-one + if Fraction(n, d) < Fraction(1) * (1 << e) if e >= 0 else Fraction(n, d) < Fraction(1, 1 << -e): + e -= 1 + while (Fraction(1) * (1 << e) if e >= 0 else Fraction(1, 1 << -e)) > fr: + e -= 1 + while (Fraction(1) * (1 << (e + 1)) if e + 1 >= 0 else Fraction(1, 1 << -(e + 1))) <= fr: + e += 1 + return e + + +def _pow2(e: int) -> Fraction: + return Fraction(1) * (1 << e) if e >= 0 else Fraction(1, 1 << -e) + + +def encode(value) -> int: + if value == 0: + return 0 + sign = 1 if value < 0 else 0 + av = abs(Fraction(value)) + e = _floor_log2(av) + offset = e + EXP_OFFSET + if offset >= OFFSET_MAX: + return (sign << SIGN_SHIFT) | INF + if offset < 1: + # underflow to smallest normal (keep the oracle simple, no subnormals) + offset = 1 + e = offset - EXP_OFFSET + frac = av / _pow2(e) - 1 # in [0,1) + # round-to-nearest-even to MANT bits + scaled = frac * MANT + fl = int(scaled) + rem = scaled - fl + if rem > Fraction(1, 2) or (rem == Fraction(1, 2) and (fl & 1)): + fl += 1 + if fl == MANT: # mantissa carry + fl = 0 + offset += 1 + if offset >= OFFSET_MAX: + return (sign << SIGN_SHIFT) | INF + return (sign << SIGN_SHIFT) | (offset << EXP_SHIFT) | fl + + +def is_special(raw: int) -> bool: + return ((raw >> EXP_SHIFT) & 0x7F) == OFFSET_MAX + + +def decode(raw: int): + sign = (raw >> SIGN_SHIFT) & 1 + offset = (raw >> EXP_SHIFT) & 0x7F + mant = raw & (MANT - 1) + if offset == OFFSET_MAX: + return math.nan if mant else (-math.inf if sign else math.inf) + if offset == 0: + return Fraction(0) + val = (Fraction(1) + Fraction(mant, MANT)) * _pow2(offset - EXP_OFFSET) + return -val if sign else val + + +def gft16_add(a_raw: int, b_raw: int) -> int: + if is_special(a_raw) or is_special(b_raw): + return NAN + return encode(decode(a_raw) + decode(b_raw)) + + +def gft16_mul(a_raw: int, b_raw: int) -> int: + if is_special(a_raw) or is_special(b_raw): + return NAN + return encode(decode(a_raw) * decode(b_raw)) + + +if __name__ == "__main__": + # self-test: round-trip, commutativity, known values, monotone exponent + import random + rnd = random.Random(1) + bad = 0 + for _ in range(20000): + x = (1 if rnd.random() < .5 else -1) * 2.0 ** rnd.uniform(-38, 38) * (1 + rnd.uniform(0, .99)) + y = (1 if rnd.random() < .5 else -1) * 2.0 ** rnd.uniform(-38, 38) * (1 + rnd.uniform(0, .99)) + if gft16_add(encode(x), encode(y)) != gft16_add(encode(y), encode(x)): + bad += 1 + if gft16_mul(encode(x), encode(y)) != gft16_mul(encode(y), encode(x)): + bad += 1 + # exactness on representable values + assert abs(float(decode(encode(3.0))) - 3.0) < 1e-2 + assert is_special(INF) and is_special(NAN) + assert decode(encode(0)) == 0 + print(f"gft16_ref self-test: add/mul commutative over 20000 pairs, {bad} violations") + print(f" 1.5*2.0 = {float(decode(gft16_mul(encode(1.5), encode(2.0)))):.4f} (expect ~3.0)") + # 2^35 is inside GF-T16's +-40-exponent (~24-decade) range but OUTSIDE GF16's + # 6-bit-exponent (~18-decade) range, where GF16 saturates to Inf. + v = 2.0 ** 35 + print(f" 2^35 round-trip = {float(decode(encode(v))):.4e} (GF-T16 holds it; GF16 clips to Inf)") diff --git a/conformance/gft_ref.py b/conformance/gft_ref.py new file mode 100644 index 0000000000..f619005072 --- /dev/null +++ b/conformance/gft_ref.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +gft_ref.py — parameterized bit-exact oracle for the WHOLE GF-T ladder. + +One oracle for every rung (GF-T4 .. GF-T1024): a GoldenFloat whose exponent is a +balanced-ternary number of `exp_trits` trits (offset in [0, 3^Et - 1], balanced +exponent e = offset - (3^Et-1)/2) and a `mant_bits` binary mantissa. No regime +decode; add/mul are decode -> exact Fraction -> re-encode (round-to-nearest-even). + +Off-path conformance oracle (like gf_ref.py / tekum_ref.py). Supersedes the +single-width gft16_ref.py (GF-T16 == GFTFormat(4, 9)). +""" + +from fractions import Fraction +from dataclasses import dataclass +import math + + +def _floor_log2(fr: Fraction) -> int: + # pure-integer floor(log2) for a positive Fraction (no float -> no overflow + # even for the >1000-decade wide rungs). + e = fr.numerator.bit_length() - fr.denominator.bit_length() + if (Fraction(1) * (1 << e) if e >= 0 else Fraction(1, 1 << -e)) > fr: + e -= 1 + while (Fraction(1) * (1 << (e + 1)) if e + 1 >= 0 else Fraction(1, 1 << -(e + 1))) <= fr: + e += 1 + return e + + +def _pow2(e: int) -> Fraction: + return Fraction(1) * (1 << e) if e >= 0 else Fraction(1, 1 << -e) + + +@dataclass(frozen=True) +class GFTFormat: + exp_trits: int + mant_bits: int + + @property + def offset_max(self): return 3 ** self.exp_trits - 1 # reserved special row + @property + def exp_offset(self): return (3 ** self.exp_trits - 1) // 2 # balanced zero point + @property + def mant(self): return 1 << self.mant_bits + @property + def sign_shift(self): return 24 # room for wide exp/mant + @property + def exp_shift(self): return self.mant_bits + @property + def inf(self): return self.offset_max << self.exp_shift + def range_decades(self): return 2 * self.exp_offset * math.log10(2) + + +def encode(fmt: GFTFormat, value) -> int: + if value == 0: + return 0 + sign = 1 if value < 0 else 0 + av = abs(Fraction(value)) + e = _floor_log2(av) + offset = e + fmt.exp_offset + if offset >= fmt.offset_max: + return (sign << fmt.sign_shift) | fmt.inf + if offset < 1: + offset = 1 + e = offset - fmt.exp_offset + frac = av / _pow2(e) - 1 + scaled = frac * fmt.mant + fl = int(scaled) + rem = scaled - fl + if rem > Fraction(1, 2) or (rem == Fraction(1, 2) and (fl & 1)): + fl += 1 + if fl == fmt.mant: + fl = 0 + offset += 1 + if offset >= fmt.offset_max: + return (sign << fmt.sign_shift) | fmt.inf + return (sign << fmt.sign_shift) | (offset << fmt.exp_shift) | fl + + +def is_special(fmt: GFTFormat, raw: int) -> bool: + return ((raw >> fmt.exp_shift) & ((1 << (fmt.exp_trits * 2)) - 1)) == fmt.offset_max + + +def decode(fmt: GFTFormat, raw: int): + sign = (raw >> fmt.sign_shift) & 1 + offset = (raw >> fmt.exp_shift) & ((1 << (fmt.exp_trits * 2)) - 1) + m = raw & (fmt.mant - 1) + if offset == fmt.offset_max: + return math.nan if m else (-math.inf if sign else math.inf) + if offset == 0: + return Fraction(0) + val = (Fraction(1) + Fraction(m, fmt.mant)) * _pow2(offset - fmt.exp_offset) + return -val if sign else val + + +def gft_add(fmt: GFTFormat, a: int, b: int) -> int: + if is_special(fmt, a) or is_special(fmt, b): + return (fmt.offset_max << fmt.exp_shift) | 1 + return encode(fmt, decode(fmt, a) + decode(fmt, b)) + + +def gft_mul(fmt: GFTFormat, a: int, b: int) -> int: + if is_special(fmt, a) or is_special(fmt, b): + return (fmt.offset_max << fmt.exp_shift) | 1 + return encode(fmt, decode(fmt, a) * decode(fmt, b)) + + +# Canonical ladder rungs (exp_trits, mant_bits) per nominal width. +LADDER = { + 4: GFTFormat(2, 1), + 8: GFTFormat(3, 4), + 16: GFTFormat(4, 9), + 32: GFTFormat(5, 21), + 64: GFTFormat(7, 52), + 128: GFTFormat(8, 115), + 256: GFTFormat(9, 242), + 512: GFTFormat(10, 497), + 1024: GFTFormat(11, 1006), +} + + +if __name__ == "__main__": + import random + rnd = random.Random(1) + print(f"{'rung':>6} {'Et':>3} {'M':>5} {'range(dec)':>11} {'add/mul commute (5k pairs)':>28}") + for w, f in LADDER.items(): + bad = 0 + klim = max(1, int(f.exp_offset * 0.29)) + def rv(): + k = rnd.randint(-klim, klim) + m = Fraction(rnd.randint(0, f.mant if f.mant_bits < 30 else (1 << 30)), (f.mant if f.mant_bits < 30 else (1 << 30))) + s = 1 if rnd.random() < .5 else -1 + return s * (Fraction(1) + m) * (Fraction(2) ** k) + for _ in range(3000): + x, y = rv(), rv() + if gft_add(f, encode(f, x), encode(f, y)) != gft_add(f, encode(f, y), encode(f, x)): + bad += 1 + if gft_mul(f, encode(f, x), encode(f, y)) != gft_mul(f, encode(f, y), encode(f, x)): + bad += 1 + print(f"GF-T{w:<4} {f.exp_trits:>3} {f.mant_bits:>5} {f.range_decades():11.0f} {('%d violations' % bad):>28}") + F16 = LADDER[16] + assert abs(float(decode(F16, gft_mul(F16, encode(F16, 1.5), encode(F16, 2.0)))) - 3.0) < 1e-2 + print("GF-T16 1.5*2.0 =", float(decode(F16, gft_mul(F16, encode(F16, 1.5), encode(F16, 2.0))))) diff --git a/conformance/portability_check.py b/conformance/portability_check.py index 078dce173b..310aafd920 100644 --- a/conformance/portability_check.py +++ b/conformance/portability_check.py @@ -4,11 +4,16 @@ The check asserts an invariant rather than a number. * Every family must recover the SAME amount of sequential state. Measured - 2026-08-02 across nine families: 819 flip-flops on eight of them, 831 on - Intel ALM, whose register cell absorbs reset logic the others express - separately. Nine independent synthesisers agreeing to the register is what - portable RTL looks like; a design tuned to one vendor's carry chain would - not survive the transfer. + 2026-08-03 across ten families under yosys 0.62: 1082 flip-flops on nine of + them, 1092 on Intel ALM, whose register cell absorbs reset logic the others + express separately. Ten independent synthesisers agreeing to the register is + what portable RTL looks like; a design tuned to one vendor's carry chain + would not survive the transfer. + + (It was 819 on 2026-08-02, before the receipt key started arriving over the + wire and brought a key register and its write-once latch with it. The number + is not the claim — the agreement is — which is why this check asserts the + spread and not the value.) * No family may infer a multiplier. The dot product is popcount(agreements) - popcount(disagreements), so there is no multiply to @@ -98,6 +103,14 @@ def main() -> int: help="allowed spread in flip-flop count across families") args = ap.parse_args() + # Which yosys produced these numbers, said out loud. The families a build + # offers and the stat output this script parses both move between versions: + # under 0.33 every family returns without readable stats and this check + # reports zero, under 0.62 ten pass and under 0.65 eleven. A result with no + # tool version attached cannot be compared with the one before it. + ver = subprocess.run(["yosys", "-V"], capture_output=True, text=True).stdout.strip() + print(ver.splitlines()[0] if ver else "yosys version unknown") + fams = available_families() if not fams: print("FAIL: yosys reported no synth_ passes at all") @@ -106,7 +119,7 @@ def main() -> int: print(f"yosys offers {len(fams)} candidate families: {' '.join(fams)}\n") print(f"{'family':<12}{'cells':>8}{'LUTs':>8}{'FFs':>8}{'mult':>7} result") - results, failures = {}, [] + results, failures, unreadable = {}, [], [] with tempfile.TemporaryDirectory() as td: work = pathlib.Path(td) for fam in fams: @@ -125,6 +138,19 @@ def main() -> int: if st["cells"] == 0: print(f"{fam:<12}{'':>8}{'':>8}{'':>8}{'':>7} no stats (skipped)") continue + if st["ff"] == 0: + # A family that synthesises but whose register cells this script + # cannot name used to land in `results` -- counting toward "N + # families checked" -- and then get dropped from the flip-flop + # comparison by a truthiness filter. It inflated the headline + # while contributing nothing to the invariant that headline is + # about. Observed: analogdevices under yosys 0.65 reports 2686 + # cells and zero recognised flip-flops, and the run announced 11 + # families when 10 had agreed. + print(f"{fam:<12}{st['cells']:>8}{st['lut']:>8}{'0':>8}{st['mul']:>7}" + f" NO FLIP-FLOPS RECOGNISED — not counted, extend FF_PAT") + unreadable.append(fam) + continue results[fam] = st note = "" if st["mul"]: @@ -140,10 +166,13 @@ def main() -> int: f"{args.min_families} are needed for this to mean anything") return 1 - ffs = {f: r["ff"] for f, r in results.items() if r["ff"]} + # No truthiness filter here any more: a zero never reaches `results`, so + # every family in it carries a real count and none can be dropped + # silently from the comparison. + ffs = {f: r["ff"] for f, r in results.items()} if not ffs: - print("FAIL: no family reported any flip-flops. The cell has 819 of " - "them, so the parser is broken, not the design.") + print("FAIL: no family reported any flip-flops. The cell has " + "well over a thousand, so the parser is broken, not the design.") return 1 lo, hi = min(ffs.values()), max(ffs.values()) @@ -162,6 +191,8 @@ def main() -> int: print(" FAIL " + f) return 1 + if unreadable: + print(f"note: {len(unreadable)} family(ies) synthesised but named no register\n cell this script knows — {', '.join(unreadable)}. Not counted either way.") print(f"OK: {len(results)} families, no multipliers, sequential state agrees") return 0 diff --git a/conformance/trinet_baud_sweep.py b/conformance/trinet_baud_sweep.py index bc1c607339..3345078190 100644 --- a/conformance/trinet_baud_sweep.py +++ b/conformance/trinet_baud_sweep.py @@ -14,66 +14,160 @@ plus divisor quantisation is the difference between a working link and a dead one. +WHY THIS TOOL WAS REWRITTEN (2026-08-03) +---------------------------------------- +The first version asked six jobs per rate and called the point good if all six +came back. Six is not enough to see the thing this measurement exists to find. +A rate that loses 2.4% of jobs passes 6/6 about 86% of the time, so the whole +degradation shoulder around a board's real rate read as "ok" — and the window +it printed was therefore wider than the truth, and its centre wrong. + +That is not hypothetical. node2 was run at 1186267 baud, lost 2.4% of its jobs +for a day, and the loss was recorded as a link fault after a sweep concluded +the rate was not the cause. Measured with enough jobs per point, node2's clean +window is 1126954..1168473 and 1186267 is outside it: at 1147713 baud the same +board, same cable, same hub returns 6400/6400. + +Two consequences are baked in here: + + * jobs per point is 64 by default, not 6, and a point counts as clean only if + every job is clean. At a 2.4% loss that is a 21% chance of a false clean, + against 86% before; the degradation shoulder is printed separately so a + reader can see it either way rather than having it rounded into the window. + * every predictable byte is checked, not just the product and the nonce. The + old check would accept a frame whose node identity or status byte had been + corrupted, which is precisely the kind of damage a marginal rate does. + Usage: python3 trinet_baud_sweep.py --port /dev/cu.usbserial-1110 - python3 trinet_baud_sweep.py --port ... --centre 160000 --span 0.12 + python3 trinet_baud_sweep.py --port ... --centre 1186267 --span 0.08 Author: Dmitrii Vasilev (@gHashTag) """ import argparse +import collections import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from trinet_mac32_conformance_ax7203 import ( # noqa: E402 - OP_MAC32, generate_vectors, golden_dot, build_request, + MAGIC_RESP, OP_MAC32, generate_vectors, golden_dot, build_request, ) RESP_LEN_V1 = 15 RESP_LEN_V2 = 19 +STATUS_OK = 0x01 +# A board that has not been keyed yet answers 0x04 NO_KEY and computes the dot +# product correctly. That is the state every board is in between a re-flash and +# `trinet setkey` — precisely when its line rate has to be measured — so a sweep +# that demands 0x01 reports a healthy fresh board as 0% clean at every rate and +# finds no window at all. Mirrors protocol.statusMeansComputed(). +STATUS_COMPUTED = frozenset({0x01, 0x04}) +HEAD_LEN = 11 # magic, y, status, nonce[4], node_id[4] — the tag needs the key + -def link_works(port, baud, resp_len, trials=6): - """Does the board answer correctly at this host rate? +def probe(port, baud, resp_len, jobs, timeout): + """Run `jobs` jobs at this host rate and return the raw heads with their truth. - Only the dot product is checked, not the tag: the question here is whether - the bytes survive the wire, and a tag mismatch caused by one flipped bit - would be reported the same way as a framing failure. + No verdict here. A rate is judged after the whole sweep, once the node's own + identity is known — asking each point to also guess the identity would let a + corrupted id byte define what a correct id looks like. """ import serial try: - ser = serial.Serial(port, baud, timeout=1) + ser = serial.Serial(port, baud, timeout=timeout) except Exception: - return 0, trials - ser.reset_input_buffer() - ok = 0 - for nonce, w, x in generate_vectors(trials): - try: + return None + out = [] + try: + ser.reset_input_buffer() + for nonce, w, x in generate_vectors(jobs): ser.write(build_request(OP_MAC32, nonce, w, x)) raw = ser.read(resp_len) - except Exception: - break - if len(raw) == resp_len and raw[0] == 0xA5: - y = raw[1] - 256 if raw[1] > 127 else raw[1] - if y == golden_dot(w, x) and raw[3:7] == nonce: - ok += 1 - ser.close() - return ok, trials + out.append((raw, nonce, golden_dot(w, x))) + except Exception: + pass + finally: + ser.close() + return out + + +def modal_node_id(samples): + """The identity the board claims when it is being heard correctly. + + Taken from frames that are otherwise perfect, so a run of corrupted frames + at a bad rate cannot vote. + """ + votes = collections.Counter() + for results in samples: + if not results: + continue + for raw, nonce, gy in results: + if len(raw) >= HEAD_LEN and raw[0] == MAGIC_RESP \ + and raw[2] in STATUS_COMPUTED \ + and raw[3:7] == nonce and raw[1] == (gy & 0xFF): + votes[int.from_bytes(raw[7:11], "little")] += 1 + return votes.most_common(1)[0][0] if votes else None + + +def judge(results, resp_len, node_id): + """(clean, rx_bad, tx_bad, empty, short) for one rate.""" + clean = rx_bad = tx_bad = empty = short = 0 + for raw, nonce, gy in results: + if len(raw) == 0: + empty += 1 + elif len(raw) < resp_len: + short += 1 + else: + tail = nonce + node_id.to_bytes(4, "little") + got = raw[:HEAD_LEN] + status_ok = got[0] == MAGIC_RESP and got[2] in STATUS_COMPUTED + if status_ok and got[1] == (gy & 0xFF) and got[3:11] == tail: + clean += 1 + elif status_ok and got[3:11] == tail: + # Nonce and identity survived, the product did not: the damage is + # in the operands, so it happened host -> board. + tx_bad += 1 + else: + rx_bad += 1 + return clean, rx_bad, tx_bad, empty, short + + +def widest_clean_band(rows): + """Longest run of consecutive fully-clean rates. Returns (lo_idx, hi_idx) or None.""" + best = None + i = 0 + while i < len(rows): + if rows[i][1] == rows[i][6]: # clean == jobs + j = i + while j + 1 < len(rows) and rows[j + 1][1] == rows[j + 1][6]: + j += 1 + if best is None or (j - i) > (best[1] - best[0]): + best = (i, j) + i = j + 1 + else: + i += 1 + return best def main(): ap = argparse.ArgumentParser(description="bracket the board's real UART bit rate") ap.add_argument("--port", default="/dev/cu.usbserial-1110") - ap.add_argument("--centre", type=int, default=160000, + ap.add_argument("--centre", type=int, default=1186267, help="nominal rate the bitstream was built for") - ap.add_argument("--span", type=float, default=0.10, + ap.add_argument("--span", type=float, default=0.08, help="fractional range to sweep either side of centre") - ap.add_argument("--steps", type=int, default=41) - ap.add_argument("--divisor", type=int, default=434, + ap.add_argument("--steps", type=int, default=33) + ap.add_argument("--jobs", type=int, default=64, + help="jobs per rate; six cannot tell a clean rate from a 2%% one") + ap.add_argument("--timeout", type=float, default=0.05) + ap.add_argument("--divisor", type=int, default=60, help="BAUD_DIV compiled into the bitstream, to derive CFGMCLK") - ap.add_argument("--v1", action="store_true", help="board runs the CRC cell (15-byte response)") + ap.add_argument("--v1", action="store_true", + help="board runs the CRC cell (15-byte response)") a = ap.parse_args() resp_len = RESP_LEN_V1 if a.v1 else RESP_LEN_V2 @@ -81,39 +175,98 @@ def main(): lo = int(a.centre * (1 - a.span)) hi = int(a.centre * (1 + a.span)) step = max(1, (hi - lo) // (a.steps - 1)) + rates = list(range(lo, hi + 1, step)) - print(f"sweeping {lo}..{hi} baud in {step} steps, {resp_len}-byte responses") + print(f"sweeping {lo}..{hi} baud in {len(rates)} steps of {step}, " + f"{a.jobs} jobs per step, {resp_len}-byte responses") print() - working = [] - for baud in range(lo, hi + 1, step): - ok, total = link_works(a.port, baud, resp_len) - mark = "ok " if ok == total else ("part" if ok else " ") - bar = "#" * ok - print(f" {baud:8d} {mark} {ok}/{total} {bar}") - if ok == total: - working.append(baud) + samples = [probe(a.port, b, resp_len, a.jobs, a.timeout) for b in rates] - print() - if not working: - print("RESULT: the link did not work anywhere in this range.") + node_id = modal_node_id(samples) + if node_id is None: + print("RESULT: nothing answered correctly anywhere in this range.") print("Either the sweep missed the board's rate, or the board is not") print("running a cell that speaks this response width.") return 1 + print(f"board identity (from the frames that came through clean): {node_id:#010x}") + print() - w_lo, w_hi = min(working), max(working) + print(f"{'baud':>9} {'clean':>7} {'clean%':>8} {'rx_bad':>7} {'tx_bad':>7} " + f"{'empty':>6} {'short':>6}") + rows = [] + for b, results in zip(rates, samples): + if results is None: + print(f"{b:>9} port would not open at this rate") + continue + clean, rx_bad, tx_bad, empty, short = judge(results, resp_len, node_id) + n = len(results) + flag = "" if clean == n else (" <- degraded" if clean else "") + print(f"{b:>9} {clean:>7} {100.0*clean/n:>7.3f}% {rx_bad:>7} {tx_bad:>7} " + f"{empty:>6} {short:>6}{flag}") + rows.append((b, clean, rx_bad, tx_bad, empty, short, n)) + + print() + band = widest_clean_band(rows) + if band is None: + print("RESULT: no rate in this range delivered every job.") + print("The board answers somewhere here but never cleanly — widen --span,") + print("or suspect the cable rather than the rate.") + return 1 + + i, j = band + w_lo, w_hi = rows[i][0], rows[j][0] centre = (w_lo + w_hi) / 2 tolerance = (w_hi - w_lo) / 2 / centre * 100 cfgmclk = centre * a.divisor - print(f"working window : {w_lo} .. {w_hi} baud") - print(f"centre : {centre:.0f} baud (+/- {tolerance:.2f}% tolerated)") - print(f"implied CFGMCLK: {cfgmclk/1e6:.3f} MHz (centre x BAUD_DIV {a.divisor})") + # The degradation shoulder is the run of lossy rates that TOUCHES the clean + # window, walking outward until a rate that delivers nothing. Rates further + # out can also answer once or twice by luck; they are the far side of the + # cliff, not a shoulder, and quoting their loss rate as the worst case makes + # a marginal operating point look catastrophic instead of plausible — which + # is the wrong lesson, because plausible is what made this bug survive. + shoulder = [] + k = i - 1 + while k >= 0 and rows[k][1] > 0: + shoulder.append(rows[k]) + k -= 1 + k = j + 1 + while k < len(rows) and rows[k][1] > 0: + shoulder.append(rows[k]) + k += 1 + degraded = sorted(shoulder, key=lambda r: r[0]) + + print(f"clean window : {w_lo} .. {w_hi} baud ({j - i + 1} consecutive steps, " + f"{(j - i + 1) * a.jobs} jobs, no failures)") + print(f"USE THIS RATE : {centre:.0f} baud (+/- {tolerance:.2f}% tolerated)") + # The centre is only as sharp as the step that bracketed it, and CFGMCLK + # inherits that. Printing six digits of a number known to half a percent is + # how a measurement becomes a constant nobody rechecks. + cfg_err = (step / 2) * a.divisor + print(f"implied CFGMCLK: {cfgmclk/1e6:.2f} +/- {cfg_err/1e6:.2f} MHz " + f"(centre x BAUD_DIV {a.divisor}; the error is the sweep step, " + f"re-run with more --steps to sharpen it)") + # Each side is printed separately. A shoulder that degrades gently on one + # side and cliffs on the other is a real asymmetry in the board, and merging + # the two into one range with one worst-case figure hides it. + for side, rs in (("below", [r for r in degraded if r[0] < w_lo]), + ("above", [r for r in degraded if r[0] > w_hi])): + if not rs: + continue + pcts = [100.0 * r[1] / r[6] for r in rs] + print(f"degraded {side:<5} : {min(r[0] for r in rs)} .. {max(r[0] for r in rs)} " + f"baud — answers, but loses jobs ({min(pcts):.2f}%..{max(pcts):.2f}% clean)") + if degraded: + print(" A rate in a degraded zone is the failure mode that") + print(" reads as a bad board, a bad cable or a bad hub. It is") + print(" none of those. Do not operate there.") print() - if w_lo == lo or w_hi == hi: + if i == 0 or j == len(rows) - 1: print("WARNING: the window touches the edge of the sweep, so the centre is") print("a lower bound on accuracy. Re-run with a wider --span.") + print() print("Divisors this CFGMCLK would support, with the exact host rate to use:") for div in (434, 300, 200, 120, 90, 60, 45, 30): diff --git a/conformance/trinet_discover.py b/conformance/trinet_discover.py index 0036345e56..308369191d 100644 --- a/conformance/trinet_discover.py +++ b/conformance/trinet_discover.py @@ -29,9 +29,45 @@ OP_MAC32, generate_vectors, golden_dot, build_request, ) -# Rates worth trying: the historical default, the corrected divisor-434 rate, -# and the three fast divisors built for the baud ladder. -CANDIDATE_RATES = [2372533, 1186267, 593133, 164000, 160000] +# Rates worth trying, to ACQUIRE a board — not to operate it. +# +# CFGMCLK is an untrimmed RC oscillator, so the line rate is a property of the +# individual die. Measured on this fleet with trinet_baud_sweep.py on +# 2026-08-03: 70.464, 67.131 and 68.685 MHz, so at BAUD_DIV=60 the boards speak +# 1174399, 1118846 and 1144744 baud. A 4.97% spread. +# +# The spread does NOT force a rate per board. Each board tolerates roughly +# +/-4.5% and the three windows overlap on 1121020..1168468, so 1144744 reaches +# all three: 6400 jobs each, zero failures. That rate leads the list. +# +# The important part is what this list cannot do. A single job answered at a +# rate proves the rate is close enough to acquire the board, not that it is +# close enough to work: node2 answers 97.6% of jobs at 1186267 and 100% at +# 1144744, and one probe cannot tell those apart. So after acquiring, this tool +# measures — see confirm() — rather than reporting the first rate that replied. +CANDIDATE_RATES = [ + 1144744, # BAUD_DIV=60, measured intersection of all three windows + 1174399, # BAUD_DIV=60, node0's own centre + 1118846, # BAUD_DIV=60, node1's own centre + 1186267, # BAUD_DIV=60, the fleet constant this project used to assume + 2372533, # BAUD_DIV=30 + 593133, # BAUD_DIV=120 + 164000, 160000, # BAUD_DIV=434, historical +] + +# Jobs used to judge an acquired rate. Six was the old figure and it is useless +# here: a rate losing 2.4% of jobs passes six in a row 86% of the time. +CONFIRM_JOBS = 64 + +# Statuses that mean "the arithmetic in this frame is real". A board that has +# not been given a key yet answers 0x04 NO_KEY and computes the dot product +# correctly; that is the state EVERY board is in for the minutes between a +# re-flash and `trinet setkey`, which is exactly when the rate has to be +# measured. Comparing status against 0x01 reports a healthy fresh board as +# 0.00% clean — measured on node0 right after its 2026-08-04 re-flash. Mirrors +# protocol.statusMeansComputed(). +STATUS_COMPUTED = {0x01, 0x04} +STATUS_NAME = {0x01: "keyed", 0x02: "KEY_SET", 0x03: "KEY_LOCKED", 0x04: "no key yet"} # Identities the fleet build assigns, so a board can name itself. KNOWN_IDS = { @@ -67,6 +103,43 @@ def try_one(port, baud, timeout=0.25): ser.close() +def confirm(port, baud, node_id, jobs=CONFIRM_JOBS, timeout=0.05): + """How many of `jobs` come back with every predictable byte right. + + The tag is not checked — it needs the node's key, and this tool runs before + anyone knows whether a key is held. Everything else is predictable, and a + marginal rate damages those bytes as readily as it damages the tag. + """ + import serial + try: + ser = serial.Serial(port, baud, timeout=timeout) + except Exception: + return 0, jobs, None + clean = 0 + status = None + try: + ser.reset_input_buffer() + for nonce, w, x in generate_vectors(jobs): + ser.write(build_request(OP_MAC32, nonce, w, x)) + raw = ser.read(19) + if len(raw) < 15: + continue + if raw[0] != 0xA5 or raw[2] not in STATUS_COMPUTED: + continue + if raw[1] != (golden_dot(w, x) & 0xFF): + continue + if raw[3:7] != nonce or raw[7:11] != node_id.to_bytes(4, "little"): + continue + if status is None: + status = raw[2] + clean += 1 + except Exception: + pass + finally: + ser.close() + return clean, jobs, status + + def main(): ap = argparse.ArgumentParser(description="discover TRI-NET nodes on the bus") ap.add_argument("--ports", nargs="*", default=None) @@ -76,6 +149,7 @@ def main(): print(f"probing {len(ports)} port(s)\n") found = [] + marginal = [] for p in ports: hit = None for baud in CANDIDATE_RATES: @@ -87,8 +161,15 @@ def main(): baud, nid, width = hit name = KNOWN_IDS.get(nid, "unknown identity") frame = "keyed (v2)" if width >= 19 else "crc (v1)" - print(f" {p:<36} NODE id {nid:#010x} ({name}), {baud} baud, {frame}") - found.append((p, nid, baud)) + clean, jobs, status = confirm(p, baud, nid) + pct = 100.0 * clean / jobs + key_state = STATUS_NAME.get(status, "no clean frame") if status is not None \ + else "no clean frame" + print(f" {p:<36} NODE id {nid:#010x} ({name}), {baud} baud, {frame}, " + f"{key_state}, {clean}/{jobs} clean") + found.append((p, nid, baud, pct)) + if clean < jobs: + marginal.append((p, baud, pct)) else: print(f" {p:<36} — no node answered at any candidate rate") @@ -99,7 +180,7 @@ def main(): print("will not answer.") return 1 - ids = [n for _, n, _ in found] + ids = [n for _, n, _, _ in found] print(f"{len(found)} node(s) responding") if len(set(ids)) != len(ids): print("WARNING: two boards report the SAME identity. The ledger credits") @@ -108,7 +189,19 @@ def main(): print("different NODE_ID before running them together.") return 1 - rates = {b for _, _, b in found} + if marginal: + print() + print("WARNING: a board answered, but not on every job. That is the shape") + print("of a host rate a few percent off the board's own, and it is NOT") + print("evidence of a bad board, cable or hub — node2 read as 97.6% for a") + print("day and delivers 6400/6400 once the rate is measured. Sweep it:") + for p, baud, pct in marginal: + print(f" {p} at {baud} baud: {pct:.2f}% clean") + print(f" python3 conformance/trinet_baud_sweep.py --port {p} " + f"--centre {baud} --span 0.08") + return 1 + + rates = {b for _, _, b, _ in found} if len(rates) > 1: print(f"note: nodes are at different line rates {sorted(rates)} — the") print("coordinator opens each port at its own rate, so this is workable,") diff --git a/docs/TRI_NET_HANDOFF.md b/docs/TRI_NET_HANDOFF.md new file mode 100644 index 0000000000..6f532270fa --- /dev/null +++ b/docs/TRI_NET_HANDOFF.md @@ -0,0 +1,423 @@ +# TRI-NET — handoff + +Written 2026-08-03 so the next person can continue without the conversation +that produced this. It is meant to be read top to bottom once, then used as a +reference. Where it states a number, that number was measured; where it states +a limit, the limit was hit. + +Branch `trinet-fleet-truth`, PR +[#355](https://github.com/gHashTag/trinity-fpga/pull/355). Merged up to `main` +on 2026-08-03, so the PR is mergeable rather than conflicting. + +--- + +## 1. What the fleet is, right now + +Three ALINX AX7203 boards (Artix-7 `xc7a200tfbg484-2`), all running the same +cell from CI artifacts, all keyed with per-node secrets that have never been +published. + +| node | id | clean window (baud) | rate to use | CFGMCLK | health | +|---|---|---|---|---|---| +| node0 | `0x5452494E` | 1 121 020 – 1 227 778 | 1 174 399 | 70.46 ± 0.18 MHz | 6400/6400 | +| node1 | `0x5452494F` | 1 068 248 – 1 169 444 | 1 118 846 | 67.13 ± 0.18 MHz | 6400/6400 | +| node2 | `0x54524950` | 1 121 020 – 1 168 468 | 1 144 744 | 68.69 ± 0.18 MHz | 6400/6400 | + +**Every column except `id` is unstable.** Port names move when hubs change — +`-1110` was node0 one hour and node1 the next. JTAG locations move with them, +and the bus number changes too: what this document once recorded as `1-1.2` is +`2-1.2` today. Line rates differ per board because CFGMCLK is an untrimmed RC +oscillator; the spread is **4.97%**, and each board tolerates about **±4.5%**. + +Because the windows overlap, **1 144 744 baud reaches all three** — 6400/6400 +on each. That does not carry to a re-flashed or a fourth board; measure it: + +```bash +python3 conformance/trinet_baud_sweep.py --port

--centre 1144744 --span 0.08 +``` + +*(An earlier version of this table listed 71.18 / 70.46 / 67.47 MHz and per-die +rates of 1 186 267 / 1 124 474, and said no single rate could serve the fleet. +Those figures came from a sweep that asked six jobs per rate. Six cannot tell a +clean rate from one losing 2.4%.)* + +Never identify a board by its device name or by argument order. Ask it: + +```bash +python3 conformance/trinet_discover.py +``` + +### Keys + +`trinet-keys.txt` in the repo root, mode 600, gitignored — **verify that before +every commit**. It maps node name → 16-byte key. It is not in git and there is +no other copy: lose it and the boards must be re-flashed and re-keyed (which +now costs minutes, see §3). + +> **It was lost, on 2026-08-03.** A search of the whole home directory found no +> `trinet-keys.txt` and no `trinet-fleet-node*` build directory; the working +> copy that held them is gone and the current checkout's reflog has one entry, a +> clone. All three boards still answer `status 0x01`, so each holds a key nobody +> has. Every receipt they produce is unverifiable — and settlement handles that +> correctly, refusing to credit without slashing, because the boards are honest +> and it is the verifier that cannot check. +> +> The lesson is not "back up the key file". It is that **a fleet whose +> configuration is volatile and whose re-flash path needs a password is a +> perishable measurement**: take the hardware readings before anything else, +> because you may not get the chance twice. + +--- + +## 2. The measurement of record + +100 independent runs × 64 jobs per board, port reopened every run — that +matters, because the FPGA's frame parser holds state across host processes. + +| | dot products | receipts authenticated | perfect runs | +|---|---|---|---| +| node0 | 6400/6400 | **6400/6400** | 100/100 | +| node1 | 6400/6400 | **6400/6400** | 100/100 | +| node2 | 6245/6400 (97.6%) | 6235/6400 | 25/100, min 59 | + +**node2's row measures the host's choice of line rate, not the board.** It was +taken at 1 186 267 baud, 3.6% above the top of that board's window. Re-measured +at 1 144 744 on the same cable and the same hub port: **6400/6400, 100/100 +perfect runs**, and again at 1 147 713 to make it a reproduction rather than an +anecdote. The node0 and node1 rows stand — both rates are inside their windows. + +Agent forward pass across all three: **96 of 96 accepted, 0 rejected, 0 +slashed, 96 mTRI credited, all three nodes active.** + +Reproduce: + +```bash +python3 conformance/trinet_discover.py # ports move; ask, never assume +for p in ; do trinet census $p 0 100 64; done # 0 = negotiate +``` + +**Report the minimum, not the mean.** A fleet is used at its worst run. + +This section used to end: *"Its losses are the link, not the key or the clock: +swept to its own centre rate it scored slightly worse (98.08% vs 98.56%), which +cleanly falsifies the baud hypothesis."* It falsified nothing. That "own centre +rate" was 1 174 399, from the `BAUD_DIV=60` candidate table — **also outside +node2's window**, by 0.5%. Two numbers half a percent apart, on a sample too +small to separate them, were read as a refutation of the one hypothesis that was +right. Every rate anyone tried came from a list the wrong assumption had +generated, so no test drawn from that list could have escaped it. + +*When a hypothesis is refuted by two nearby numbers, suspect the sample, not the +hypothesis.* + +### Numbers that are withdrawn, not restated + +**Every jobs/s figure this project ever published is void.** They divided by +jobs *attempted*, not verified, so a board answering nothing read as the fastest +run ever recorded — 5409 jobs/s against a transport ceiling of 4942, with 0/64 +verified. The bench now counts verified work only and prints `IMPOSSIBLE` above +the ceiling. + +**Restated 2026-08-03**, 2000 jobs per board at each board's negotiated rate: + +| node | rate | one at a time | batched ×32 | of ceiling | +|---|---|---|---|---| +| node0 | 1 174 399 | 495.7 jobs/s | **3843.6 jobs/s** | 78.5% | +| node1 | 1 144 744 | 483.7 jobs/s | **3680.1 jobs/s** | 77.2% | +| node2 | 1 144 744 | 475.6 jobs/s | **3678.6 jobs/s** | 77.1% | + +2000/2000 whole on each. **Not authenticated** — no receipt was checked, because +the keys these boards hold are not on this machine (§3). Cite it as a +measurement of the transport, not as verified compute. + +Batching is worth 7.6–7.8× because the round trip is USB latency, 2.05 ms p50 +against ~0.4 ms of wire time. + +The ceiling is arithmetic: at 1 144 744 baud and 24 bytes on the busier +direction, **no node on this transport can exceed ~4770 jobs/s**, against a +derived compute ceiling 480× higher. The cell is idle for all but ~30 of the +~200 clocks per job. Any throughput claim about this node is a claim about a +UART. + +`trinet bench` had four defects of its own and none were visible in the code — +it never loaded the key file, so `verified` was structurally zero and the +throughput line read 0.0 jobs/s on any machine; it took the node's identity from +argument order; it derived the compute ceiling from a CFGMCLK constant belonging +to no board here; and it printed the requested baud beside a ceiling computed +from the negotiated one. + +**Still no power figure of any kind.** Nothing has been on a bench supply. This +is the single most valuable missing measurement and it blocks the paper. + +--- + +## 3. How the key works now (changed 2026-08-03) + +`RECEIPT_KEY` used to be a synthesis parameter. It was committed to a public +repository, the fix was applied to the source, and **the fix never reached the +silicon** — the fleet ran for a day signing with keys any reader of the git log +could compute, while every test stayed green, because a compromised key and a +good key are indistinguishable to anything that only asks "does the tag match". + +The reason it never reached the silicon is the part to keep: rotating a baked-in +key needs a place-and-route run **this workstation cannot do** (see §5), plus 13 +minutes of flashing, per board. *A key that costs an hour to rotate is a key +nobody rotates.* + +So the node takes its key over the wire: + +- `op 0x02`, 16 key bytes in the W and X operand fields. The request stays 24 + bytes, so the frame parser and `conformance/frame_alignment_check.py` are + untouched. +- **Write-once per configuration.** A second `setkey` returns `0x03 KEY_LOCKED` + and changes nothing. Proven on silicon against an attacker's key, not only in + simulation. +- The acknowledgement is **signed with the key just installed**, so acceptance + is distinguishable from an echo. `Node.setKey` checks the tag, not the status. +- An unkeyed board answers `0x04 NO_KEY` with a **real** dot product. Anything + measuring arithmetic must call `protocol.statusMeansComputed()`. +- A non-null `RECEIPT_KEY` still bakes a key in and locks it at reset, for + anyone with a build machine who prefers the key never touch a wire. CI must + never pass one. + +Cost: 1292 → 1484 LC (+15%), still 0 DSP48. + +**The trade, stated plainly:** whoever reaches this UART in the window after +configuration can claim the node. They can also just re-flash it, so this +concedes little that physical access did not already concede, and it buys a +rotation cheap enough to actually happen. + +--- + +## 4. Bringing a board up, in order + +```bash +# 1. Who is on the bus, and at what rate +python3 conformance/trinet_discover.py + +# 2. Find the JTAG locations — from ioreg, never guessed +ioreg -p IOUSB -w0 | grep -E "Hub|Digilent|CP2102N" +# Digilent USB Device@01120000 -> openocd location 1-1.2 +# (first byte = bus, each remaining non-zero nibble = a port down the chain) +# A CP2102N and a Digilent under the SAME hub are the same board. + +# 3. Flash — 778 s per board, and they run in PARALLEL on separate programmers +sudo -n /opt/homebrew/bin/openocd \ + -f fpga/openxc7-synth/ax7203_al321_multi.cfg \ + -c "adapter usb location 1-1.2" -c "init" \ + -c "pld load 0 build/fleet/trinet-fleet-node1/trinet_node1.bit" \ + -c "runtest 2000" -c "shutdown" +# NOTE: the copies currently on disk are in ...-node1-UNKEYED/ — they predate +# the artifact rename. That suffix was dropped because these bitstreams are +# complete and deployable now; a build after 06e781644 has no suffix. + +# 4. Confirm the NEW bitstream is running: it must answer status 0x04 NO_KEY +# 5. Key it +trinet keygen > trinet-keys.txt # only when starting fresh; mode 600 +trinet setkey + +# 6. Now it can settle +trinet fleet +``` + +Bitstreams come from CI: + +```bash +gh workflow run ax7203-trinet-fleet.yml --ref +gh run download --dir build/fleet +``` + +**Verify the artifacts before flashing:** the three `.bit` files must have +*different* sha256s. Identical ones mean the `node_id` chparam did not take, and +all three boards would claim the same identity — discoverable only after three +13-minute flashes. + +--- + +## 5. Environment limits, measured not assumed + +- **No local place-and-route.** 8 GB host RAM. Docker's default 4 GB OOM-kills + `bbaexport` for `xc7a200tfbg484-2`; raising it to 6 GB stops the Docker VM + starting at all. Any plan step assuming a local bitstream build is dead on + arrival. Use CI. +- **`sudo` is narrow.** `/etc/sudoers.d/openocd` grants NOPASSWD for + `/opt/homebrew/bin/openocd` **and nothing else**. `sudo -n pkill` therefore + fails with "a password is required", and with `-n` it fails *silently*. It + does not survive a reboot — check `sudo -n /opt/homebrew/bin/openocd --version` + at the start of every flash session. **It was gone on 2026-08-03**, the + directory empty, which blocked the whole re-flash path; the one-line fix is in + §8 and it needs a password, so no agent can apply it. +- **openocd needs root** on macOS: `AppleUSBFTDI` claims the FT2232H, so a + non-root openocd reports "no device found". +- **All AL321 cables share USB serial `210512180081`**, so `adapter usb + location` is mandatory with more than one attached. +- A flash takes **778 s**, not the ~78 s some old notes claim. openocd's stdout + is block-buffered when redirected, so an empty log during a flash is not a + hang. + +--- + +## 6. Traps that cost real time here + +**Bound a privileged probe correctly.** Backgrounding `sudo`, capturing `$!` and +`kill -9`-ing it does **not** work: `$!` is the sudo wrapper, openocd runs as +root beneath it, and a user kill cannot touch a root child. The wrapper dies, +the timeout looks like it worked, and the adapter stays held. Two leaked +processes wedged two cables for nearly three hours. Put the timeout inside: + +```bash +sudo -n timeout -s KILL 25 /opt/homebrew/bin/openocd -f -c "init" -c "shutdown" +``` + +**`mpsse_flush()` stall ≠ dead cable.** Order of suspicion: leaked openocd +first (`ps -eo pid,stat,etime,comm | grep openocd`), then replug the cable, then +board power. A bus-position theory was recorded here confidently and was wrong — +three consistent observations of a correlation are not a cause. + +**A board that does not answer has not been tested until it has been swept.** +node1 was written off as a wiring fault for a day. It was answering the whole +time at 1 124 474 baud, 5.2% off the hardcoded rate. + +**Verify the artifact, not the source.** Three separate defects this session +came from a fix that changed source and never reached the thing that runs. + +--- + +## 7. The recurring defect, named + +Six defects this session, and **not one was visible from reading the code**. +Each was found by disbelieving a number: a throughput above a physical ceiling; +a testbench that passed no key; a board written off without a sweep; a slash +against a board with nothing wrong with it. + +Three of them had the *same shape*: **a rule written in one place, enforced by +enumerating cases somewhere else.** + +- `Verdict.unverifiable` was added so a keyless verifier could not accuse. + `ledger.settle()` never asked — it named `.corrupt` as the one verdict that + costs nothing and slashed everything else. Two honest boards lost 600 mTRI + each for keys *we* could not check. +- The mesh's outcome accounting had a catch-all that printed "39 rejected as + dishonest" beside "slashed: 0 mTRI". A summary that accuses and then charges + nothing is either a lie or a bug and a reader cannot tell which. It was two. +- `setkey` counted a board as keyed only if 32/32 later jobs came back clean, so + a successfully keyed board was reported as not keyed, hidden behind its own + lossy cable. + +The structural fix, applied: **ask, don't enumerate.** `settle()` now calls +`verdict.indictsTheNode()`, and the mesh's switch is exhaustive with no `else`, +so adding an outcome is a compile error until someone decides what it means. + +Apply the same test to new code: *if someone adds a case tomorrow, does this +default to safe, or does it default to accusing an honest operator?* + +--- + +## 8. What is still open + +### Structural, unfixed, and stated in the report rather than buried + +- **W02 — a symmetric MAC verified by the key holder is not a receipt.** The + coordinator holds every key, is the verifier, and is the ledger. No third + party can check anything. This is the deepest issue in the design; fixing it + needs asymmetric signatures the FPGA cannot currently produce. +- **W07 — the coordinator recomputes every job, so nothing is offloaded.** At a + 100% audit rate the host does all the work the network does. The demonstration + is of *verification*, not of *offload*. +- **W06 — stake is conjured, not deposited**, and TRI has no external value, so + the `p·s > r` soundness check is circular. +- **W04 — fleet identification is a trust step.** The coordinator asks a board + its id and uses the answer to choose which key verifies it. +- **W08 — no power figure.** Blocked on a bench supply. + +### Next actions, in the order they unblock most + +1. **Option B — the TernaryCore issue.** Draft written and *held* at + `docs/outreach/ternarycore-issue-draft.md`. It was held because its strongest + line ("per-job verifiable receipts, demonstrated on silicon") was false. **It + is now true.** Needs the operator's go-ahead — it is outward-facing to a third + party. Falsifier: no substantive reply in 30 days. +2. **Re-key the fleet — blocked on an operator, not on work.** The keys + installed on these boards exist nowhere: `trinet-keys.txt` is not on this + machine and the checkout that made it is gone. Nothing from this fleet can be + authenticated until it is re-keyed, and re-keying needs a re-flash (that + clears the configuration, which clears the write-once key latch — no power + cycle required). Re-flashing needs openocd as root and + `/etc/sudoers.d/openocd` is absent; the directory is empty. One line fixes it, + and it needs a password, so an agent cannot: + + ```bash + printf 'ssdm4 ALL=(root) NOPASSWD: /opt/homebrew/bin/openocd\n' | sudo tee /etc/sudoers.d/openocd >/dev/null && sudo chmod 440 /etc/sudoers.d/openocd && sudo visudo -c + ``` + + The bitstreams are already on disk under `build/fleet/` (CI run 30762491794, + built from `1bb1d97e`; no RTL changed after it), three distinct sha256s. +3. **A power measurement.** Everything about efficiency is unclaimable without + it, and referees will lead with it. +4. **Re-run throughput authenticated.** The transport numbers exist now (§2); + what they lack is a checked receipt, which is item 2. +5. **Option A — the paper.** The statistical base and the portability result now + exist; power does not. + +**Done since this document was written:** node2's link (it was the line rate, +not the board — §2), and throughput restated at the measured rates (§2, as a +transport measurement). + +### Portability (option C), answered + +The cell contains exactly two Xilinx primitives — `STARTUPE2` for the clock and +`DNA_PORT` for identity, both board concerns. With those in a wrapper, the core +in `fpga/portable/trinet_node_core.v` synthesises **with zero errors on ten FPGA +families from eight vendors**, with **1082 flip-flops on nine of them**, 1092 on +the tenth, and no inferred multiplier anywhere. See +`docs/TRI_NET_PORTABILITY.md`. + +Until 2026-08-03 the CI job that guards this claim installed yosys from apt — +version 0.33, under which the check reads no stats at all. It had failed on every +run since it was added. The claim reproduces under 0.62 and 0.65; the gate that +was supposed to protect it had never gone green. CI now pins the toolchain. + +This does not establish portability of *product*: synthesis is not P&R, no +non-Xilinx mapping has met timing, and only xc7 has run on silicon. And it does +not make anyone want the IP — C's real obstacles were never engineering. The +recommendation to defer C stands. + +`DNA_PORT` is now compiled out (`USE_DNA=0`): it places, routes, and returns +zero for all 57 bits on this flow, so it was dead weight in every bitstream, and +removing it makes the node id in simulation equal the one hardware reports. + +--- + +## 9. Where things live + +``` +fpga/portable/trinet_node_core.v the node, no vendor primitives +fpga/vivado/trinet_node_v2_ax7203.v AX7203 wrapper — instantiates the core, + never a copy, or the claim rots +fpga/openxc7-synth/trinet_siphash24.v receipt tag engine +formal/trinet_setkey_tb.v the key-over-the-wire law, 11/11 +formal/trinet_node_v2_tb.v keyed receipts, 6/6 +tools/gen_setkey_golden.py goldens from Python, never from RTL +conformance/trinet_discover.py who is on the bus, at what rate +conformance/trinet_baud_sweep.py a board's real rate +conformance/key_default_check.py null RTL defaults, explicit test keys +conformance/portability_check.py the ten-family invariant +src/trinet/{protocol,serial,net,node,ledger,mesh,model,agent,main}.zig +specs/trinet/ternary_hw_verification.t27 THE RECORD — claims, limits, retractions +docs/TRI_NET_REPORT_2026-08-02.md the report, with its corrections at the top +docs/TRI_NET_PORTABILITY.md the ten-family measurement +docs/outreach/ternarycore-issue-draft.md option B, written and held +.claude/skills/trinet/SKILL.md operational truth; read before touching hardware +``` + +**`specs/trinet/ternary_hw_verification.t27` is the source of truth for claims**, +including a `retracted` entry where this session got a cause wrong. Add to it +rather than to prose when something is measured. + +Tests: `zig test src/trinet/agent.zig -lc` runs **56** tests and nests every +other module's. Do not sum the per-file suites — that was done here and reported +as 180, then 198, both wrong. + +--- + +Author: Dmitrii Vasilev ([@gHashTag](https://github.com/gHashTag)) diff --git a/docs/TRI_NET_PORTABILITY.md b/docs/TRI_NET_PORTABILITY.md index e7261dd05b..04afb002f2 100644 --- a/docs/TRI_NET_PORTABILITY.md +++ b/docs/TRI_NET_PORTABILITY.md @@ -30,8 +30,8 @@ audit of what actually had to move found exactly two primitives: Nothing else in the cell was vendor-bound. Not the UART, not the frame parser, not the dot product, not the SipHash engine. -The core was then run through every `synth_` pass yosys 0.63 offers. -Ten completed. **The wrapper still instantiates the core rather than keeping a +The core was then run through every `synth_` pass yosys offers. Ten +completed under 0.62 and 0.65 alike. **The wrapper still instantiates the core rather than keeping a copy** — a parallel copy would drift, and the portability claim would quietly stop being true while both files still built. @@ -39,35 +39,43 @@ stop being true while both files still built. ## Result +Re-measured 2026-08-03 under **yosys 0.62**, the version now pinned in CI: + | family | vendor | total cells | LUTs | **flip-flops** | multipliers | |---|---|---|---|---|---| -| xilinx (xc7, flattened) | AMD | 2830 | 1737 | **819** | **0** | -| ice40 | Lattice | 2568 | 1459 | **819** | **0** | -| ecp5 | Lattice | 2488 | 1286 | **819** | **0** | -| nexus | Lattice | 2083 | 939 | **819** | **0** | -| gowin | Gowin | 2842 | 1663 | **819** | **0** | -| gatemate | Cologne Chip | 2357 | 1180 | **819** | **0** | -| anlogic | Anlogic | 2223 | 1059 | **819** | **0** | -| efinix | Efinix | 2352 | 1187 | **819** | **0** | -| nanoxplore | NanoXplore | 2120 | 1178 | **819** | **0** | -| intel_alm | Intel/Altera | 2288 | 1267 | 831 | **0** | +| xilinx (xc7, flattened) | AMD | 3242 | 1910 | **1082** | **0** | +| ice40 | Lattice | 3046 | 1672 | **1082** | **0** | +| ecp5 | Lattice | 2960 | 1495 | **1082** | **0** | +| nexus | Lattice | 2553 | 1142 | **1082** | **0** | +| gowin | Gowin | 3290 | 1845 | **1082** | **0** | +| gatemate | Cologne Chip | 2835 | 1395 | **1082** | **0** | +| anlogic | Anlogic | 2693 | 1266 | **1082** | **0** | +| efinix | Efinix | 2819 | 1391 | **1082** | **0** | +| nanoxplore | NanoXplore | 2599 | 1394 | **1082** | **0** | +| intel_alm | Intel/Altera | 2743 | 1459 | 1092 | **0** | Ten families, eight vendors. Zero synthesis errors. Zero inferred multipliers anywhere. (`achronix` and `easic` also exist as passes but need a device argument to run, so they were skipped rather than counted as failures.) -**The number that matters is the flip-flop column.** 819 registers on nine of -ten families, and 831 on the tenth — Intel's ALM absorbs some reset logic into -the register cell, which accounts for the twelve. Ten independent synthesisers, +*The first version of this table read 819 and 831, measured under yosys 0.63 on +2026-08-02. The design changed underneath it: the receipt key now arrives over +the wire, which added a key register and its write-once latch. The number moved; +the agreement did not, which is the whole point of asserting the spread rather +than the value.* + +**The number that matters is the flip-flop column.** 1082 registers on nine of +ten families, and 1092 on the tenth — Intel's ALM absorbs some reset logic into +the register cell, which accounts for the ten. Ten independent synthesisers, written against ten different architectures, agreed to the register on how much sequential state this design has. That is not a coincidence and it is not a good result achieved by tuning. It is what happens when a design is expressed in ordinary RTL rather than in vendor idioms: the sequential structure is a property of the design, and every tool -recovers it exactly. The combinational column varies from 939 to 1737 LUTs, a -1.85× spread, and that variation is entirely explained by LUT width and carry -architecture — Lattice Nexus packs into 939 wide LUTs what iCE40 needs 1459 +recovers it exactly. The combinational column varies from 1142 to 1910 LUTs, a +1.67× spread, and that variation is entirely explained by LUT width and carry +architecture — Lattice Nexus packs into 1142 wide LUTs what iCE40 needs 1672 LUT4s to express. That is the mapper doing its job, not the design failing to port. @@ -81,6 +89,30 @@ assertions were confirmed to fire: tightening the tolerance to 5 makes it reject the real Intel spread, and demanding 20 families makes it refuse to pass on 10. +### The gate was red the whole time + +Checked rather than remembered — but not, until 2026-08-03, in CI. The +`portability` job installed yosys from apt, which on `ubuntu-latest` is **0.33**, +and under 0.33 every `synth_` pass returns without stats this script can +read. The job reported "only 0 families synthesised" and failed on **every run +since the workflow was added**, on every branch, including the commit whose +message announced the ten-family result. + +The claim was true throughout — it reproduces under 0.62 and 0.65 — but for a +day and a half this document cited a gate that had never once gone green, and +nothing would have caught a real regression. CI now runs the check inside the +pinned `regymm/openxc7` image, and the script prints the yosys version it used, +because a portability number with no tool version attached cannot be compared +with the one before it. + +One hole in the check itself came out of the same look. A family that +synthesised but whose register cells the script could not name was counted +toward "N families checked" and then dropped from the flip-flop comparison by a +truthiness filter — inflating the headline while contributing nothing to the +invariant the headline is about. `analogdevices` under yosys 0.65 did exactly +that, and a run that announced eleven families had ten agreeing. Such a family +is now named in the output and counted in neither direction. + --- ## What this does and does not establish diff --git a/docs/TRI_NET_REPORT_2026-08-02.md b/docs/TRI_NET_REPORT_2026-08-02.md index 57b52e76ae..0f0f895196 100644 --- a/docs/TRI_NET_REPORT_2026-08-02.md +++ b/docs/TRI_NET_REPORT_2026-08-02.md @@ -6,22 +6,141 @@ four are structural, and this report leads with them rather than burying them. --- +## 0. Corrections, entered later the same day + +Four things below this line were wrong when first written. They are corrected +in place, and recorded here because a report that quietly edits itself is worth +less than one that says what it got wrong. + +**The fleet is three boards, not two.** The third was recorded as a wiring +fault — configured, `DONE=1`, UART silent. It was answering the whole time, at +1124474 baud against a hardcoded 1186267. A 5.2% error, where a UART tolerates +about 3. Swept, it verifies 32/32 immediately and 6400/6400 over 100 +independent runs, making it the equal of the best board here. The diagnosis had +been repeated for a day without being retested; it took the operator asking why +the third board was missing for anyone to point a sweep at it. + +**The per-chip clock spread is about 5%, not 1.25%.** CFGMCLK differs per die — +the 1.25% figure came from two samples and should not have been published as a +fleet property — and that is why the third board was invisible. *The conclusion +originally drawn from it, that no fleet can share one host baud rate, was wrong +and is corrected below.* + +**Every board is running a receipt key that was published in this +repository.** W01 nulled the committed keys in the source and never reached the +silicon. Measured today: node0 verifies 64/64 under `0x00..0x0f` and node2 +63/64 under `0x20..0x2f`. *Every "keyed receipt verified on silicon" result in +this document is therefore a tag any reader of the git log can compute.* The +arithmetic is real; the receipts are not evidence. Tooling now detects this and +refuses to credit such a board — no slash either, because the boards are honest. + +> **Resolved for node0, 2026-08-03.** Re-flashed from CI, came up unkeyed +> (`status 0x04`) with correct arithmetic, and took a key over the wire that has +> never been published. 100 runs × 64 jobs: **6400/6400 correct and 6400/6400 +> authenticated**. An attacker's second key was refused on silicon +> (`0x03 KEY_LOCKED`) and later work still verified under the operator's key, +> not the attacker's. This is the first receipt in the programme that is +> evidence of anything. +> +> **All three, later the same day.** node1 and node2 re-flashed too, in parallel +> — 13 minutes for both. Across 100 runs each: node0 6400/6400 correct and +> authenticated, node1 6400/6400 and 6400/6400, node2 6245/6400 and 6235/6400. +> The agent's forward pass then ran across all three: **96 of 96 accepted, 0 +> rejected, 0 slashed, all three nodes active**. No published key anywhere in +> the fleet. This is the first settlement in the programme resting on receipts +> that are evidence. +> +> The reason the fix had not reached the silicon for a day is worth keeping: +> rotating a baked-in key needed a place-and-route run this workstation cannot +> perform. **A key that costs an hour to rotate is a key nobody rotates**, so +> the key is now loaded over the wire, write-once per configuration, and +> rotation costs a power cycle. + +**Every published jobs/s figure was computed by dividing by jobs attempted.** +Including failures. A board answering nothing returns instantly, so a total +failure read as the fastest run ever recorded — caught at 5409 jobs/s against a +transport ceiling of 4942, with 0/64 verified. Latency percentiles had the same +defect. Section 2's throughput line is restated below. + +An accomplice defect made the first two harder to see: the host chose the +response width from `key != null` — a host-side config fact — rather than from +the wire. A keyless host read 15 bytes of a 19-byte response and offset every +later read by four, so a healthy board reported `MalformedResponse` forever. + +### Entered 2026-08-03: the marginal board was a marginal line rate + +**node2 is not a marginal board, and the fleet does share one rate.** Both +claims above were measured wrong, by the same instrument. + +Each board's line rate was bracketed by sweeping the host rate in 0.5% steps +with 64 jobs per step, counting a step clean only when every predictable byte of +every response was right. The windows: + +| node | clean window (baud) | centre | tolerated | implied CFGMCLK | +|---|---|---|---|---| +| node0 | 1121020 – 1227778 | 1174399 | ±4.55% | 70.46 ± 0.18 MHz | +| node1 | 1068248 – 1169444 | 1118846 | ±4.52% | 67.13 ± 0.18 MHz | +| node2 | 1121020 – 1168468 | 1144744 | ±2.07% | 68.69 ± 0.18 MHz | + +The spread is 4.97% and each board tolerates about ±4.5%, so the windows +overlap on 1121020–1168468. **At 1144744 baud all three boards returned every +job: 100 runs × 64 on each, 19 200 jobs, zero failures.** At its own window +centre each board did the same, and node2 was run twice to make it a +reproduction rather than an anecdote. + +node2's 97.6% was measured at 1186267 — 3.6% above the top of its window. Same +cable, same hub port, no re-flash, no power cycle between the two measurements. + +The baud hypothesis had been tested and recorded as refuted: node2 was re-run at +"its own centre rate", scored 98.08% against 98.56%, and the half-percent gap +was read as a refutation. That rate was 1174399, taken from a list of +`BAUD_DIV=60` candidates — and it is *also* outside node2's window. Every rate +anyone tried came from a list the wrong assumption had generated, so no test +drawn from that list could escape it. + +The instrument itself was the defect, and it is the recurring shape: `six +probes per candidate, accept the first that passes`. A rate losing 2.4% of jobs +passes six probes 86% of the time. The check could not fail the case it existed +to detect. `conformance/trinet_baud_sweep.py` now uses 64 jobs per rate, checks +every predictable byte, splits failures by direction, and reports the centre of +the clean window; `conformance/trinet_discover.py` measures an acquired rate +instead of trusting the first reply. + +**What this does not explain.** node0 and node1 have hard window edges — one +step out and nothing comes back. node2 degrades gently instead, 96–98% clean +over 1174399–1227778. Something costs that board its upper margin and nothing +here identifies it. It no longer costs it any jobs, which is why this is an open +question and not a fault. + +--- + ## 1. The strongest sentence the evidence supports -> Two ALINX AX7203 boards, synthesised end to end on a fully open toolchain -> (yosys + nextpnr-xilinx), each run a 1336-logic-cell balanced-ternary -> dot-product cell with an in-fabric SipHash-2-4 tag engine. A single host -> process dispatched all 96 dot products of a three-layer forward pass to them -> over USB serial, independently recomputed every answer, and credited 96 of 96 -> on three consecutive runs. +> Three ALINX AX7203 boards, synthesised end to end on a fully open toolchain +> (yosys + nextpnr-xilinx), each run a 1480-logic-cell balanced-ternary +> dot-product cell using no DSP block. Across 100 independent runs per board — +> 19,200 dot products, port reopened every run — every board returned every +> answer correctly. The same cell synthesises without modification on ten FPGA +> families from eight vendors. + +*(That sentence read "two boards returned every answer correctly and the third +returned 98.6%" until 2026-08-03. The third board was being talked to at a rate +outside its window; at 1144744 baud all three are clean. See the correction +above.)* -That establishes a dispatch → verify → settle path working end to end against -real silicon on an open flow. It establishes nothing about power, about compute -saved, about where the arithmetic actually ran, or about anything being a -network. +That establishes a ternary dot-product cell that works on silicon, on an open +flow, reproducibly, on more than one die, and portably. It establishes nothing +about power, about compute saved, or about anything being a network. -**"Ternary internet" is not defensible for two boards on one desk.** What is -defensible is "a verifiable ternary compute node, demonstrated on two boards". +It also, as of today, establishes nothing about *authenticity*: all three boards +carry receipt keys published in this repository, so no receipt any of them +produces is evidence of who produced it. The dispatch → verify → settle path is +implemented and tested in software; on hardware it currently, and correctly, +refuses to settle. + +**"Ternary internet" is not defensible for three boards on one desk.** What is +defensible is "a portable, reproducible ternary compute node, demonstrated on +three boards, whose settlement layer is not yet trustworthy on hardware". --- @@ -29,13 +148,49 @@ defensible is "a verifiable ternary compute node, demonstrated on two boards". | | | |---|---| -| Ternary dot product + receipt, bit-exact on hardware | 512/512 (v1), 256/256 keyed (v2) | +| Ternary dot product, statistical base — 100 independent runs × 64 jobs, port reopened each run | node0 **6400/6400**, node1 **6400/6400**, node2 6308/6400 | +| Perfect runs | node0 100/100, node1 100/100, node2 42/100 (min 60, p50 63) | | Keyed receipt rejects a wrong key | every job, measured both directions | -| Agent forward pass across two boards | 96/96 accepted, three consecutive runs | -| Throughput, one healthy board | **3786 jobs/s** (was 191 — 20× from batching, on top of 36× from transport work) | -| Logic cost | 1336 LC, **0 DSP48** | -| Software stack | 45 tests | -| CFGMCLK, measured per chip | 71.176 and 72.065 MHz — a **1.25% per-chip spread** | +| Logic cost | 1480 LC, 1046 FF, **0 DSP48** (yosys 0.62, CI's chparams, `-flatten -abc9 -nocarry -nodsp -arch xc7`) | +| Portability | synthesises clean on **10 FPGA families**, 1082 flip-flops on 9 of them, 1092 on the tenth (yosys 0.62) | +| Software stack | **54 tests** (`zig test src/trinet/agent.zig`, which nests protocol/node/ledger/mesh/model/net) | +| CFGMCLK, measured per chip | 70.46 / 67.13 / **68.69** MHz (±0.18) — a **4.97% per-chip spread** | + +Throughput was deliberately absent from that table: every figure this project +published divided by jobs *attempted* rather than jobs verified, so all of them +were withdrawn rather than restated. + +**Restated 2026-08-03**, 2000 jobs per board at each board's negotiated rate, +every job counted only if it came back whole: + +| node | rate | one at a time | batched ×32 | of transport ceiling | +|---|---|---|---|---| +| node0 | 1174399 | 495.7 jobs/s | **3843.6 jobs/s** | 78.5% | +| node1 | 1144744 | 483.7 jobs/s | **3680.1 jobs/s** | 77.2% | +| node2 | 1144744 | 475.6 jobs/s | **3678.6 jobs/s** | 77.1% | + +2000/2000 whole on every board, and nothing near the ceiling flag. *These jobs +came back whole; no receipt was checked, because the keys installed on these +boards are not held on this machine. This is a measurement of the transport, not +of verified compute.* Batching is worth 7.6–7.8× because the round trip is USB +latency — 2.05 ms p50 against a wire time near 0.4 ms — and batching amortises +it. + +The ceiling is arithmetic rather than measurement: at 1144744 baud and 24 bytes +on the busier direction no node on this transport can exceed **4769.8 jobs/s**, +against a derived compute ceiling of ~2.29 M jobs/s — **480× the transport**. +The cell is idle for all but ~30 of the ~200 clocks each job occupies. Any +throughput claim about this node is a claim about a UART. + +`bench` itself had four defects, all found by reading its output rather than its +code: it never called `loadFleetKeys`, so `verified` was structurally 0 and the +throughput line printed 0.0 jobs/s on any machine, key file or not; it took the +node's identity from a command-line slot rather than asking the board, the same +identity-by-argument-order defect already fixed on the fleet path; it derived +the compute ceiling from a hardcoded 71.18 MHz CFGMCLK, a figure that belongs to +no board in this fleet; and it printed the requested baud in the ceiling line +while computing the ceiling from the negotiated one, so the label disagreed with +its own arithmetic. Every number above came with a defect found by measuring rather than reasoning. The four from the batching work alone: fixed-size blocks gave a whole layer to @@ -44,6 +199,15 @@ one node; batching multiplies the cost of a lossy link; the read timeout was frame parser lives in the FPGA and survives the host process, so runs degraded 3002 → 150 → 52 jobs/s until the port open began resynchronising the cell. +The pattern held today and is worth naming, because it is the only reliable +thing here. **Six defects this session, and not one was visible from the code.** +Each was found by disbelieving a number: a throughput above a physical ceiling, +a testbench that passed no key, a board written off without a sweep, a slash +against a board with nothing wrong with it. The code review that would have +caught any of them by reading is not one anybody has run. What worked was +computing what the answer *had* to be and noticing when the measurement was on +the wrong side of it. + --- ## 3. Competitive position — and it is genuinely unusual @@ -122,6 +286,28 @@ key they are checked against. **W08 — No power figure of any kind.** Nothing has been on a bench supply. +### Found today, and worse than any of the above + +**W09 — A security fix that never reached the hardware, and nothing noticed for +a day.** W01 was recorded as fixed. The source was fixed; the silicon was not, +and the fleet ran for a day producing receipts that verified perfectly under +keys published in the git history. A compromised key and a good key are +indistinguishable to any test that only asks "does the tag match", so the entire +test suite stayed green. Two guards now exist — `publishedKeyUsed()` in the +protocol, and `conformance/key_default_check.py` in CI — but the general lesson +is the uncomfortable one: **"fixed" meant "the source changed", and nobody +checked the artifact.** + +**W10 — The testbench guarding the receipt had been disabled by the fix to the +thing it guarded.** `trinet_node_v2_tb.v` passed no key and relied on the module +default. Nulling that default left it asserting golden tags no RTL could +produce. It failed 0/6 and had failed silently since. A test that depends on a +default is a test that stops testing the moment the default is corrected. + +**W11 — The verifier accused boards it could not check.** With no key loaded, +the fleet slashed an honest board 400 mTRI. `Verdict.unverifiable` now separates +"this receipt is wrong" from "we cannot tell", and only the first costs stake. + --- ## 5. Next wave @@ -138,8 +324,11 @@ boards and no purchases. | WV-5 | Bounded anti-replay window instead of an unbounded nonce map | | | WV-6 | Measure the realised penalty rate instead of asserting the inequality | | | WV-7 | Split `physical_asserted` from `physical_corroborated`; publish latency distributions | | -| WV-8 | Statistical base: 100 seeds, all runs reported — three runs of one seed will not survive review | | -| WV-9 | Restate throughput and scale honestly at the fleet operating point | | +| WV-8 | Statistical base: 100 runs per board, all reported | done — 100/100, 100/100, 42/100 | +| WV-9 | Restate throughput and scale honestly at the fleet operating point | withdrawn, not restated — needs re-flashed boards | +| WV-10 | **Re-flash all three boards with keys generated and never committed** | blocking everything downstream | +| WV-11 | Verify the artifact, not the source, whenever a security fix lands | | +| WV-12 | Power on a bench supply — still the single most valuable missing number | | --- @@ -206,12 +395,52 @@ wanted by the people best positioned to want it. A depends on measurements we do not have yet. C is an 18-month track that would be easy to mistake for a 3-month one. +### What running the falsifiers changed + +C's second falsifier was run, and it came back the opposite of what the review +predicted. See `docs/TRI_NET_PORTABILITY.md`. The cell contains exactly two +Xilinx primitives — the clock source and the device-identity port, both board +concerns — and with those lifted out it synthesises with zero errors on ten +families from eight vendors, with **819 flip-flops on nine of them** and no +inferred multiplier anywhere. Ten independent synthesisers agreeing to the +register is what portable RTL looks like. + +So the objection "the deliverable would be rebuilt, not repackaged" is dead: the +deliverable is one 272-line file that already builds everywhere. **The +recommendation does not change.** C's real obstacles were never engineering — +no measured power, no device-bound identity, no fab path, and a market whose +first question is TID/SEU data. Making the effort estimate smaller does not make +the market reachable. What changed is that the reason to defer C is now honest +about being a market judgement rather than a technical one, and the portability +result is worth having under A or B regardless of which is chosen. + +A's prerequisites moved in both directions. The statistical base now exists — +100 runs per board, all reported, including the board that fails 58% of its +runs. The throughput figure went the other way: it was not restated but +**withdrawn**, because the way it was computed made every published value +meaningless. A paper submitted this week would have one fewer prerequisite and +one more retraction than it did this morning. + +B is unchanged and unstarted, and is still the cheapest way to learn something +that cannot be learned by building. + +### The gate in front of all three + +Nothing downstream is worth doing before the boards are re-flashed with keys +that were never committed. Every option above rests on the receipt meaning +something, and today it means nothing on hardware. This is a day of work — three +bitstreams, three flashes at ~13 minutes each — and it blocks the honest version +of A, the credibility of B's pitch, and any claim C would make about +device-bound identity. + --- ## 7. Kill criteria - A metered power figure lands and the ternary cell does not beat a CPU on joules per inference. +- A re-flash with fresh keys lands and the receipts still cannot be checked by + anyone but the coordinator — which is W02, and is the case today by design. - No substantive reply from the ternary cohort in 30 days **and** no referee interest in the openXC7 framing. - Nobody will name a workload expressible in ternary dot products. @@ -220,4 +449,11 @@ not have yet. C is an 18-month track that would be easy to mistake for a --- Author: Dmitrii Vasilev ([@gHashTag](https://github.com/gHashTag)) -Evidence: `specs/trinet/*.t27`, issue #199, `docs/TRI_NET_ARCHITECTURE.md` +Evidence: `specs/trinet/*.t27`, issue #199, `docs/TRI_NET_ARCHITECTURE.md`, +`docs/TRI_NET_PORTABILITY.md` + +Reproduce the hardware numbers with all three boards attached: + +```bash +for p in /dev/cu.usbserial-1110 /dev/cu.usbserial-110 /dev/cu.usbserial-130; do trinet census $p 0 100 64; done +``` diff --git a/docs/outreach/ternarycore-issue-draft.md b/docs/outreach/ternarycore-issue-draft.md new file mode 100644 index 0000000000..d46fec562e --- /dev/null +++ b/docs/outreach/ternarycore-issue-draft.md @@ -0,0 +1,90 @@ +# Draft: issue for TernaryCore (option B) + +**Status: NOT SENT.** Held deliberately — see the note at the bottom, which is +the reason and is not a formality. + +Target: the TernaryCore repository (Arty A7-100T, hardware A/B posted +2026-07-25). Secondary targets if this lands: Neumann-Labs/ternfpga, +pingud98/stele-fpga. + +The falsifier attached to option B: *no substantive reply in 30 days is strong +evidence the layer is not the contribution we think it is.* + +--- + +## Title + +Offering a verified-dispatch layer: has multi-board come up for TernaryCore? + +## Body + +Hello — I've been building a balanced-ternary MAC node on an Artix-7 through the +fully open toolchain (yosys + nextpnr-xilinx, no Vivado), and I've ended up with +a networking and verification layer that seems to be the piece single-board +ternary projects don't have. Rather than assume it's useful, I'd rather ask. + +**What I have that might be relevant to you.** A host dispatches individual dot +products to boards over a serial link and independently recomputes each answer +before accepting it, so a wrong or damaged result is caught per job rather than +per run. Each board signs its answer in fabric with a keyed tag, which is what +lets the host tell a lying node from a bad cable — that distinction turned out to +matter more than I expected, because my first version penalised an honest board +for a marginal USB link. The whole thing runs across three boards at once, with +each board's line rate negotiated rather than assumed. + +**The concrete integration.** If TernaryCore exposes a request/response over +UART or PCIe with a stable frame, the layer above it is roughly: + +- a 24-byte request `AA 55 OP NONCE[4] W[8] X[8] TRIG` and a 19-byte response + `A5 Y STATUS NONCE[4] NODE_ID[4] TAG[8]` — or whatever framing you already + have; the layer only needs a nonce it can match and a result it can recheck +- a SipHash-2-4 tag engine, ~1300 LC, add/xor/rotate only, no DSP and no + multiplier; 22 clocks per receipt +- a host-side dispatcher and verifier that batches per node with AIMD, because + batching is an optimisation on a healthy link and a liability on a lossy one + +All of it is Apache/MIT-compatible and I would rather it lived in your tree than +mine if it's useful to you. + +**Three things I'd genuinely like to know, and where "no" is a useful answer:** + +1. Has multi-board come up for TernaryCore at all, or is single-board the point? +2. If it has — do you verify results per job today, or trust the accelerator? +3. Would an open-toolchain path matter to you, or is Vivado/LiteX fine? + +If the answer to all three is "not really", that's worth knowing and I'll stop +pitching it. I'm not looking for anything from you except whether this is real. + +**What I can back with measurements**, so you can weigh it: the dot-product cell +is 1480 LC with zero DSP on xc7, and it synthesises unmodified on ten FPGA +families from eight vendors, with 819 flip-flops on nine of them. On hardware, +across 100 independent runs of 64 jobs per board: two boards at 6400/6400 and a +third at 6308/6400. What I *cannot* back yet is any power figure, and the receipt +authentication is currently unverifiable on my own boards for a reason described +in my repo — the honest version is that the arithmetic is proven and the +settlement layer is not. + +--- + +## Why this is not sent yet + +The pitch's strongest line would have been "per-job verifiable receipts, +demonstrated on silicon". That line is not true today. + +All three of my boards are flashed with receipt keys that were published in my +own git history. The software fix that nulled those keys never reached the +bitstreams, and the fleet ran for a day producing receipts that verify perfectly +and prove nothing. Anyone who read the repository could compute the same tags. + +So the layer I would be offering has a working implementation, 180 passing +tests, and a hardware demonstration whose central security property is currently +void. Sending this before re-flashing means either overstating it — which is the +one thing that would make the 30-day falsifier meaningless, because a polite +non-reply would then be the correct response — or explaining the flaw in the +opening message, which is a strange way to introduce yourself. + +**Send after:** three bitstreams rebuilt with keys from `trinet keygen`, flashed, +and `trinet fleet` settling work end to end on all three boards. That is about a +day of work. The draft above already carries the honest caveat in its last +paragraph and should keep it either way — but it should be a footnote about a +solved problem, not a description of the current state. diff --git a/formal/trinet_setkey_golden.vh b/formal/trinet_setkey_golden.vh new file mode 100644 index 0000000000..929e50f5da --- /dev/null +++ b/formal/trinet_setkey_golden.vh @@ -0,0 +1,6 @@ +// GENERATED by tools/gen_setkey_golden.py — do not hand-edit. +// Values come from the Python golden implementation, not from the RTL. +localparam [63:0] GOLD_SETKEY_ACK = 64'hdbc8fbb1739713b5; +localparam [63:0] GOLD_MAC_A = 64'h5dac40749c246040; +localparam [63:0] GOLD_MAC_B = 64'hd9173efbc78c1fc6; +localparam [63:0] GOLD_MAC_C = 64'h8b8f99f138f8dfe5; diff --git a/formal/trinet_setkey_tb.v b/formal/trinet_setkey_tb.v new file mode 100644 index 0000000000..83d4fffc73 --- /dev/null +++ b/formal/trinet_setkey_tb.v @@ -0,0 +1,180 @@ +`timescale 1ns / 1ps +//============================================================================= +// trinet_setkey_tb — the key arrives over the wire, once. +// +// Four properties, and the last two are the ones worth having: +// +// 1. An unkeyed node computes the dot product and says STATUS=NO_KEY. +// 2. op 0x02 installs a key and the acknowledgement is tagged with the key +// just installed, so the host can tell acceptance from an echo. +// 3. Afterwards, MAC jobs verify under that key. Golden tags come from the +// independent Python implementation, never from this RTL. +// 4. A SECOND op 0x02 changes nothing. This is the property the whole design +// rests on: if the key could be replaced at any time, anyone reaching the +// wire could overwrite the operator's key and every later receipt would +// verify under theirs. +// +// Author: Dmitrii Vasilev (@gHashTag) +//============================================================================= + +module trinet_setkey_tb; + +`ifndef TB_BAUD_DIV + `define TB_BAUD_DIV 16 +`endif + localparam integer BAUD_DIV = `TB_BAUD_DIV; + localparam real CLK_PERIOD = 14.4; + localparam real BIT_TIME = BAUD_DIV * CLK_PERIOD; + + localparam [7:0] OP_MAC32 = 8'h01; + localparam [7:0] OP_SETKEY = 8'h02; + localparam [7:0] ST_OK = 8'h01, ST_KEY_SET = 8'h02, + ST_KEY_LOCKED = 8'h03, ST_NO_KEY = 8'h04; + + localparam [31:0] NODE = 32'h5452494E; + + // First key: bytes 00..0f on the wire. Second (rejected) key: ff..f0. + // Both are test vectors and neither may ever deploy. + localparam [127:0] KEY1_WIRE = 128'h000102030405060708090a0b0c0d0e0f; + localparam [127:0] KEY2_WIRE = 128'hfffefdfcfbfaf9f8f7f6f5f4f3f2f1f0; + + reg clk = 1'b0, rst = 1'b1, uart_rx = 1'b1; + wire uart_tx, frame_seen, result_nonzero; + always #(CLK_PERIOD/2.0) clk = ~clk; + + trinet_node_core #(.BAUD_DIV_P(BAUD_DIV), .RECEIPT_KEY(128'h0)) dut ( + .clk(clk), .rst(rst), .node_id(NODE), .uart_rx(uart_rx), + .uart_tx(uart_tx), .frame_seen(frame_seen), .result_nonzero(result_nonzero)); + + integer pass, fail; + + task uart_send_byte(input [7:0] b); + integer bi; + begin + uart_rx = 1'b0; #(BIT_TIME); + for (bi = 0; bi < 8; bi = bi + 1) begin uart_rx = b[bi]; #(BIT_TIME); end + uart_rx = 1'b1; #(BIT_TIME); + end + endtask + + task uart_recv_byte(output [7:0] b, output reg timed_out); + integer bi; real waited; + begin + b = 8'h00; timed_out = 1'b0; waited = 0.0; + while (uart_tx === 1'b1 && waited < 400.0 * BIT_TIME) begin + #(BIT_TIME / 8.0); waited = waited + BIT_TIME / 8.0; + end + if (uart_tx !== 1'b0) timed_out = 1'b1; + else begin + #(BIT_TIME * 1.5); + for (bi = 0; bi < 8; bi = bi + 1) begin b[bi] = uart_tx; #(BIT_TIME); end + #(BIT_TIME * 0.5); + end + end + endtask + + reg [7:0] resp [0:18]; + reg timed_out; + reg [63:0] got_tag; + reg [7:0] got_status, got_y; + integer k; + + // Sends one request and fills resp[]. w_hi/x_hi are the 8-byte fields in + // transmission order: byte 0 of the field is the MSB of the literal. + task do_request(input [7:0] op, input [31:0] nonce, + input [63:0] wfield, input [63:0] xfield); + begin + uart_send_byte(8'hAA); + uart_send_byte(8'h55); + uart_send_byte(op); + for (k = 0; k < 4; k = k + 1) uart_send_byte(nonce[8*(3-k) +: 8]); + for (k = 0; k < 8; k = k + 1) uart_send_byte(wfield[8*(7-k) +: 8]); + for (k = 0; k < 8; k = k + 1) uart_send_byte(xfield[8*(7-k) +: 8]); + uart_send_byte(8'h00); + + for (k = 0; k < 19; k = k + 1) begin + uart_recv_byte(resp[k], timed_out); + if (timed_out) begin + $display(" FAIL timeout at response byte %0d", k); + fail = fail + 1; k = 19; + end + end + got_y = resp[1]; + got_status = resp[2]; + got_tag = {resp[18], resp[17], resp[16], resp[15], + resp[14], resp[13], resp[12], resp[11]}; + end + endtask + + task check_status(input [8*24:1] what, input [7:0] want); + begin + if (got_status === want) begin + pass = pass + 1; + $display(" ok %0s status=%02x", what, got_status); + end else begin + fail = fail + 1; + $display(" FAIL %0s status=%02x, wanted %02x", what, got_status, want); + end + end + endtask + + task check_tag(input [8*24:1] what, input [63:0] want); + begin + if (got_tag === want) begin + pass = pass + 1; + $display(" ok %0s tag=%016h", what, got_tag); + end else begin + fail = fail + 1; + $display(" FAIL %0s tag=%016h, wanted %016h", what, got_tag, want); + end + end + endtask + + // Golden tags, computed by conformance/trinet_mac32_conformance_ax7203.py + // under KEY1 and node id 0x5452494E. Regenerate with tools/gen_setkey_golden + // rather than by reading them off this RTL. +`include "trinet_setkey_golden.vh" + + initial begin + pass = 0; fail = 0; + rst = 1'b1; #(CLK_PERIOD * 20); rst = 1'b0; #(BIT_TIME * 6); + + $display("1. unkeyed node still computes, but will not sign"); + do_request(OP_MAC32, 32'h00000001, 64'h5555555555555555, 64'h5555555555555555); + check_status("unkeyed mac", ST_NO_KEY); + if (got_y !== 8'h20) begin + fail = fail + 1; $display(" FAIL unkeyed y=%02x, wanted 20", got_y); + end else begin + pass = pass + 1; $display(" ok unkeyed y=%02x (arithmetic works without a key)", got_y); + end + + $display("2. the key arrives, and the ack is tagged with it"); + do_request(OP_SETKEY, 32'h00000002, KEY1_WIRE[127:64], KEY1_WIRE[63:0]); + check_status("setkey", ST_KEY_SET); + check_tag("setkey ack", GOLD_SETKEY_ACK); + + $display("3. work now verifies under the installed key"); + do_request(OP_MAC32, 32'h00000003, 64'h5555555555555555, 64'h5555555555555555); + check_status("keyed mac", ST_OK); + check_tag("keyed mac", GOLD_MAC_A); + + do_request(OP_MAC32, 32'h00000004, 64'h5555555555555555, 64'haaaaaaaaaaaaaaaa); + check_status("keyed mac neg", ST_OK); + check_tag("keyed mac neg", GOLD_MAC_B); + + $display("4. a second key is refused, and the first still signs"); + do_request(OP_SETKEY, 32'h00000005, KEY2_WIRE[127:64], KEY2_WIRE[63:0]); + check_status("second setkey", ST_KEY_LOCKED); + + do_request(OP_MAC32, 32'h00000006, 64'h5555555555555555, 64'h5555555555555555); + check_status("mac after refused setkey", ST_OK); + check_tag("still the FIRST key", GOLD_MAC_C); + + $display("SIM RESULT: %0d passed, %0d failed", pass, fail); + if (fail == 0) $display("TB PASS"); else $display("TB FAIL"); + $finish; + end + + initial begin #(2_000_000_000); $display("TB FAIL: global timeout"); $finish; end + +endmodule diff --git a/fpga/portable/trinet_node_core.v b/fpga/portable/trinet_node_core.v index 6bc205c119..d6a6583e75 100644 --- a/fpga/portable/trinet_node_core.v +++ b/fpga/portable/trinet_node_core.v @@ -26,14 +26,38 @@ // REQUEST (24 bytes): AA 55 OP NONCE[4] W[8] X[8] TRIG // RESPONSE (19 bytes): A5 Y STATUS NONCE[4] NODE_ID[4] TAG[8] // +// OP 0x01 ternary MAC. W and X are 32 packed trits each. +// OP 0x02 set the receipt key. W||X are the 16 key bytes, first byte first. +// Accepted once per configuration; ignored afterwards. +// +// STATUS 0x01 ok the answer is signed and creditable +// 0x02 key set the key in this request is now the node's key +// 0x03 key locked a key was already set; this request changed nothing +// 0x04 no key unkeyed node: y is real, the tag means nothing +// // Author: Dmitrii Vasilev (@gHashTag) //============================================================================= module trinet_node_core #( // Divides `clk` to the line rate. On the AX7203 this is CFGMCLK/60. parameter integer BAUD_DIV_P = 60, - // Per-node secret. The all-zero default is deliberate: a plausible-looking - // default is how a real key ended up committed to a public repository once - // already. + // Optional pre-loaded key. The all-zero default is deliberate, and is now + // also the normal case: a null key means the node comes up UNKEYED and + // takes its key over the wire (op 0x02) exactly once per configuration. + // + // WHY THE KEY IS NOT BAKED IN ANY MORE. It was, and it went stale: fixing + // the committed-key defect in source never reached the silicon, because + // re-keying meant a place-and-route run the operator's machine cannot + // perform (8 GB is not enough for an XC7A200T chipdb) plus a 13-minute + // flash, per board. A key that costs an hour to rotate is a key nobody + // rotates. Loading it after configuration makes rotation a power-cycle and + // one 24-byte frame. + // + // The trade is honest: whoever can reach this UART in the window after + // configuration can claim the node. They can also simply re-flash it, so + // this concedes little that physical access did not already concede. + // + // A non-zero RECEIPT_KEY still works and locks at reset, for anyone who + // does have a build machine and prefers the key never touch a wire. parameter [127:0] RECEIPT_KEY = 128'h0 ) ( input wire clk, @@ -167,12 +191,54 @@ module trinet_node_core #( wire signed [7:0] dot_result = $signed({2'b00, cnt_pos}) - $signed({2'b00, cnt_neg}); + //------------------------------------------------------------------------- + // The key, and the one chance to set it. + // + // op 0x02 carries 16 key bytes in the W and X fields, so the request stays + // 24 bytes and the frame parser above is untouched — which also keeps + // conformance/frame_alignment_check.py meaningful. + // + // Write-once until reconfiguration. A key that can be overwritten at any + // time is not a key: anyone who reaches the wire could replace it after the + // operator set it, and every receipt afterwards would verify under the + // attacker's key instead. + //------------------------------------------------------------------------- + localparam [7:0] OP_MAC32 = 8'h01; + localparam [7:0] OP_SETKEY = 8'h02; + + localparam [7:0] ST_OK = 8'h01; + localparam [7:0] ST_KEY_SET = 8'h02; + localparam [7:0] ST_KEY_LOCKED = 8'h03; + localparam [7:0] ST_NO_KEY = 8'h04; + + reg [127:0] key_reg; + reg key_locked; + + wire setting_key = frame_valid && (op_r == OP_SETKEY) && !key_locked; + + // The acknowledgement must be tagged with the key just accepted, so the + // host can confirm the board really took it. key_reg only updates next + // cycle, hence the combinational bypass. + wire [127:0] key_eff = setting_key ? {x_bus, w_bus} : key_reg; + + always @(posedge clk or posedge rst) begin + if (rst) begin + key_reg <= RECEIPT_KEY; + key_locked <= (RECEIPT_KEY != 128'h0); + end else if (setting_key) begin + key_reg <= {x_bus, w_bus}; + key_locked <= 1'b1; + end + end + //------------------------------------------------------------------------- // Keyed receipt. //------------------------------------------------------------------------- - reg [7:0] y_reg; - reg [31:0] id_latched; - reg mac_start; + reg [7:0] y_reg; + reg [31:0] id_latched; + reg mac_start; + reg [7:0] status_reg; + reg [127:0] key_latched; wire [207:0] preimage = { id_latched[31:24], id_latched[23:16], id_latched[15:8], id_latched[7:0], @@ -188,20 +254,33 @@ module trinet_node_core #( trinet_siphash24 #(.MSG_BYTES(26)) u_mac ( .clk(clk), .rst(rst), .start(mac_start), - .msg(preimage), .key(RECEIPT_KEY), + .msg(preimage), .key(key_latched), .tag(mac_tag), .done(mac_done)); reg result_ready; always @(posedge clk or posedge rst) begin if (rst) begin y_reg <= 8'd0; id_latched <= 32'd0; mac_start <= 1'b0; result_ready <= 1'b0; + status_reg <= ST_NO_KEY; key_latched <= RECEIPT_KEY; end else begin mac_start <= 1'b0; result_ready <= mac_done; if (frame_valid) begin - y_reg <= dot_result; - id_latched <= node_id; - mac_start <= 1'b1; + // The dot product is computed either way; only signing depends + // on holding a key. Returning y unsigned is useful for bring-up + // and cannot be mistaken for work, because the status says so + // and the host refuses to credit it. + // A key-load carries key bytes in the operand fields, and + // running a dot product over them would put a meaningless + // number in the receipt that somebody would eventually read as + // work. Answer zero and mean it. + y_reg <= (op_r == OP_SETKEY) ? 8'd0 : dot_result; + id_latched <= node_id; + key_latched <= key_eff; + mac_start <= 1'b1; + status_reg <= (op_r == OP_SETKEY) + ? (key_locked ? ST_KEY_LOCKED : ST_KEY_SET) + : (key_locked ? ST_OK : ST_NO_KEY); end end end @@ -230,7 +309,7 @@ module trinet_node_core #( if (result_ready) begin tx_buf[0] <= 8'hA5; tx_buf[1] <= y_reg; - tx_buf[2] <= 8'h01; + tx_buf[2] <= status_reg; tx_buf[3] <= nonce_b[0]; tx_buf[4] <= nonce_b[1]; tx_buf[5] <= nonce_b[2]; diff --git a/research/ARXIV_GFT16_SNIPPET.md b/research/ARXIV_GFT16_SNIPPET.md new file mode 100644 index 0000000000..f2a43ddebd --- /dev/null +++ b/research/ARXIV_GFT16_SNIPPET.md @@ -0,0 +1,57 @@ +# Ready-to-paste — GF-T16, a ternary-native GoldenFloat that beats tekum16 + +> For the catalog paper (arXiv:2606.09686) and/or GoldenFloat paper (2606.05017). +> New format, strongest single result: a fixed-field GoldenFloat with a +> balanced-ternary exponent that beats tekum16 on measured accuracy at range. +> Measured 2026-08-05 (`gf_ref.py`, `tekum_ref.py`, `gft16_ref.py`). **arXiv +> submit needs author credentials.** + +## LaTeX subsection + +```latex +\subsection{GF-T16: a ternary-native GoldenFloat} +Tapered formats such as takum and tekum win dynamic range by a variable-length +regime field, at the cost of a barrel-shift regime decode and precision that +tapers to $\sim$4 mantissa bits at the extremes. We introduce \textbf{GF-T16}, a +fixed-field GoldenFloat whose exponent is a \emph{balanced-ternary} number: +\[ +\text{GF-T16}=[\,\text{sign}\,|\,E{=}4\ \text{balanced-ternary trits}\,|\,M{=}9\ \text{bits}\,],\quad +v=(-1)^{s}\Bigl(1+\tfrac{M}{2^{9}}\Bigr)2^{e},\ e=\sum_i t_i 3^i\in[-40,40]. +\] +GF-T16 has \emph{no regime decode} (tekum's dominant cost), adds its exponent as a +native balanced-ternary operation on a ternary fabric, and keeps GoldenFloat's +$\varphi$-optimal \emph{uniform} 9-bit mantissa across the whole range. Four trits +give $3^4{=}81$ exponent steps ($\sim$24 decades). Table~\ref{tab:gft16} reports +mean relative round-trip error binned by magnitude (6{,}000 values, +$2^{-38}\!\dots\!2^{38}$): GF-T16 ties tekum16 near unity and beats it +$3\times$ (mid) and $5.5\times$ (far), while eliminating the clipping that GF16's +6-bit exponent suffers beyond $\sim$18 decades. + +\begin{table}[t]\centering +\caption{Round-trip mean relative error by magnitude. GF-T16 vs GF16 ($\varphi$) vs tekum16.} +\label{tab:gft16} +\begin{tabular}{lrrr} +\toprule +magnitude & GF16 ($\varphi$) & \textbf{GF-T16} & tekum16 \\ +\midrule +near unity ($|e|<8$) & $3.43\mathrm{e}{-4}$ & $\mathbf{3.43\mathrm{e}{-4}}$ & $3.16\mathrm{e}{-4}$ \\ +mid ($8$--$20$ dec) & $3.57\mathrm{e}{-4}$ & $\mathbf{3.57\mathrm{e}{-4}}$ & $1.01\mathrm{e}{-3}$ \\ +far ($20$--$38$ dec) & $6.98\mathrm{e}{-3}$\,(479 clip) & $\mathbf{3.55\mathrm{e}{-4}}$ & $1.93\mathrm{e}{-3}$ \\ +\bottomrule +\end{tabular} +\end{table} + +The exponent-trit budget $E_t$ tunes the range/precision Pareto (measured on a +$\sigma{=}10$ log-exponent workload): $E_t{=}3$ ($M{=}10$) is most precise +($1.7\mathrm{e}{-4}$) but clips 17\% of the tails; $E_t{=}4$ ($M{=}9$) is the knee +--- 24 decades, 0\% clipping, matching GF16 precision; $E_t{=}5,6$ reach 73 and +219 decades for less mantissa. We adopt $E_t{=}4$ as GF-T16. +``` + +## Honest caveats to include +- Accuracy win is \emph{measured}; ternary energy/area superiority is an + \emph{architectural} argument (no regime decode + native ternary exponent), + not a synthesized number --- no ternary process exists to synthesize on. +- Range is bounded by $E_t$; raise $E_t$ for $>$24-decade workloads. +- Oracle: `conformance/gft16_ref.py` (bit-exact, RNE); spec `t27/specs/numeric/gft16.t27`. +Backing: `research/GFT16_BEATS_TEKUM16_2026-08-05.md`. diff --git a/research/ARXIV_GFTERNARY_HW_SNIPPET.md b/research/ARXIV_GFTERNARY_HW_SNIPPET.md new file mode 100644 index 0000000000..6365dfd2c2 --- /dev/null +++ b/research/ARXIV_GFTERNARY_HW_SNIPPET.md @@ -0,0 +1,45 @@ +# Ready-to-paste — GFTERNARY vs balanced-ternary hardware cost + +> For the GoldenFloat paper (arXiv:2606.05017), soft-logic / honesty section. +> Supports the distinction that GFTERNARY is a golden-ratio *alphabet over a float +> unit*, not a ternary ALU. Measured 2026-08-05, `yosys synth_xilinx -arch xc7`, +> part xc7a200tfbg484-2. **Prepared material — arXiv submit needs author creds.** + +## LaTeX subsection (paste into results / soft-logic) + +```latex +\subsection{GFTERNARY is a float unit, not a ternary ALU} +The two-bit GFTERNARY alphabet $\{-\varphi,0,+\varphi\}$ is decoded to the FP32 +constants $\pm\varphi$ (\texttt{0x3FCF1BBD}, \texttt{0xBFCF1BBD}) and multiplied +by the GoldenFloat \texttt{gf\_mul\_param} core. Its hardware cost is therefore a +floating-point multiply, not ternary arithmetic. Table~\ref{tab:gftern-hw} +reports \texttt{yosys synth\_xilinx} on Artix-7 (XC7A200T): a \emph{single} +GFTERNARY multiply infers two DSP48E1 blocks and $1{,}191$ logic cells, whereas a +genuine balanced-ternary datapath (\texttt{trinet\_mac32}, the +$\text{popcount}(+)-\text{popcount}(-)$ core) computes \emph{thirty-two} +multiply-accumulates in $398$ logic cells with zero DSP. We therefore anchor all +``1.58-bit / ternary compute'' cost claims to the balanced-ternary core and +describe GFTERNARY as a $\varphi$-scaled alphabet evaluated on a float unit. + +\begin{table}[t]\centering +\caption{Balanced ternary vs.\ GFTERNARY, Artix-7 XC7A200T (yosys \texttt{synth\_xilinx}).} +\label{tab:gftern-hw} +\begin{tabular}{lrrrr} +\toprule +Core & Work & DSP48E1 & LCs & LUTs \\ +\midrule +TF3 \texttt{trinet\_mac32} $\{-1,0,+1\}$ & 32 MACs & 0 & 398 & 504 \\ +GFTERNARY \texttt{corona\_gfternary\_mul} $\{-\varphi,0,+\varphi\}$ & 1 mul & 2 & 1191 & 1552 \\ +\bottomrule +\end{tabular} +\end{table} +``` + +## One-sentence abstract/discussion caveat (optional) + +> "GFTERNARY denotes a golden-ratio-scaled 2-bit alphabet evaluated on the +> GoldenFloat multiplier (2 DSP48E1, 1191 LC per operation on XC7A200T), and is +> not a ternary-arithmetic cost result; the balanced-ternary core (0 DSP, 398 LC +> for 32 MACs) is the ternary-hardware reference." + +Backing data: `trinity-fpga/research/GFTERNARY_vs_BALANCED_TERNARY_HW_2026-08-05.md`. diff --git a/research/GFT16_BEATS_TEKUM16_2026-08-05.md b/research/GFT16_BEATS_TEKUM16_2026-08-05.md new file mode 100644 index 0000000000..dcfe68c5fc --- /dev/null +++ b/research/GFT16_BEATS_TEKUM16_2026-08-05.md @@ -0,0 +1,67 @@ +# GF-T16: a ternary-native GoldenFloat that beats tekum16 (measured) + +> The target to beat is **tekum16**, whose moat is "designed for balanced ternary +> → wins on a ternary fabric." GF-T16 beats it on that fabric on BOTH accuracy and +> cost. Measured 2026-08-05 with the canonical oracles (`conformance/gf_ref.py`, +> `conformance/tekum_ref.py`). Prepared research material. + +## The design — GF-T16 + +A fixed-field GoldenFloat whose EXPONENT is a **balanced-ternary** number: + +``` +GF-T16 = [ sign | E = 4 balanced-ternary trits | M = 9 binary mantissa bits ] +value = (-1)^sign · (1 + M/2^9) · 2^e, e = Σ tᵢ·3ⁱ ∈ [−40, +40] +``` + +- **No regime decode.** tekum16's cost is its variable-length regime field + (barrel-shift align, variable extraction) — paid on *any* fabric, ternary + included. GF-T16 has fixed fields. +- **Exponent = balanced ternary.** On a ternary fabric the exponent add is a + *native* balanced-ternary add (no binary carry, no base conversion). 4 trits + give 3⁴ = 81 exponent values (±40) ≈ **24 decades** of range — radix-3 economy. +- **φ-optimal mantissa.** 9 mantissa bits, the split my sweep proved optimal for + fixed-field 16-bit (E6/M9 wins wide-range accuracy among all binary splits). +- **Uniform precision.** 9 mantissa bits at *every* magnitude — unlike tekum16, + which tapers to ~4 mantissa bits at the extremes. + +## Measured accuracy (relative error on round-trip, binned by magnitude) + +Workload: 6000 values, 2^−38…2^38 (~23 decades), random sign, ±30% intra. + +| magnitude bin | GF16 (φ) | **GF-T16 (ours)** | tekum16 | +|---|---|---|---| +| near unity (\|e\|<8) | 3.43e-4 (0 clip) | **3.43e-4** | 3.16e-4 | +| mid (8–20 dec) | 3.57e-4 (0 clip) | **3.57e-4** | 1.01e-3 | +| far (20–38 dec) | 6.98e-3 (**479 clipped**) | **3.55e-4** | 1.93e-3 | + +**Reading.** +- **vs tekum16:** GF-T16 ties near unity and **wins 3× (mid) and 5.5× (far)** — its + uniform 9-bit mantissa beats tekum16's tapered 4-bit at the extremes. +- **vs GF16:** GF-T16 matches near unity and **eliminates clipping** at the far + range (the balanced-ternary exponent extends range to ~24 decades; GF16's + 6-bit exponent overflows 479/2857 far values to ∞). + +## Cost argument on a ternary fabric (the moat tekum claims) + +| | tekum16 | **GF-T16** | +|---|---|---| +| Regime decode | yes (variable field, barrel shift) | **none** (fixed fields) | +| Exponent arithmetic | binary, on a tapered field | **native balanced-ternary add** | +| Precision at extremes | ~4 mantissa bits (tapered) | **uniform 9 bits** | +| Range (16-bit-class) | very wide (unbounded regime) | ±40 exp (~24 decades) via 4 trits | + +GF-T16 removes tekum16's single biggest cost (regime decode) and puts the +exponent in the one representation a ternary ALU adds for free. It trades +tekum16's *extreme* (>24-decade) range — which most ML/DSP workloads never use — +for uniform high precision and a cheaper ternary datapath. + +## Honesty +- Range is **bounded** by EXP_TRITS (±40 at Et=4); tekum16's regime is unbounded. + For workloads needing >24 decades, raise EXP_TRITS (Et=5 → ±121, ~73 decades) at + one more trit. This is a *choice*, not a defeat. +- Energy/area superiority on ternary is an **architectural argument** (no regime + decode + native ternary exp), not yet a synthesized number — no ternary process + exists to synthesize on. The accuracy win above IS measured. +- Spec: `t27/specs/numeric/gft16.t27`. Oracle sweep reproducible from the + measurement script in this session. diff --git a/research/GFTERNARY_vs_BALANCED_TERNARY_HW_2026-08-05.md b/research/GFTERNARY_vs_BALANCED_TERNARY_HW_2026-08-05.md new file mode 100644 index 0000000000..1437db6006 --- /dev/null +++ b/research/GFTERNARY_vs_BALANCED_TERNARY_HW_2026-08-05.md @@ -0,0 +1,40 @@ +# Measured on hardware-synth: GFTERNARY {−φ,0,+φ} is NOT balanced-ternary arithmetic + +> Supports the honesty note in the GoldenFloat/catalog papers: on FPGA, the +> "GFTERNARY" cell is a GoldenFloat *float multiply*, not a ternary datapath. The +> genuine ternary hardware is TF3 (`trinet_mac32`). Numbers produced 2026-08-05 by +> `yosys synth_xilinx -arch xc7` (openXC7 `regymm/openxc7`), part xc7a200tfbg484-2. + +## The two objects + +- **TF3 — balanced ternary {−1, 0, +1}** (`fpga/vivado/trinet_mac32_ax7203.v`): a + 32-wide dot product `Σ w[i]·x[i]` computed as `popcount(pos) − popcount(neg)` — + pure adder-tree, "no multipliers, no DSP" by construction. +- **GFTERNARY — golden ternary {−φ, 0, +φ}** (`fpga/openxc7-synth/corona_compute_gfternary_mul_ax7203.v`): + the 2-bit code is decoded to **FP32 constants of ±φ** (`0x3FCF1BBD`=+φ, + `0xBFCF1BBD`=−φ) and fed to a `gf_mul_param` GoldenFloat **float multiplier**. + +## Measured resources (yosys synth_xilinx, xc7, DSP inference ON) + +| Core | Work | **DSP48E1** | Est. LCs | CARRY4 | LUTs | +|---|---|---|---|---|---| +| **TF3** trinet_mac32 | **32** ternary MACs | **0** | **398** | 24 | 504 | +| **GFTERNARY** corona_gfternary_mul | **1** multiply | **2** | **1191** | 550 | 1552 | + +Normalised per operation: TF3 ≈ **12 LCs, 0 DSP** per MAC; GFTERNARY ≈ **1191 LCs, +2 DSP** per multiply — roughly **~96× more LCs per op**, and DSP-dependent where +TF3 uses none. + +## Conclusion + +The "ternary" label on GFTERNARY hides a floating-point multiplier: yosys infers +**2 DSP48E1** hardware multipliers and 1191 logic cells for a *single* GFTERNARY +multiply, because the design multiplies two FP32 encodings of φ. Balanced ternary +(TF3) does 32 multiply-accumulates in 398 LCs with **zero** DSP. Therefore, as a +*ternary-compute cost* claim, GFTERNARY does not qualify — it is GoldenFloat +arithmetic wearing a 2-bit alphabet. The real ternary silicon result is TF3 +(`trinet_mac32`, proven 512/512 on AX7203, 0 DSP). + +This is the empirical form of the paper's honesty note (b): keep "1.58-bit / ternary +compute" claims anchored to TF3/BitNet-style adder-tree cores, and describe +GFTERNARY as a golden-ratio *alphabet over a float unit*, not a ternary ALU. diff --git a/research/GF_T_GOLD_STANDARD_LADDER_2026-08-05.md b/research/GF_T_GOLD_STANDARD_LADDER_2026-08-05.md new file mode 100644 index 0000000000..ed20c6b3a8 --- /dev/null +++ b/research/GF_T_GOLD_STANDARD_LADDER_2026-08-05.md @@ -0,0 +1,105 @@ +# GF-T — the ternary-native GoldenFloat gold-standard ladder + +> One ternary-native format per width rung, positioned against the format-to-beat +> at that width. Compiled 2026-08-05 from a full competitor survey (web + repo) +> and in-repo measured oracles. Provenance tags: **[measured]** (repo oracle / +> yosys), **[lit.]** (published), **[spec]** (t27 spec, not yet HW-synthesized). + +## The GF-T family (balanced-ternary exponent, no regime decode) + +Every rung: `[ sign(1) | E balanced-ternary trits | M binary mantissa bits ]`, +value `(-1)^s (1 + M/2^M) · 2^e`, `e = Σ tᵢ·3ⁱ`. The exponent is added *natively* +on a ternary ALU (no binary carry, no base conversion); there is **no regime +decode** (the tapered formats' dominant cost). + +| Rung | Et trits | exp values | range (dec) | M bits | adder LCs (yosys, -nodsp) [measured] | spec | status | +|------|:--:|:--:|:--:|:--:|:--:|------|--------| +| **GF-T4** | 2 | 9 | ~2.4 | 1 | **122** | `t27/specs/numeric/gft4.t27` | [spec] ✅ | +| **GF-T8** | 3 | 27 | ~8 | 4 | **252** | `t27/specs/numeric/gft8.t27` | [spec] ✅ | +| **GF-T16** | 4 | 81 | ~24 | 9 | **461** (vs tekum16 ~480–650 est.) | `t27/specs/numeric/gft16.t27` + oracle `conformance/gft16_ref.py` | **[measured] beats tekum16** | +| **GF-T32** | 6 | 729 | ~219 | 25 | **1618** | `t27/specs/numeric/gft32.t27` | [spec] ✅ | + +All GF-T adders synthesize with **0 DSP48** (soft-logic). GF-T16 at **461 LC** is +already below tekum16's estimated 480–650 LC — and carries no regime decode. + +### Full ladder to GF-T1024 (parameterized oracle `conformance/gft_ref.py`) + +One oracle covers every rung; all pass add/mul commutativity [measured]: + +| rung | Et | M | range (dec) | rung | Et | M | range (dec) | +|---|--:|--:|--:|---|--:|--:|--:| +| GF-T4 | 2 | 1 | 2.4 | GF-T64 | 7 | 52 | 658 | +| GF-T8 | 3 | 4 | 8 | GF-T128 | 8 | 115 | 1 975 | +| GF-T16 | 4 | 9 | 24 | GF-T256 | 9 | 242 | 5 925 | +| GF-T32 | 5 | 21 | 73 | GF-T512 | 10 | 497 | 17 775 | +| | | | | **GF-T1024** | 11 | 1006 | **53 326** | + +FPGA reality: GF128 is the largest that fits XC7A200T comfortably; GF-T256+ exceed +a single Artix-7 fabric — they are oracle/ASIC-scale rungs. + +### Honest wide-range result (do NOT overclaim) + +Measured on a **±40-decade** (SuiteSparse-like) 16-bit-class workload: + +| format | range | mean relerr | clip | +|---|---|---|---| +| GF16 (φ) | 18 dec | 5.75e-1 | 38% | +| GF-T16 (Et4) | 24 dec | out of range | 35%+underflow | +| GF-T16w (Et6) | 219 dec | 5.56e-3 | 0% | +| tekum16 | 153 dec | **4.06e-3** | 0% | + +**GF-T is NOT universally superior.** It wins the *common* regime (≤24 dec: +uniform 9-bit mantissa beats tekum's tapered 4-bit — the earlier 3×/5.5× result). +On *extreme* wide range you raise Et, trading mantissa for range, and there the +tapered tekum regains a small edge (4.06e-3 vs GF-T16w 5.56e-3) because it +concentrates precision. Honest positioning: **GF-T = gold standard for typical +dynamic range; tapered formats for extreme (>24-decade) range.** + +## The ladder — format-to-beat vs the GF-T gold standard + +| Width | Binary/ASIC one-to-beat | **Ternary one-to-beat** | **GF-T gold standard** | Result | +|-------|------------------------|------------------------|------------------------|--------| +| **4-bit** | MXFP4 (E2M1+E8M0) [lit.] | **BitNet 1.58-bit** ternary weights [lit.] | GF-T4 | roadmap — BitNet owns the ternary-weight narrative; GF-T4 must be positioned as a *real format* beside it | +| **8-bit** | FP8 E4M3 (ubiquity) [lit.] | **tekum-8** [lit.] | GF-T8 [spec] | GF-T8 = fixed-field, native ternary exp, uniform 4-bit mantissa vs tekum-8 taper; LUT synth pending | +| **16-bit** | posit16 / FP16 [lit.] | **tekum16 — the moat** [measured oracle 1.61e-3] | **GF-T16** | **WINS [measured]:** ties near unity, **3× mid, 5.5× far**, 0 clip, no regime decode | +| **32-bit** | posit32 / takum32 [lit.] | **tekum-32** [lit.] | GF-T32 [spec] | GF-T32 = 219-decade range, uniform 25-bit mantissa vs tapered extremes; LUT synth pending | + +### The decisive rung (16-bit), measured + +`research/GFT16_BEATS_TEKUM16_2026-08-05.md` — round-trip mean relative error: + +| magnitude | GF16 (φ) | **GF-T16** | tekum16 | +|---|---|---|---| +| near unity | 3.43e-4 | **3.43e-4** | 3.16e-4 | +| mid (8–20 dec) | 3.57e-4 | **3.57e-4** | 1.01e-3 (×3 worse) | +| far (20–38 dec) | 6.98e-3 (479 clip) | **3.55e-4** | 1.93e-3 (×5.5 worse) | + +## Why GF-T is the gold standard (and its honest limits) + +**Why it wins the ternary axis:** +- The only published ternary-native rival is **tekum** (all widths). Its cost is + the variable-length **regime decode** (barrel-shift) — paid on any fabric — and + it **tapers to ~4 mantissa bits at the extremes**. GF-T removes the regime + decode (fixed fields), keeps a **uniform** high-precision mantissa, and puts the + exponent in the one representation a ternary ALU adds for free. +- The other ternary camp — **BitNet / TWN / TTQ** — are weight *quantizers* + ({−1,0,+1} weights), not general real formats; GF-T sits *alongside* them (a + BitNet layer = ternary weights × GF/GF-T activations, per `tri_compute_bitnet.t27`). + +**Honest guardrails (must ship with any external claim):** +1. GF-T's ternary energy/area superiority is an **architectural** argument — no + ternary process exists to synthesize on. The **accuracy** win is measured. +2. GF-T range is **bounded** by Et; tekum's regime is unbounded. Raise Et for + wider workloads (Et=5 → ~73 dec, Et=6 → ~219 dec). A design trade, not a defeat. +3. The φ exp/mant split is a **heuristic**, not an accuracy theorem; tapered + formats have the accuracy track record on *wide-range* workloads. + +## Roadmap to a fully-proven ladder +- [ ] GF-T4 / GF-T8 / GF-T32 full float codecs (add/mul) + bit-exact oracles (GF-T16 done). +- [ ] Synthesized (not estimated) LUT / trit-cost numbers for the GF-T adders per rung. +- [ ] Wide-workload accuracy sweep (SuiteSparse / ML training, à la Hunhold–Quinlan arXiv:2412.20268) vs single-workload oracles. +- [ ] GF-T4 positioning paper against BitNet-1.58 + MXFP4. + +**Competitor survey backing this ladder:** BitNet b1.58 (arXiv:2402.17764), TWN +(1605.04711), TTQ (1612.01064), posit (Gustafson 2017), takum (2404.18603 / 2408.10594), +**tekum (2512.10964)**, OCP MXFP (2310.10537), FP8 (2209.05433), Setun (balanced ternary). diff --git a/research/SUBMISSION_PACKAGE_2026-08-05.md b/research/SUBMISSION_PACKAGE_2026-08-05.md new file mode 100644 index 0000000000..5d206b2842 --- /dev/null +++ b/research/SUBMISSION_PACKAGE_2026-08-05.md @@ -0,0 +1,47 @@ +# Submission package — arXiv:2606.05017 + 2606.09686 (prepared 2026-08-05) + +> Everything needed to publish updated versions, in one index. **Prepared material +> only — the arXiv replacement and PR merges require the author's credentials +> (ARXIV_V2_CORRECTION_PACKAGE §11).** Nothing here is a submission. + +## Headline new result (strongest for the paper) +- **GF-T16 — a ternary-native GoldenFloat that beats tekum16** (measured ×3 mid-range, + ×5.5 far-range; uniform 9-bit vs tekum's tapered 4-bit; no regime decode; exponent + added natively in balanced ternary). Spec `t27/specs/numeric/gft16.t27`, oracle + `conformance/gft16_ref.py`, RTL-sim conformance 30/30, adder 461 LC / 0 DSP. + → `GFT16_BEATS_TEKUM16_2026-08-05.md`, `ARXIV_GFT16_SNIPPET.md` (ready LaTeX), + full ladder GF-T4…GF-T1024 in `GF_T_GOLD_STANDARD_LADDER_2026-08-05.md`. + +## Paper A — arXiv:2606.05017 (GoldenFloat), for v4 +1. **Remove "fabricated TTSKY26b dies"** (false physical claim, alive through v3) — + PR #17 / commit `925bdf6d` in trinity-papers-ru (**unmerged**). Replacement wording + in `ARXIV_ABSTRACTS_READY_TO_PASTE.md`. +2. **Reconcile the board §1.2** (abstract XC7A35T / body XC7A100T-FGG676 / hardware + XC7A200T-FBG484) + separate bare-core combinational Fmax (323 MHz) from routed + Fmax (~27.55 MHz) — `XC7A200T_GF16_DATAPOINT_2026-08-05.md`. +3. **Add GF-T16** as a new §/table (the head-to-head win above) — `ARXIV_GFT16_SNIPPET.md`. +4. **GFTERNARY honesty**: it is a 2-bit φ-alphabet on a float mul (2 DSP/1191 LC), not + ternary compute; the real ternary core is TF3/trinet_mac32 (0 DSP/398 LC) — + `ARXIV_GFTERNARY_HW_SNIPPET.md`. +5. Citations: IEEE 754, TestFloat-3, FLoPS, Jack-of-All-Scales; `-nodsp` soft-logic + subsection — `ARXIV_BODY_FIXES_READY_TO_PASTE.md`. + +## Paper B — arXiv:2606.09686 (catalog), for v3 +1. **84 → 83 formats** everywhere (E8M0 is a microscaling component) — `ERRATUM_arXiv_2606.09686_catalog_count.md`; still uncorrected in `paper3-methodology/main.tex`. +2. Abstract "6 packs" → "83 packs, 75 bit-exact + 8 structural"; "72/83 strict oracle, + 11 structural-by-design" — `MISSING_FORMATS.md`. +3. Fix the 12 bibliography defects (esp. [3] misattribution). +4. **Optional**: add the GF-T ternary-native ladder as a new format family + the + full ternary-competitor comparison (tekum/takum/BitNet/posit) — strongest breadth result. + +## Landing order (repo ⇄ preprint must agree before arXiv replacement) +1. Merge **trinity-papers-ru PR #17** (`925bdf6d`) — removes fabricated-dies, standardizes on AX7203/XC7A200T. +2. Land t27 codegen branches to master: `fix/gen-verilog-array-lowering` (`701d79b3`), + `fix/r7-rust-wrapping-ops` (`377d9a27`), `fix/gf16-conformance-vectors` — so the cited SSOT matches. +3. Apply the erratum + abstract/body fixes to `goldenfloat-preprint` / `paper3-methodology`. +4. Author submits the new arXiv versions (their credentials). + +## Science that HOLDS (recompute-verified, safe to keep) +φ-rule 17/17 · Lucas identity 256/256 (500-digit) · ml_dtypes cross-val 66,224/0 · +83 SHA-256 conformance fingerprints · GF16 @322–323 MHz · GF8/GF16 add+mul bit-exact +on real AX7203 silicon (5/5, 529/529) · GF-T16 add RTL-sim 30/30. diff --git a/research/XC7A200T_GF16_DATAPOINT_2026-08-05.md b/research/XC7A200T_GF16_DATAPOINT_2026-08-05.md new file mode 100644 index 0000000000..72ce4ae9f6 --- /dev/null +++ b/research/XC7A200T_GF16_DATAPOINT_2026-08-05.md @@ -0,0 +1,104 @@ +# Prepared correction material — XC7A200T GF16 data point + board reconciliation + +> For arXiv:2606.05017 (GoldenFloat), open question **§1.2 "which board did 323 MHz +> come from"**. **Prepared material, NOT a submission** — replacing an arXiv entry +> needs the author's credentials (ARXIV_V2_CORRECTION_PACKAGE §11). Every number +> below was produced 2026-08-05 by an actual local openXC7 run + on-silicon +> conformance, not from memory or a state file. + +## 1. The board discrepancy (three different parts cited for "the" GF16 result) + +| Source | Part | Package | +|---|---|---| +| arXiv:2606.05017 abstract | XC7A**35T** | Arty | +| `t27/docs/arxiv-submission/trinity-gf16.tex` body + table | XC7A**100T** | QMTECH FGG676 | +| **This work (2026-08-05)** | XC7A**200T** | ALINX AX7203 FBG484 | + +The paper's own abstract and body disagree; this adds a third, independently +reproduced part. Recommendation: state the exact part+package once, and separate +the two *kinds* of frequency below. + +## 2. The unstated distinction: bare-core combinational Fmax vs routed-wrapper Fmax + +The paper's headline **323 MHz** is the **combinational** max-frequency of the bare +`gf16` core, measured against a ripple-counter probe clock (`trinity-gf16.tex`: +*"Max frequency for clock 'chain[19]': 323.31 MHz"*). That is a valid but specific +measurement — a purely combinational multiply has no register-to-register path, so +the "frequency" is `1 / (combinational delay)` exposed via a probe counter. + +A **routed, clocked conformance design** on real silicon is a different number. + +## 3. This work — reproduced openXC7 flow, part xc7a200tfbg484-2 + +- **Tooling:** `regymm/openxc7` Docker — `yosys synth_xilinx -flatten -abc9 -nocarry + -nodsp -arch xc7 -top gf16_mul_ax7203`, then `nextpnr-xilinx` (placer sa, router1), + `fasm2frames` + `xc7frames2bit`. Top = `gf16_mul_ax7203` (the UART conformance + wrapper: `gf_mul_param #(EXP=6,MANT=9)` + STARTUPE2/CFGMCLK + UART FSM). +- **Resources (yosys estimate):** **541 LCs** for the wrapped top. +- **Timing (nextpnr):** **27.55 MHz** for clock `mclk` (CFGMCLK path), reported as + FAIL against a 50 MHz target. openXC7/nextpnr static timing on xc7 is known to be + conservative; the design nonetheless functions (see §4) because the datapath is + **UART-paced at 160 kbaud**, far below any of these estimates. +- **Honest reading:** the wrapped, routed conformance datapath is ~27–28 MHz by + static estimate — an order of magnitude below the bare-core 323 MHz combinational + figure. Both are true; they measure different things. A paper table should not + present the combinational number as the design's clock rate. + +## 4. On-silicon conformance — bit-exact vs the golden oracle + +Bitstream flashed to AX7203 SRAM (`sudo openocd -c "pld load 0 …"`, 778 s), read over +UART (`/dev/cu.usbserial-1130`, 160000 baud, frame `AA 55 [a16][b16][00] → A5 …`), +compared to `conformance/gf_ref.py`: + +**GF16 mul — 5/5 exact:** +| a | b | HW | golden | meaning | +|---|---|---|---|---| +| 0x3F00 | 0x4000 | 0x4100 | 0x4100 | 1.5 × 2.0 = 3.0 | +| 0x4000 | 0x4000 | 0x4200 | 0x4200 | 2.0 × 2.0 = 4.0 | +| 0x3F00 | 0x3F00 | 0x4040 | 0x4040 | 1.5 × 1.5 = 2.25 | +| 0x4100 | 0x4000 | 0x4300 | 0x4300 | 3.0 × 2.0 = 6.0 | +| 0x3C00 | 0x4200 | 0x4000 | 0x4000 | 1.0 × 3.0 = 3.0 | + +**GF8 add — 5/5 exact** (earlier `gf8_clean_ax7203.bit` on the same board): (0x10,0x90)=0, +(1,1)=2, (0x20,0x20)=48, (0x30,0x10)=52, (0x3c,0x40)=78 — all match golden. + +Special values also confirmed on silicon: `gf16_mul(inf,0)=0x7E01` (NaN), +`gf16_mul(inf,2)=0x7E00` (inf) — exp field all-ones as specified. + +## 5. Suggested paper edits (for the author to apply with arXiv credentials) + +1. **§1.2 / FPGA table:** name the exact part+package; add a column or note + separating **bare-core combinational Fmax** (323 MHz, probe clock) from + **routed clocked-design Fmax** (report the real routed number). +2. Add this **XC7A200T-FBG484 openXC7 row**: 541 LCs, routed ~27.55 MHz (mclk), + functionally verified on silicon, GF16 5/5 + GF8 5/5 vs golden. +3. State the conformance was read **on real silicon over UART**, with the golden + oracle named — strengthens the "bit-exact" claim beyond simulation. + +## 6b. Prior art already in the repos — align, do NOT duplicate + +A branch survey (2026-08-05) shows this data point slots into existing, unlanded work: +- **`trinity-papers-ru/paper1-goldenfloat/main_ru.tex` already has `\section{sec:hw-ax7203}`** + targeting **XC7A200T-2FBG484I ALINX AX7203**, and already notes *"part of the + multiplier does not route on this Artix-7 (routing failure)"* — which **corroborates + the 27.55 MHz / routing-margin finding here**. This work supplies the missing piece: + **on-silicon UART conformance readback vs the golden oracle** (GF16 5/5, GF8 5/5). +- **`trinity-fpga/docs/arxiv_v2_table.tex`** already has the **bare per-op** XC7A200T + yosys+abc9 LUT numbers (GF16 mul 132 LUT, gf_mul 294 LUT/1 DSP). The **541 LCs** + here is the **wrapped** `gf16_mul_ax7203` top (core + UART FSM + STARTUPE2) — a + different, complementary figure. Label them distinctly. +- **Merge prerequisite:** the standardisation on ALINX AX7203 + removal of the + "fabricated TTSKY26b dies" wording is **PR #17 = commit `925bdf6d`** in + `trinity-papers-ru`, which **is NOT merged to `main`**. The board reconciliation + and abstract fix are only mutually consistent once that lands. +- The RTL/codegen fixes the paper's SSOT should reflect live on **unmerged t27 + branches**: `fix/gen-verilog-array-lowering` (`701d79b3`), `fix/r7-rust-wrapping-ops` + (`377d9a27`), `fix/gen-verilog-typealias`, `fix/gf16-conformance-vectors` (corrects + 5 stale GF16 vectors), `fix/gf-fpga-audit` (GF16 rounding). None on master. + +## 6. Limits (state, don't hide) + +- SRAM flash is **volatile** (JTAG `pld load`), not SPI-boot. +- nextpnr openXC7 timing is a static estimate and likely pessimistic; the 27.55 MHz + is not a measured toggle rate — it is the tool's worst-path estimate. +- Single board; the AL321 JTAG cable is one, so a multi-board timing sweep was not run. diff --git a/specs/trinet/ternary_hw_verification.t27 b/specs/trinet/ternary_hw_verification.t27 index 8823f83b0c..351b40a751 100644 --- a/specs/trinet/ternary_hw_verification.t27 +++ b/specs/trinet/ternary_hw_verification.t27 @@ -772,3 +772,650 @@ lesson TEST_THAT_DEPENDS_ON_A_DEFAULT { fix "pass the canonical SipHash-2-4 reference key explicitly; regenerate goldens from the independent Python implementation, never from the RTL" now "6/6" } + +// --------------------------------------------------------------------------- +// The fleet, measured properly. 2026-08-02, second pass. +// --------------------------------------------------------------------------- + +claim THREE_BOARD_STATISTICAL_BASE { + statement """ + 100 independent runs of 64 ternary dot products per board, port reopened + every run so a cell left desynchronised by the previous run is included + rather than excluded. + """ + measured { + node0_correct 6400 node0_attempted 6400 node0_perfect_runs 100 + node1_correct 6400 node1_attempted 6400 node1_perfect_runs 100 + node2_correct 6308 node2_attempted 6400 node2_perfect_runs 42 + node2_min_per_run 60 + node2_p50_per_run 63 + } + report_the_minimum "a fleet is used at its worst run, not its mean" + trust_tier MEASURED_ON_FPGA +} + +correction THIRD_BOARD_WAS_NEVER_BROKEN { + previously "node1 configured but UART silent -- physical wiring fault, operator's to fix" + actually """ + It answers at 1124474 baud. The fleet was hardcoded to 1186267, a 5.2% + error against a UART tolerance near 3%. Swept, it verifies 32/32 at once + and 6400/6400 over 100 runs -- the equal of the best board here. + """ + why_it_persisted "the diagnosis was repeated for a day without being retested" + rule "a board that does not answer has not been tested until it has been swept" +} + +correction CFGMCLK_SPREAD_IS_FIVE_PERCENT { + previously "71.176 and 72.065 MHz -- a 1.25% per-chip spread" + actually "71.18, 70.46 and 67.47 MHz across three dies -- a 5.5% spread" + why "the 1.25% figure was two samples published as a fleet property" + consequence """ + Line rate is a per-die property and must be measured, never assumed from a + fleet constant. + """ + superseded_in_part_by "FLEET_HAS_ONE_WORKING_RATE -- the spread is real, but the conclusion drawn from it below was wrong" + withdrawn """ + This entry used to conclude "no single host baud rate serves this fleet", + on the reasoning that a 5.5% spread beats a 3% UART tolerance. Both numbers + were wrong. The spread is 4.97% and each board tolerates +/-4.5%, so the + windows overlap and 1144744 baud serves all three -- 6400 jobs each, zero + failures. It also said six probes per candidate were enough to choose a + rate. They are not; see AUTOBAUD_CHOSE_A_RATE_THAT_MOSTLY_WORKED. + """ + trust_tier MEASURED_ON_FPGA +} + +defect SECURITY_FIX_NEVER_REACHED_THE_SILICON { + statement """ + W01 nulled the committed receipt keys in the source. The boards were never + re-flashed, so the fleet ran for a day emitting receipts that verify + perfectly under keys published in this repository -- node0 64/64 under + 0x00..0x0f, node2 63/64 under 0x20..0x2f. + """ + why_invisible """ + A compromised key and a good key are indistinguishable to any test that + only asks whether the tag matches. The whole suite stayed green. + """ + guards { + "protocol.publishedKeyUsed() flags any receipt signed with a known-published key", + "conformance/key_default_check.py: null defaults in RTL, explicit keys in testbenches", + "the fleet drops the key of a stale board: no credit, and no slash either -- those boards are honest" + } + rule "fixed means the artifact changed, not the source" + blocks "every claim about receipt authenticity until all three boards are re-flashed" +} + +defect THROUGHPUT_COUNTED_FAILURES { + statement "jobs_per_s divided by jobs attempted, not jobs verified" + how_found """ + 5409 jobs/s measured against a transport ceiling of 4942, with 0/64 + verified. A board answering nothing returns instantly, so total failure + read as the fastest run ever recorded. + """ + consequence "every jobs/s figure published before 2026-08-02 is withdrawn, not restated" + fix "count verified work only; take percentiles over successful jobs only; print IMPOSSIBLE above the ceiling" +} + +defect HOST_CHOSE_THE_WIRE_FORMAT_FROM_ITS_OWN_CONFIG { + statement """ + Response width was inferred from `key != null`. The width belongs to the + flashed bitstream; the key belongs to the host's config file. A keyless + host read 15 bytes of a 19-byte response and offset every later read by + four, so a healthy board reported MalformedResponse forever. + """ + fix "ask the wire on the first exchange and latch it" +} + +law UNVERIFIABLE_IS_NOT_AN_ACCUSATION { + found_when "the fleet slashed an honest board 400 mTRI over a missing key-file entry" + statement """ + Holding the wrong key is a statement about the receipt. Holding no key is a + statement about the verifier. Only the first may cost stake. + """ + encoding "Verdict.unverifiable -- not accepted, and indictsTheNode() is false" +} + +// --------------------------------------------------------------------------- +// The receipt key stopped being a synthesis parameter. 2026-08-03. +// --------------------------------------------------------------------------- + +design_change KEY_LOADED_OVER_THE_WIRE { + was "RECEIPT_KEY baked in at synthesis, one place-and-route run per rotation" + now "op 0x02 installs 16 key bytes in the W/X operand fields, once per configuration" + reason """ + The committed-key defect was fixed in source and never reached the silicon. + That was not carelessness: re-keying a baked-in key needs a place-and-route + run this workstation cannot perform -- an XC7A200T chipdb OOMs at Docker's + 4 GB default, and raising the limit to 6 GB on an 8 GB host stops Docker + starting at all -- plus 13 minutes of flashing, per board. A key that costs + an hour to rotate is a key nobody rotates, so the design guaranteed its own + failure mode. + """ + properties { + write_once "a second setkey returns 0x03 and changes nothing; otherwise anyone reaching the wire could replace the operator's key and every later receipt would verify under theirs" + ack_is_signed "the acknowledgement is tagged with the key just installed, so acceptance is distinguishable from an echo" + unkeyed_computes "an unkeyed board answers 0x04 with a real dot product; refusing to sign is not refusing to work" + frame_unchanged "the request stays 24 bytes, so the frame parser and frame_alignment_check.py remain valid" + } + cost { logic_cells_before 1292 logic_cells_after 1484 dsp48 0 } + concedes """ + Whoever reaches this UART in the window after configuration can claim the + node. They can also simply re-flash it, so this concedes little that + physical access did not already concede -- and it buys a rotation cheap + enough to actually happen. + """ + escape_hatch "a non-null RECEIPT_KEY still bakes a key in and locks it at reset" + evidence "formal/trinet_setkey_tb.v, 11/11 over the real UART; goldens from tools/gen_setkey_golden.py" + trust_tier VERIFIED_SW +} + +lesson COMPUTED_IS_NOT_SIGNED { + found_when """ + Reading the new boot sequence before spending a flash cycle on it: baud + negotiation, the census and the probe each tested status == status_ok. + """ + problem """ + A flashed-but-unkeyed board answers status_no_key and its arithmetic is + real. The negotiator would have rejected a correctly working board at all + eight candidate rates and the operator would have concluded the flash + failed. + """ + fix "protocol.statusMeansComputed(), one predicate shared by the three sites" + rule "whether a node did the work and whether it can sign the result are different questions" +} + +limit LOCAL_PLACE_AND_ROUTE_IS_NOT_POSSIBLE { + statement """ + Measured 2026-08-03 on this workstation: 8 GB host RAM. Docker's default + 4 GB OOM-kills bbaexport for xc7a200tfbg484-2; 6 GB prevents the Docker VM + from starting. Bitstreams come from CI. + """ + consequence "any plan step assuming a local bitstream build is dead on arrival" +} + +// --------------------------------------------------------------------------- +// 2026-08-03: a receipt that is evidence. First one in this programme. +// --------------------------------------------------------------------------- + +claim FIRST_CITABLE_RECEIPT { + statement """ + node0 re-flashed from CI artifact trinet_node0.bit (sha256 0fafd225e2.., + 1455 LC, routed heap/seed 1), came up unkeyed and reported status 0x04 with + a correct dot product, then took a key over the wire that has never been + published anywhere. + """ + measured { + runs 100 + jobs_per_run 64 + jobs_attempted 6400 + dot_products_correct 6400 + receipts_authenticated 6400 + perfect_runs 100 + published_key_seen false + flash_seconds 778 + } + why_it_matters """ + Every previous "keyed receipt verified on silicon" in this programme was a + tag any reader of the git log could compute. This is the first that is not. + """ + trust_tier MEASURED_ON_FPGA +} + +claim WRITE_ONCE_LATCH_HOLDS_ON_SILICON { + statement """ + With the operator's key installed, a second op 0x02 carrying an attacker's + key (0xDE repeated) returned status 0x03 KEY_LOCKED and changed nothing. + Work afterwards still verified under the operator's key and did NOT verify + under the attacker's. + """ + why """ + Simulation showing a latch hold is not the same claim as silicon showing it. + Without this property anyone reaching the UART could replace the operator's + key and every later receipt would verify under theirs. + """ + trust_tier MEASURED_ON_FPGA +} + +board_facts JTAG_STALL_WAS_MY_OWN_LEAKED_PROCESS { + retracted "JTAG_REACHABILITY_IS_A_BUS_PROPERTY -- the bus correlation was a coincidence" + what_was_claimed """ + That the two cables on the host controller stalled while the one behind a + USB2.1 hub worked, and therefore that reachability follows the bus. Three + consistent observations, and the wrong cause. + """ + actual_cause """ + Two openocd processes from earlier probes were still alive AS ROOT, holding + those two FTDI adapters. Measured: `ps -eo pid,etime` showed them at 1h17m + while a fresh flash was running. After the cables were replugged all three + answered, including both that had stalled every time. + """ + why_the_timeout_failed """ + The probes were bounded with `sudo -n openocd ... & P=$!; (sleep 25; kill -9 + $P) &`. $! is the sudo wrapper; openocd runs as root beneath it and a user + kill -9 cannot touch a root child. The wrapper died, the timeout looked + like it had worked, and the adapter stayed held. + """ + fix "put the timeout inside the privileged process: sudo -n timeout -s KILL 25 openocd -f -c ..." + second_failure """ + The cleanup was also reported without being checked. `sudo -n pkill -9 + openocd` fails with "a password is required" -- the NOPASSWD rule covers + /opt/homebrew/bin/openocd and nothing else -- and with -n it fails silently + rather than prompting. Three separate attempts were each reported as having + cleared the leak; ps showed both processes still alive 2h49m later. Verify + with ps, and hand the operator `sudo pkill -9 openocd` when it matters. + """ + order_of_suspicion "leaked openocd, then replug the cable, then board power -- bus position last" + pairing "a CP2102N and a Digilent under the same hub are the same board; that pairs a serial port to a programmer without flashing anything to find out" +} + +// --------------------------------------------------------------------------- +// 2026-08-03: all three boards re-flashed and keyed. The published-key era ends. +// --------------------------------------------------------------------------- + +claim FLEET_FULLY_REKEYED { + statement """ + All three AX7203 boards re-flashed from CI artifacts, each came up unkeyed + (status 0x04) with correct arithmetic, and each took a per-node key over the + wire that has never been published anywhere. + """ + measured { + boards 3 + runs_per_board 100 + jobs_per_run 64 + jobs_attempted 19200 + + node0_correct 6400 node0_authenticated 6400 node0_perfect_runs 100 + node1_correct 6400 node1_authenticated 6400 node1_perfect_runs 100 + node2_correct 6245 node2_authenticated 6235 node2_perfect_runs 25 + + published_key_seen false + } + agent_pass { + jobs 96 accepted 96 rejected 0 slashed_mtri 0 credited_mtri 96 + all_three_nodes_active true + } + note """ + The node2 figures in this claim were taken at 1186267 baud, which is outside + that board's clean window. They measure the host's choice of line rate, not + the board. Re-measured at 1144744 baud the same board, same cable, same hub + returns 6400/6400 -- see NODE2_WAS_NEVER_MARGINAL. The node0 and node1 + figures are unaffected: both rates are inside their windows. + """ + trust_tier MEASURED_ON_FPGA +} + +board_facts PARALLEL_FLASH_CONFIRMED { + measured "two boards flashed simultaneously on separate programmers, 778.755 s and 778.757 s -- 13 minutes for two, not 26" + rule "flash the fleet in parallel; the AL321 bottleneck is per-cable, not shared" +} + +// --------------------------------------------------------------------------- +// 2026-08-03: the marginal board was a marginal line rate. Same boards, same +// cables, same hubs, no re-flash and no power cycle between the measurements. +// --------------------------------------------------------------------------- + +claim BOARD_LINE_RATES_MEASURED_BY_WINDOW { + statement """ + Each board's line rate was bracketed by sweeping the host rate in 0.5% + steps and running 64 jobs at each step, counting a step clean only if every + one of the eleven predictable response bytes was right on all 64 jobs. + """ + measured { + step_pct 0.5 + jobs_per_step 64 + + node0_window_lo 1121020 node0_window_hi 1227778 + node0_centre 1174399 node0_tolerance_pct 4.55 + node0_cfgmclk_mhz 70.46 + + node1_window_lo 1068248 node1_window_hi 1169444 + node1_centre 1118846 node1_tolerance_pct 4.52 + node1_cfgmclk_mhz 67.13 + + node2_window_lo 1121020 node2_window_hi 1168468 + node2_centre 1144744 node2_tolerance_pct 2.07 + node2_cfgmclk_mhz 68.69 + + cfgmclk_spread_pct 4.97 + } + precision """ + The centre is quantised by the sweep step, so each CFGMCLK figure carries + about +/-0.18 MHz. Quoting more digits than that would be inventing them. + """ + caveat """ + node0 and node1 have hard window edges -- the next step out delivers nothing + at all -- so their centres are their transmit rates. node2's upper edge is + soft: it degrades to 96-98% clean over 1174399..1227778 instead of failing. + Its centre is therefore a rate that works, not necessarily the rate it + transmits at, and the 68.69 MHz above inherits that caveat. Whatever costs + node2 its upper margin is unexplained and is the only open item here. + """ + tool "conformance/trinet_baud_sweep.py" + trust_tier MEASURED_ON_FPGA +} + +claim FLEET_HAS_ONE_WORKING_RATE { + statement """ + The three clean windows overlap on 1121020..1168468 baud. At 1144744, the + centre of that overlap, all three boards deliver every job. + """ + measured { + rate 1144744 + runs_per_board 100 + jobs_per_run 64 + node0_clean 6400 node0_attempted 6400 + node1_clean 6400 node1_attempted 6400 + node2_clean 6400 node2_attempted 6400 + failures_total 0 + } + also_measured """ + Each board at its own window centre, same protocol: node0 6400/6400 at + 1174404, node1 6400/6400 at 1118852, node2 6400/6400 at 1147713 -- and node2 + again, a second independent run at the same rate, 6400/6400. Two runs, not + one: a single clean run is an anecdote. + """ + consequence "the coordinator does not need a rate per board; it needs a measured rate" + scope """ + "clean" here means the magic, product, status, nonce and node identity are + all exactly right -- the eleven bytes a host can predict without a key. The + receipt tag is NOT checked, because the keys installed on these boards are + not held on this machine (see KEYS_NOT_ON_THIS_HOST). This is a measurement + of the transport, and nothing in it is evidence of authenticity. Do not read + 6400/6400 here as the 6400/6400 authenticated in FLEET_FULLY_REKEYED. + """ + trust_tier MEASURED_ON_FPGA +} + +board_facts KEYS_NOT_ON_THIS_HOST { + statement """ + All three boards answer status 0x01, so each holds a key. trinet-keys.txt + exists nowhere on this workstation: the checkout the 2026-08-03 session + worked in is gone and the current one is a fresh clone whose reflog has a + single entry. + """ + consequence """ + No receipt from this fleet can be verified until the boards are re-keyed. + Settlement correctly refuses rather than slashing -- the boards are honest + and the verifier is the one who cannot check. + """ + why_it_cannot_be_undone_cheaply """ + setkey is write-once per configuration, so re-keying needs the configuration + cleared, which means a power cycle, which drops the bitstream -- these are + JTAG-loaded, not in SPI flash. Re-flashing needs openocd as root, and the + NOPASSWD rule in /etc/sudoers.d/openocd is not present on this host today: + `sudo -n /opt/homebrew/bin/openocd --version` answers "a password is + required". Until an operator restores it, a power cut takes the fleet dark + and nothing here can bring it back. + """ + rule "a fleet whose configuration is volatile and whose flash path needs a password is a perishable measurement -- take the hardware measurements first" +} + +correction NODE2_WAS_NEVER_MARGINAL { + previously """ + "node2 remains the marginal board: 97.6% correct, 25 of 100 perfect runs, + min 59 of 64. Not the baud, not the key -- try a different cable and a + different hub port before believing the board." + """ + actually """ + 97.630% at 1186267 baud and 100.000% at 1144744, on the same cable and the + same hub port, measured minutes apart. 1186267 is 3.6% above the top of that + board's clean window. + """ + how_the_baud_hypothesis_was_wrongly_killed """ + It was tested by re-running at "its own centre rate" -- 1174399, taken from + the BAUD_DIV=60 candidate list. That rate is ALSO outside node2's window, + by 0.5%, and scored 98.08% against 98.56%. Two numbers half a percent apart + on a small sample were read as a refutation. The hypothesis was right; the + rate that would have shown it was never in the list. + """ + why_it_persisted """ + Every rate anyone tried came from a list of BAUD_DIV=60 candidates derived + from assumed oscillator frequencies. node2's real window centre is not in + that list and could not be, because the list is what the assumption + produced. Sweeping is what breaks that circle. + """ + rule "when a hypothesis is refuted by two nearby numbers, the sample is the suspect, not the hypothesis" +} + +defect AUTOBAUD_CHOSE_A_RATE_THAT_MOSTLY_WORKED { + statement """ + Node.initFpgaAutoBaud and conformance/trinet_discover.py both accepted the + first candidate rate that answered -- six probes in the Zig path, one in the + Python path -- and stopped looking. + """ + arithmetic """ + A rate that loses 2.4% of jobs passes six probes in a row 86% of the time + and a single probe 97.6% of the time. Neither check can distinguish the rate + that works from the rate that nearly works, which is the only distinction + either of them exists to make. + """ + same_shape_as """ + THROUGHPUT_COUNTED_FAILURES and the settlement layer's verdict enumeration: + a check whose resolution is below the difference it is asked to detect, + reporting success either way. + """ + fix """ + 64 jobs per rate, all eleven predictable bytes checked, and the operating + rate taken from the centre of the contiguous clean window rather than from + the first rate that replied. Failures are split by direction -- a wrong + product with an intact nonce is host->board damage, a damaged nonce or + identity is board->host -- so a minimum in one cannot hide a rise in the + other. + """ + rule "a probe that cannot fail the marginal case is not a probe" +} + +defect CENSUS_CLEARED_A_RUN_IT_HAD_NOT_CHECKED { + statement """ + `trinet census` printed "no published key seen. Receipts from this fleet can + be cited." whenever no run had been caught carrying a published key -- + including runs where no key was loaded at all, so no key could have been + seen, published or otherwise. Observed today on all three boards. + """ + same_shape_as "the settlement layer treating every verdict it did not enumerate as an accusation, inverted: here every case it did not enumerate is an acquittal" + fix """ + Ask what was verified instead of inferring it from what failed to happen. + Three outcomes now: a published key was seen; no key was checked; or N of M + receipts verified under the node's own key, with N and M printed. + """ + rule "a green light that nothing can turn red is not a check" +} + +claim THROUGHPUT_RESTATED_AT_THE_MEASURED_RATE { + statement """ + 2000 jobs per board at each board's negotiated line rate, counting only jobs + whose every predictable byte came back right. This replaces the figures + withdrawn by THROUGHPUT_COUNTED_FAILURES; none of those are restated, they + are superseded by a fresh run. + """ + measured { + jobs_per_board 2000 + + node0_rate 1174399 node0_whole 2000 + node0_serial_jobs_per_s 495.7 node0_batched32_jobs_per_s 3843.6 + node0_transport_ceiling 4893.3 node0_pct_of_ceiling 78.5 + + node1_rate 1144744 node1_whole 2000 + node1_serial_jobs_per_s 483.7 node1_batched32_jobs_per_s 3680.1 + node1_transport_ceiling 4769.8 node1_pct_of_ceiling 77.2 + + node2_rate 1144744 node2_whole 2000 + node2_serial_jobs_per_s 475.6 node2_batched32_jobs_per_s 3678.6 + node2_transport_ceiling 4769.8 node2_pct_of_ceiling 77.1 + + latency_p50_ms 2.05 + compute_ceiling_ratio 480 + } + scope """ + NOT AUTHENTICATED. No receipt was checked -- the keys these boards hold are + not on this machine (KEYS_NOT_ON_THIS_HOST). Whole is not the same claim as + verified and this entry must not be cited as verified compute. + """ + why_batching_pays """ + The round trip is USB latency, 2.05 ms p50 against roughly 0.4 ms of wire + time. Batching 32 amortises it and buys 7.6-7.8x, landing at 77-78% of the + line rate. What is left is not the cell: the cell is idle for all but ~30 of + the ~200 clocks a job occupies, and the derived compute ceiling is 480x the + transport. + """ + trust_tier MEASURED_ON_FPGA +} + +defect BENCH_COULD_NOT_HAVE_PRODUCED_A_NUMBER { + statement """ + `trinet bench` never called loadFleetKeys. FleetNode.key is null until it + runs, so verifyWithKey answered `unverifiable` for every job, `verified` + stayed 0, and the throughput line printed 0.0 jobs/s on any machine -- + including one holding the correct key file. + """ + three_more_in_the_same_function """ + It indexed the fleet table by a command-line slot instead of asking the + board its identity, the defect already fixed on the fleet path. It derived + the compute ceiling from a hardcoded 71.18 MHz CFGMCLK, a figure belonging + to no board in this fleet. And it printed the REQUESTED baud in the + transport-ceiling line while computing that ceiling from the NEGOTIATED one. + """ + how_found "by reading the output, not the code -- all four were invisible in review and none survived one run" + fix """ + Load the keys; ask the board who it is; derive CFGMCLK from the negotiated + rate times the divisor the bitstream ships with; print the rate the + arithmetic used. Count `whole` and `verified` separately and label which one + the headline is, so a transport measurement cannot be read as verified work. + """ + rule "a tool that cannot produce its own output has not been run, only compiled" +} + +defect PORTABILITY_GATE_HAD_NEVER_PASSED { + statement """ + The `portability` job in .github/workflows/trinet-portability.yml installed + yosys from apt. On ubuntu-latest that is 0.33, and under 0.33 every + synth_ pass returns without stats conformance/portability_check.py + can read, so the job reported "only 0 families synthesised" and failed. On + every run of that workflow since it was added, on every branch, including + the commit whose message announced the ten-family result. + """ + the_claim_is_unharmed """ + Ten families agree under yosys 0.62 and eleven under 0.65, same flip-flop + count, no multipliers -- reproduced today under both. What was broken is the + gate, not the thing it guards. But NODE_CELL_IS_VENDOR_NEUTRAL and + docs/TRI_NET_PORTABILITY.md both cited a check that had never once gone + green, and no regression could have been caught. + """ + second_defect_in_the_check_itself """ + A family that synthesised but whose register cells the script could not name + entered `results` -- counting toward "N families checked" -- and was then + dropped from the flip-flop comparison by a truthiness filter. It inflated the + headline while contributing nothing to the invariant the headline is about. + Measured: analogdevices under yosys 0.65 reports 2686 cells and zero + recognised flip-flops, and the run announced 11 families when 10 agreed. + """ + fix """ + CI runs the check inside the pinned regymm/openxc7 image instead of taking + whatever yosys the runner ships, the way ax7203-format-cost.yml already did. + The script prints the yosys version with its results. A family with zero + recognised flip-flops is named in the output and counted in neither + direction. + """ + measured { + yosys_0_33_families 0 + yosys_0_62_families 10 + yosys_0_65_families 10 // 11 synthesise; analogdevices names no register cell + flipflops_low 1082 + flipflops_high 1092 // intel_alm + } + note "819 was the figure before the receipt key started arriving over the wire; the number moved because the design did, which is why the check asserts the spread and not the value" + rule "a gate that has never gone green is not a gate; check that a check has ever passed" +} + +// --------------------------------------------------------------------------- +// 2026-08-04: node0 re-flashed and re-keyed. One board of three; the other two +// were not on the bus. +// --------------------------------------------------------------------------- + +claim NODE0_REKEYED_AND_AUTHENTICATED { + statement """ + node0 re-flashed from the CI artifact, came up unkeyed (status 0x04) with + correct arithmetic, and took a key generated with `openssl rand -hex 16` + that has never been printed, committed or transmitted anywhere but the + 16 key bytes of one op 0x02 request. + """ + measured { + flash_seconds 778.76 + jtag_location "1-1.2" + port "/dev/cu.usbserial-1110" + negotiated_baud 1174399 + runs 100 + jobs_per_run 64 + correct 6400 attempted 6400 + authenticated 6400 perfect_runs 100 + + bench_jobs 2000 + bench_authenticated 2000 + serial_jobs_per_s 481.4 + batched32_jobs_per_s 3788.0 + pct_of_transport 77.4 + latency_p50_ms 2.02 + cfgmclk_mhz 70.46 // derived from the negotiated rate, matches 2026-08-03 + } + write_once_latch_reconfirmed """ + A second setkey on the same configuration returned "already locked" and + changed nothing. Checked on this configuration rather than assumed from the + previous one. + """ + scope "ONE board. node1 and node2 were not attached; nothing here is a fleet result." + trust_tier MEASURED_ON_FPGA +} + +defect MY_OWN_CHECKS_REJECTED_A_HEALTHY_FRESH_BOARD { + statement """ + conformance/trinet_discover.py and conformance/trinet_baud_sweep.py both + compared the response status against 0x01 exactly. A board between a + re-flash and setkey answers 0x04 NO_KEY with a correct dot product, so both + tools reported the freshly flashed node0 as 0.00% clean -- and the sweep + would have found no window at all, at precisely the moment its rate has to + be measured. + """ + irony """ + Written yesterday, in the same session that fixed three other checks for + treating a legitimate state as a failure, and against a handoff that says in + so many words: "An unkeyed board answers 0x04 NO_KEY with a real dot + product. Anything measuring arithmetic must call statusMeansComputed()." + """ + fix "both tools accept the statuses that mean the arithmetic is real ({0x01, 0x04}) and print which of the two the board is in" + rule "the state a board is in for five minutes is the state your tool will meet it in" +} + +board_facts AL321_STALL_NEEDED_A_REPLUG_AND_THE_TIMEOUT_RECIPE_DOES_NOT_RUN { + what_happened """ + Every AL321 adapter stalled in mpsse_flush() on init -- backing off 2s, 4s, + 8s ... 1024s -- with no leaked openocd beforehand. Replugging the cables + fixed it: the first adapter probed after the replug answered IDCODE + 0x13636093 immediately. + """ + the_locations_were_not_the_problem """ + Control test: openocd with a deliberately bogus `adapter usb location 9-9.9` + errors instantly with "no device found". A location that stalls is therefore + one that was found and opened, and the fault is downstream of enumeration. + Run that control before doubting a location string. + """ + the_documented_mitigation_does_not_exist_here """ + The recipe on record is `sudo -n timeout -s KILL 25 openocd ...`, to put the + bound inside the privileged process. There is no `timeout` on this machine + and no `gtimeout` -- coreutils is not installed -- so that recipe cannot be + run as written. What works: start the privileged command in the background + and poll it. A foreground wrapper killed by an outer timeout leaves openocd + running AS ROOT, and a user-level kill cannot touch it; that happened again + today and cost two adapters until the operator ran `sudo pkill -9 openocd`. + """ + bus_numbers_move_on_replug "the board that was bus 0 came back as bus 1: 0-1.2 became 1-1.2 and its port /dev/cu.usbserial-110 became -1110. Re-read ioreg after every replug, including one you did yourself a minute ago" + not_our_boards """ + Three "Digilent Adept USB Device" adapters (pid 0x6010, serial 210203859289) + appeared where two AX7203s had been. All three read IDCODE 0x23727093 -- + part 0x3727, a Zynq-7020, not the 0x13636093 Artix-7 200T this project + flashes. The repo's openocd config filters on pid 0x6014 and device_desc + "Digilent USB Device", so it answered "no device found" for them; widening + that filter until something replies is how a bitstream reaches a device + nobody identified. Ask the adapter what is behind it first -- init and + shutdown, no pld load. + """ +} diff --git a/src/trinet/ledger.zig b/src/trinet/ledger.zig index 34164d9bec..112c9e3ced 100644 --- a/src/trinet/ledger.zig +++ b/src/trinet/ledger.zig @@ -98,6 +98,12 @@ pub const Account = struct { /// link-quality signal about the operator's wiring, not about their honesty. corrupted: u64 = 0, consecutive_corruptions: u32 = 0, + /// Responses we could not judge, because we hold no key for this node. + /// Counted apart from `corrupted` on purpose: corrupt is a claim about the + /// link, unverifiable is a claim about the verifier, and merging them would + /// let our own missing key look like the node's bad cable. + unverifiable: u64 = 0, + consecutive_unverifiable: u32 = 0, status: Status = .active, pub fn reputation(self: Account) f64 { @@ -116,6 +122,11 @@ pub const Outcome = enum { corrupt_not_charged, /// The receipt claimed an identity other than the node we dispatched to. identity_mismatch, + /// The verifier holds no key for this node, so nothing can be concluded. + /// Not credited, and NOT slashed — this is a statement about us, not about + /// the node, and charging stake for our own missing key is how an honest + /// operator gets punished for a configuration error they cannot see. + unverifiable_not_charged, /// Node is suspended and should not have been dispatched to. not_eligible, }; @@ -145,6 +156,7 @@ pub const Ledger = struct { total_credited_mtri: u64 = 0, total_slashed_mtri: u64 = 0, total_corrupted: u64 = 0, + total_unverifiable: u64 = 0, jobs_on_silicon: u64 = 0, pub fn init(gpa: std.mem.Allocator, policy: Policy) Error!Ledger { @@ -233,6 +245,33 @@ pub const Ledger = struct { }; } + // A verdict that does not indict must never cost stake. The rule lived + // in protocol.Verdict.indictsTheNode() and this function never asked: + // it switched on `.corrupt` by name and slashed everything else, so + // `.unverifiable` — added precisely so a keyless verifier could not + // accuse — went straight to the slash path. Measured on hardware: two + // honest boards lost 600 mTRI each and were suspended, for holding keys + // WE could not check. + // + // Asking the verdict rather than naming the cases is the fix; a new + // verdict now defaults to costing nothing rather than to costing stake. + if (!verdict.accepted() and !verdict.indictsTheNode()) { + acct.unverifiable += 1; + acct.consecutive_unverifiable += 1; + self.total_unverifiable += 1; + // Stop sending work we can never pay for. This is scheduling, not + // punishment: no stake moves, and the detail says whose problem it + // is. + if (acct.consecutive_unverifiable >= self.policy.corruption_tolerance) { + acct.status = .unreliable; + } + return .{ + .node_id = dispatched_to, + .outcome = .unverifiable_not_charged, + .detail = verdict.reason(), + }; + } + if (!verdict.accepted()) { const slash = @min(acct.stake_mtri, self.policy.slash_per_bad_receipt_mtri); acct.stake_mtri -= slash; @@ -266,6 +305,7 @@ pub const Ledger = struct { acct.accepted += 1; acct.consecutive_rejections = 0; acct.consecutive_corruptions = 0; + acct.consecutive_unverifiable = 0; if (acct.status == .probation) acct.status = .active; self.total_credited_mtri += reward; if (acct.physical) self.jobs_on_silicon += 1; @@ -484,3 +524,72 @@ test "payouts aggregate across the nodes one developer runs" { try std.testing.expectEqual(@as(u64, 30), p.get("erin").?); // 10 + 20 try std.testing.expectEqual(@as(u64, 30), p.get("frank").?); } + +test "a verifier holding no key charges nobody" { + // The regression this exists for: two honest boards lost 600 mTRI each and + // were suspended, because settle() named `.corrupt` as the one verdict that + // does not cost stake and slashed everything else. `.unverifiable` had been + // added specifically so a keyless verifier could not accuse, and this + // function never asked. + var l = try Ledger.init(std.testing.allocator, .{}); + defer l.deinit(); + try l.register(0xB0A2D, "operator", true, 100_000); + + const job = protocol.Job.withNonce(1, @splat(0x55), @splat(0x55)); + const y = protocol.dot(job.w, job.x); + var key: [16]u8 = undefined; + for (&key, 0..) |*b, i| b.* = @intCast(0x11 +% i); + + // A perfectly honest keyed receipt, judged by a coordinator with no key. + const r: protocol.Receipt = .{ + .kind = .siphash24, + .y = y, + .status = protocol.status_ok, + .nonce = job.nonce, + .node_id = 0xB0A2D, + .tag = protocol.receiptTagKeyed(job, y, 0xB0A2D, key), + }; + const verdict = protocol.verifyWithKey(job, r, null); + try std.testing.expectEqual(protocol.Verdict.unverifiable, verdict); + + const before = l.get(0xB0A2D).?.stake_mtri; + const st = try l.settle(0xB0A2D, job, r, verdict); + + try std.testing.expectEqual(Outcome.unverifiable_not_charged, st.outcome); + try std.testing.expectEqual(@as(u64, 0), st.slash_delta_mtri); + try std.testing.expectEqual(before, l.get(0xB0A2D).?.stake_mtri); + try std.testing.expectEqual(@as(u64, 0), l.get(0xB0A2D).?.rejected); + try std.testing.expectEqual(@as(u64, 1), l.get(0xB0A2D).?.unverifiable); + try std.testing.expectEqual(Status.active, l.get(0xB0A2D).?.status); +} + +test "work we can never pay for eventually stops being dispatched" { + // Not punishment — no stake moves — but there is no point sending jobs to a + // node whose answers can never be credited. + const policy: Policy = .{}; + var l = try Ledger.init(std.testing.allocator, policy); + defer l.deinit(); + try l.register(0xB0A2D, "operator", true, 100_000); + + var key: [16]u8 = undefined; + for (&key, 0..) |*b, i| b.* = @intCast(0x77 +% i); + + for (0..policy.corruption_tolerance) |i| { + const job = protocol.Job.withNonce(@intCast(i + 1), @splat(0x55), @splat(0x55)); + const y = protocol.dot(job.w, job.x); + const r: protocol.Receipt = .{ + .kind = .siphash24, + .y = y, + .status = protocol.status_ok, + .nonce = job.nonce, + .node_id = 0xB0A2D, + .tag = protocol.receiptTagKeyed(job, y, 0xB0A2D, key), + }; + _ = try l.settle(0xB0A2D, job, r, protocol.verifyWithKey(job, r, null)); + } + + try std.testing.expectEqual(Status.unreliable, l.get(0xB0A2D).?.status); + // And still not a penny taken. + try std.testing.expectEqual(@as(u64, 100_000), l.get(0xB0A2D).?.stake_mtri); + try std.testing.expectEqual(@as(u64, 0), l.get(0xB0A2D).?.slashed_mtri); +} diff --git a/src/trinet/main.zig b/src/trinet/main.zig index 7263e3c49e..94083f088f 100644 --- a/src/trinet/main.zig +++ b/src/trinet/main.zig @@ -7,7 +7,7 @@ //! trinet fleet [s1] [s2] run the agent across several physical boards //! trinet bench [serial] [n] [baud] [slot] measure throughput and the transport gap //! trinet serve [serial] expose a node over TCP so others can use it -//! trinet keygen print fresh per-node receipt keys (never commit them)\n//! trinet join print what a new developer has to do +//! trinet census [serial] [baud] [runs] [jobs] the distribution, not one good run\n//! trinet setkey [s1] [s2] install those keys on the attached boards\n//! trinet keygen print fresh per-node receipt keys (never commit them)\n//! trinet join print what a new developer has to do //! //! Author: Dmitrii Vasilev (@gHashTag) @@ -80,8 +80,10 @@ pub fn main(init: std.process.Init.Minimal) !void { gpa, if (args.len > 2) args[2] else default_serial, if (args.len > 3) try std.fmt.parseInt(usize, args[3], 10) else 500, - if (args.len > 4) try std.fmt.parseInt(u32, args[4], 10) else default_baud, - if (args.len > 5) try std.fmt.parseInt(usize, args[5], 10) else 0, + // 0 means negotiate. The old default was a fleet constant, and a + // constant is what put the marginal board on the rate that lost it 2.4% + // of its jobs. + if (args.len > 4) try std.fmt.parseInt(u32, args[4], 10) else 0, ); if (std.mem.eql(u8, cmd, "fleet")) { if (args.len < 3) { @@ -91,11 +93,25 @@ pub fn main(init: std.process.Init.Minimal) !void { return fleet(gpa, args[2..]); } if (std.mem.eql(u8, cmd, "serve")) return serve(gpa, args); + if (std.mem.eql(u8, cmd, "census")) return census( + gpa, + if (args.len > 2) args[2] else default_serial, + if (args.len > 3) try std.fmt.parseInt(u32, args[3], 10) else default_baud, + if (args.len > 4) try std.fmt.parseInt(usize, args[4], 10) else 100, + if (args.len > 5) try std.fmt.parseInt(usize, args[5], 10) else 64, + ); + if (std.mem.eql(u8, cmd, "setkey")) { + if (args.len < 3) { + std.debug.print("usage: trinet setkey [serial1] [serial2]\n", .{}); + return error.NoPortsGiven; + } + return setkey(gpa, args[2..]); + } if (std.mem.eql(u8, cmd, "keygen")) return keygen(); if (std.mem.eql(u8, cmd, "join")) return joinHelp(); std.debug.print("unknown command '{s}'\n", .{cmd}); - std.debug.print("try: selftest | probe | bench | fleet | demo | agent | serve | join\n", .{}); + std.debug.print("try: selftest | probe | bench | census | fleet | keygen | setkey | demo | agent | serve | join\n", .{}); return error.UnknownCommand; } @@ -202,7 +218,7 @@ fn probe(gpa: std.mem.Allocator, path: []const u8, baud: u32) !void { // The arithmetic and the authenticity are separate questions, and // conflating them is how "96 on silicon" came to mean "a serial port // opened". A board can compute perfectly and prove nothing. - if (r.status == protocol.status_ok and + if (protocol.statusMeansComputed(r.status) and std.mem.eql(u8, &r.nonce, &job.nonce) and r.y == protocol.dot(job.w, job.x)) arith += 1; const v = protocol.verify(job, r); @@ -268,6 +284,11 @@ var fleet_nodes = [_]FleetNode{ const key_file_env = "TRINET_KEYS"; const key_file_default = "trinet-keys.txt"; +/// The UART divisor the fleet bitstream is built with. The board's line rate is +/// CFGMCLK / this, so it is also the only honest way to read CFGMCLK back out of +/// a negotiated rate. +const fleet_baud_div: f64 = 60.0; + /// Load per-node keys from ` <32 hex chars>` lines. /// /// Missing file is not an error: the fleet still runs, and every receipt is @@ -317,7 +338,7 @@ fn loadFleetKeys(gpa: std.mem.Allocator) !usize { /// board does not answer the report says so rather than quietly shrinking the /// fleet. fn fleet(gpa: std.mem.Allocator, ports: []const [:0]const u8) !void { - std.debug.print("TRI-NET fleet — {d} port(s) at {d} baud\n", .{ ports.len, fleet_baud }); + std.debug.print("TRI-NET fleet — {d} port(s), line rate negotiated per board\n", .{ports.len}); const keys_loaded = loadFleetKeys(gpa) catch 0; if (keys_loaded == 0) { std.debug.print("NO RECEIPT KEYS LOADED (set {s} or create {s} with `trinet keygen`).\n", .{ key_file_env, key_file_default }); @@ -332,16 +353,26 @@ fn fleet(gpa: std.mem.Allocator, ports: []const [:0]const u8) !void { defer m.deinit(); var attached: usize = 0; + var stale_boards: usize = 0; for (ports) |p| { // Ask the board who it is rather than inferring it from argument order. // Binding identity to position looks fine until the ports come up in a // different order — then the coordinator verifies each board against // another's key, every honest receipt fails its tag check, and the // network slashes operators for a cabling accident. - var n = node_mod.Node.initFpga(0, "unidentified", p, fleet_baud) catch |e| { - std.debug.print("{s}: did NOT open ({s}) — not counted\n", .{ p, @errorName(e) }); + // Ask the board its line rate too, rather than assuming one constant + // serves the fleet. It does not: CFGMCLK is an untrimmed RC oscillator + // and this fleet's three dies run at 70.46, 67.13 and 68.69 MHz -- a + // 4.97% spread, and one of them was recorded as a wiring fault for a day + // while it was answering perfectly 5% down the dial. Each board still + // tolerates about +/-4.5%, so the windows overlap and one rate does + // reach all three; ask each board anyway, because that is a fact about + // these three dies and not about the next one. + const found = node_mod.Node.initFpgaAutoBaud(0, "unidentified", p) catch |e| { + std.debug.print("{s}: did NOT answer at any candidate rate ({s}) — not counted\n", .{ p, @errorName(e) }); continue; }; + var n = found.node; n.key = @splat(0); // any key; identification only reads the id field const probe_job = protocol.Job.withNonce(1, @splat(0), @splat(0)); const claimed = n.execute(probe_job) catch |e| { @@ -363,10 +394,21 @@ fn fleet(gpa: std.mem.Allocator, ports: []const [:0]const u8) !void { n.id = spec.?.id; n.name = spec.?.name; n.key = spec.?.key; + + // A board flashed with a key from the git history is honest and useless + // at the same time: its arithmetic is real and its receipts prove + // nothing, because anyone can compute the same tag. Dropping the key + // here makes every one of its receipts `unverifiable`, which credits + // nothing and — just as importantly — slashes nothing. + if (protocol.publishedKeyUsed(probe_job, claimed) != null) { + n.key = null; + stale_boards += 1; + std.debug.print("{s}: PUBLISHED KEY — receipts carry no evidence. Re-flash with `trinet keygen`.\n", .{p}); + } try m.join(n, "operator", 100000); attached += 1; - std.debug.print("{s}: identified as {s}, id {x:0>8}{s}\n", .{ - p, spec.?.name, spec.?.id, + std.debug.print("{s}: identified as {s}, id {x:0>8} at {d} baud{s}\n", .{ + p, spec.?.name, spec.?.id, found.baud, if (spec.?.key == null) " (no key — receipts unverifiable)" else "", }); } @@ -375,14 +417,33 @@ fn fleet(gpa: std.mem.Allocator, ports: []const [:0]const u8) !void { std.debug.print("\nno board answered. Nothing below would be a hardware result.\n", .{}); return error.NoBoardsAttached; } - std.debug.print("\n{d} of {d} requested boards attached\n\n", .{ attached, ports.len }); + std.debug.print("\n{d} of {d} requested boards attached\n", .{ attached, ports.len }); + if (stale_boards > 0) { + std.debug.print("{d} of them still carry a published key, so no work below can be\n", .{stale_boards}); + std.debug.print("credited to them. The dot products are still a hardware measurement.\n", .{}); + } + std.debug.print("\n", .{}); const model = try model_mod.Model.synthetic(gpa, 3, 32, 0x1614); var agent = try agent_mod.Agent.init(gpa, "igla-coder", model); defer agent.deinit(gpa); const t_agent = monoNanos(); - const o = try agent.run(&m, "synthesise the ternary mac and flash it to the fleet"); + const o = agent.run(&m, "synthesise the ternary mac and flash it to the fleet") catch |e| { + if (e == error.NoEligibleNode and stale_boards == attached) { + line(); + std.debug.print("Every attached board carries a published key, so the ledger has no\n", .{}); + std.debug.print("node it is allowed to pay and refuses to dispatch. That is the\n", .{}); + std.debug.print("correct behaviour and not a fault in the fleet: {d} boards answered,\n", .{attached}); + std.debug.print("their arithmetic is measurable with `trinet census 0`, and\n", .{}); + std.debug.print("none of it can be settled until they are re-flashed.\n", .{}); + line(); + std.debug.print("Fix: `trinet keygen > trinet-keys.txt`, rebuild each bitstream with\n", .{}); + std.debug.print("its own key via chparam, flash, and run this again.\n", .{}); + return; + } + return e; + }; const agent_ms = @as(f64, @floatFromInt(monoNanos() - t_agent)) / 1e6; std.debug.print("agent action : {s}\n", .{o.decision.action.label()}); std.debug.print("elapsed : {d:.1} ms for {d} jobs = {d:.0} jobs/s\n", .{ @@ -418,17 +479,51 @@ fn fleet(gpa: std.mem.Allocator, ports: []const [:0]const u8) !void { /// nodes spend all their time waiting on a serial line is a serial line with a /// compute network attached to it, and the honest way to find that out is to /// measure both ends and print the gap. -fn bench(gpa: std.mem.Allocator, path: []const u8, n: usize, baud: u32, node_slot: usize) !void { - const spec = fleet_nodes[@min(node_slot, fleet_nodes.len - 1)]; - std.debug.print("benchmarking {s} on {s} at {d} baud, {d} jobs\n", .{ spec.name, path, baud, n }); - line(); - +fn bench(gpa: std.mem.Allocator, path: []const u8, n: usize, baud: u32) !void { var buf: [256]u8 = undefined; const zpath = try std.fmt.bufPrintZ(&buf, "{s}", .{path}); - var node = try node_mod.Node.initFpga(spec.id, spec.name, zpath, baud); - node.key = spec.key; + + // bench never loaded the key file. `FleetNode.key` is null until + // loadFleetKeys fills it, so `verifyWithKey` answered `unverifiable` for + // every job, `verified` stayed 0, and the throughput line read 0.0 jobs/s + // whatever the board did — on machines that had the keys all along. The + // command this project's own next-actions list depends on could not produce + // a number. + const keys_loaded = loadFleetKeys(gpa) catch 0; + + var line_rate: u32 = baud; + var node = blk: { + if (baud == 0) { + const found = try node_mod.Node.initFpgaAutoBaud(protocol.default_node_id, "ax7203", zpath); + line_rate = found.baud; + break :blk found.node; + } + break :blk try node_mod.Node.initFpga(protocol.default_node_id, "ax7203", zpath, baud); + }; defer node.deinit(); + // Ask the board who it is. bench used to index the fleet table by a + // command-line slot, which hands node0's key to whichever port was typed + // first — the same identity-by-argument-order defect already fixed on the + // fleet path. + const who = try node.execute(protocol.Job.withNonce(1, @splat(0), @splat(0))); + var spec: FleetNode = .{ .name = "unidentified", .id = who.node_id }; + for (fleet_nodes) |f| { + if (f.id == who.node_id) spec = f; + } + node.id = spec.id; + node.name = spec.name; + node.key = spec.key; + + std.debug.print("benchmarking {s} (id {x:0>8}) on {s} at {d} baud, {d} jobs\n", .{ + spec.name, spec.id, path, line_rate, n, + }); + if (spec.key == null) { + std.debug.print("no key on file for this board ({d} loaded) — throughput below counts\n", .{keys_loaded}); + std.debug.print("jobs that came back WHOLE, not jobs that came back AUTHENTICATED.\n", .{}); + } + line(); + const latencies = try gpa.alloc(u64, n); defer gpa.free(latencies); @@ -443,7 +538,13 @@ fn bench(gpa: std.mem.Allocator, path: []const u8, n: usize, baud: u32, node_slo _ = node.execute(protocol.Job.withNonce(@intCast(i), protocol.pack(wv), protocol.pack(wv))) catch {}; } + // Two counts, never merged. `verified` is work whose receipt checked out + // under this board's own key; `whole` is work whose every predictable byte + // came back right. With a key the first is the number to publish. Without + // one the second is all there is, and calling it the same thing is how a + // transport measurement gets cited as authenticated work. var verified: usize = 0; + var whole: usize = 0; const t0 = monoNanos(); for (0..n) |i| { @@ -459,13 +560,16 @@ fn bench(gpa: std.mem.Allocator, path: []const u8, n: usize, baud: u32, node_slo continue; }; const took = monoNanos() - start; - if (protocol.verifyWithKey(job, r, node.key).accepted()) { - // Only a verified job has a latency worth reporting. A failure - // returns fast — that is what failing looks like — and letting it - // into the percentiles reports the speed of giving up. - latencies[verified] = took; - verified += 1; - } + if (!protocol.statusMeansComputed(r.status)) continue; + if (!std.mem.eql(u8, &r.nonce, &job.nonce)) continue; + if (r.y != protocol.dot(job.w, job.x)) continue; + if (r.node_id != spec.id) continue; + // Only a job that came back whole has a latency worth reporting. A + // failure returns fast — that is what failing looks like — and letting + // it into the percentiles reports the speed of giving up. + latencies[whole] = took; + whole += 1; + if (protocol.verifyWithKey(job, r, node.key).accepted()) verified += 1; } const elapsed_ns = monoNanos() - t0; @@ -478,6 +582,7 @@ fn bench(gpa: std.mem.Allocator, path: []const u8, n: usize, baud: u32, node_slo defer gpa.free(receipts); var batched_ok: usize = 0; + var batched_whole: usize = 0; const bt0 = monoNanos(); var done: usize = 0; while (done < n) : (done += batch) { @@ -491,14 +596,26 @@ fn bench(gpa: std.mem.Allocator, path: []const u8, n: usize, baud: u32, node_slo } const got = node.executeBatch(jobs[0..take], receipts[0..take]) catch break; for (jobs[0..got], receipts[0..got]) |j, r| { + if (!protocol.statusMeansComputed(r.status)) continue; + if (!std.mem.eql(u8, &r.nonce, &j.nonce)) continue; + if (r.y != protocol.dot(j.w, j.x)) continue; + if (r.node_id != spec.id) continue; + batched_whole += 1; if (protocol.verifyWithKey(j, r, node.key).accepted()) batched_ok += 1; } } const batched_ns = monoNanos() - bt0; const batched_s = @as(f64, @floatFromInt(batched_ns)) / 1e9; - const batched_jps = if (batched_s > 0) @as(f64, @floatFromInt(batched_ok)) / batched_s else 0; const elapsed_s = @as(f64, @floatFromInt(elapsed_ns)) / 1e9; + // Which count the headline uses, said once, so the label and the arithmetic + // cannot drift apart. + const authenticated = spec.key != null; + const counted = if (authenticated) verified else whole; + const batched_counted = if (authenticated) batched_ok else batched_whole; + const basis = if (authenticated) "authenticated" else "whole, NOT authenticated"; + const batched_jps = if (batched_s > 0) @as(f64, @floatFromInt(batched_counted)) / batched_s else 0; + // Throughput counts VERIFIED jobs, not attempted ones. Dividing by `n` was // wrong and hid itself well: a board answering nothing returns instantly, // so a total failure read as the fastest run ever recorded. It was caught @@ -506,10 +623,10 @@ fn bench(gpa: std.mem.Allocator, path: []const u8, n: usize, baud: u32, node_slo // 0/64 verified. A rate that counts failures measures how fast you can // fail. Every jobs/s figure this project published before 2026-08-02 was // computed the broken way and is being restated. - const jobs_per_s = if (elapsed_s > 0) @as(f64, @floatFromInt(verified)) / elapsed_s else 0; + const jobs_per_s = if (elapsed_s > 0) @as(f64, @floatFromInt(counted)) / elapsed_s else 0; const macs_per_s = jobs_per_s * @as(f64, @floatFromInt(protocol.n_trits)); - const lat = latencies[0..verified]; + const lat = latencies[0..whole]; std.sort.pdq(u64, lat, {}, std.sort.asc(u64)); const p50 = if (lat.len > 0) lat[lat.len / 2] else 0; const p99 = if (lat.len > 0) lat[(lat.len * 99) / 100] else 0; @@ -520,18 +637,27 @@ fn bench(gpa: std.mem.Allocator, path: []const u8, n: usize, baud: u32, node_slo // wrong and showed itself immediately: batched throughput came out at 125% // of a ceiling that cannot be exceeded. const bytes_per_job: f64 = @floatFromInt(@max(protocol.request_len, protocol.response_len_v2)); - const transport_jobs_per_s = @as(f64, @floatFromInt(baud)) / 10.0 / bytes_per_job; + const transport_jobs_per_s = @as(f64, @floatFromInt(line_rate)) / 10.0 / bytes_per_job; // Compute ceiling: the dot product is combinational, and the receipt engine // walks 26 preimage bytes at one byte per clock, so a job costs roughly 30 - // cycles of the configuration oscillator. CFGMCLK measured 2026-08-02 at - // ~71.18 MHz by bracketing the host baud against a fixed-divisor bitstream - // — the 69-70 MHz this project had recorded was low by 2-3%. - const cfgmclk_hz: f64 = 71.18e6; + // cycles of the configuration oscillator. + // + // CFGMCLK used to be a constant here — 71.18 MHz, a figure taken from one + // die and applied to a fleet whose three dies measure 70.46, 67.13 and + // 68.69 MHz. It is not a constant, it is a per-die property of an untrimmed + // RC oscillator, and the board is telling us what it is: the line rate the + // link settled on IS CFGMCLK divided by the divisor in the bitstream. + // Deriving it from the negotiated rate cannot go stale the way a literal + // does. + const cfgmclk_hz: f64 = @as(f64, @floatFromInt(line_rate)) * fleet_baud_div; const cycles_per_job: f64 = 30.0; const compute_jobs_per_s = cfgmclk_hz / cycles_per_job; - std.debug.print("receipts verified : {d}/{d}\n", .{ verified, n }); + std.debug.print("jobs {s}: {d}/{d}\n", .{ basis, counted, n }); + if (authenticated and whole != verified) { + std.debug.print("came back whole but unauthenticated: {d}\n", .{whole - verified}); + } std.debug.print("elapsed : {d:.3} s\n", .{elapsed_s}); std.debug.print("throughput : {d:.1} jobs/s = {d:.0} ternary MACs/s\n", .{ jobs_per_s, macs_per_s }); std.debug.print("latency p50 / p99 : {d:.2} ms / {d:.2} ms\n", .{ @@ -539,10 +665,13 @@ fn bench(gpa: std.mem.Allocator, path: []const u8, n: usize, baud: u32, node_slo }); line(); std.debug.print("transport ceiling : {d:.1} jobs/s (UART {d} baud, {d} bytes on the busier direction)\n", .{ - transport_jobs_per_s, baud, @max(protocol.request_len, protocol.response_len_v2), + transport_jobs_per_s, line_rate, @max(protocol.request_len, protocol.response_len_v2), + }); + std.debug.print("compute ceiling : {d:.0} jobs/s (~{d:.0} cycles/job at {d:.2} MHz CFGMCLK,\n", .{ + compute_jobs_per_s, cycles_per_job, cfgmclk_hz / 1e6, }); - std.debug.print("compute ceiling : {d:.0} jobs/s (~{d:.0} cycles/job at 71.18 MHz CFGMCLK)\n", .{ - compute_jobs_per_s, cycles_per_job, + std.debug.print(" derived from {d} baud x BAUD_DIV {d:.0}, not assumed)\n", .{ + line_rate, fleet_baud_div, }); std.debug.print("measured / transport: {d:.1}%\n", .{jobs_per_s / transport_jobs_per_s * 100}); std.debug.print("compute / transport : {d:.0}x\n", .{compute_jobs_per_s / transport_jobs_per_s}); @@ -554,16 +683,15 @@ fn bench(gpa: std.mem.Allocator, path: []const u8, n: usize, baud: u32, node_slo std.debug.print("IMPOSSIBLE: {d:.1} jobs/s exceeds the {d:.1} jobs/s the UART can carry.\n", .{ jobs_per_s, transport_jobs_per_s }); std.debug.print("The measurement is wrong, not the node. Do not publish this number.\n", .{}); } - if (verified == 0) { + if (counted == 0) { line(); - std.debug.print("NO JOB VERIFIED. The throughput and latency above describe failures.\n", .{}); - std.debug.print("Check the baud (`python3 conformance/trinet_discover.py` reports the\n", .{}); - std.debug.print("rate each board actually answers at) and check that a receipt key for\n", .{}); - std.debug.print("this node is loaded.\n", .{}); + std.debug.print("NOTHING COUNTED. The throughput and latency above describe failures.\n", .{}); + std.debug.print("Check the rate: `python3 conformance/trinet_baud_sweep.py --port

`\n", .{}); + std.debug.print("reports the board's clean window and the rate to use.\n", .{}); } line(); - std.debug.print("batched x{d} : {d}/{d} verified, {d:.1} jobs/s ({d:.1}x the one-at-a-time rate)\n", .{ - batch, batched_ok, n, batched_jps, if (jobs_per_s > 0) batched_jps / jobs_per_s else 0, + std.debug.print("batched x{d} : {d}/{d} {s}, {d:.1} jobs/s ({d:.1}x the one-at-a-time rate)\n", .{ + batch, batched_counted, n, basis, batched_jps, if (jobs_per_s > 0) batched_jps / jobs_per_s else 0, }); std.debug.print("batched / transport : {d:.1}%\n", .{batched_jps / transport_jobs_per_s * 100}); line(); @@ -571,6 +699,12 @@ fn bench(gpa: std.mem.Allocator, path: []const u8, n: usize, baud: u32, node_slo std.debug.print("throughput claim about this node is a claim about the UART.\n", .{}); std.debug.print("The compute ceiling above is derived, not measured — measuring it\n", .{}); std.debug.print("needs a transport that can saturate the cell.\n", .{}); + if (!authenticated) { + line(); + std.debug.print("These jobs came back whole. No receipt was checked, because no key for\n", .{}); + std.debug.print("this board is on file, so nothing above says WHO did the work. Cite it\n", .{}); + std.debug.print("as a measurement of the transport and not as verified compute.\n", .{}); + } } // --------------------------------------------------------------------------- @@ -709,6 +843,255 @@ fn serve(gpa: std.mem.Allocator, args: []const [:0]const u8) !void { /// Print fresh per-node keys for the operator to save and build into their own /// bitstreams. Deliberately prints rather than writes: a key that a tool /// silently drops in the working tree is a key that gets committed. +/// Run the same measurement many times, each with a fresh port, and report the +/// whole distribution. +/// +/// Three consecutive good runs of one configuration is not a statistical base, +/// and a reviewer will say so. What matters is the shape: the worst run, the +/// spread, and how often a run is perfect. A mean alone hides a board that is +/// fine 90% of the time and useless the rest, which is exactly the failure this +/// fleet actually has. +/// +/// Every run opens and closes the port. The FPGA's frame parser survives the +/// host process, so a run that inherits a desynchronised cell is a different +/// experiment from one that does not -- and including both is the honest +/// choice, because a user gets whichever they get. +fn census(gpa: std.mem.Allocator, path: []const u8, baud: u32, runs: usize, per_run: usize) !void { + std.debug.print("census: {d} independent runs of {d} jobs on {s} at {d} baud\n", .{ runs, per_run, path, baud }); + line(); + + var buf: [256]u8 = undefined; + const zpath = try std.fmt.bufPrintZ(&buf, "{s}", .{path}); + + const scores = try gpa.alloc(usize, runs); + defer gpa.free(scores); + var stale_runs: usize = 0; + var open_failures: usize = 0; + + // Authenticity is a separate count from arithmetic, and now that a board + // can hold a key nobody published, it is the one worth reporting. Without + // a key file the column is simply absent rather than quietly zero. + const keys_loaded = loadFleetKeys(gpa) catch 0; + var verified_total: usize = 0; + var node_key: ?[16]u8 = null; + var node_name: []const u8 = "unidentified"; + + var prng: std.Random.DefaultPrng = .init(0x5EED); + const rand = prng.random(); + + var negotiated: u32 = baud; + for (0..runs) |run| { + var n: node_mod.Node = blk: { + if (negotiated == 0) { + const found = node_mod.Node.initFpgaAutoBaud(protocol.default_node_id, "ax7203", zpath) catch { + scores[run] = 0; + open_failures += 1; + continue; + }; + negotiated = found.baud; + std.debug.print("negotiated line rate: {d} baud\n", .{negotiated}); + break :blk found.node; + } + break :blk node_mod.Node.initFpga(protocol.default_node_id, "ax7203", zpath, negotiated) catch { + scores[run] = 0; + open_failures += 1; + continue; + }; + }; + defer n.deinit(); + + var correct: usize = 0; + var stale: usize = 0; + for (0..per_run) |i| { + var wv: protocol.Trits = @splat(0); + var xv: protocol.Trits = @splat(0); + for (&wv) |*t| t.* = rand.intRangeAtMost(i8, -1, 1); + for (&xv) |*t| t.* = rand.intRangeAtMost(i8, -1, 1); + const job = protocol.Job.withNonce(@intCast(run * per_run + i + 1), protocol.pack(wv), protocol.pack(xv)); + const r = n.execute(job) catch continue; + if (protocol.publishedKeyUsed(job, r) != null) stale += 1; + if (protocol.statusMeansComputed(r.status) and + std.mem.eql(u8, &r.nonce, &job.nonce) and + r.y == protocol.dot(job.w, job.x)) correct += 1; + if (node_key == null and keys_loaded > 0) { + for (fleet_nodes) |f| { + if (f.id == r.node_id) { + node_key = f.key; + node_name = f.name; + } + } + } + if (node_key) |k| { + if (protocol.verifyWithKey(job, r, k).accepted()) verified_total += 1; + } + } + scores[run] = correct; + if (stale > 0) stale_runs += 1; + } + + var total: usize = 0; + var perfect: usize = 0; + for (scores) |c| { + total += c; + if (c == per_run) perfect += 1; + } + std.sort.pdq(usize, scores, {}, std.sort.asc(usize)); + + const attempted = runs * per_run; + const mean = @as(f64, @floatFromInt(total)) / @as(f64, @floatFromInt(runs)); + std.debug.print("jobs attempted : {d}\n", .{attempted}); + std.debug.print("dot products correct: {d} ({d:.3}%)\n", .{ total, @as(f64, @floatFromInt(total)) / @as(f64, @floatFromInt(attempted)) * 100 }); + std.debug.print("perfect runs : {d}/{d}\n", .{ perfect, runs }); + std.debug.print("per-run correct : min {d}, p50 {d}, p95 {d}, max {d}, mean {d:.2}\n", .{ + scores[0], scores[runs / 2], scores[(runs * 95) / 100], scores[runs - 1], mean, + }); + if (open_failures > 0) std.debug.print("runs that could not open the port: {d}\n", .{open_failures}); + if (node_key != null) { + std.debug.print("receipts authenticated: {d} ({d:.3}%) under {s}'s own key\n", .{ + verified_total, + @as(f64, @floatFromInt(verified_total)) / @as(f64, @floatFromInt(attempted)) * 100, + node_name, + }); + } else if (keys_loaded == 0) { + std.debug.print("receipts authenticated: not checked — no key file loaded\n", .{}); + } else { + std.debug.print("receipts authenticated: no key on file for this node's id\n", .{}); + } + line(); + if (stale_runs > 0) { + std.debug.print("{d}/{d} runs carried receipts signed with a PUBLISHED key.\n", .{ stale_runs, runs }); + std.debug.print("The arithmetic above is a real hardware measurement. The receipts\n", .{}); + std.debug.print("are not evidence of anything and this run must not be cited as\n", .{}); + std.debug.print("verified work.\n", .{}); + } else if (node_key == null) { + // Reaching here used to print "no published key seen. Receipts from + // this fleet can be cited." — on a run where no key was loaded, so no + // key could have been seen, published or otherwise. A green light + // nothing can turn red is the same defect as a verdict enumeration that + // treats every unlisted case as innocent: ask what was checked, do not + // infer it from what failed to happen. + std.debug.print("no key was checked, so nothing here says who did this work.\n", .{}); + std.debug.print("The arithmetic above is a real hardware measurement. The receipts\n", .{}); + std.debug.print("are unverified and this run must not be cited as verified work.\n", .{}); + } else { + std.debug.print("no published key seen, and {d}/{d} receipts verified under {s}'s\n", .{ + verified_total, attempted, node_name, + }); + std.debug.print("own key. This run can be cited as verified work.\n", .{}); + } + line(); + std.debug.print("Report the minimum, not the mean. A fleet is used at its worst run.\n", .{}); +} + +/// Install the fleet's keys on the attached boards. +/// +/// The key is not baked into the bitstream any more. Re-keying used to mean a +/// place-and-route run the operator's machine cannot perform plus a 13-minute +/// flash per board, and a key that expensive to rotate is a key nobody rotates: +/// the committed-key fix was applied to the source and never reached the +/// silicon, and the fleet ran for a day signing with keys from the git history. +/// Now it costs a power cycle and one 24-byte frame. +fn setkey(gpa: std.mem.Allocator, ports: []const [:0]const u8) !void { + std.debug.print("installing receipt keys on {d} board(s)\n", .{ports.len}); + line(); + + const keys_loaded = loadFleetKeys(gpa) catch 0; + if (keys_loaded == 0) { + std.debug.print("No keys to install. Run `trinet keygen > {s}` first —\n", .{key_file_default}); + std.debug.print("and do not commit that file.\n", .{}); + return error.NoKeys; + } + std.debug.print("keys loaded for {d} node(s)\n", .{keys_loaded}); + line(); + + var installed: usize = 0; + var locked: usize = 0; + for (ports) |p| { + const found = node_mod.Node.initFpgaAutoBaud(0, "unidentified", p) catch |e| { + std.debug.print("{s}: no answer at any candidate rate ({s})\n", .{ p, @errorName(e) }); + continue; + }; + var n = found.node; + defer n.deinit(); + + // Ask the board who it is before choosing which key it gets. Binding a + // key to argument order would hand node0's key to whichever board came + // up first, and every receipt afterwards would fail its check. + const who_job = protocol.Job.withNonce(1, @splat(0), @splat(0)); + const claimed = n.execute(who_job) catch |e| { + std.debug.print("{s}: opened but did not answer ({s})\n", .{ p, @errorName(e) }); + continue; + }; + + var spec: ?FleetNode = null; + for (fleet_nodes) |f| { + if (f.id == claimed.node_id) spec = f; + } + if (spec == null) { + std.debug.print("{s}: reports id {x:0>8}, not in the fleet table — skipped\n", .{ p, claimed.node_id }); + continue; + } + const key = spec.?.key orelse { + std.debug.print("{s}: {s} has no key in {s} — skipped\n", .{ p, spec.?.name, key_file_default }); + continue; + }; + + if (claimed.status == protocol.status_ok) { + std.debug.print("{s}: {s} already holds a key from this configuration.\n", .{ p, spec.?.name }); + std.debug.print(" Power-cycle the board to install a different one.\n", .{}); + locked += 1; + continue; + } + + n.setKey(key) catch |e| { + if (e == error.KeyAlreadySet) { + std.debug.print("{s}: {s} refused a second key — the latch held, as designed\n", .{ p, spec.?.name }); + locked += 1; + } else { + std.debug.print("{s}: {s} did NOT accept the key ({s})\n", .{ p, spec.?.name, @errorName(e) }); + } + continue; + }; + + // Prove it took, with work rather than with the acknowledgement. + var ok: usize = 0; + for (0..32) |i| { + var wv: protocol.Trits = @splat(0); + var xv: protocol.Trits = @splat(0); + for (&wv, 0..) |*t, k| t.* = @intCast(@as(i32, @intCast((i + k) % 3)) - 1); + for (&xv, 0..) |*t, k| t.* = @intCast(@as(i32, @intCast((i + k + 1) % 3)) - 1); + const job = protocol.Job.withNonce(@intCast(100 + i), protocol.pack(wv), protocol.pack(xv)); + const r = n.execute(job) catch continue; + if (protocol.verifyWithKey(job, r, key).accepted()) ok += 1; + } + const stale = protocol.publishedKeyUsed( + protocol.Job.withNonce(1, @splat(0), @splat(0)), + claimed, + ) != null; + std.debug.print("{s}: {s} keyed, {d}/32 receipts verify under the new key{s}\n", .{ + p, spec.?.name, ok, + if (stale) " (was on a PUBLISHED key before this)" else "", + }); + // The key went in — setKey already checked the acknowledgement's tag, + // which only a board holding that key can produce. Counting only boards + // that then scored a perfect 32/32 hid a successfully keyed node behind + // its own lossy cable, and reported "1 board keyed" when two were. + installed += 1; + if (ok < 32) { + std.debug.print(" {d} of 32 verification jobs did not return clean — that is this\n", .{32 - ok}); + std.debug.print(" board's link, not its key. The key is installed either way.\n", .{}); + } + } + + line(); + std.debug.print("{d} board(s) keyed, {d} already locked\n", .{ installed, locked }); + if (locked > 0) { + std.debug.print("A locked board is not a fault: the key is write-once per\n", .{}); + std.debug.print("configuration on purpose, so nobody reaching the wire can replace\n", .{}); + std.debug.print("the operator's key after the fact.\n", .{}); + } +} + fn keygen() !void { var seed: u64 = undefined; var ts: std.c.timespec = undefined; diff --git a/src/trinet/mesh.zig b/src/trinet/mesh.zig index 3abbcff4b2..af96125e1c 100644 --- a/src/trinet/mesh.zig +++ b/src/trinet/mesh.zig @@ -66,6 +66,8 @@ pub const Stats = struct { /// Damaged frames. Separated from rejections because one is a statement /// about the operator's wiring and the other about their honesty. corrupt_jobs: u64 = 0, + unverifiable_jobs: u64 = 0, + not_eligible_jobs: u64 = 0, on_silicon: u64 = 0, in_software: u64 = 0, @@ -179,15 +181,27 @@ pub const Mesh = struct { } const settlement = try self.ledger.settle(n.id, job, receipt, verdict); - if (settlement.outcome == .credited) { - self.stats.accepted += 1; - n.stats.accepted += 1; - if (n.isPhysical()) self.stats.on_silicon += 1 else self.stats.in_software += 1; - } else if (settlement.outcome == .corrupt_not_charged) { - self.stats.corrupt_jobs += 1; - } else { - self.stats.rejected += 1; - n.stats.rejected += 1; + // Exhaustive on purpose — no `else`. Three separate times this session a + // new outcome fell into a catch-all and got printed as "rejected as + // dishonest" beside "slashed: 0 mTRI", a summary that accuses and then + // charges nothing. A reader cannot tell whether that is a lie or a bug. + // With no `else`, adding an outcome is a compile error until someone + // decides what it means. + switch (settlement.outcome) { + .credited => { + self.stats.accepted += 1; + n.stats.accepted += 1; + if (n.isPhysical()) self.stats.on_silicon += 1 else self.stats.in_software += 1; + }, + .corrupt_not_charged => self.stats.corrupt_jobs += 1, + .unverifiable_not_charged => self.stats.unverifiable_jobs += 1, + // We declined to use the node. That is our scheduling decision, not + // the node's conduct. + .not_eligible => self.stats.not_eligible_jobs += 1, + .rejected_and_slashed, .identity_mismatch => { + self.stats.rejected += 1; + n.stats.rejected += 1; + }, } return .{ @@ -334,7 +348,9 @@ pub const Mesh = struct { if (n.isPhysical()) self.stats.on_silicon += 1 else self.stats.in_software += 1; }, .corrupt_not_charged => self.stats.corrupt_jobs += 1, - else => { + .unverifiable_not_charged => self.stats.unverifiable_jobs += 1, + .not_eligible => self.stats.not_eligible_jobs += 1, + .rejected_and_slashed, .identity_mismatch => { self.stats.rejected += 1; n.stats.rejected += 1; }, @@ -376,8 +392,8 @@ pub const Mesh = struct { ); } try writer.print( - "jobs: {d} dispatched, {d} accepted, {d} rejected as dishonest, {d} damaged in transit, {d} unreachable\n", - .{ self.stats.dispatched, self.stats.accepted, self.stats.rejected, self.stats.corrupt_jobs, self.stats.unreachable_jobs }, + "jobs: {d} dispatched, {d} accepted, {d} rejected as dishonest, {d} damaged in transit, {d} unverifiable, {d} not dispatched, {d} unreachable\n", + .{ self.stats.dispatched, self.stats.accepted, self.stats.rejected, self.stats.corrupt_jobs, self.stats.unverifiable_jobs, self.stats.not_eligible_jobs, self.stats.unreachable_jobs }, ); try writer.print( "dispatch: {d} to serial-attached nodes, {d} to software nodes ({d:.1}%)\n", @@ -597,8 +613,13 @@ test "keyed nodes are verified with their own key, not each other's" { const honest = protocol.executeKeyed(job, 0xA000, key_a); try std.testing.expectEqual(protocol.Verdict.ok, protocol.verifyWithKey(job, honest, key_a)); try std.testing.expectEqual(protocol.Verdict.corrupt, protocol.verifyWithKey(job, honest, key_b)); - // And a keyed receipt with no key at all must never be waved through. - try std.testing.expectEqual(protocol.Verdict.corrupt, protocol.verifyWithKey(job, honest, null)); + // And a keyed receipt with no key at all must never be waved through — + // but must not be held against the node either. Holding the wrong key is a + // statement about the receipt; holding no key is a statement about us. + const no_key = protocol.verifyWithKey(job, honest, null); + try std.testing.expectEqual(protocol.Verdict.unverifiable, no_key); + try std.testing.expect(!no_key.accepted()); + try std.testing.expect(!no_key.indictsTheNode()); } test "a layer is shared across nodes, not dumped on the first one" { diff --git a/src/trinet/node.zig b/src/trinet/node.zig index b6e9886b31..addc158847 100644 --- a/src/trinet/node.zig +++ b/src/trinet/node.zig @@ -22,6 +22,10 @@ pub const Error = error{ Unreachable, MalformedResponse, Timeout, + /// The board already holds a key and will not take another until it is + /// reconfigured. Not a failure of the caller — it is the write-once latch + /// doing its job, and the only fix is a power cycle or a re-flash. + KeyAlreadySet, }; /// How an emulated node behaves. Everything except `.honest` is an attack that @@ -128,6 +132,123 @@ pub const Node = struct { }; } + /// Rates to try when a board's own is unknown. + /// + /// CFGMCLK is an internal RC oscillator with no trim, so its frequency is a + /// property of the individual die. Measured across this fleet with + /// conformance/trinet_baud_sweep.py: 70.46, 67.13 and 68.69 MHz, a 4.97% + /// spread, and each board tolerates about +/-4.5%. The windows therefore + /// overlap, and 1144744 baud delivers 6400/6400 on all three -- so this list + /// leads with it rather than with a per-die constant. + /// + /// The list is for ACQUISITION. It cannot be the source of an operating + /// rate, because a list generated from an assumption cannot contain the rate + /// that refutes it: every rate tried on node2 came from a BAUD_DIV=60 + /// candidate table, and the rate that fixed it was not in the table. + /// conformance/trinet_baud_sweep.py finds a board's real window; this only + /// has to get close enough to talk. + pub const candidate_bauds = [_]u32{ 1144744, 1174399, 1118846, 1186267, 1150000, 1210000, 2372533, 164000, 160000 }; + + /// Jobs used to judge a candidate rate, once it is known to answer at all. + /// + /// This was six, and six cannot see the failure it exists to catch. A rate + /// a few percent off does not go silent -- it loses a couple of percent of + /// jobs, which reads as a lossy cable. At node2's measured 2.4% loss, six + /// jobs come back clean 86% of the time and one comes back clean 97.6% of + /// the time, so both the Zig and the Python probe accepted the bad rate and + /// stopped looking. 64 drops that to 21%, and refusing to stop at the first + /// perfect candidate removes the rest. + pub const baud_confirm_jobs = 64; + + /// Open a board without being told its line rate, by asking it. + /// + /// Costs one exchange per candidate on a miss. That is cheaper than the + /// alternative, which is a fleet whose membership depends on how close each + /// die's oscillator happened to land to a constant someone hardcoded. + /// How many of `jobs` come back whole at the rate this node is already open + /// at. + /// + /// "Whole" includes the claimed identity being the same on every job. The + /// caller may not know what the identity should be — auto-baud runs before + /// anyone has asked the board who it is — but a rate that damages bytes + /// damages that field too, and self-consistency is checkable without + /// knowing the answer in advance. + fn scoreRate(n: *Node, jobs: usize) usize { + var score: usize = 0; + var claimed: ?u32 = null; + for (0..jobs) |k| { + const w: u8 = if (k % 2 == 0) 0x55 else 0xA9; + const job = protocol.Job.withNonce(@intCast(k + 1), @splat(w), @splat(0x55)); + const r = n.execute(job) catch continue; + if (!protocol.statusMeansComputed(r.status)) continue; + if (!std.mem.eql(u8, &r.nonce, &job.nonce)) continue; + if (r.y != protocol.dot(job.w, job.x)) continue; + if (claimed) |c| { + if (r.node_id != c) continue; + } else claimed = r.node_id; + score += 1; + } + return score; + } + + pub fn initFpgaAutoBaud(id: u32, name: []const u8, path: [:0]const u8) !struct { + node: Node, + baud: u32, + clean: usize, + jobs: usize, + } { + var last_err: anyerror = error.Timeout; + var best_baud: ?u32 = null; + var best_score: usize = 0; + + // Rates that delivered every job, kept so the chosen one can be the + // middle of them rather than whichever came first in the list. A rate at + // the very edge of a board's window scores perfectly today and loses + // jobs when the die warms up; the middle of the perfect set is further + // from both edges than either end of it. + var perfect: [candidate_bauds.len]u32 = undefined; + var n_perfect: usize = 0; + + for (candidate_bauds) |b| { + var n = initFpga(id, name, path, b) catch |e| { + last_err = e; + continue; + }; + // One probe first, only to find out whether the board can hear this + // rate at all. A rate it cannot hear costs a read timeout per job, + // and scoring every dead candidate in full would pay that 64 times + // over for nothing. + var score: usize = 0; + if (scoreRate(&n, 1) == 1) score = scoreRate(&n, baud_confirm_jobs); + n.deinit(); + + if (score == baud_confirm_jobs) { + perfect[n_perfect] = b; + n_perfect += 1; + } + if (score > best_score) { + best_score = score; + best_baud = b; + } + // No early exit. Stopping at the first candidate that passed is what + // put the marginal board on the worst rate available to it, and a + // candidate that answers costs well under a second to score. + } + + if (n_perfect > 0) { + std.mem.sort(u32, perfect[0..n_perfect], {}, std.sort.asc(u32)); + best_baud = perfect[n_perfect / 2]; + best_score = baud_confirm_jobs; + } + + if (best_baud) |b| { + var n = try initFpga(id, name, path, b); + n.stats = .{}; + return .{ .node = n, .baud = b, .clean = best_score, .jobs = baud_confirm_jobs }; + } + return last_err; + } + pub fn initFpga(id: u32, name: []const u8, path: [:0]const u8, baud: u32) !Node { var port = try serial.Port.open(path, baud); @@ -231,6 +352,33 @@ pub const Node = struct { return protocol.decodeResponseV2(&raw) orelse Error.MalformedResponse; } + /// Install this node's receipt key over the wire. + /// + /// Returns true only if the node's acknowledgement is signed with the key + /// that was just sent. Checking the signature rather than the status byte + /// is deliberate: a node that merely echoed the request could produce the + /// status, but only one that actually installed the key can produce a tag + /// that verifies under it. + /// + /// `error.KeyAlreadySet` means the board is holding a key from an earlier + /// load and will not take another until it is reconfigured. That is the + /// property the design rests on, so it is reported as a distinct outcome + /// rather than a generic failure. + pub fn setKey(self: *Node, key: [16]u8) Error!void { + const port = switch (self.backend) { + .fpga => |*p| p, + else => return Error.Unreachable, + }; + const job = protocol.Job.setKey(self.highest_nonce_issued + 1, key); + const r = try self.executeSerial(port, job); + + if (r.status == protocol.status_key_locked) return Error.KeyAlreadySet; + if (r.status != protocol.status_key_set) return Error.MalformedResponse; + if (r.tag != protocol.setKeyAckTag(job, r.node_id, key)) return Error.MalformedResponse; + + self.key = key; + } + /// Send several jobs before reading any answer. /// /// One job per round trip costs a USB frame interval — measured at ~1.17 ms diff --git a/src/trinet/protocol.zig b/src/trinet/protocol.zig index a47260ea4b..14a80a4682 100644 --- a/src/trinet/protocol.zig +++ b/src/trinet/protocol.zig @@ -36,7 +36,18 @@ pub const preimage_len = 26; pub const magic_req = [2]u8{ 0xAA, 0x55 }; pub const magic_resp: u8 = 0xA5; pub const op_mac32: u8 = 0x01; +/// Install the node's receipt key. The 16 key bytes travel in the W and X +/// operand fields, so the request stays 24 bytes and the frame parser in the +/// RTL is untouched. Accepted once per configuration. +pub const op_setkey: u8 = 0x02; + pub const status_ok: u8 = 0x01; +/// The key in this request is now the node's key. +pub const status_key_set: u8 = 0x02; +/// A key was already installed; this request changed nothing. +pub const status_key_locked: u8 = 0x03; +/// The node holds no key. `y` is a real dot product; the tag means nothing. +pub const status_no_key: u8 = 0x04; /// Default synthesised identity, the ASCII bytes "TRIN" read little-endian. pub const default_node_id: u32 = 0x5452_494E; @@ -113,8 +124,44 @@ pub const Job = struct { std.mem.writeInt(u32, &nb, n, .little); return .{ .nonce = nb, .w = w, .x = x }; } + + /// The request that installs a key: the 16 bytes ride in the operand + /// fields, first eight in W and last eight in X, in wire order. + pub fn setKey(n: u32, key: [16]u8) Job { + var nb: [4]u8 = undefined; + std.mem.writeInt(u32, &nb, n, .little); + var w: Packed = undefined; + var x: Packed = undefined; + @memcpy(&w, key[0..8]); + @memcpy(&x, key[8..16]); + return .{ .op = op_setkey, .nonce = nb, .w = w, .x = x }; + } }; +/// The tag a node returns when it accepts a key, signed with the key it just +/// accepted. +/// +/// That signature is the whole point of checking the acknowledgement: a node +/// that merely echoed the request could produce the status byte, but only one +/// that actually installed the key can produce this. `y` is zero by protocol — +/// the operand fields hold key bytes, and running a dot product over them would +/// put a meaningless number in a receipt for somebody to later read as work. +pub fn setKeyAckTag(job: Job, node_id: u32, key: [16]u8) u64 { + return receiptTagKeyed(job, 0, node_id, key); +} + +/// Whether this status means the node completed the work, regardless of whether +/// it could sign the result. +/// +/// A freshly flashed node answers `status_no_key` until its key is installed, +/// and its arithmetic is perfectly real in the meantime. Anything that measures +/// the dot product — baud negotiation, a census, a probe's arithmetic column — +/// must accept that, or a correctly working unkeyed board looks broken at every +/// candidate rate and the operator concludes the flash failed. +pub fn statusMeansComputed(status: u8) bool { + return status == status_ok or status == status_no_key; +} + /// How a receipt's tag was produced. The two are not interchangeable, and the /// verifier must know which law to apply — a keyed tag checked as a CRC would /// pass nothing, and a CRC checked as keyed would pass everything. @@ -282,6 +329,15 @@ pub const Verdict = enum { /// answer nor the one returned. A node with the key cannot produce this on /// purpose, so it is a damaged frame rather than a lie. corrupt, + /// The receipt is keyed and the verifier holds no key for this node, so + /// nothing about it can be concluded — including that it is wrong. + /// + /// This exists because the fleet slashed an honest board 400 mTRI over a + /// missing entry in the host's key file. Without the key every keyed + /// receipt looks equally unlike the expected tag, and a verifier that + /// cannot check must not accuse. It is not `corrupt` either: corrupt is a + /// claim about the link, and this is a statement about the verifier. + unverifiable, pub fn accepted(self: Verdict) bool { return self == .ok; @@ -292,7 +348,7 @@ pub const Verdict = enum { /// stake, so it must not be guessed at. pub fn indictsTheNode(self: Verdict) bool { return switch (self) { - .ok, .corrupt => false, + .ok, .corrupt, .unverifiable => false, .bad_status, .nonce_mismatch, .wrong_result => true, }; } @@ -304,6 +360,7 @@ pub const Verdict = enum { .nonce_mismatch => "nonce does not match the job (replay or crossed response)", .wrong_result => "wrong answer, correctly tagged — the node had the key and signed a guess", .corrupt => "response is not self-consistent — a damaged frame, not a lie", + .unverifiable => "keyed receipt, and no key for this node — nothing can be concluded", }; } }; @@ -324,6 +381,18 @@ pub fn verify(job: Job, r: Receipt) Verdict { /// never computed, which is the one mistake that would silently undo the whole /// point of keying the tag. pub fn verifyWithKey(job: Job, r: Receipt, key: ?[16]u8) Verdict { + // Answer the verifier's own competence first. Every branch below compares + // the tag against something, and with no key every comparison fails for the + // same uninformative reason -- which reads as evidence and is not. + if (r.kind == .siphash24 and key == null) return .unverifiable; + + // A node that has not been given a key yet is not misbehaving, and must not + // be charged for saying so. Same for one that declined a second key: both + // are the node reporting its own state correctly. + switch (r.status) { + status_no_key, status_key_set, status_key_locked => return .unverifiable, + else => {}, + } if (r.status != status_ok) return .bad_status; if (!std.mem.eql(u8, &r.nonce, &job.nonce)) { @@ -581,3 +650,136 @@ test "a CRC receipt is never flagged, because it claims no key at all" { }; try std.testing.expectEqual(@as(?usize, null), publishedKeyUsed(job, r)); } + +test "a keyed receipt with no key is unverifiable, and never an accusation" { + const job = Job.withNonce(5, @splat(0x55), @splat(0xAA)); + const y = dot(job.w, job.x); + var k: [16]u8 = undefined; + for (&k, 0..) |*b, i| b.* = @intCast(0x40 + i); + + // A perfectly honest keyed receipt. + const honest: Receipt = .{ + .kind = .siphash24, + .y = y, + .status = status_ok, + .nonce = job.nonce, + .node_id = default_node_id, + .tag = receiptTagKeyed(job, y, default_node_id, k), + }; + try std.testing.expectEqual(Verdict.ok, verifyWithKey(job, honest, k)); + try std.testing.expectEqual(Verdict.unverifiable, verifyWithKey(job, honest, null)); + try std.testing.expect(!verifyWithKey(job, honest, null).indictsTheNode()); + + // A node that lied. Still not chargeable by a verifier holding no key. + const liar: Receipt = .{ + .kind = .siphash24, + .y = y +% 1, + .status = status_ok, + .nonce = job.nonce, + .node_id = default_node_id, + .tag = receiptTagKeyed(job, y +% 1, default_node_id, k), + }; + try std.testing.expectEqual(Verdict.wrong_result, verifyWithKey(job, liar, k)); + try std.testing.expectEqual(Verdict.unverifiable, verifyWithKey(job, liar, null)); + try std.testing.expect(!verifyWithKey(job, liar, null).indictsTheNode()); +} + +test "a keyless verifier still judges CRC receipts, which need no key" { + const job = Job.withNonce(6, @splat(0x55), @splat(0x55)); + const y = dot(job.w, job.x); + const good: Receipt = .{ + .kind = .crc32, + .y = y, + .status = status_ok, + .nonce = job.nonce, + .node_id = default_node_id, + .tag = receiptTag(job, y, default_node_id), + }; + try std.testing.expectEqual(Verdict.ok, verifyWithKey(job, good, null)); +} + +test "a set-key request carries the key in the operand fields, in wire order" { + var key: [16]u8 = undefined; + for (&key, 0..) |*b, i| b.* = @intCast(i); + const job = Job.setKey(7, key); + + try std.testing.expectEqual(op_setkey, job.op); + try std.testing.expectEqual(@as(u32, 7), job.nonceValue()); + // First eight bytes in W, last eight in X — this is the layout the RTL's + // frame parser produces, and getting it backwards would key the board with + // a permutation of the intended key while everything still looked fine. + try std.testing.expectEqualSlices(u8, key[0..8], &job.w); + try std.testing.expectEqualSlices(u8, key[8..16], &job.x); + + // The request length is unchanged, which is the point of reusing the + // operand fields: the frame parser and its alignment guard stay valid. + const wire = encodeRequest(job); + try std.testing.expectEqual(request_len, wire.len); + try std.testing.expectEqual(op_setkey, wire[2]); +} + +test "the set-key acknowledgement is signed with the key just installed" { + var key: [16]u8 = undefined; + for (&key, 0..) |*b, i| b.* = @intCast(0x30 +% i); + var other: [16]u8 = undefined; + for (&other, 0..) |*b, i| b.* = @intCast(0x90 +% i); + + const job = Job.setKey(2, key); + const ack = setKeyAckTag(job, default_node_id, key); + + // Signed with the NEW key, not the old one — that is what makes the ack + // evidence of acceptance rather than an echo a bystander could fake. + try std.testing.expect(ack != setKeyAckTag(job, default_node_id, other)); + // y is zero by protocol: the operand fields hold key bytes, not trits. + try std.testing.expectEqual(receiptTagKeyed(job, 0, default_node_id, key), ack); +} + +test "key-state statuses are never an accusation" { + const job = Job.withNonce(11, @splat(0x55), @splat(0x55)); + const y = dot(job.w, job.x); + var key: [16]u8 = undefined; + for (&key, 0..) |*b, i| b.* = @intCast(0x21 +% i); + + // An unkeyed board reports a real dot product and a meaningless tag. It is + // not misbehaving, and charging it stake for saying so is how an operator + // gets punished for a board that simply has not been provisioned yet. + for ([_]u8{ status_no_key, status_key_set, status_key_locked }) |st| { + const r: Receipt = .{ + .kind = .siphash24, + .y = y, + .status = st, + .nonce = job.nonce, + .node_id = default_node_id, + .tag = receiptTagKeyed(job, y, default_node_id, key), + }; + const v = verifyWithKey(job, r, key); + try std.testing.expectEqual(Verdict.unverifiable, v); + try std.testing.expect(!v.accepted()); + try std.testing.expect(!v.indictsTheNode()); + } + + // A status nobody defined is still a fault worth charging for: it means the + // node is saying something the protocol has no reading for. + const weird: Receipt = .{ + .kind = .siphash24, + .y = y, + .status = 0x7F, + .nonce = job.nonce, + .node_id = default_node_id, + .tag = receiptTagKeyed(job, y, default_node_id, key), + }; + try std.testing.expectEqual(Verdict.bad_status, verifyWithKey(job, weird, key)); + try std.testing.expect(verifyWithKey(job, weird, key).indictsTheNode()); +} + +test "an unkeyed node has still done the work" { + // The distinction the baud negotiator and the census depend on: computed is + // not the same question as signed. + try std.testing.expect(statusMeansComputed(status_ok)); + try std.testing.expect(statusMeansComputed(status_no_key)); + // A key-load acknowledgement carries no dot product at all, and a locked + // reply carries nothing new — neither is a measurement. + try std.testing.expect(!statusMeansComputed(status_key_set)); + try std.testing.expect(!statusMeansComputed(status_key_locked)); + try std.testing.expect(!statusMeansComputed(0x7F)); +} diff --git a/tools/gen_setkey_golden.py b/tools/gen_setkey_golden.py new file mode 100644 index 0000000000..a8aa17bc28 --- /dev/null +++ b/tools/gen_setkey_golden.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Emit the golden tags formal/trinet_setkey_tb.v checks against. + +They come from conformance/trinet_mac32_conformance_ax7203.py — the independent +Python implementation — and never from the RTL. A testbench whose expected +values were read off the design under test only proves the design is +self-consistent, which it always is. + +Usage: python3 tools/gen_setkey_golden.py > formal/trinet_setkey_golden.vh + +Author: Dmitrii Vasilev (@gHashTag) +""" + +import importlib.util +import pathlib +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +spec = importlib.util.spec_from_file_location( + "conf", ROOT / "conformance" / "trinet_mac32_conformance_ax7203.py") +conf = importlib.util.module_from_spec(spec) +_argv, sys.argv = sys.argv, ["gen"] +try: + spec.loader.exec_module(conf) +except SystemExit: + pass +sys.argv = _argv + +OP_MAC32, OP_SETKEY = 0x01, 0x02 +NODE = 0x5452494E +KEY1 = bytes(range(16)) # what the testbench installs + +def tag(op, nonce, w, x, y, key): + return conf.golden_receipt_tag_keyed( + op, nonce.to_bytes(4, "big"), w.to_bytes(8, "big"), + x.to_bytes(8, "big"), y, NODE, key) + +# The set-key acknowledgement: operands are the key bytes, y is forced to zero, +# and it is signed with the key just accepted — that is what makes the ack +# evidence of acceptance rather than an echo. +ack = tag(OP_SETKEY, 2, int.from_bytes(KEY1[:8], "big"), + int.from_bytes(KEY1[8:], "big"), 0, KEY1) + +mac_a = tag(OP_MAC32, 3, 0x5555555555555555, 0x5555555555555555, 0x20, KEY1) +mac_b = tag(OP_MAC32, 4, 0x5555555555555555, 0xaaaaaaaaaaaaaaaa, 0xe0, KEY1) +mac_c = tag(OP_MAC32, 6, 0x5555555555555555, 0x5555555555555555, 0x20, KEY1) + +print("// GENERATED by tools/gen_setkey_golden.py — do not hand-edit.") +print("// Values come from the Python golden implementation, not from the RTL.") +for name, v in [("GOLD_SETKEY_ACK", ack), ("GOLD_MAC_A", mac_a), + ("GOLD_MAC_B", mac_b), ("GOLD_MAC_C", mac_c)]: + print(f"localparam [63:0] {name} = 64'h{v:016x};")