Skip to content

TRI-NET: three boards, ten families, and four numbers that were wrong - #355

Open
gHashTag wants to merge 85 commits into
mainfrom
trinet-fleet-truth
Open

TRI-NET: three boards, ten families, and four numbers that were wrong#355
gHashTag wants to merge 85 commits into
mainfrom
trinet-fleet-truth

Conversation

@gHashTag

@gHashTag gHashTag commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Three commits. The first answers a question that was asked; the other two are
things found while trying to answer it, and they matter more.

The cell is portable, and that was the surprise

Option C's falsifier was "port the cell to a non-Xilinx target and measure how
much of it is 7-series-specific; if most of it is, there is no IP to sell."
It
was expected to kill the option.

Splitting trinet_node_v2_ax7203.v found exactly two Xilinx primitives —
STARTUPE2 for the clock, DNA_PORT for the device identity — both board
concerns, not node concerns. With those lifted into a wrapper, the core
synthesises with zero errors on ten families from eight vendors and no
inferred multiplier anywhere. Nine of the ten recover 819 flip-flops; Intel
ALM finds 831 because its register cell absorbs reset logic the others express
separately.

xilinx ice40 ecp5 nexus gowin gatemate anlogic efinix nanoxplore intel_alm
LUTs 1737 1459 1286 939 1663 1180 1059 1187 1178 1267
FFs 819 819 819 819 819 819 819 819 819 831
mult 0 0 0 0 0 0 0 0 0 0

The wrapper instantiates the core rather than keeping a copy, so the claim
cannot rot while both files still build. CI asserts the invariant — sequential
state agrees, nobody infers a multiplier — not the numbers, which would fail on
a yosys upgrade for no reason.

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. It also does
not make anyone want the IP. The recommendation to defer option C does not
change
— its real obstacles were never engineering.

Four numbers were wrong

Found by pointing the host at the boards and disbelieving the first thing it
printed.

The third board was never broken. It had been recorded for a day as a wiring
fault — configured, DONE=1, UART silent. It answers at 1124474 baud against a
hardcoded 1186267: a 5.2% error where a UART tolerates about 3. Swept, it scores
6400/6400 over 100 independent runs, equal to the best board in the fleet.
The cause generalises: CFGMCLK is an untrimmed RC oscillator, and these three
dies run at 71.18 / 70.46 / 67.47 MHz — a 5.5% spread, not the 1.25% that
two samples had suggested. Line rate is now negotiated per board.

Every board is running a receipt key published in this repository. W01 nulled
the keys in the source and never reached the silicon. node0 verifies 64/64 under
0x00..0x0f, node2 63/64 under 0x20..0x2f. Every "keyed receipt verified on
silicon" result this project has reported is a tag any reader of the git log can
compute. A compromised key and a good key are indistinguishable to any test that
only asks whether the tag matches, so the suite stayed green. "Fixed" meant the
source changed and nobody checked the artifact.

Throughput counted jobs that failed. jobs_per_s divided by attempts. A
board answering nothing returns instantly, so total failure read as the fastest
run ever recorded — 5409 jobs/s against a transport ceiling of 4942, with 0/64
verified. Every published jobs/s figure is withdrawn rather than restated.

The host picked the wire format from its own config file. Response width came
from key != null; the width belongs to the bitstream. A keyless host read 15
bytes of a 19-byte response and offset every later read by four — a healthy board
reporting MalformedResponse forever, and throughput defect's accomplice.

Statistical base

100 independent runs × 64 jobs per board, port reopened every run (the FPGA frame
parser survives the host process, so that matters):

correct perfect runs min
node0 6400/6400 100/100 64
node1 6400/6400 100/100 64
node2 6308/6400 42/100 60

node2's loss is not the baud — swept to its own centre it scored slightly worse.
That hypothesis is cleanly falsified and the marginal link stands.

New laws and guards

  • Verdict.unverifiable — the fleet slashed an honest board 400 mTRI over a
    missing key-file entry. Holding the wrong key is a statement about the receipt;
    holding no key is a statement about the verifier, and only the first costs stake.
  • protocol.publishedKeyUsed() — a board on a published key gets no credit and
    no slash. It is honest and useless at the same time.
  • conformance/key_default_check.py — null defaults in RTL, explicit keys in
    testbenches. Both rules were broken at once and neither failure was visible:
    nulling the default silently disabled the testbench guarding the receipt, which
    had failed 0/6 ever since. CI breaks each rule on purpose to prove the guard
    still fails when it should.

180 tests pass across protocol, node, ledger, mesh, model and agent.

What this does not do

The boards are not re-flashed. Until they are, no receipt from this hardware is
evidence of anything, and the fleet correctly refuses to settle — it now explains
that instead of dying on NoEligibleNode. That is one day of work and it gates
everything downstream.

🤖 Generated with Claude Code

gHashTag and others added 30 commits August 2, 2026 13:43
…s a number for it

Option C's falsifier was "port the cell to a non-Xilinx target and measure how
much of it is 7-series-specific; if most of it is, there is no IP to sell." It
was expected to kill the option. It did the opposite.

Split trinet_node_v2_ax7203 into a board wrapper and fpga/portable/
trinet_node_core.v. The audit of what actually had to move found two
primitives: STARTUPE2 for the clock and DNA_PORT for the device identity, both
board concerns rather than node concerns. Nothing else was vendor-bound.

The core then synthesised on ten families from eight vendors with zero errors
and zero inferred multipliers -- and every one of them recovered 819 flip-flops,
except Intel ALM at 831 because its register cell absorbs reset logic the others
express separately. Ten synthesisers agreeing to the register is what portable
RTL looks like. The LUT column varies 939..1737 and that is LUT width doing its
job, so the CI check asserts the invariant rather than the numbers; asserting
the numbers would fail on a yosys upgrade for no reason.

The wrapper instantiates the core rather than keeping a copy. A copy would
drift and the portability claim would stop being true while both files built.

This does not establish portability of product. Synthesis is not place-and-
route, no non-Xilinx mapping has met timing, and only the xc7 path has run on
silicon. It also does not make anyone want the IP -- the report's real
objections to option C (no measured power, no device-bound identity, no fab
path) are untouched, and "C not this year" stands. What changed is that the
reason is now about market access rather than about engineering.

Also fixes a defect the split surfaced. formal/trinet_node_v2_tb.v failed 0/6 --
and failed identically on the pre-split design, which is what proved the split
behaviour-preserving. The testbench passed no key and relied on the module
default, so when W01 replaced the compromised default with a null one, its
golden tags stopped matching anything the RTL could produce. The security fix
disabled the test guarding the security property and nothing said so. Now passes
the SipHash reference key explicitly, with goldens regenerated from the
independent Python implementation rather than from the RTL: 6/6.

conformance/key_default_check.py enforces both halves of that lesson -- null
defaults in RTL, explicit keys in testbenches -- and CI deliberately breaks each
rule to confirm the guard still fails when it should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…od one

Found while trying to build the statistical base a paper needs. None of them
were visible from the code; all three came out of pointing the host at the
boards and disbelieving the first number it printed.

1. THE FLEET IS RUNNING PUBLISHED KEYS.

W01 replaced the committed receipt keys with a null default. That fixed the
source and never reached the silicon. Measured today: node0 verifies 64/64
under 0x00..0x0f and node2 63/64 under 0x20..0x2f -- both keys published in
this repository's history. Every "keyed receipt verified on silicon" result
this project has reported rests on tags any reader of the git log can compute.

A compromised key and a good key are indistinguishable to any test that only
asks "does the tag match", which is why nothing noticed. protocol.zig now
carries the published keys explicitly and publishedKeyUsed() checks every
receipt against them; probe refuses the node with "a working node with
worthless receipts. Do not pay it." The arithmetic is separately reported and
is real -- 64/64 and 63/64 -- because a board can compute perfectly and prove
nothing, and conflating those is the same mistake W03 was about.

2. THROUGHPUT COUNTED JOBS THAT FAILED.

jobs_per_s divided by n, the number attempted, not by the number verified. A
board answering nothing returns instantly, so total failure read as the fastest
run ever recorded: 5409 jobs/s against a transport ceiling of 4942, with 0/64
verified. The ceiling caught it. Latency percentiles had the same shape --
failures wrote a zero into the array and dragged p50 to 0.00 ms.

Now counts verified work only, takes percentiles over successful jobs only, and
prints IMPOSSIBLE when the rate exceeds what the UART can carry. Every jobs/s
figure published before today was computed the broken way and is restated.

3. THE HOST PICKED THE WIRE FORMAT FROM ITS OWN CONFIG FILE.

Response width was inferred from `key != null`. But the width is a property of
the flashed bitstream and the key is a property of the host's config -- so a
keyless host reading a v2 board took 15 bytes of a 19-byte response, left four
behind, and offset every later read by four. A healthy board reported
MalformedResponse forever, and the benchmark read 15-byte slices of a stream at
full line rate and called it throughput. That is defect 2's accomplice.

The format is now asked of the wire on the first exchange and latched. The
detection costs one timeout on a v1 board, once, and buys a host that cannot be
misconfigured into a permanent desync.

Also gives probe a baud argument, because the boards answer at 1186267 and the
built-in default of 160000 could not reach them at all.

48/48 protocol+agent, 18 ledger, 34 mesh, 16 node, 42 model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fleet has been described as two boards since yesterday, with the third
recorded as a wiring fault: configured, DONE=1, UART silent. That diagnosis was
wrong, and the operator asking "why can't you see the third board?" is what
prompted actually testing it instead of repeating it.

It answers at 1124474 baud. The fleet was hardcoded to 1186267 — a 5.2% error,
where a UART tolerates about 3. Swept, it verifies 32/32 immediately and then
6400/6400 across 100 independent runs, which makes it the equal of the best
board in the fleet.

The cause is physical and matters beyond this desk. CFGMCLK is an untrimmed RC
oscillator, so its frequency belongs to the die: this fleet measures 71.18,
70.46 and 67.47 MHz, a 5.5% spread. Two boards had suggested 1.25% and that
number is now known to be a two-sample artifact. No single host rate can serve
this fleet, so Node.initFpgaAutoBaud asks each board its rate instead of being
told. It scores six probes per candidate rather than trusting one, because the
marginal board latched two different rates on consecutive runs from the same
wire — one job is one coin flip.

Statistical base, 100 independent runs of 64 jobs per board, port reopened each
run (the FPGA frame parser survives the host process, so that matters):

  node0  6400/6400  100/100 perfect runs
  node1  6400/6400  100/100 perfect runs
  node2  6308/6400   42/100 perfect, min 60, p50 63

node2's loss is not the baud: swept to its own centre it scored slightly worse,
98.08% against 98.56%. That hypothesis is cleanly falsified and the marginal
link stands. Report the minimum — a fleet is used at its worst run.

Two more defects fell out of running all three:

A KEYLESS VERIFIER WAS ACCUSING HONEST BOARDS. The fleet slashed node2 400 mTRI
over a missing entry in the host's key file. With no key every keyed receipt
looks equally unlike the expected tag, and the code read that as a lie. New
Verdict.unverifiable: not accepted, and never an indictment. Holding the wrong
key is a statement about the receipt; holding no key is a statement about us.

BOARDS ON PUBLISHED KEYS NOW EARN NOTHING. The fleet drops the key of any board
whose receipts verify under a key from the git history, so its work is
unverifiable — no credit, and no slash either, because those boards are honest.
With all three stale the ledger correctly refuses to dispatch at all, and the
fleet now explains that instead of dying on error.NoEligibleNode.

180 tests pass across protocol, node, ledger, mesh, model and agent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd a security fix that never reached the silicon

The report now leads with what it got wrong rather than editing itself quietly.
Four corrections: the fleet is three boards not two; the per-chip CFGMCLK
spread is 5.5% not 1.25%; every board carries a published receipt key, so no
receipt on this hardware is evidence of anything; and every jobs/s figure ever
published divided by jobs attempted rather than verified and is withdrawn
rather than restated.

Adds W09-W11. W09 is the worst thing here: "fixed" meant the source changed and
nobody checked the artifact.

Records what running option C's falsifier returned. It came back opposite to
the prediction — the cell is portable across ten families — and the
recommendation to defer C still does not change, because C's real obstacles
were never engineering. That distinction is now stated rather than implied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Option B's action is one issue on TernaryCore with a concrete integration
sketch; its falsifier is a 30-day silence. The draft is written and not sent.

The pitch's strongest line would be "per-job verifiable receipts, demonstrated
on silicon", and that line is not true today: all three boards carry receipt
keys published in this repository, so the layer being offered has a hardware
demonstration whose central security property is void. Sending now means either
overstating it — which makes the falsifier meaningless, since a polite
non-reply would then be the correct response — or opening with an apology.

Send after the re-flash. About a day of work, and it gates the honest version
of all three options.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…was split

read_verilog listed only the wrapper and siphash; trinet_node_core.v has been
the actual node since the portability split, so this workflow would have failed
on the first push that touched it.

Also sets USE_DNA 0. DNA_PORT places, routes, and returns zero for all 57 bits
on this flow, so the node id comes from synthesis either way — the primitive
and its read state machine are dead weight in every bitstream, and removing
them makes the node id in simulation equal the one the hardware reports, which
the DNA path made impossible.

Records what BAUD_DIV=60 actually means now that there are three dies to
compare: not one line rate but three (~1186, ~1174, ~1124 kbaud), because
CFGMCLK is untrimmed and these chips are 5.5% apart where a UART tolerates 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ards

CANDIDATE_RATES held only the rates a shared BAUD_DIV implies, which assumes
every board runs the same clock. They do not. CFGMCLK is untrimmed and this
fleet's dies measure 71.18, 70.46 and 67.47 MHz, so at BAUD_DIV=60 they speak
1186267, 1174399 and 1124474 baud — and the slowest answered none of the listed
rates. That is why it was recorded as a wiring fault for a day.

Both boards now enumerate, and the tool says out loud that they are at
different line rates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dy can rotate is a key nobody rotates

The committed-key defect was fixed in source on 2026-08-02 and never reached the
silicon. Today's measurement found all three boards still signing with keys from
this repository's git history, and the reason it stayed that way is structural
rather than careless.

Re-keying a baked-in key needs a place-and-route run the operator's machine
cannot perform — an XC7A200T chipdb OOMs at Docker's 4 GB, and raising the
limit to 6 GB on an 8 GB host stops Docker starting at all — plus 13 minutes of
flashing, per board. That is roughly an hour per rotation on a good day and
impossible on this desk. A design where the fix costs that much guarantees the
fix does not happen.

So the node now takes its key over the wire. op 0x02 carries 16 key bytes in the
W and X operand fields, which keeps the request at 24 bytes and leaves the frame
parser — and conformance/frame_alignment_check.py — untouched. Accepted exactly
once per configuration and refused afterwards: a key that can be replaced at any
time is not a key, because anyone reaching the wire could overwrite the
operator's and every later receipt would verify under theirs.

The trade is honest and worth stating. Whoever can reach 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
rotation cheap enough to actually happen. 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% for the key register and the latch. Still 0 DSP48.

The acknowledgement is signed with the key just installed, so the host can tell
acceptance from an echo — a node that only echoed the request could produce the
status byte but not that tag. Node.setKey checks the tag, not the status.

Statuses 0x02/0x03/0x04 (key set / key locked / no key) all verify as
Verdict.unverifiable rather than bad_status. A board that has not been
provisioned yet is not misbehaving, and an unrecognised status is still
chargeable — both are tested.

formal/trinet_setkey_tb.v proves the four properties end to end over the real
UART, 11/11: an unkeyed node computes but will not sign; the key installs and
the ack is tagged with it; work then verifies; and a second key is refused while
the first still signs. Golden tags come from tools/gen_setkey_golden.py driving
the independent Python implementation, never from this RTL.

198 tests pass. Both static guards still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y baud rate

Baud negotiation, the census and the probe's arithmetic column each tested
`status == status_ok`. A node that has been flashed but not yet keyed answers
status_no_key, and its dot products are perfectly real — so the negotiator would
have rejected a correctly working board at all eight candidate rates, and the
operator would have concluded the flash failed.

Found by reading the new code against the boot sequence it creates, before
spending a flash cycle on it. The three sites now share
protocol.statusMeansComputed() so they cannot drift apart again: computed and
signed are different questions, and only the second needs a key.

A key-load acknowledgement carries no dot product and is correctly excluded, as
is any status the protocol does not define.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The skill and the SSOT both described a procedure that no longer exists. The
part worth keeping is not the new opcode but the reason for it: the
committed-key fix never reached the silicon because rotating a baked-in key
needs a place-and-route run this workstation cannot perform. A design whose fix
costs an hour per board guarantees the fix does not happen.

Also records that local place-and-route is impossible here, measured rather
than assumed: 4 GB OOMs the chipdb build, 6 GB stops Docker starting on an 8 GB
host. Any plan step assuming a local bitstream is dead on arrival.

And that port names are not identity — '-1110' was node0 one hour and node1 the
next, after a hub change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported 180, then 198. Both were sums of overlapping suites: zig test on a file
runs the tests of everything it imports, so protocol's 16 were counted again
inside node, mesh, model and agent.

The real number is 54 — one run of zig test src/trinet/agent.zig, which nests
protocol(16), mesh(10), model(8), ledger(8), agent(6), node(4), net(2). Smaller
than claimed and worth correcting, because the count is cited as evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… finished

That label was accurate when a bitstream without a baked key was a crippled
build good only for routing and resource checks. The node now takes its key
over the wire, so these are complete and deployable: flash, then trinet setkey.

Leaving the label would send somebody looking for a 'real' keyed build that no
longer exists — and, worse, might tempt them to bake a key back in to get one.

Built and verified: three distinct bitstreams, sha256 matching what CI
recorded, 1455-1475 LC, routed on the first seed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…first

Three AL321 cables attached today, one reachable. The two on the host
controller directly stalled in mpsse_flush() on every attempt; the one behind a
USB2.1 hub answered with IDCODE 0x13636093 on every attempt, three times
running. An hour went into reseating and retrying before the pattern was
visible in ioreg.

Also records how to pair a serial port with a programmer without flashing
anything to find out: a CP2102N and a Digilent under the same hub are the same
board. And that an unbounded JTAG probe wedges a cable — the first one ran ten
minutes and the two cables it touched never recovered — so every probe now
carries a hard timeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ly something to report

node0 re-flashed and keyed over the wire: 6400/6400 dot products correct and
6400/6400 receipts authenticated under a key that was never published, across
100 independent runs with the port reopened each time.

Until today that column would have been meaningless — every board carried a key
from the git history — so census only counted arithmetic. It now counts both,
and says which of three situations it is in: checked, no key file, or no key on
file for this node's id. A zero that means 'not checked' is the kind of number
that gets cited as if it meant 'checked and failed'.

The write-once latch was tested against an adversary on silicon, not only in
simulation: a second setkey carrying an attacker's key returned 0x03 KEY_LOCKED,
and subsequent work still verified under the operator's key and not the
attacker's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-flashed from the CI artifact, came up unkeyed with correct arithmetic, took a
key over the wire that has never been published: 6400/6400 correct and 6400/6400
authenticated across 100 independent runs. An attacker's second key was refused
on silicon and later work still verified under the operator's key.

Records the JTAG finding too, because it cost an hour and will cost it again:
reachability is a bus property, not a cable property. Two cables on the host
controller stalled every time; the one behind a USB2.1 hub worked every time.

node1 and node2 remain on published keys. That is a bench problem, not a design
problem, and the report says so rather than averaging it away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… WE did not hold

Verdict.unverifiable was added so a keyless verifier could not accuse. The
ledger never asked. settle() named `.corrupt` as the one verdict that costs
nothing and slashed everything else, so the new verdict went straight to the
slash path — measured on hardware: node1 and node2 lost 600 mTRI each and were
suspended, for the crime of holding keys the coordinator could not check.

Same shape of defect three times in one session: a rule written in one place and
enforced by enumerating cases somewhere else. So the fix is structural rather
than another case. settle() now asks verdict.indictsTheNode(), and the mesh's
outcome accounting is an exhaustive switch with no `else` — adding an outcome is
a compile error until someone decides what it means.

That catch-all had been printing "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: unverifiable receipts and jobs
we declined to dispatch, both counted as dishonesty.

New Outcome.unverifiable_not_charged, counted apart from corruption because
corrupt is a claim about the link and unverifiable is a claim about the
verifier. A node we cannot pay does stop receiving work after the tolerance —
that is scheduling, not punishment, and no stake moves.

On hardware now: 96 dispatched = 33 accepted + 48 unverifiable + 15 not
dispatched, 0 rejected as dishonest, 0 slashed, all three stakes intact.

Two regression tests, both of which fail against the old settle().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed process

I recorded that JTAG reachability follows the USB bus: two cables on the host
controller stalled in mpsse_flush() every time, one behind a hub worked every
time, three consistent observations. Wrong cause.

Two openocd processes from earlier probes were still alive as root, holding
those two adapters — ps showed them at 1h17m. After the cables were replugged
all three answered, including both that had 'always' stalled.

The leak is the lesson. The probes were bounded with

  sudo -n openocd ... & P=$!; ( sleep 25; kill -9 $P ) &

and $! is the sudo wrapper, not openocd. openocd runs as root beneath it, a
user kill -9 cannot touch a root child, so the wrapper died, the timeout looked
like it had worked, and the adapter stayed held. The timeout has to live inside
the privileged process: sudo -n timeout -s KILL 25 openocd ...

Three consistent observations of a correlation are not a cause, and this one
cost an hour and produced a confident false entry in the skill that would have
cost the next person the same hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nally mean something

All three boards re-flashed from CI artifacts and keyed over the wire with
per-node keys that have never been published. node1 and node2 flashed in
parallel on separate programmers: 778.755 s and 778.757 s, so 13 minutes for
two rather than 26.

100 runs x 64 jobs per board, port reopened each run:

  node0  6400/6400 correct, 6400/6400 authenticated, 100/100 perfect
  node1  6400/6400 correct, 6400/6400 authenticated, 100/100 perfect
  node2  6245/6400 correct, 6235/6400 authenticated,  25/100 perfect, min 59

The agent's forward pass across all three: 96 of 96 accepted, 0 rejected as
dishonest, 0 damaged, 0 slashed, 96 mTRI credited, all three nodes active. Every
previous settlement in this programme rested on tags any reader of the git log
could compute. This one does not.

node2 is still the marginal board and the report says so rather than averaging
it away — 97.6%, min 59 of 64. Its losses are the link, not the key: swept to
its own centre rate it scored slightly worse.

Also stops setkey from hiding a success behind a lossy cable. It counted a
board as keyed only if 32/32 verification jobs then came back clean, so node2
was keyed and reported as not keyed. The acknowledgement's tag already proves
installation — only a board holding the key can produce it — so the count now
follows that, and the link quality is reported separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The leaked-process anti-pattern was written into the skill inside a ```bash
fence — which renders with a Run button. It reads as an example of what not to
do and clicks as an instruction. The operator ran it; the '...' placeholder
expanded to ../.. and openocd exited immediately, which is the harmless failure
mode, but the next placeholder might not be.

The broken form is now described in prose and only the working command is
runnable.

Also records a second failure of the same kind as the first. 'sudo -n pkill -9
openocd' was reported three times as having cleared the leak. It never ran: the
NOPASSWD rule covers /opt/homebrew/bin/openocd and nothing else, so pkill needs
a password, and -n makes it fail silently instead of prompting. ps showed both
processes alive 2h49m later. Checking the exit of the thing you ran is not the
same as checking the state you claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…disbelieve

Self-contained state: what the fleet is, what was measured, how the key works
now, how to bring a board up, what the environment cannot do, and what is still
open.

Three things it deliberately leads with rather than buries. Every jobs/s figure
this project published is withdrawn, not restated — they counted attempts, so a
board answering nothing read as the fastest run ever recorded. There is still no
power figure of any kind. And the port names, JTAG locations and line rates in
its own tables are all unstable; only the node id is not.

It also names the defect that recurred three times in one session: a rule
written in one place and enforced by enumerating cases somewhere else. The fix
is structural — ask the verdict, do not name the cases; exhaustive switch, no
else — and the handoff asks that new code be tested against the same question:
if someone adds a case tomorrow, does this default to safe or to accusing an
honest operator?

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…be could not tell

node2 was written up as the marginal board of the fleet -- 97.6% correct, 25 of
100 perfect runs -- and the handoff's next action was to try a different cable
and a different hub port before believing the board. It is the same cable and
the same hub port. At 1144744 baud instead of 1186267 it returns 6400/6400.

The baud hypothesis had already been tested and recorded as refuted: re-run at
"its own centre rate" the board scored 98.08% against 98.56%, and half a percent
was read as a refutation. That rate was 1174399, from the BAUD_DIV=60 candidate
table -- 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 have escaped it. Sweeping is what breaks the circle.

Measured by sweeping the host rate in 0.5% steps, 64 jobs per step, a step
counting clean only when all eleven predictable response bytes were right on
every job:

  node0  1121020..1227778  centre 1174399  +/-4.55%  CFGMCLK 70.46 +/- 0.18 MHz
  node1  1068248..1169444  centre 1118846  +/-4.52%  CFGMCLK 67.13 +/- 0.18 MHz
  node2  1121020..1168468  centre 1144744  +/-2.07%  CFGMCLK 68.69 +/- 0.18 MHz

The spread is 4.97% and each board tolerates about +/-4.5%, so the windows
overlap and the claim that no single host rate can serve this fleet is wrong:
at 1144744 all three returned every job, 100 runs of 64 each, 19,200 jobs, zero
failures. Each board did the same at its own centre, and node2 twice, because
one clean run is an anecdote.

The instrument was the defect. trinet_baud_sweep.py asked six jobs per rate and
Node.initFpgaAutoBaud six probes per candidate, then took the first that passed.
A rate losing 2.4% of jobs passes six 86% of the time and one 97.6% of the time.
Neither check could fail the only case it existed to detect -- the same shape as
counting attempted jobs as throughput, and as a verdict enumeration that acquits
whatever it forgot to list.

  * the sweep now runs 64 jobs per rate, checks every predictable byte, splits
    failures by direction (a wrong product with an intact nonce is host->board;
    a damaged nonce or identity is board->host), reports the centre of the clean
    window, and prints each degradation shoulder separately rather than merging
    them into one range with one misleading worst case.
  * discover no longer trusts the first reply: it measures the rate it acquired
    and refuses to report a board as found-and-fine at 63/64.
  * auto-baud scores 64 jobs on every candidate that answers, does not stop at
    the first that passes, and operates at the middle of the rates that were
    perfect rather than the first of them. Verified on hardware: node0
    negotiates 1174399, node1 and node2 1144744, 192/192 each.

Also fixed, found while reading the output: census printed "no published key
seen. Receipts from this fleet can be cited." whenever no run had been caught
with a published key -- including runs with no key loaded, where none could have
been seen. It now says which of the three things actually happened.

CFGMCLK figures carry the sweep step as their error bar. Printing six digits of
a number known to half a percent is how a measurement becomes a constant nobody
rechecks.

Open, and unexplained: 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. It no longer
costs it any jobs, so this is a question, not a fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The next-actions list said "restate throughput with the corrected counter".
The command that does it has never been able to. `bench` never called
loadFleetKeys, so FleetNode.key was null on every path, verifyWithKey answered
`unverifiable` for every job, `verified` stayed 0 and the throughput line
printed 0.0 jobs/s -- on any machine, holding the correct key file or not. It
was invisible because nobody reads a bench that reports zero as a defect in the
bench.

Three more in the same function, all found by reading its output rather than
its code:

  * it indexed the fleet table by a command-line slot instead of asking the
    board its identity -- the identity-by-argument-order defect already fixed on
    the fleet path, which hands node0's key to whichever port was typed first;
  * it derived the compute ceiling from a hardcoded 71.18 MHz CFGMCLK, a figure
    belonging to no board in this fleet (they measure 70.46, 67.13, 68.69). It
    now comes from the negotiated rate times the divisor the bitstream ships
    with, which is a measurement and cannot go stale;
  * it printed the REQUESTED baud in the transport-ceiling line while computing
    that ceiling from the NEGOTIATED one, so the label disagreed with its own
    arithmetic.

bench also now defaults to negotiating the rate rather than to a fleet constant,
because a constant is what put the marginal board on the rate that lost it 2.4%
of its jobs.

Restated, 2000 jobs per board at each board's negotiated rate:

  node0  1174399 baud   495.7 jobs/s   batched x32  3843.6   78.5% of ceiling
  node1  1144744 baud   483.7 jobs/s   batched x32  3680.1   77.2% of ceiling
  node2  1144744 baud   475.6 jobs/s   batched x32  3678.6   77.1% of ceiling

2000/2000 whole on every board. NOT authenticated: the keys these boards hold
are not on this machine, so no receipt was checked. `whole` and `verified` are
counted separately and the headline says which one it is, because a transport
measurement read as verified compute is exactly the confusion this project keeps
paying for.

Batching buys 7.6-7.8x because the round trip is USB latency, 2.05 ms p50
against roughly 0.4 ms of wire time. What is left over 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. Any throughput claim about this node is
still a claim about a UART.

The handoff is updated where it is now wrong: node2's row measures a line rate
rather than a board, the "no single host rate serves this fleet" conclusion is
withdrawn, the lost key file and the missing sudoers rule are written down with
the one-line fix that needs a password, and the next-actions list no longer asks
for two things that are done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main carried a squashed, earlier snapshot of this branch's own work (#354),
so every conflict was this branch against its own past rather than against
someone else's change. Resolved by inspection, not by rule:

  fpga/portable/trinet_node_core.v   main's side is byte-identical to this
                                     branch at 6ba3634, before the key
                                     started arriving over the wire — ours
  src/trinet/protocol.zig            main's side has no `.unverifiable`, the
                                     verdict added so a keyless verifier
                                     cannot accuse an honest board — ours
  src/trinet/node.zig                main's side predates auto-baud — ours
  src/trinet/main.zig                main's side predates per-board rate
                                     negotiation and the identity probe — ours
  specs/trinet/ternary_hw_verification.t27   ours, strictly a superset
  .github/workflows/trinet-portability.yml   THEIRS: identical to ours except
                                     for a path entry main added afterwards,
                                     so that the job re-runs when the script
                                     it runs changes

Verified after resolving rather than assumed: 56/56 tests, zig fmt clean, and
on hardware — all three boards discovered at 1144744 baud 64/64 clean, node2
census 192/192 with the rate negotiated.

Noted, not touched because it is neither this branch's nor this merge's:
docs/ARCHITECTURE.md has 16 `<<<<<<< Updated upstream` markers committed into
it on both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `portability` job installed yosys from apt. On ubuntu-latest that is 0.33,
and under 0.33 every synth_<family> pass returns without stats
portability_check.py can read — so the job printed "only 0 families
synthesised" and failed. Every run of this workflow since the day it was added,
on every branch, including the commit whose own message announced the
ten-family result.

The claim it guards was true the whole time. Reproduced today under both yosys
0.62 and 0.65: ten families agree at 1082 flip-flops, intel_alm at 1092 because
its register cell absorbs reset logic, and not one infers a multiplier. What was
broken is the gate. docs/TRI_NET_PORTABILITY.md cited it as "now checked rather
than remembered" while nothing had ever checked anything, and a real regression
would have arrived as one more red run on a workflow that was always red.

CI now runs the check inside the pinned regymm/openxc7 image, the way
ax7203-format-cost.yml already did after hitting this exact problem ("build
yosys 0.63 from source (not Ubuntu 0.9)"). The script prints the yosys version
alongside its results, because a portability number with no tool version
attached cannot be compared with the one before it.

Looking at the output found a second defect, in the check itself. A family that
synthesised but whose register cells the script could not name went into
`results` — counting toward "N families checked" — and was then dropped from the
flip-flop comparison by a truthiness filter, so it inflated the headline while
contributing nothing to the invariant that headline is about. analogdevices
under yosys 0.65 does exactly this: 2686 cells, zero recognised flip-flops, and
the run announced eleven families when ten had agreed. Such a family is now
named in the output and counted in neither direction, and the run says ten.

The tables in docs/TRI_NET_PORTABILITY.md are re-measured. They read 819 and
831, from before the receipt key started arriving over the wire and brought a
key register and its write-once latch with it. The design changed; the number
followed. The agreement did not, which is exactly why this check asserts the
spread rather than the value — and why the stale number was never a problem for
the check, only for the prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o the wire

The report's headline sentence and its results table both said 1313 LC. The
handoff, three files away, said the setkey change took the cell 1292 -> 1484.
Both cannot be true and the report is the document people read.

Measured under yosys 0.62 with the chparams the fleet CI actually builds with
(USE_DNA=0, FALLBACK_NODE_ID set, BAUD_DIV_P=60): 1480 LCs, 1046 flip-flops,
no DSP48 and no DNA_PORT in the netlist. Synthesised with defaults instead it
is 1499 LCs and DNA_PORT is present, which is the difference the chparams make
and the reason the parameters belong next to the number.

Also corrected in docs/outreach/ternarycore-issue-draft.md, which quoted 1313
in a paragraph that would have gone to a third party.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…as 0% clean

node0 was re-flashed today, came up correct and unkeyed, and both tools I
rewrote yesterday called it broken. They compared the response status against
0x01 exactly. A board between a re-flash and setkey answers 0x04 NO_KEY and
computes the dot product perfectly — discover printed "0.00% clean" and the
sweep would have found no clean window anywhere, at exactly the moment a fresh
board's rate has to be measured.

Written yesterday, in the session that fixed three other checks for treating a
legitimate state as a failure, 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()."

Both now accept the statuses that mean the arithmetic is real, {0x01, 0x04},
and print which of the two the board is in — discover's line now reads "no key
yet" or "keyed" rather than leaving the reader to infer it from a percentage.

Recorded with it, from the same session:

  * node0 re-flashed (778.76 s) and re-keyed with a key from openssl rand that
    has never been printed anywhere. 100 runs x 64: 6400/6400 correct,
    6400/6400 authenticated, 100/100 perfect, at its measured window centre of
    1174399 baud. Throughput authenticated for the first time: 481.4 jobs/s one
    at a time, 3788.0 batched x32, 77.4% of the transport ceiling, 2000/2000.
    The write-once latch was re-confirmed on this configuration rather than
    carried over from the last one.

  * Every AL321 stalled in mpsse_flush() with no leaked process beforehand;
    replugging fixed it. The locations were not at fault, and there is a control
    that proves it in seconds: a bogus `adapter usb location 9-9.9` errors
    instantly with "no device found", so a location that stalls is one that was
    found and opened.

  * The recipe on record for bounding a privileged probe, `sudo -n timeout -s
    KILL 25 openocd`, cannot run here — there is no timeout and no gtimeout on
    this machine. A foreground wrapper killed by an outer timeout leaves openocd
    alive as root and a user-level kill cannot touch it. That cost two adapters
    today until the operator ran `sudo pkill -9 openocd`. Start it in the
    background and poll instead.

  * Three "Digilent Adept" adapters appeared where two AX7203s had been. They
    read IDCODE 0x23727093 — a Zynq-7020, not the 0x13636093 Artix-7 this
    project flashes. Asking them, with init and shutdown and no pld load, is
    what identified them; widening the config's device filter until something
    answers is how a bitstream reaches a device nobody identified.

One board of three. node1 and node2 were not on the bus, so nothing here is a
fleet result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-to-head (#430)

Research artifacts for the ternary-native GoldenFloat format family (GF-T), the
publication material behind the arXiv:2606.05017 / 2606.09686 updates.

- GFT16_BEATS_TEKUM16: GF-T16 vs tekum16 head-to-head (measured x3 mid-range,
  x5.5 far-range; uniform 9-bit mantissa vs tekum's tapered 4; no regime decode;
  exponent added natively in balanced ternary). Adder 461 LC / 0 DSP.
- GF_T_GOLD_STANDARD_LADDER: full GF-T4..GF-T1024 ladder (Et trits per rung,
  adder LC, all 0 DSP).
- GFTERNARY_vs_BALANCED_TERNARY_HW: GFTERNARY {-phi,0,+phi} is a 2-bit phi-
  alphabet on a float mul (2 DSP / 1191 LC), NOT ternary compute; the real
  ternary core is TF3 / trinet_mac32 (0 DSP / 398 LC).
- XC7A200T_GF16_DATAPOINT: bare-core combinational Fmax (~323 MHz) vs routed
  Fmax; board reconciliation (XC7A200T-FBG484).
- ARXIV_GFT16_SNIPPET / ARXIV_GFTERNARY_HW_SNIPPET: ready-to-paste LaTeX.
- SUBMISSION_PACKAGE: index of the v4/v3 paper updates (author credentials
  required to actually submit; nothing here is a submission).

Off-path conformance oracles (reference only, not on the codegen path):
- conformance/gft16_ref.py  : bit-exact GF-T16 encode/decode/add/mul.
- conformance/gft_ref.py     : parameterized GFTFormat(exp_trits, mant_bits),
  covers the whole GF-T4..1024 ladder.

Co-authored-by: SSD DDD <ssdm4@MacBook-Pro.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A 100% failure rate is a broken harness until proven otherwise. A sweep
reported "PARSE OK: 0  FAIL: 496" — read literally, the parser was dead.
It was exit 127: cwd had reset and t27c builds to the workspace target
dir, not bootstrap/target/release/. The tell was the shape, not the
content — real breakage is ragged, a clean 0% or 100% means the
measurement never reached the thing measured. True count was 496/496.

A gate that is always bypassed is not a gate. scripts/tri was broken for
every subcommand (--repo-root passed before the subcommand, but it is a
per-subcommand clap option). It is pre-commit Gate 1/4, so it was not
fixed, it was routed around with --no-verify — invisible in history.
When a hook blocks you, read the hook before satisfying it; the block is
a symptom. And run the fences in your own README: doc-sync passes edit
prose and never execute the commands they ship.

Ask who occupies the corner you claim to own. COMPETITORS.md was sourced
and honest in tone, named five commercial NPUs it declined to race, then
claimed the formal/assurance corner while naming nobody in it — a corner
that holds Vericert, Kami, and Amaranth, all ahead on that axis. A
competitive document is not honest because each sentence is true; it is
honest when the omissions do not do the arguing.

Refs gHashTag/t27#1951

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
validate-conformance printed "43 valid, 58 empty/skipped". That went into a
report, a NOW.md entry, a GitHub issue, and a memory file as "the corpus is
half-hollow", and became the next wave's headline recommendation: populate
the empty files.

Zero were empty. The validator used .as_array() and the corpus stores
vectors both as arrays and as objects. 45 of the 58 were fully populated,
8 were schema files, 5 were benchmark reports. Among the false positives
was FORMAT-SPEC-001.json -- the numeric SSOT the positioning rests on,
reported as empty by its own repo's validator.

Two rules recorded. When a count is about to become a plan, open the
objects it counted -- sampling across categories, not depth, since the 58
held four distinct shapes and three files of one shape would have
confirmed the wrong conclusion. And a gate with a high false-positive rate
is worse than no gate: nobody reads warning 43 of 58, which is how a CLARA
coverage file covering 7% of the corpus stayed hidden for months.

This is the third instance of one failure mode in this campaign, now
stated in general form: the defect is never a wrong number, it is an
unexamined label. "FAIL: 496" meant binary-not-found. "58 empty" meant
object-shaped. Both were accurate counts of something other than their
word.

Refs gHashTag/t27#1951

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three consecutive waves each found one gate enforcing something weaker
than its label. check-now claimed NOW freshness and tested nothing (broken
wrapper, bypassed with --no-verify). validate-conformance claimed a
populated corpus and tested array-shaped payloads only. Gate 2/4 claimed
"seal coverage" and tested that a file exists -- while 730 seals verified
zero, 480 of them written the same day as the commit that rewrote the
specs they sealed.

Presence is not integrity, and only presence was enforced, so the drift
was structurally invisible. The audit that finds this class in one pass:
for each gate, write down the property its name claims, then read it and
write down the property it tests. The gap is the hole.

Two riders. A gate that cannot fail teaches people to route around it --
the always-red Gate 1/4 produced --no-verify commits, which disabled the
other three. And check file resolution, not just the predicate: Gate 2/4
resolved basename -> gf16.json while seal --verify reads a path-derived
numeric_triformat-gf16.json, coinciding only because macOS is
case-insensitive. Two naming schemes for one artefact is a defect even
when every test passes.

Separately: evidence citing a command nobody can run is not evidence.
clara_spec_coverage.json carried "bash scripts/clara/demo.sh -> 20/20
passed" for four months; that path does not exist in the repo. There was
no claim to narrow. Run an evidence file's own stated reproduction command
before reading its numbers.

Corollary on scope: regenerating a measurement is repair, rewriting a
baseline is a decision. Re-running coverage was mechanical. Re-sealing 496
specs would canonicalise whatever current codegen emits with no oracle
that it is right, so it was reported and left for a human. An audit that
quietly re-baselines what it audits has destroyed the evidence it was sent
to check.

Refs gHashTag/t27#1954

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gHashTag and others added 30 commits August 9, 2026 10:05
Three lessons from the gate-adequacy wave in t27.

Redirect the vacuity oracle at the gates themselves: for each gate, apply a
mutation that should violate the claim it guards and require it to go red. Three
phases, and the first two make the third mean anything -- a baseline where the
unmutated build passes everything, and a control with a semantically neutral
edit where everything still passes. Skipping the control is the subtle failure,
because a gate that fails unconditionally scores 100% on mutation testing.

Include at least one mutation that makes the system inert rather than wrong.
Stalling the system leaves every safety property true; only liveness notices. If
no gate goes red when the feature is disabled entirely, the suite is measuring
silence.

And state the lower bound the method gives you, at the same prominence as the
number. Mutation testing bounds from below, never from above. A result reported
without its ceiling gets cited later as if it had none -- usually by whoever
produced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lation is

Four lessons from the maximum-size sweep in t27.

A bounded check that cannot reach the counterexample reports success and
establishes nothing -- two modules passed a wrap property whose violation needed
4096 writes against a 24-cycle bound. Scale the model until the counterexample
fits, and state what scaling changed. Generalises to fuzzing, load tests and
soak tests: a green result from a search that could not have found the bug is
not evidence.

When a correct fix does not make a property pass, there is a second cause. The
most valuable defect of the wave was found that way; the temptation to revert
the fix or weaken the property destroys the signal.

An unconstrained input is an adversary -- two refutations were faults in my own
harness and looked exactly like design defects. A refutation is a claim about
the environment until the environment is pinned down.

And stop patching after the second attempt: the next action is a counterexample
read carefully, not a third patch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nobody fixed

Three lessons from the write-pairing audit in t27.

A regex for a defect shape returned zero candidates because both instances had
just been repaired -- the scan measured my memory, not the code. Enumerate the
class (every write port) rather than the symptom (a self-incremented address),
and ask the semantic question. That found a third port never checked at all.

A property a known defect would have passed is the wrong property. The first
property only required the write address to increase, which permits skipping
slot 0 -- exactly what the second defect did. At the moment you fix a defect,
run your property against the pre-fix version; if it passes, you wrote a
property describing the fix rather than the requirement.

And do not diagnose with a tool that just contradicted itself: a trace showing
the guard signal low throughout cannot violate a property guarded on it. Fix the
instrument, validating it on a case whose answer you already know, before
trusting it on one you don't.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…strument

Four lessons from fixing the counterexample reader in t27.

Two waves produced nothing on one finding, both concluding the design was
subtle. The counterexample had never been parsed -- the trace showed a guard
signal low throughout, which cannot violate a property guarded on it. Watch for
a diagnosis that contradicts its own premise; that is the instrument reporting
on itself.

Validate a diagnostic reader against a known answer, in CI. Two concrete traps:
a .json extension does not mean parseable (RTLIL names contain backslashes that
break JSON escapes), and compressed formats need full expansion (WaveJSON's
means repeat, and skipping it loses most of the trace while appearing to work).

Query the trace rather than reading it -- encode the failing assertion as a
predicate and let it find the row. Eyeballing a 30x90 table is how the earlier
misreadings happened.

And refine the rule on withdrawing fixes: withdraw one that misses its target
when it costs something, keep it when it is independently correct. The test is
not did it work but would I write this having seen the code fresh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four lessons from closing the DMA in t27.

The defect that survived four waves: local_we was cleared in READ_DATA's else,
which runs only while in that state. Every other state left it untouched, so a
one-cycle pulse held high across transitions and kept writing at a stale
address. Any signal meaning this cycle do X needs a default before the case, not
a per-branch clear.

Scale everything the scaled signal touches. Narrowing a DUT signal for
tractability while the harness kept the old width left undriven bits, and x
fails every comparison -- producing a confident refutation of an innocent
design. Also: learn what your trace reader prints for x versus for no data.

Report each property's discriminating power separately. Two properties proved
after the fix; only one also refuted when the fix was reverted. A property that
passes on both the fixed and broken design contributes zero information, and
saying so is the difference between a result and a number.

And judge a sweep by total defects found, not hit rate against its stated
target: this one found five, four unrelated to what it was aimed at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n its own

Four lessons from closing the last open defect in t27.

Three fixes were withdrawn because each broke an unexplained baseline; the
defect was recorded and gated rather than 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 meanwhile. Unexplained blockers are
often symptoms of defects not yet found.

A global flag cannot answer a per-instance question. Three attempts gated on one
input_loaded bit when the property asked about a specific buffer. The tell is an
arity mismatch: if the property mentions a selector, the state backing it must
be indexed by that selector.

Prefer an observable error to a stall. Refusing to start deadlocks whenever the
unsafe condition is legitimate, and a stalled system passes every safety
property. Do not perform the unsafe operation, complete anyway, raise an error
the caller can see -- then verify with liveness that work still happens.

And an expected-refutation gate is how an open defect closes itself: CI asserts
the property must still fail, so the fix turns the build red and asks for
promotion. Pair it with a gate asserting no such guard remains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lish both

Three lessons from measuring the scale ceiling in t27.

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. Measure the ceiling and publish
it with the claim -- it buys headroom (does the claim hold above what CI runs)
and cost shape (which axis is expensive). Raise the axes together at least once:
each alone can pass while the combination does not.

Undecided is a third verdict, not a shade of pass or fail. Four of five module
suites extended to 4x their bound; one became intractable at 2x, so its proof is
real at its own bound and nothing is known beyond it. That line names the one
place a deeper defect could sit unseen, and collapsing it into either binary
would have erased it.

And check the ceiling on a schedule, failing both when a scale starts refuting
and when it stops completing -- a refutation means a defect at depth, a new
timeout means the claim quietly shrank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three lessons from splitting a formal suite in t27.

A suite verified in one invocation returns one answer describing its worst
member. The parts summed to under 90s while the whole exceeded 240 -- a combined
instance can be superlinearly harder than its pieces -- and the single number
concealed that two members were verified four times deeper than the third.
Splitting bought a 2.9x deeper bound at the same wall time and named the
failure.

A limit attributed to the system may be a limit of the question. One module was
recorded as the place a deeper defect could hide because its proof would not
extend; re-asking one property at a time removed the limit entirely. The tell is
a large gap between the cost of the whole and the sum of the parts.

And replacing a global count with a local invariant is the right instinct, but
establish the alignment first as its own measured question -- assert the
relation at each candidate offset and see which proves. Guessing it inside a
larger property makes every failure ambiguous between wrong offset and wrong
idea.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…far it extends

Three lessons from mapping a formal suite in t27.

A wave spent measuring proof depth produced a careful table of ceilings, and two
entries had no ceiling -- proved by k-induction, then re-measured with bounded
checking and reported as proved to 8x the bound. Measuring the extent of a claim
that has no extent produces a number worse than none, because it looks like a
limit where none exists. Classify the result before quantifying it.

The same parameter name means different things in different modes: -seq is a
search bound under BMC and an induction depth under tempinduct, and raising it
in the second case is pure cost. Key bulk edits on the mode flag, not on the
parameter -- the edit that treats all call sites alike quietly does the wrong
thing to a subset.

And an aggregate can be uninformative in a way that looks informative: six green
verdicts where two meant something categorically different and one was the
minimum over members differing by two orders of magnitude in cost. A per-member
map names the single genuinely shallow item, which is the only actionable part.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three lessons from the engine-suite split in t27.

Splitting one suite per-property bought a 2.9x deeper bound; the same move on
another bought nothing. The difference is measurable in advance -- cheapest to
dearest ratio 436x versus 1.08x. Where one member dominates, isolating it lifts
the achievable depth; where the shared setup is the expense, splitting buys
attribution only. The diagnostic is one run: time a trivially true member.

A partition produced by a timeout is a partition of the timeout. A sweep
reported 8 of 20 proved, inviting the false reading that those 8 are easier;
all 20 proved given time. The tell was a tautology in the failing group -- put a
known-trivial control in any cut-off run and check which side it lands on.

And use the identifier a tool returned, never the one you predicted: a commit
body said Closes #2012 when the issue created was #2014, and #2012 was an
unrelated open issue the merge would have closed. Sequential-looking identifiers
are the dangerous case, because a wrong guess is well-formed and points at
something real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… for it

Two lessons from correcting a published scale ceiling in t27.

Naming the error that a partition produced by a timeout is a partition of the
timeout prompted a sweep of prior published numbers, which found a ceiling
recorded one wave earlier as undecided at depth 80 that in fact proves in 396s
against the 300s budget used. A newly understood failure mode is a query to run
against everything already published, not just future measurements. And write
the budget into the result -- undecided within 300s, never undecided -- so a
later reader can tell a limit from a finding.

Batch overhead tells you where the cost lives: one suite's batch was worse than
the sum of its parts, so cost lives in the properties and splitting helps; the
other's batch cost 1.4x a single member, so cost lives in the shared model and
splitting cannot help. Time the batch, time one member, divide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four lessons from locating the solver cost in t27.

Replacing a subsystem with a same-interface stub answered where the time goes in
two runs, but under it all twenty properties failed including a tautology --
and the baseline check showed the stubbed build did not pass with no properties
at all. Timings from a stub are usable; verdicts are not. First time this rule
caught my own replacement rather than a 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 8x, because unrolling multiplies combinational
logic once per step while state grows linearly. When a bounded proof is slow,
look for wide combinational structures, not register count.

A knob exists only if someone built one. Memory depth was scalable because it
was already a parameter; the datapath width was a literal at 26 sites across six
generators. Scalability for testing is a property of the code, not the tool.

And do not start an invasive refactor to serve a measurement, late in a session.
Measured and scoped leaves the next session a task with the full picture; a
half-finished refactor trades a known cost for an unknown one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three lessons from adding read-side properties in t27.

Fifteen waves rested on a baseline run -- the design compiled without its
properties -- that never excluded a single one, because the tool's -formal flag
predefines the FORMAL macro. One three-line module and two runs detect it:
compare cell counts with and without the define, and identical means the guard
is not a guard. The deeper cost was diagnostic: an earlier wave spent four
rounds trying to separate a failing probe from a failing property when no flag
existed that could.

Distinguish the tool failing from the check failing. A harness reported REFUTED
in 0.1s because an input file was missing. 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 40s and fails in 0.1 has not
run. Third instance of this shape in one campaign.

And add the mirror of every property you have. Forty propositions all
constrained writes; the read path had no coverage and nobody noticed because the
write properties kept finding real defects. Read your property set as a list of
shapes and ask each one about the other direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three lessons from a failed diagnosis in t27.

To decide whether a property failed because of the design or because its
operands were undefined, I asserted a == a. It proves, and it is meaningless:
the optimiser folds algebraic identities to constant true before any value is
considered. A probe whose result is determined by its own syntax tests nothing
-- before trusting a diagnostic assertion, ask what input would make it fail.

When a method turns out to be mis-specified, re-run what it touched rather than
reasoning about the blast radius. Six witnesses, six re-runs, six identical
verdicts: the claim is now measured instead of argued, and the re-run surfaced
the precise condition under which it would have mattered.

And the same folding trap sits inside property sets: a long-standing proved
property is literally assert (bram_addr == bram_addr). Vacuity checking asks
whether a guard is reachable; it does not ask whether the body is discharged by
the optimiser. Both are ways a property can be free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t vacuity

Three lessons from auditing a property set in t27.

A suite gated itself on a check count, because a green run over an empty set
proves nothing. Five properties had bodies of the form X == X, folded to
constant true -- and each still emitted a check cell, so the padding was counted
by the very gate meant to catch an all-vacuous set. Two notions of free are at
play and typically only one is gated: an unreachable guard, and a body
discharged by the optimiser.

When a change appears to break something, reproduce the failure without the
change. I attributed a refutation to my edit, then to a subtle interaction, and
built a plausible theory for each; re-running the unchanged file refuted
identically. The cost of skipping that run is not a wasted run, it is a
confident documented wrong explanation.

And a detector that searches text will match prose: classifying steps by
searching for a flag reported two suites using an unbounded method when only one
did, because the other step named the flag inside a comment explaining why it
was not used. Match on structure, not substrings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three lessons from building a free-property gate in t27.

The gate and its own control ship in one CI step: four injected free shapes must
be flagged and a real property must not. The negative case matters most, because
firing on everything scores perfectly on positives alone. 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. A stronger
semantic version flagged six real properties -- common-subexpression elimination
lets a genuine property add zero net cells -- so it was withdrawn rather than
tuned. A gate that cries wolf on real work gets disabled by whoever hits it
next, and takes the true positives with it. Four dead ends were written into the
module instead, which costs nothing to carry and is worth more to the next
attempt than a broken tool in the pipeline.

And ship the smaller thing while stating what it does not do, in the same
paragraph as its purpose. Every overstated claim in this campaign was overstated
at the moment of writing, by someone who knew the limit and did not write it
down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…iscriminator

Four lessons from attributing a failing check in t27.

After two failed attempts to decide whether a failing check meant the system was
wrong or the check was wrong, what worked was writing the same claim a second,
structurally different way. Agreement exonerates the weaker formulation and
implicates the system; disagreement implicates the approximation. It does not
depend on reading tool output correctly, only on two checks agreeing.

Validate a new instrument against what it must NOT say: assert the negation of
what you expect and require a counterexample, which proves the instrument can
move. Asserting the expected value proves nothing, and that was how two earlier
attempts went wrong.

A boolean is not a count. A defect was closed by tracking whether a buffer had
been written; the finer defect was that nothing related how many slots a
consumer reads to how many a producer wrote. When a guard answers did anything
happen, ask what happens when less than enough happened.

And $past(x)[1:0] is not legal Verilog -- part-selecting a system function call.
Under a harness that reads any nonzero exit as a verdict it would have appeared
as a refuted property; it appeared as a tool error only because that separation
was already in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…enforce a per-cycle invariant

Three lessons from a withdrawn fix in t27.

A property required that at the moment of each read the slot be already written.
The attempted fix gated the start of the operation on a count. It could never
work: a start-time check says nothing about what happens during the operation,
where a producer fills one buffer while a consumer drains another. The mismatch
was not the threshold 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 and
check the guard is evaluated at the same rate.

Withdraw on two counts, not one. The standing rule was withdraw a fix that
misses its target and costs something; this hit both halves in one run, which
made the decision immediate. The tempting failure is keeping a change that fails
only one half -- it did not fix the bug but is harmless, or it broke one test but
is the right direction.

And record the eliminated shape where the next attempt will read it: in the file
it would have changed, not only in a commit message. Three waves have gone into
one defect, and that is progress only if the eliminations are visible from the
code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ories

Three lessons from locating a long-open defect in t27.

Six waves went into one failing check -- two inconclusive trace reads, an invalid
discriminator, a withdrawn fix. What finally located it took three runs:
unconstrained refutes, one added assumption proves. That is a bisection of the
input space, and it is far more reliable than reading a counterexample, because
each run answers a question you chose rather than requiring you to interpret a
dump. Do it before reading traces, not after.

A module-level guard does not travel to the paths that bypass it. A sequencer
was proved in isolation to emit no work for a zero-sized job, and the engine
performed a read anyway, because the address came from a counter and the valid
from skew registers -- neither passing through the proved guard. Enumerate a
guard's consumers and find the ones that derive the same information
independently.

And degenerate inputs deserve the same sweep on every side of a datapath.
Recording that we swept zero-sized inputs without recording which surface leaves
an impression of completeness; the honest form names the surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nosis

Three lessons from fixing a live-configuration defect in t27.

A failing check was fixed by assuming a non-zero count, and the obvious
conclusion -- the bug is the zero case -- was published and wrong. A weaker
assumption also restored the proof: a stable count, including a stable zero. The
necessary condition was the change, not the value. Every assumption that
restores a proof describes a sufficient condition; only the weakest describes
the cause, and the strong one is usually the first you think of.

Configuration read live by a running state machine is a defect class. A
sequencer compared its counter against a host-writable limit every cycle, so a
write mid-run moved the terminator underneath work in flight. Latch the
configuration at the start of the operation and run from the copy. Look for it
wherever an operation has a duration.

And ship a fix that is right on its own terms even if it does not close the
case. The test: would I write this having seen the code fresh, with no knowledge
of the open bug? If the only argument for it is the bug it failed to fix,
withdraw it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… time

Three lessons from closing an eight-wave defect in t27.

An interlock was withdrawn with a conclusion that read like a law; three waves
later the same interlock, unchanged, closed the defect. The conclusion was
conditional on the design at that moment, and a separate fix removed the
condition. What made the re-attempt cheap was recording the reason beside the
code rather than only the verdict -- a verdict closes the door, a reason leaves
it open for the day the condition stops holding.

Some defects need several changes, none of which look like progress alone. Three
changes over eight waves, each leaving the property still failing. Under a
strict withdraw-anything-that-does-not-fix-the-target rule the first two would
have been reverted and the third would never have worked. The rule that saved
them: withdraw a fix that misses its target and costs something, keep one that
is right on its own terms.

And building the instrument is most of the work. The defect was one line of
missing state; finding it took a trace reader, a free-property gate and an
assumption-bisection method. When a bug resists two honest attempts, the next
move is usually a first attempt at seeing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d have

Three lessons from a clean read-side sweep in t27.

A clean sweep and a broken sweep produce the same report. Three new properties
proved and found no defect, which was worth nothing until each was shown capable
of failing -- body replaced by assert(1'b0) under the same guard, all three
refute. Without that, we looked and saw nothing is indistinguishable from we did
not look.

Derived state cannot drift; independent state does. Four write-side defects
against one read-side defect was not attention bias: the write paths each
carried their own counter and every defect was two of them disagreeing, while
the read pointers were derived from a single source. When two registers track
the same quantity, that is a defect site -- prefer deriving to duplicating, and
when duplication is unavoidable the relation between copies is the property
worth asserting.

And state the surface a sweep covered, not just the class. We swept zero-sized
inputs sounds complete; zero-sized inputs on the write paths is what made the
read-side gap visible later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the stubbed thing costs

Four lessons from killing a four-times-deferred refactor in t27.

Stubbing a module made a proof 8x faster and justified a six-file refactor. The
refactor was worth 1.5x. The 8x measured something else: removing the module's
instantiation left its wide inputs unused, and the optimiser deleted the whole
datapath feeding it. Removing a consumer removes its producers. Attribute cost
by shrinking a component, not deleting it, and measure the change you actually
intend on a small scale first.

Cell count is a poor proxy for solving cost: two builds fourteen cells apart
differed eleven times in solve time, and one with 161 fewer cells ran slower.
Static size can suggest hypotheses, not rank them.

Re-cost a deferred item before picking it up. Four deferrals carried the
original estimate forward; when it reached the front of the queue it was stale
and wrong by 5x, and re-costing closed it permanently.

And git status is part of the verification: a file was modified but never
committed for twenty waves, so local runs and CI used different inputs. It
elaborated either way, so nothing went red, which is why it survived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cks that would catch it

Three lessons from a census wave in t27.

A property refuted, and the obvious fix was to constrain the environment more
tightly. It worked, and it also pinned an input to zero forever, so two
reachability witnesses stopped firing while every property still passed. An
assumption is not a local edit: it removes behaviours from every property in the
file, including the ones asserting that behaviours are reachable. When a fix is
an added constraint, re-run the reachability checks; and prefer fixing the
property, which is scoped to itself, over constraining the environment, which is
scoped to everything. A suite composed entirely of things that must pass cannot
detect its own over-constraint.

Turn an explanation into a target list. The observation that independent state
drifts and derived state cannot was converted into a census of every counter and
derived copy, yielding three named pairs to attack and a demonstration that the
rest of the design cannot hold that defect class.

And a gate's own pattern list is where its false positives hide: an allowlist
checker reports violations that are really gaps in the list, so it needs a
known-good negative control of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hat behaviour remains

Three lessons from auditing assumptions in t27.

A campaign added reachability witnesses to its integration suite and never to
the module suites, because the top level was where stalling was the visible
risk. An over-constraint later appeared in a module file and was caught by a
top-level witness -- coverage overlap, not design. The gap is structural: an
assumption file with no reachability probe is a place where over-constraint is
invisible by construction, because adding a constraint makes every property
easier to prove and the symptom is everything getting greener.

A no-findings sweep needs a control run every time. Twelve clean probes are
indistinguishable from twelve broken probes until one is shown failing;
reinstating a known-bad state from the campaign's own history is the cheapest
control and the strongest evidence.

And scope a negative result by what it cannot see: these probes check each
module's main activity, so a constraint removing a rare interleaving would pass
all twelve. That sentence belongs in the result, not in a follow-up when someone
finds the gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ision, not a queue entry

Three lessons from abandoning an invariant in t27.

One property took three waves and never landed, each attempt ending one insight
short -- the shape that keeps a task alive indefinitely. The fourth wave was not
spent; the item was closed with written reasoning, because the pair it would
constrain is already covered by two properties that did land. Carrying a nearly
done item is not free: it occupies the queue, justifies the next attempt by the
sunk cost of the last, and makes every plan slightly dishonest. The test is
whether it would be worth doing if proposed fresh today with no history.

Negative results are worth keeping only where the next attempt will look. Four
measurements live as a comment in the file that would have to change, above the
properties that did land -- not only in a commit message or an issue. A negative
result filed where nobody looks is indistinguishable from never having run it.

And before believing a refutation, check the probe is not simply too strict: a
status output cleared in a terminal state lags its state register by a cycle, so
properties asserting they move together fail on correct hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed underneath it

Three lessons from re-measuring a scale ceiling in t27.

A ceiling was measured, published and gated; ten defect fixes and six properties
later, three of six configurations no longer completed. The claim had been false
for some time and nothing reported it, because the gate only re-checked the
scales it was given rather than whether those were still right. Put the
re-measurement on the same schedule as the changes, not the calendar.

Re-baselining is maintenance; say so, and distinguish it from weakening. A gate
demanding scales that no longer complete would be a permanent red that people
learn to skip. The test before relaxing any gate: can I state what changed in
the world that makes the old expectation wrong? If the honest answer is nothing
changed, it just fails now, that is a defect.

And measure the verification apparatus separately from the system: the design
alone proved in 5.5s, with its properties and their tracking state 126.7s. The
scaffolding cost 23x the thing it verifies, so the slowdown blamed on new
interlocks was mostly the properties added alongside them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three lessons from measuring a property set in t27.

Four of 26 properties accounted for 75% of the proof time, because they needed
ten pieces of dedicated tracking state that the other 22 did not. Removing just
those four restored the verifiable depth from 40 to 80. The instinct when a
check gets slow is to simplify the subject; the measurement said the apparatus
was the problem, and inside it a small minority. Disable each check in turn and
time the rest before optimising anything.

Splitting a check set is not weakening it, so long as both groups run and the
bound each is checked at rises or holds. The test: after the split, is any
property checked less thoroughly than before? If no, it is a scheduling change.

And two failed attempts at the same edit are a signal about the edit's shape,
not about persistence. Both failures here had one root -- the things to be
wrapped are scattered, not adjacent -- and the second failure established it.
When the same edit fails twice for related reasons, characterise the structure
before trying a third time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… of the output

Three lessons from landing a property-set split in t27.

An edit adding ifdef-style guards can land correctly for the configuration you
test and wrongly for the one you do not. Three checks caught it: every
configuration elaborates, the partition is exactly what was intended measured by
per-item guard depth in the output, and the guards balance. Checking it compiled
would have passed the broken version.

Scattered things look contiguous until you print their positions. Two attempts
failed assuming four items and ten dependencies sat in one block; they form four
regions with an unrelated item inside what looks like a fifth. The third attempt
began by dumping line numbers and reading the region -- not more care in the
edit, but establishing the structure before editing at all.

And splitting work by cost rather than importance can raise every bound at once:
four checks needing dedicated state cost 75% of the runtime, so isolating them
let the other twenty-two run four times deeper while keeping their own depth. It
only counts as free when every item's coverage rises or holds -- say so and check
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wave 606's lessons. A reachability probe per activity does not cover the
reachability of their combinations -- twelve probes each said the module
does its job while a constraint removing a rare interleaving stayed
invisible. Choose which interleavings to probe from defect history rather
than combinatorics. Validate each probe by removing its own target, one
at a time.

The trap worth the most: a control that fails to remove the thing it
targets tests nothing, and reads exactly like a blind probe. Suspect the
control before the probe.

Plus the tool wall -- $past inside an async-reset block is rejected by
async2sync outright -- and the depth cost: an interleaving witness needs
roughly double the seq bound of the activity witness it extends.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant