Skip to content

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

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

TRI-NET: three boards, ten families, and four numbers that were wrong#355
gHashTag wants to merge 197 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 12, 2026 13:07
…omponent built to tolerate it

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd one without a denominator

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ad as an empty shell

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…me cannot notice its siblings

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…us before publishing a ratio

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ber can be the symptom

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ons coarser than the defect hide partial fixes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e warnings above the error

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…; ask Print Assumptions

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…structive checks need death-proof restores

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n, do not read it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…m or it recurs in one wave

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ge notes are a queue

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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