Skip to content

Demand-path merges (enqueueMergeForRange, produce level ≥ 2) still bypass the strict-adjacency grammar — can mint a non-contiguous L2 that kv-unified then rejects forever #95

Description

@Anarchid

If you run a resident on oldest-first or flat-profile (or any picker that emits produce ops with level >= 2) and later switch it to kv-unified, its first compile can fail with Canonical summary forest rejected … summary L2-N owns non-contiguous live leaves and keep failing every turn. The strict adjacency fix in d2c04bf ("enforce canonical merge ownership", 0.8.0) closed the threshold grouping path (contiguousMergeCandidates) and sanitizes persisted queues on load, but the demand path still enqueues whatever unmerged sources fall inside the requested range, holes included. Nothing validates a merge group's contiguity at execution time either, so a crossed node written this way is permanent until repaired by hand.

Where

src/strategies/autobiographical.ts @ main (0e2302f):

  • enqueueMergeForRange (line ~3888): collects every unmerged L_{n-1} whose sourceRange.first or .last is in range, then sources.slice(0, N) (line ~3935) and enqueueMerge. No first === previousEnd + 1 check, no split into runs. A chunk in the range whose L1 has not landed yet (late compression, refusal quarantine, Quarantine-skipped chunk at compressionQueue head silently consumes ticks and starves the merge queue #55-style starvation) is simply skipped over and its neighbours are folded across it.
  • contiguousMergeCandidates (line ~6520) has the strict-run logic; sanitizePersistedMergeQueue (line ~3577) has the same predicate for persisted entries. Neither is applied to a freshly enqueued demand merge.
  • enqueueMerge only dedups against the merge quarantine hash.

Emitters today: oldest-first.ts returns { kind: 'produce', request: { level: targetLevel, range } } for any level; kv-stable and kv-unified emit level-1 demand only, so they cannot reach this path themselves — but the archive they inherit can carry nodes minted by it.

Real-world instance (how we found it)

A production resident (Lynx Knowledge Resident, conhost 0.7.4 / CM 0.6.3, Aug 27 → Sep 9) ran flat-profile because its recipe omitted foldingStrategy. On 2026-09-02, with mergeThreshold: 4, chunk c-2's L1 landed 78 minutes late (L1-8) while L1-0..3 were folded into L2-4 across it; L2-9 then took L1-8 + L1-5..7. Result: L2-4 owns chunks {0,1,3,4}, L2-9 owns {2,5,6,7}. That was the 0.6.3 threshold grammar (mergeContiguityGapLimit 300 + gapHasPendingLowerCoverage never firing for L1→L2), which d2c04bf fixed — but the demand path in that same version had (and still has) no guard at all, and both were live. Switching that store to kv-unified with strict flags on 2026-09-09 hard-downed it: 19 consecutive pre-flight rejections, every 5 minutes, until preserveGapBearingSummaries: true was hot-flipped. Audit of the 2026-09-15 snapshot: 318 reachable summaries, still exactly those 2 non-contiguous, 0 new ones in 124 summaries minted under 0.8.0 — consistent with the threshold path being fixed and the demand path simply not being exercised by kv-stable.

Suggested fix

  1. Run the demand candidates through the same run-splitting as contiguousMergeCandidates (sort by live first position, split on first !== runEnd + 1), and enqueue only the run(s) with ≥ 2 members; log the hole like warnMergeExclusion does.
  2. Cheaper belt-and-braces: apply the sanitizePersistedMergeQueue predicate inside enqueueMerge (or at executeMerge) so no path can write a crossed node, whatever grammar produced the group.
  3. Docs: unified-solve-design.md §2.1 lists "legacy restore or branch insertion" as the producers of interleaved ownership. Pre-0.8.0 merge grouping (threshold and demand) is a third; every store that ran CM < 0.8.0 should be assumed to carry some, which is the case for the gap-preserving default the migration guide already recommends.

Related: #55 (quarantined chunk at queue head starves the merge queue — the same "hole with no L1 yet" shape), #68.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions