ADR 0039: The staged merge of MOSP_ESCHER’s CBST behaviours (part 1: chain bound, integrity, scratch growth, compaction)#
Status: Accepted under delegation (2026-10-07; GOVERNANCE.md, “Delegation of technical ADRs”). It carries out M8a step 4 (PLAN 6.4.5 step 2, 4.7.3, 8.4; the M8a acceptance criteria 10, 11 and parts of 13 and 16). It changes no rule the author approved and weakens no comparison: ESCHER-GPU’s traces are still compared in the
exactfield set at every commit, and MOSP_ESCHER’s traces are judged tree by tree against the host model under the same preset. Step 5 (automatic compaction,mosp_escher(), dynG’s default) extends this ADR: ADR 0040, which replaces the staging preset bymosp_escher().Date: 2026-10-07
Deciders: the AI assistant, on the author’s behalf (M8a, step merge-invariant)
Number: 0035 is the 0.2.0 release branch’s, 0036-0038 this milestone’s.
Context#
PLAN 6.4.5 step 2 adds MOSP_ESCHER@4b86159’s (B) behaviours to the port of ESCHER-GPU@abcf9b2’s
(A) store (ADR 0038) one at a time, replaying both originals’ traces after each. This first part
merges the behaviours that change no trace of A: construct validation, the fill clamps, the
caller’s row counts, the bounded chain walk, the integrity checks, the find.cu and
delete_avail.cu differences, scratch growth, an explicit compact(), the accounting, and a rule
for dead or absent keys. B’s automatic compaction (the trigger points inside fill and insert),
its preset and dynG’s default preset are step 5.
Decision#
1. One behaviour per commit, judged by a staging preset#
The staging preset
store_policy::mosp_escher_staged()(store_policy.hpp) is B’s behaviours merged so far: B’s fixed capacity (growth_rule::none: where B compacts, the store throwscapacity_error), no compaction of its own, alignment 4, from (g) on B’s scratch growth. It exists for the merge steps; step 5 replaces it withmosp_escher().The comparator resolves the same name to the host-model policy with the same behaviours (
staged_host_policy()incpp/tests/parity/escher/cbst_compare.cpp: A’s behaviour plus, commit by commit, B’s validation, clamps, caller counts, scratch growth andcompact(), with dynG’s exceptions). B’s traces replayed under the staging preset must diverge in exactly the trees, ops and fields where the host model under it does (ADR 0037’s first-divergence rule;compare.py cbst_trace --store device --policy mosp_escher_staged, the gtestMospEscherTracesDivergeWhereTheModelDoes), so every remaining divergence is caused by a behaviour the store has not merged.parity/cbst_merge_steps.shruns B’s sets under bothescher_gpu()and the staging preset from the commit that adds it, and lists each set’s first divergences by field.A’s traces stay under
escher_gpu()and must match in theexactfield set (offsets, occupancy, tail capacity,device_bytes()= A’sdeviceBytes(), the per-op degree-bin launches, the integrity verdicts) at every commit; the one documented divergence of ADR 0038 (construct_below_payload) is unchanged.
At the end of this part B’s small sets under the staging preset diverge in 18 trees of
mosp_escher_test (11 test_cbst_smoke constructs without row counts, key.occupancy: B’s
legacy occupancy 0, which dynG never takes over; 7 trees at their first automatic compaction,
status: step 5) and 4 trees of mosp_escher_targeted (the two automatic compactions, B’s
capacity throw after its compaction, the construct below its payload); before the merge they
diverged in 50 and 7 (34 + 4 more at their first scratch growth). The table of every commit is
in parity/results/M8a.md, section 12.
2. The behaviours and every deviation from B’s literal code#
behaviour |
B’s source |
dynG |
deviation from B’s literal code |
|
|---|---|---|---|---|
(a) |
construct validation |
|
|
|
(b) |
fill clamps |
|
the three fill kernels use the occupancy clamped to [0, tail capacity] |
B clamps only the room left ( |
(c) |
caller row counts |
|
|
B’s |
(d) |
the bounded chain walk |
|
|
the bound (section 3) and the error: B returned |
(e) |
integrity |
|
|
new in neither original: |
(f) |
|
|
|
|
(g) |
scratch growth |
|
|
computed in 64 bits and capped (B’s |
(h) |
explicit |
|
|
the warp walk is bounded (section 3); compacted rows that need more than the capacity (a live row of L values takes (L+4)/4*4 slots, more than a surplus row’s) grow the payload by the policy, or throw |
(i) |
accounting |
|
|
B’s process-global mempool release threshold is not taken over (a test checks the default pool’s attribute is unchanged) |
(j) |
dead or absent keys |
(no rule in either original) |
section 5 |
dynG’s |
3. The chain bound#
B bounded readFlat at a literal 1,024 hops and then treated the row as ended. dynG does not port
the literal bound:
Under
escher_gpu()a V2H row gains a segment for every fill that overflows its tail and is never compacted, so a legitimate row can have more than 1,024 hops; B’s walk would truncate it silently (the testTwoThousandOverflowingAppendsReadBackCompletely: 2,000 hops, read back completely by the integrity checker, the row locator,rows()and the unfill writer).The bound is the used payload: a well-formed row reads each of the
initial_payload_sizeused slots at most once, so a walk that reads more slots than that, or leaves [0, used), is a broken (cyclic or dangling) chain. It never hangs and never returns a truncated row.Evidence that B’s bound never triggers in B’s traces: the longest chain of every trace of both originals is 98 hops (
escher_trace.py summary,max_chain_hops:mosp_escher_test98,mosp_escher_targeted4, the DBLP runs 3; A’s sets 3-4;parity/results/M8a.md, section 5). So the literal bound is not observable in B’s own traces (the stop condition of the step does not apply) and replacing it changes no trace.
4. The device error word (the control block)#
PLAN 4.7.3’s sticky device error word gets its first users in the store: a few words on the device
(cbst_context::d_control: the device_error bits, row-walk overruns, rejected items, a path
counter of serial unfills) and their pinned host mirror. Every read point of the store queues the
copy of the words before its synchronization and throws after it if a kernel raised an error
(internal_error, with the counts): the error of a walk or an item surfaces at the operation’s
own read point (append, insert, compact, rows) or, for the operations without one (erase,
remove_values), at the next operation’s or at the new check_errors() (one synchronization; the
container’s end of an update). The store is then poisoned. download() does not check. The words
are outside A’s accounting scope: device_bytes() and allocated_bytes() do not count them.
5. Appends and removals on dead or absent keys#
Neither original defined it: an item naming an absent key was skipped silently, one naming a
deleted key (which keeps its node) wrote to or compacted its row like a live one. dynG skips the
item, counts it and raises the device error word (lookup.cuh); the next read point throws
internal_error (the hypergraph container never names such a key: it is a broken invariant of
the caller), and the store is poisoned. Erase keeps both originals’ behaviour (absent keys
ignored; a deleted key erased again stays deleted). An empty tree’s append and remove_values name
absent keys and therefore throw now (ADR 0038 had them change nothing). No item of the four small
sets names a dead or absent key (0 of 1.87 M fill, 1.84 M unfill and 0.29 M erase items).
6. The other differing files (behaviour (k))#
file |
difference |
disposition |
|---|---|---|
|
B: |
merged into |
|
B: |
merged (e) |
|
B: sequential, |
not needed: A’s parallel 64-bit version with the 2^31 check (PLAN 6.4.5 step 3, ADR 0038) |
|
B: |
not ported: |
|
B: no |
union: A’s declarations kept, the compaction kernels added (h) |
|
B: no |
A’s kept (the scan is the default); B’s |
|
|
not needed: dead code in both ( |
|
A: |
not needed: debug printing, replaced by the log sink (ADR 0038, c2) |
|
B only: |
not needed: dynG’s error classes and |
|
identical |
nothing to merge |
Consequences#
Every behaviour of B that does not change A’s traces is in the store, each in its own commit with its replays (
parity/results/M8a.md, section 12); A’s traces match in every recorded field at every commit; B’s traces diverge only at their first automatic compaction (step 5) and at the constructs without row counts.Handoff to step 5:
mosp_escher()replaces the staging preset (B’s trigger points: a fill whose overflow segments do not fit compacts and refills the rest once; an insert whose surplus rows do not fit compacts first; thencapacity_error); B’sescher::EscherErrormust be mapped to the dynG exception of each throw site in the comparator; the 11test_cbst_smokeconstructs withrowOccupancy = nullptrneed a documented divergence (B’s legacy occupancy 0 is never taken over), or criterion 10(b)’s “no divergence” cannot hold for them.
Later changes#
2026-10-08 (the M8a review fixes): what “as the model” compares. A tree of B’s traces replayed under a preset other than B’s own diverges as the model only when the store’s whole first divergence equals the model’s: the same op, and the same set of (field, key, expected, actual) of every mismatch there (
parity/cbst_trace_goldens.pyfirst_divergence_sets; before, only the op and the first field name were compared). Two outputs that are not values of the parity are compared by their meaning, not their text: the status of an op that threw by its exception type (each store words the message its own way), with no InsertMapping for a thrown insert; and the status of an op that the preset does not have at all (compact()beforec05d7cc), which the store reports as unsupported and the model as “the policy has no …”. Every other field, key and value counts as reported; the model’s other “undefined” statuses stay distinct. The whole merge-step table passes under this rule (parity/results/M8a.md, section 14).