Skip to content

DecodingUs Grid — the AppView side, end to end - #32

Merged
JamesKane merged 7 commits into
mainfrom
feat/grid-p0-coordination
Aug 25, 2026
Merged

DecodingUs Grid — the AppView side, end to end#32
JamesKane merged 7 commits into
mainfrom
feat/grid-p0-coordination

Conversation

@JamesKane

Copy link
Copy Markdown
Owner

The coordination half of the distributed compute grid:
the AppView publishes public-ENA work units, volunteer Navigator instances lease them,
compute, and submit signed digests; agreeing results canonicalize and pay compute credit.

This is everything except the Navigator edge. 19 unit tests and 18 live-Postgres tests pass.

commit what
4671ad9 0075_grid.sql + du_db::grid — claim, lease, submit, credit, leaderboard
cf3ad2a run-once grid-curate — the work list
324a078 two fixes from the first live-DB run
17cdbb3 run-once grid-validate + grid-reap — adaptive replication
d4ae531 the nine /api/v1/grid/* endpoints
9b8acc4 0076base_count, so per-gigabase credit means something
fc6f8ec shared-crate pin → b11ba77 (Provenance)

The design was written well ahead of the code, and four of its mechanisms didn't survive contact

Each is corrected in the design doc (§12) as well as here.

The unit state machine can't express replication. §3 gives the work unit
AVAILABLE → LEASED → SUBMITTED → CANONICAL, and sets required_replicas = 2 two paragraphs
later. A unit routinely needs a second independent result while a first node holds a lease, so
LEASED would have to mean "…and also still claimable", which is not a state. work_unit.state
now carries only the genuinely exclusive milestones and claimability is derived from the lease
and submission rows. No replica counter exists, so none can drift.

Curation needed no ENA integration. §4.5 budgeted one. crawl_project had already resolved
every run at file level, so curation is a projection of existing tables and makes zero network
calls — which also delivers §6.2's ENA fair-use control for free.

The reaper doesn't free replica slots. claim already ignores any lease past expires_at, so
a slot frees itself the instant a lease lapses — the catalogue keeps flowing even while the reaper
is down
, which is better than the design claimed. What the reaper is actually for is recording the
EXPIRED outcome (trust tiering needs it; no derived query recovers it) and letting the same node
retry after overrunning.

The spot-check needed no schema. A trusted node's lone submission that draws the 5% simply
doesn't canonicalize yet: required_replicas rises to 2 and the unit stays claimable, so the shadow
arrives through the ordinary claim path.

Three places a contract was avoided rather than created

  • Digest bucketing moved server-side. §5.2 had the node submit pre-bucketed values, making the
    bucket function a cross-repo contract — a Navigator release rounding differently would produce
    unexplained DIVERGENT verdicts against honest nodes. Server-side it exists once and can be
    retuned without redeploying a client.
  • submit recomputes the digest hash rather than trusting it. A signature over a hash proves
    only that the signer knew the hash; without recomputation a node could sign a good result, post a
    different one, and still audit clean later.
  • claim normalizes data_kinds before verifying, so ["CRAM","cram"] and ["cram","CRAM"]
    aren't different signed strings for one request.

Judgement calls worth a look

  • Trust derives from grid work only, never the social reputation score. Those are different
    claims — community standing must not vouch for a machine's output.
  • A contested unit blames nobody. Two conflicting clusters are no evidence about which is
    wrong, and MAX_DIVERGENCE_FOR_TRUSTED = 0 makes a wrong DIVERGENT mark permanent.
  • Two independent contributors agreeing always suffice, whatever their tier — otherwise a young
    fleet where nobody is trusted yet could never canonicalize anything.
  • The credit ledger is integer milli-cobblestones. NUMERIC has no Rust mapping here (sqlx is
    built without a decimal feature) and f64 is the wrong shape for a column summed over every
    contribution ever made.
  • est_bases prefers ENA's measured base_count over reads × read_length, which is a
    mean-length approximation and wrong for every long-read platform. Where neither exists the value
    stays NULL and the job warns — a fabricated number in a ledger that pays people is worse than an
    honest absence. Old rows need run-once ena-base-count; a re-crawl can't fix them, because
    ingest_libraries is idempotent at sample granularity.

Verifying

Live-DB tests skip without DATABASE_URL, so cargo test --workspace is green without one. To run
them for real, note the Apple container published port is unusable — it completes the TCP
handshake and resets on the first protocol byte — so use the container's vmnet address:

PGHOST=$(container inspect du-testpg | python3 -c "import sys,json;print(json.load(sys.stdin)[0]['networks'][0]['ipv4Address'].split('/')[0])")
DATABASE_URL="postgres://postgres:dev@$PGHOST:5432/postgres?sslmode=disable" \
  cargo test -p du-db --test grid -- --test-threads=1

Serially, with ≥4 GB: each test migrates its own database, and ten concurrent CREATE EXTENSION postgis calls against 1 GB kill the backends at migration 1.

CI will likely be red, and not because of this branch

origin/main already fails cargo clippy --workspace -- -D warnings under current stable clippy,
in files this branch never touches: du-db/src/place.rs:297 (is_none_or, stable in 1.82 vs the
declared MSRV 1.80) and du-web/src/origins_layout.rs:505,512. All arrived with 0dabbab. Happy
to fix them here or in a separate PR — say which you'd prefer.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SequryftjcFkLnjQRMKCx3

JamesKane and others added 7 commits August 24, 2026 14:01
The DecodingUs Grid publishes public-ENA work units that volunteer Navigator
instances lease, compute, and return as a signed digest. This is the coordination
half — the catalogue, the lease, the submission, the credit ledger — which is the
part with no existing analogue anywhere in the three repos. Design:
`documents/design/distributed-compute-grid.md` in the DUNavigator repo, §4 and §12.

Reuses rather than rebuilds: `fed.pds_node` for the node registry (built in
0008_fed.sql for almost exactly this and never wired), `fed.device_key` plus
`sig::verify_signed{,_fresh}` for auth, and `ident.users` for credit attribution.
`fed.pds_submission` is deliberately NOT reused — its status lifecycle means curator
review of a proposed variant call, which is a different thing from digest quorum,
and overloading it would leave both meanings unreadable.

**Claimability is derived, not stored.** The design gives the work unit the states
AVAILABLE → LEASED → SUBMITTED → CANONICAL, but sets `required_replicas` to 2 two
paragraphs later — so a unit routinely needs a second independent result while a
first node still holds a lease, and a unit flipped to LEASED can never reach that
second node. LEASED would have to mean "…and also still claimable", which is not a
state. So `work_unit.state` carries only the genuinely exclusive milestones and
`claim` derives the rest: state is AVAILABLE or CONTESTED, active leases plus
non-divergent submissions are below `required_replicas`, and the calling DID holds
neither a lease nor a submission on the unit. One SELECT … FOR UPDATE SKIP LOCKED
answers that. No replica counter exists, so none can drift from the rows it would
summarise, and a claim never mutates the work unit at all.

That last clause is a correctness rule and not a rate limit: quorum means
*independent* results, so a contributor must never be re-offered a unit it already
holds or has already answered.

The credit ledger is BIGINT milli-cobblestones. NUMERIC has no Rust mapping here —
sqlx is built without a decimal feature and nothing else in the repo uses one — and
adding a workspace-wide dependency for one column is out of proportion, while f64 is
the wrong shape for a value SUMmed over every contribution ever made. NUMERIC(12,3)
had already chosen three decimals, so the integer is that value and it sums exactly.

The digest carries no mt_terminal: `App::analyze_biosample` declines to assign mtDNA
because it "is not final on CHM13", and the Grid realigns to CHM13, so requiring an
exact match on it would have blocked P1 behind a research question.

Migration comments carry the same reasoning, because `sqlx::migrate!` checksums
applied migrations — the SQL that ran can never be edited, so its comments are the
one explanation that cannot drift away from it.

Tests: two unit tests pin the canonical signed strings, which are a cross-repo
contract with the Navigator edge — an accidental reformat now fails here rather than
as a 403 against a released desktop build. Six live-Postgres tests cover replica
bounds, self-replication, the data-kind filter, lease reclamation, submit/resubmit
idempotence, and credit. THOSE SIX HAVE NOT BEEN RUN: the development host has no
reachable Postgres, so the claim SQL is unverified against a real database. Two type
ambiguities were removed pre-emptively for that reason — LIMIT takes a bigint, and
`bigint * interval` has no operator, so make_interval is used instead. Run them
before building on this:

    DATABASE_URL='postgres://…@localhost:5432/postgres?sslmode=disable' \
      cargo test -p du-db --test grid -- --nocapture

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SequryftjcFkLnjQRMKCx3
…ored

`run-once grid-curate` publishes `grid.work_unit` rows, and it makes no network
calls at all. The design (§4.5) budgeted an ENA integration here; it turned out not
to need one. `crawl_project` has already enumerated every run of a study, grouped
the runs by sample, and written each file's URL, md5 and size into
`genomics.sequence_file`. Curation is one query over tables we already have.

That is also the ENA fair-use control §6.2 asks for, arrived at by accident: a node
is handed a finished manifest and never goes discovering files for itself, so a
fleet of any size costs the ENA portal nothing.

`data_kind` is decided per sample and the manifest is then filtered to match it —
any CRAM/BAM makes it a passthrough unit carrying only aligned files, otherwise a
FASTQ unit carrying only reads. `build_libraries` already prefers aligned over FASTQ
per sample, so the two normally agree; deciding it again here means a manifest can
never list a file the data kind says the node will not open, and the download budget
cannot be inflated by files nobody fetches.

Refreshing a unit updates its manifest and sizes but never its state,
required_replicas or canonical digest. Curation describes the input and validation
owns the lifecycle, so a re-crawl must not silently un-canonicalise a finished unit
— there is a test for exactly that.

**This exposed a hole in the credit formula.** `est_bases` is `reads ×
read_length`, and the crawl leaves `read_length` unset: ENA's filereport exposes
`base_count`, but du-external's RUN_FIELDS does not request it and
`sequence_library` has nowhere to put it. So the per-Gbp term of §6.3 has nothing to
weigh a FASTQ unit by. Deriving an estimate from byte sizes was the tempting fix and
is the wrong one — a fabricated number in a ledger that pays people is worse than an
honest null. Curation publishes the null and the job warns with a count, so the hole
is visible rather than silent. Fixing it means adding `base_count` to RUN_FIELDS and
carrying it through the crawl, which belongs with that code and not here.

Three curation tests seed through `biosample::upsert_by_accession` +
`sequence::ingest_libraries` rather than writing their own rows. The query reads
JSONB paths whose shape only `ingest_libraries` defines, so a test that hand-wrote
rows could agree with the query while both disagreed with what the crawl stores.

Like the substrate commit before it, these tests compile but HAVE NOT BEEN RUN — no
reachable Postgres on the dev host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SequryftjcFkLnjQRMKCx3
…replica slot

Running the suite against a real Postgres for the first time. Both findings are the
kind that only a database can produce.

**`est_bases` decoded as NUMERIC, not INT8.** `SUM()` over bigint widens to NUMERIC
in Postgres; the operands were cast and the result was not. Review could not have
caught it — `total_bytes` on the very next line carries the `::bigint` cast, so the
omission read as consistent with its neighbour.

**The reaper does not free the replica slot, and never did.** `claim` already
ignores any lease past `expires_at`, so a unit held by a node that crashed becomes
claimable by another contributor the moment the lease lapses, with no job run in
between. That is a strictly better property than the design claims for it in §4.3:
the catalogue keeps flowing even while the reaper is down.

The test asserted the design's story rather than the code's behaviour, and the code
was right. Rewritten to pin what actually happens, and a second test added for the
reaper's real purpose — which is the two things that do need a row write:

  1. recording the EXPIRED outcome, since trust tiering must tell "timed out" from
     "gave it back" and no query over live state recovers that afterwards;
  2. letting the *same* node take a fresh lease after overrunning, because the
     self-replication guard keys on an unreleased lease regardless of expiry.

That guard is deliberate: relaxing it would let a node re-claim a unit it already
has a row for, and the partial unique index would then turn ON CONFLICT DO NOTHING
into a silently empty result with no explanation. So the node waits for the reaper,
which is the honest ordering — its first attempt really is over.

Worth stating plainly, since it is the argument for running these before building on
them: `grid-validate`'s trust tiering was about to be written on top of a wrong
model of when a lease stops counting.

All ten tests now pass. The Apple `container` published port is unusable for this —
it completes the handshake and resets on the first protocol byte — so connect to the
container's own vmnet address, which changes on every recreate. Recipe is in the
design doc §12.5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SequryftjcFkLnjQRMKCx3
`run-once grid-validate` clusters the digests on a unit, canonicalizes when one
cluster clears both the replica bar and the trust policy, and credits whoever
agreed. `run-once grid-reap` closes lapsed leases — which, per the last commit, is
not what returns a unit to the pool. Design §6.1 and §12.6.

Trust tiers derive from `grid.submission` history and never from the social
reputation score. Those are different claims: social standing says a person
participates well in the community, grid trust has to say their machine produces
correct results. Letting the first vouch for the second would let a well-regarded
member canonicalize bad output on reputation alone, which is the attack adaptive
replication exists to stop. §6.4's capped reputation event still fires *for* grid
work; the arrow only points that way.

**Bucketing moved to the server.** §5.2 had the node submit values already
bucketed, which would make the bucket function a cross-repo contract: every
Navigator release would have to round coverage exactly as the AppView expects, and
drift would surface as unexplained DIVERGENT verdicts against honest nodes. Server
side, the rule exists once and can be retuned without redeploying a client. The node
still signs the raw digest it sends, so the signature covers what it computed, and
boundary sensitivity is unchanged by where the rounding happens.

**The spot-check needed no schema.** A trusted node's lone submission that draws the
5% simply does not canonicalize yet: required_replicas rises to 2 and the unit stays
claimable, so the shadow arrives through the ordinary claim path and the next pass
confirms or contests. One code path instead of a SHADOW state and a second one. The
draw is Postgres `random()` rather than a hash of the unit id, because §6.2 requires
spot-checks to be AppView-chosen — anything derived from the unit or the digest is a
rule a contributor could compute in advance and route around. It also avoids adding
a random-number crate for a single coin flip.

Two rules worth stating because they are easy to get wrong in the other direction:

- A contested unit blames nobody. Two conflicting clusters are no evidence about
  which is wrong, so nothing is marked DIVERGENT and no reputation is docked; the
  bar rises and the tie-breaker assigns blame. A coin-flip penalty would punish
  honest work, and under MAX_DIVERGENCE_FOR_TRUSTED = 0 a wrongly-marked contributor
  loses its tier permanently.
- Two independent contributors agreeing suffice whatever their tier. Requiring a
  trusted node on top of independent agreement would deadlock a young fleet where
  nobody is trusted yet; the tier rule only governs whether one submission stands
  alone.

Results against different references, or different stack majors, are not clustered
together at all — they are answers to different questions, and pooling them would
manufacture divergence out of nothing.

Constants are conservative placeholders, as §9 asks: promoting too slowly costs
duplicated compute, promoting too quickly costs a wrong canonical result, and only
one of those is recoverable.

16 unit tests and 13 live-Postgres tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SequryftjcFkLnjQRMKCx3
Nine endpoints under /api/v1/grid: five signed mutations (node/register, claim,
heartbeat, release, submit), one signed read (mine), three public (leaderboard,
work/{accession}, stats). Auth is the same Ed25519 device-key path the D1 exchange
and the recruitment Edge use. Design §4.4 and §12.7.

**submit recomputes the digest hash rather than trusting it.** The signed message
covers a hash of the digest; the handler recomputes that hash from the body that
arrived. Without it a node could sign the hash of a good result and post a different
one, and the stored digest_sig would still look valid to an auditor later. The
canonicalization is the smallest contract available — serde_json::to_vec, key-sorted
and whitespace-free because this workspace does not enable preserve_order, and
Navigator uses the same crate under the same default. No field order to agree on and
no float formatting rules. A test pins it, so switching preserve_order on anywhere
fails there rather than as unexplained 400s against shipped desktop clients.

**claim normalizes before it verifies.** data_kinds is uppercased, deduplicated and
sorted, and the signed message covers the normalized form; otherwise ["CRAM","cram"]
and ["cram","CRAM"] are different signed strings for one request and a node whose
ordering differs gets an unexplained 403. The signature covers what the node asked
for, not what the server clamped it to — a node cannot know our bounds, and making
it guess them to produce a valid signature would be an unusable API.

Two endpoints from §4.4 are deliberately absent. /grid/node/heartbeat is redundant:
register_node already stamps last_heartbeat and is idempotent, so re-registering is
the node heartbeat, and two endpoints writing one row is drift waiting to happen.
/grid/heartbeat does not extend a lease, per §4.3 and §12.5.

/grid/mine is new. Every other endpoint either mutates or is public, which left
messages::poll with no caller, and §7.1's Grid panel needs the contributor's own
leases, history, credit and rank — which is not public data. One signed read closes
both gaps.

An uncredited contributor's rank is null rather than a number. The query returns the
position such a contributor would hold, but to a node with its first unit still in
flight that renders as "you are last" — about a board it does not yet appear on.
Null means unranked, which is what is true.

19 unit tests and 16 live-Postgres tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SequryftjcFkLnjQRMKCx3
By the time grid-validate shipped, the est_bases gap had stopped being theoretical.
The validator pays BASE_CREDIT + per-Gbp, and est_bases was NULL for essentially
every unit — so a 90 Gbp realignment earned exactly what a CRAM passthrough earned.
The ledger would have gone live quietly wrong. Design §12.8.

ENA had the figure all along: `filereport` publishes base_count on read_run, and
RUN_FIELDS simply never asked for it. It does now, and migration 0076 adds
genomics.sequence_library.base_count to hold it.

A typed column rather than the `atproto` JSONB slot, which was the other option since
that slot already carries {source, run_accession} for crawled runs. But that slot is
provenance — where a row came from. base_count is a measurement of the library, the
same kind of fact as the reads and read_length columns beside it, and it is summed
into an aggregate feeding a ledger that pays real people. In a JSON blob a missing
key reads identically to a zero; in a typed column a NULL is visible.

est_bases now prefers the measurement and falls back to reads × read_length only for
rows predating the column. That product is a mean-length approximation and is wrong
outright for variable-length reads, which is to say for every long-read platform.

Existing rows needed a job rather than a re-crawl: ingest_libraries is idempotent at
*sample* granularity and skips a sample that already has files, which is what keeps
re-crawls cheap and is not worth weakening for one column. So `run-once
ena-base-count` fills the column directly, one run at a time — filereport filters on
whatever accession it is given, so a run accession returns just that run — in
bounded batches with the study crawl's politeness gap. Re-run until nothing is
examined.

Two properties made explicit rather than incidental, both about not corrupting a
ledger:

  - the backfill only ever fills a NULL, so re-running cannot overwrite a
    measurement and silently change what a contributor was already paid for;
  - an empty base_count from ENA leaves the NULL rather than writing a zero. A
    submitter who never supplied the figure is not a run that sequenced nothing.

Where no honest figure exists the null survives and grid-curate still warns with a
count. That was right before and is still right; this makes the null rare instead of
universal.

18 live-Postgres tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SequryftjcFkLnjQRMKCx3
Picks up `du_domain::fed::Provenance`, which the Grid's fed records need to say that
a result is *about* an ownerless public ENA sample while being *authored by* the
volunteer who computed it (design §5.1).

All three pins move together — du-domain, du-atproto and du-bio are one repository,
and letting them drift to different revs of it would be a way to get two versions of
the same type in one build.

The change du-domain landed is additive: the four Grid records gained an optional,
defaulted `provenance` block that is skipped when absent, so nothing in this repo
had to change to keep compiling. That is what an additive contract change is
supposed to look like from the consumer's side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SequryftjcFkLnjQRMKCx3
@JamesKane
JamesKane merged commit b9f6c22 into main Aug 25, 2026
1 check failed
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