Beagle SCM
GRAF-001: shared/graf.js — cached ahead/behind counts per shard, wh128 pair records
Context
Every ahead/behind ask (WORK-001 renders a count per wt; status/relate/subs walk too) re-runs a full dag.js ancestor BFS per pair — no result is ever kept, though a (cur,tip) pair's counts are IMMUTABLE (commit ancestry never changes; the cache only grows, never invalidates).
- Native graf keeps per-shard
*.graf.idx wh128 LSM runs (ron60 names, .lock.graf); they DO have entries today (beagle-ext: 2 runs, ~2.3k slots) but are native-written and STALE (newest Jun 30; the POST-031 staleness ground) — jab neither reads nor writes them, only du counts their bytes.
- JS run machinery exists and is keeper.idx-shaped: shared/idxmaint.js (SLOT 16, ladder compaction, landRun/freshRunName), abc.index("wh128",{mem}) memtable (shared/relate.js), abc._seekrange_wh128 / _compact_wh128.
- shared/dag.js aheadBehind is the compute side; WALK_CAP-bounded, tolerant of shallow shards (missing ancestors end a branch quietly).
Goals
shared/graf.js: a per-(store,project)-shard ahead/behind pair cache — lazy, index-first, self-extending.
- Record: one wh128 (two u64 halves), layout
[type 4|ahbeh 20|hashlet 40][direction 4|ahbeh 20|hashlet 40] — hashlet in the MS 40 bits, count mid, type/direction the LOW nibble (u64 = hashlet<<24 | ahbeh<<4 | nibble, the store.js keyFor shape), one half per commit of the pair.
- Lookup is LAZY: aheadBehind(A,B) seeks the shard index first; a hit returns the counts with NO walk.
- A miss walks — but REUSES the index at every step: a cached (ancestor,B) pair short-circuits the remaining closure (recurrence below), so hundreds of wts sharing a handful of tips converge to O(new commits).
- New pairs land in a small MEMTABLE run (abc.index wh128 mem) and persist as a fresh small run; ladder compaction folds it in.
- Runs join the existing per-shard
*.graf.idx family (the "graf index").
Constraints
- JS only, shard-local; keeper opened by the caller (resource ownership stays at the top, CLAUDE.md §5 analogue for JS).
- Reuse idxmaint.js machinery (parameterize its keeper.idx EXT) — no second compaction/naming scheme; ron60 run names.
- Best-effort persistence: a read-only store degrades to mem-only cache, never a crash (the idxmaint norm).
- NEVER cache a truncated walk: a WALK_CAP hit or a shallow-shard dead-end yields counts that would poison the immutable cache forever — such results return uncached.
WIP
Design decisions
- RULING (chat 2026-07-18), option (a): linear stretches ride the cached-pair recurrence — ahead(A,B)=ahead(p,B)+A∉anc(B), behind(A,B)=behind(p,B)−A∈anc(B) for single-parent A; a MERGE commit triggers a real bounded walk; NO closure-size second record flavor.
- RULING (chat 2026-07-18): the 20-bit ahbeh SATURATES at 0xFFFFF = "at least that many" — 1M-commit histories happen, but nobody reads huge ah/beh distances; a walk stops counting a side at the cap and the saturated pair IS cacheable (for a fixed pair the truth never changes, so "≥cap" holds forever) — distinct from a shallow-shard dead-end, which under-counts and must stay uncached.
- RULING (chat 2026-07-18): the merge walk is a two-tip paint BFS stopped at COMMON ground — a node reached from both sides is common and stops expanding; terminate when the whole frontier is common. The stop line is the maximal-common-ancestor CUT (plural on criss-cross), not a single NCA; nothing below a common node is exclusive, so side-exclusive paint counts are exact and cost is bounded by the divergence, not history depth.
- (proposed) type nibble: a NEW pair-record type in the shard's wh128 type space, disjoint from keeper's T_COMMIT=1 / 0xF PACK rows; direction nibble distinguishes the ahead half from the behind half / pair orientation. The concrete values need the native graf type registry checked first.
- RULING (chat 2026-07-18): hashlet occupies the MS 40 bits of each u64, so numeric wh128 order == hash order; a (A,B) lookup seeks the contiguous span
[A<<24,(A+1)<<24) and matches B in the val's top 40 bits — no scan problem, mirrors keyFor's hash-MS/type-LS shape.
- RULING (chat 2026-07-18): 40-bit hashlets accepted — the in-slot count is worth the identity-space split; collision odds at fleet scale are negligible.
- (question) writing into the native-owned
*.graf.idx family: does native graf tolerate foreign type nibbles in its runs (its compactor will merge ours)? If not, a sibling ext (*.ab.graf.idx) keeps families disjoint. Also .lock.graf protocol must be honored either way.
TODOs
Blockers and bummers
- Layout/identity/family questions above block the record format freeze; everything else can proceed against a mem-only cache.
Outcome