From b8475cc86a9c5dca8c5c34f84f1e314e76369ef7 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Fri, 28 Aug 2026 13:00:42 -0700 Subject: [PATCH 01/42] =?UTF-8?q?fix(release):=20the=20release=20page=20po?= =?UTF-8?q?sts=20to=20this=20repository=20=E2=80=94=20soulcraftlabs/open-b?= =?UTF-8?q?rainy,=20never=20the=20engine's?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 11 POSTed to repos/soulcraft/brainy while printing the correct URL; dormant only because FORGEJO_RELEASE_TOKEN was unset. Found during the 10.4.4 cut verification. --- scripts/release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release.sh b/scripts/release.sh index 08293e3a..67ad1053 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -237,7 +237,7 @@ fi # and RELEASES.md are the record; this just gives The Source's UI a release page). echo -e "${BLUE}๐Ÿ”Ÿ Creating release page on The Source...${NC}" if [ -n "${FORGEJO_RELEASE_TOKEN:-}" ]; then - if curl -sf -X POST "https://source.soulcraft.com/api/v1/repos/soulcraft/brainy/releases" \ + if curl -sf -X POST "https://source.soulcraft.com/api/v1/repos/soulcraftlabs/open-brainy/releases" \ -H "Authorization: token ${FORGEJO_RELEASE_TOKEN}" -H "Content-Type: application/json" \ -d "{\"tag_name\":\"v${NEW_VERSION}\",\"name\":\"v${NEW_VERSION}\",\"prerelease\":${PRERELEASE}}" >/dev/null; then echo -e "${GREEN}โœ… Release page created on The Source${NC}\n" From 298cb6dacaac9ef80db65a723d65cbd53c15d23e Mon Sep 17 00:00:00 2001 From: David Snelling Date: Mon, 31 Aug 2026 09:07:18 -0700 Subject: [PATCH 02/42] fix(recovery): a torn generation-log tail is a terminal verdict, never a wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of one defect, found by a seeded-SIGKILL crash lane. THE FALSE POSITIVE. stampEntityTree() recorded generationStore.generation() โ€” the ALLOCATED counter, a number a write in flight has claimed and may never commit โ€” while the JSDoc beside it already said the source is the committed generation. Every crash inside a write window therefore produced a spurious verdict at the next open: either 'sourceGeneration N is ahead of the log head N-1' (the allocated generation died with the process) or 'rollup invariant nounCount: stamped X, observed Y' (the recovery fold folded facts the stamp's counts predate). Both told the operator to run repairIndex() โ€” a whole-store recount โ€” for a store that was coherent. Measured before this commit: 4 of 11 SIGKILL cycles on a healthy store raised one of the two. The stamp and the open now both read committedGeneration(), which is what every other open-time watermark in the class already reasons about. THE TERMINAL VERDICT. A stamp still ahead of committed truth after the recovery fold witnesses a generation that is not in the log โ€” the stamp's fsync outlived the tail's, and there is nothing to arrive. That is its own verdict state now ('torn'), never folded in with 'incoherent': the two have opposite cures. A writer open demotes it โ€” the unusable stamped surface is re-derived at the committed generation from the live counters, O(1), straight-line, no loop and no await on external progress, narrated with both count sets, the stamp's path and its committedAt. A read-only open cannot re-stamp, so it says so and names the cure instead of guessing, and still serves. Neither branch waits, and neither locks an owner out of a canonical tree the stamp only describes. Pins: the verifier returns the torn verdict with both generations; a fabricated head-behind-source store narrates precisely, demotes inside a bounded open, serves its rows, and is quiet at the next open (the demotion converges); a read-only open narrates the same verdict and leaves the bytes untouched. --- src/brainy.ts | 121 +++++++++++++++++++- src/db/familyStamp.ts | 32 ++++-- tests/integration/entity-tree-stamp.test.ts | 104 ++++++++++++++++- 3 files changed, 241 insertions(+), 16 deletions(-) diff --git a/src/brainy.ts b/src/brainy.ts index 70d46973..da04577e 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -12497,6 +12497,18 @@ export class Brainy implements BrainyInterface { * healed by `repairIndex()`, whose unconditional recount rebuilds the * rollups from a canonical walk and re-stamps. Best-effort: a stamp-write * fault warns loudly but never fails the flush that carried real data. + * + * THE SOURCE IS `committedGeneration()`, NEVER `generation()`. The latter is + * the ALLOCATED counter โ€” a number a write in flight has claimed and may + * never commit. Stamping it made the stamp's generation label a claim about + * counts it was not taken at, and every crash inside a write window then + * produced a spurious verdict at the next open: either `sourceGeneration N + * is ahead of the log head N-1` (the allocated generation died with the + * process) or `rollup invariant 'nounCount': stamped X, observed Y` (the + * recovery fold folded facts the stamp's counts predate). MEASURED on the + * crash-consistency lane before this line changed: 4 of 11 SIGKILL cycles on + * a coherent store raised one of those two verdicts, each of them naming + * `repairIndex()` โ€” a whole-store recount โ€” as the cure for nothing. */ private async stampEntityTree(): Promise { if (this.isReadOnly) return @@ -12507,7 +12519,7 @@ export class Brainy implements BrainyInterface { ]) await writeFamilyStamp(this.storage, ENTITY_TREE_STAMP_PATH, { family: 'entity-tree', - sourceGeneration: this.generationStore.generation(), + sourceGeneration: this.generationStore.committedGeneration(), members: { mode: 'rollup', invariants: { nounCount, verbCount } } }) } catch (error) { @@ -12520,16 +12532,24 @@ export class Brainy implements BrainyInterface { /** * @description Open-time coherence check for the entity tree's family stamp: - * compare `sourceGeneration` against the log head and the stamped rollup - * invariants against the live counters. Verdicts: + * compare `sourceGeneration` against the store's COMMITTED generation and + * the stamped rollup invariants against the live counters. Verdicts: * - `coherent` / `absent` (legacy store; first flush stamps) โ†’ silent. * - `behind` โ†’ benign for the tree (it is written BY the commit; only the * stamp is stale โ€” a crash landed between commit and flush). Refreshed at * the next flush. + * - `torn` โ†’ a TORN GENERATION-LOG TAIL, handled by + * {@link demoteTornEntityTreeStamp}: terminal, never a wait. * - `incoherent` โ†’ LOUD: the tree or its counters diverged from what was * stamped โ€” `repairIndex()` recounts from canonical and re-stamps. * Never blocks open; a fault reading the stamp is surfaced as unverifiable, * never conflated with absence. + * + * THE COMPARISON IS AGAINST `committedGeneration()`, matching what + * {@link stampEntityTree} writes and what every other open-time watermark in + * this class already reasons about (the fact-scan capability, the metadata / + * graph / HNSW watermark verdicts). Comparing against the allocated counter + * was the one place that disagreed, and disagreeing was the whole defect. */ private async verifyEntityTreeStamp(): Promise { let stamp: FamilyStamp | null @@ -12546,11 +12566,16 @@ export class Brainy implements BrainyInterface { this.storage.getNounCount(), this.storage.getVerbCount() ]) - const verdict = verifyFamilyStamp(stamp, this.generationStore.generation(), { + const verdict = verifyFamilyStamp(stamp, this.generationStore.committedGeneration(), { nounCount, verbCount }) - if (verdict.state === 'incoherent') { + if (verdict.state === 'torn') { + await this.demoteTornEntityTreeStamp(stamp as FamilyStamp, verdict.stampSource, verdict.head, { + nounCount, + verbCount + }) + } else if (verdict.state === 'incoherent') { prodLog.warn( `[Brainy] entity-tree stamp INCOHERENT at open: ${verdict.failures.join('; ')}. ` + `The canonical tree or its counters diverged from the stamped state โ€” run ` + @@ -12564,6 +12589,92 @@ export class Brainy implements BrainyInterface { } } + /** + * @description THE TERMINAL VERDICT for a torn generation-log tail. + * + * A stamp whose `sourceGeneration` sits ABOVE the store's committed + * watermark witnesses a generation that is not in the log: the stamp's fsync + * outlived the tail's. By the time this runs, log-authority recovery has + * already folded every intact fact above the manifest and advanced the + * watermark to cover them โ€” so if the stamp is STILL ahead, the generation + * it names is not merely late, it is GONE. There is nothing to wait for. + * + * That is the whole point of this method. A field report of this class + * (single-process store, abrupt termination mid-fold) described a reopen + * that narrated the tear and then held 100% CPU with zero log growth for + * eight minutes before an operator wiped the directory. A recovery that + * cannot say what it is waiting for has no business spinning; the honest + * answer here is a verdict, taken now, at O(1) cost. + * + * WHAT THE VERDICT DOES โ€” the stamped surface is UNUSABLE, so it is + * discarded rather than believed: the stamped counts describe a generation + * that never became durable, and comparing them against live counters can + * only produce noise. The tree itself is not in question (it IS canonical โ€” + * every commit writes it, and the fold re-applied every after-image the log + * still holds), so the demotion is a re-derivation of this family's verified + * surface at the generation the store can actually show: + * + * - WRITER open โ†’ re-stamp at `committedGeneration()` from the live + * counters โ€” exactly what the next flush would write, taken now so the + * tear cannot re-narrate on every subsequent open. Both count sets are + * logged so an operator can see whether anything really moved. + * - READER open โ†’ a reader cannot re-stamp. Narrate the same terminal + * verdict with the named cure and carry on serving; a read-only inspector + * is never locked out of a store, and never left waiting either. + * + * BOUNDEDNESS: straight-line code. No loop, no retry, no await on any + * external progress signal โ€” the two counter reads and one stamp write are + * the entire cost, and none of them scales with the store. + */ + private async demoteTornEntityTreeStamp( + stamp: FamilyStamp, + stampSource: number, + head: number, + observed: { nounCount: number; verbCount: number } + ): Promise { + const stamped = stamp.members.mode === 'rollup' ? stamp.members.invariants : {} + const detail = + `[Brainy] TORN GENERATION-LOG TAIL at open: ${ENTITY_TREE_STAMP_PATH} witnesses source ` + + `generation ${stampSource} (stamped ${stamp.committedAt}), but the store's committed ` + + `generation is ${head} after crash recovery โ€” the stamp's fsync outlived the log tail's, ` + + `and generation ${stampSource} is not in the log to arrive. Stamped rollups ` + + `${JSON.stringify(stamped)}; observed ${JSON.stringify(observed)}.` + + if (this.isReadOnly) { + prodLog.warn( + `${detail} This open is READ-ONLY, so the stamp cannot be re-derived: the entity-tree ` + + `family stays UNVERIFIED for this session (reads are unaffected โ€” the canonical tree ` + + `is the truth this stamp only describes). Cure: open the store with a writer, or run ` + + `brain.repairIndex() there, to recount from canonical and re-stamp.` + ) + return + } + + const startedAt = Date.now() + try { + await writeFamilyStamp(this.storage, ENTITY_TREE_STAMP_PATH, { + family: 'entity-tree', + sourceGeneration: head, + members: { + mode: 'rollup', + invariants: { nounCount: observed.nounCount, verbCount: observed.verbCount } + } + }) + prodLog.warn( + `${detail} DEMOTED: the unusable stamp was re-derived at committed generation ${head} ` + + `from the live counters in ${Date.now() - startedAt}ms โ€” terminal, not a wait. If the ` + + `observed counts above look wrong for your data, run brain.repairIndex() to recount ` + + `from canonical.` + ) + } catch (error) { + prodLog.warn( + `${detail} The demotion's re-stamp FAILED (${(error as Error).message}) โ€” the tear will ` + + `narrate again at the next open, which is the honest outcome; the store still serves ` + + `from canonical. Cure: run brain.repairIndex() to recount from canonical and re-stamp.` + ) + } + } + /** * Ask the writer process serving this data directory to flush its in-memory * indexes to disk, so a read-only inspector can observe fresh state. diff --git a/src/db/familyStamp.ts b/src/db/familyStamp.ts index 98342884..2f01e935 100644 --- a/src/db/familyStamp.ts +++ b/src/db/familyStamp.ts @@ -12,9 +12,11 @@ * the verified surface is a small set of rollup invariants (entity/ * relationship counts) plus `sourceGeneration`. * - * `sourceGeneration` is the generation of the source-of-truth log this - * projection reflects โ€” open-time coherence becomes a COMPARISON (stamp vs - * log head), not a walk: + * `sourceGeneration` is the COMMITTED generation of the source-of-truth log + * this projection reflects โ€” never the allocated counter, which names a + * generation that may never commit (see {@link StampVerdict.torn}) โ€” so + * open-time coherence becomes a COMPARISON (stamp vs committed head), not a + * walk: * * - equal + invariants hold โ†’ coherent, serve. * - behind โ†’ the projection missed the tail (crash between commit and stamp); @@ -24,6 +26,9 @@ * - invariants FAIL at equal generation โ†’ genuine incoherence: loud, and the * repair ritual (`repairIndex()`, whose recount rebuilds the rollups from a * canonical walk) heals it. + * - AHEAD โ†’ a torn generation-log tail: the stamp's fsync outlived the log + * tail's. TERMINAL, never a wait โ€” the generation the stamp names does not + * exist to arrive. * * Stamps are JSON on purpose โ€” every incident gets debugged by reading a * stamp in a terminal. @@ -70,6 +75,12 @@ export type StampVerdict = | { state: 'coherent' } | { state: 'absent' } // legacy store โ€” first stamp writes at the next flush | { state: 'behind'; stampSource: number; head: number } + /** + * TORN GENERATION-LOG TAIL: the stamp witnesses a source generation the + * store's committed watermark can no longer show. TERMINAL โ€” there is no + * generation to wait for, so the open demotes (or refuses) and never spins. + */ + | { state: 'torn'; stampSource: number; head: number } | { state: 'incoherent'; failures: string[] } | { state: 'unverifiable'; reason: string } // a FAULT reading the stamp โ€” never conflated with absence @@ -118,12 +129,15 @@ export function verifyFamilyStamp( ): StampVerdict { if (stamp === null) return { state: 'absent' } if (stamp.sourceGeneration > head) { - // A stamp AHEAD of the log claims state that never committed โ€” the - // projection was stamped against truth that a crash rolled back. - return { - state: 'incoherent', - failures: [`sourceGeneration ${stamp.sourceGeneration} is ahead of the log head ${head}`] - } + // A stamp AHEAD of committed truth witnesses a generation the store can no + // longer show: the stamp's fsync survived a crash that the log tail did + // not. This is the TORN GENERATION-LOG TAIL โ€” its own class, never folded + // in with `incoherent` (a count that drifted at a generation both sides + // agree on), because the two have opposite cures: incoherence is recounted, + // a tear is DEMOTED. It is also terminal by construction โ€” there is no + // generation the open can wait for, because the one the stamp names is + // gone. + return { state: 'torn', stampSource: stamp.sourceGeneration, head } } if (stamp.sourceGeneration < head) { return { state: 'behind', stampSource: stamp.sourceGeneration, head } diff --git a/tests/integration/entity-tree-stamp.test.ts b/tests/integration/entity-tree-stamp.test.ts index deefc5e6..23cc0a15 100644 --- a/tests/integration/entity-tree-stamp.test.ts +++ b/tests/integration/entity-tree-stamp.test.ts @@ -57,7 +57,11 @@ describe('entity-tree family stamp', () => { const invariants = (stamp.members as any).invariants expect(invariants.nounCount).toBe(await brain.storage.getNounCount()) expect(invariants.verbCount).toBe(await brain.storage.getVerbCount()) - expect(stamp.sourceGeneration).toBe(brain.generation()) + // THE SOURCE IS COMMITTED TRUTH, never the allocated counter. Stamping the + // counter labelled the stamp with a generation a write in flight had merely + // claimed, so every crash inside a write window produced a spurious verdict + // at the next open (see the torn-tail pins below). + expect(stamp.sourceGeneration).toBe(brain.generationStore.committedGeneration()) expect(stamp.generation).toBeGreaterThanOrEqual(1) }) @@ -112,6 +116,96 @@ describe('entity-tree family stamp', () => { expect(stillIncoherent).toEqual([]) }) + /** + * Rewrite the on-disk stamp so its `sourceGeneration` sits ABOVE the store's + * committed watermark โ€” the durable shape a torn generation-log tail leaves + * behind (the stamp's fsync outlived the tail's). Fabricated rather than + * crash-produced so the pin is deterministic; the seeded-SIGKILL lane + * (`scripts/crash-consistency.mjs` in the engine repo) produces the same + * shape from a real abrupt termination. + */ + const fabricateTear = (ahead: number): FamilyStamp => { + const file = path.join(dir, `${ENTITY_TREE_STAMP_PATH}.gz`) + const zlib = require('node:zlib') + const raw = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf-8')) as FamilyStamp + const torn: FamilyStamp = { ...raw, sourceGeneration: raw.sourceGeneration + ahead } + fs.writeFileSync(file, zlib.gzipSync(JSON.stringify(torn))) + return torn + } + + it('a torn generation-log tail is a TERMINAL VERDICT at open: narrated, demoted, never a wait', async () => { + for (let i = 0; i < 3; i++) + await brain.add({ data: `torn${i}`, type: 'document', metadata: { i } }) + await brain.close() + const torn = fabricateTear(5) + + const warn = vi.spyOn(prodLog, 'warn') + const startedAt = Date.now() + brain = await open() + const openMs = Date.now() - startedAt + + const tearLines = warn.mock.calls.filter((c) => String(c[0]).includes('TORN GENERATION-LOG TAIL')) + expect(tearLines.length).toBe(1) + const said = String(tearLines[0][0]) + // Narrated PRECISELY: both generations, the file, and the named cure. + expect(said).toContain(`source generation ${torn.sourceGeneration}`) + expect(said).toContain(`committed generation ${brain.generationStore.committedGeneration()}`) + expect(said).toContain(ENTITY_TREE_STAMP_PATH) + expect(said).toContain('DEMOTED') + expect(said).toMatch(/repairIndex\(\)/) + // Terminal, not a wait: the demotion is O(1) straight-line work, so a tear + // cannot turn an open into the 8-minute spin this class was reported as. + expect(openMs).toBeLessThan(30_000) + + // The store SERVES โ€” a tear in a stamp never locks an owner out of the + // canonical tree the stamp merely describes. + expect((await brain.find({ type: 'document', limit: 100 })).length).toBe(3) + + // The demotion CONVERGED: the stamp now names committed truth, and the + // next open is quiet. A verdict that re-narrates every open is a wait + // wearing a different hat. + const restamped = (await readFamilyStamp(brain.storage, ENTITY_TREE_STAMP_PATH)) as FamilyStamp + expect(restamped.sourceGeneration).toBe(brain.generationStore.committedGeneration()) + await brain.close() + const warn2 = vi.spyOn(prodLog, 'warn') + brain = await open() + expect(warn2.mock.calls.filter((c) => String(c[0]).includes('TORN'))).toEqual([]) + }) + + it('a READ-ONLY open on a torn tail refuses to guess: terminal verdict + named cure, no re-stamp', async () => { + await brain.add({ data: 'ro', type: 'document', metadata: {} }) + await brain.close() + const torn = fabricateTear(3) + + const warn = vi.spyOn(prodLog, 'warn') + const reader: any = await Brainy.openReadOnly({ + requireSubtype: false, + storage: { type: 'filesystem', path: dir }, + silent: true, + dimensions: 384 + }) + const tearLines = warn.mock.calls.filter((c) => String(c[0]).includes('TORN GENERATION-LOG TAIL')) + expect(tearLines.length).toBe(1) + const said = String(tearLines[0][0]) + expect(said).toContain('READ-ONLY') + expect(said).toContain('UNVERIFIED') + expect(said).toMatch(/repairIndex\(\)/) + await reader.close() + + // A reader never rewrites the store: read the bytes back off disk (not + // through a writer open, which would demote them) โ€” the torn stamp is + // exactly as it was found. + const onDisk = JSON.parse( + require('node:zlib') + .gunzipSync(fs.readFileSync(path.join(dir, `${ENTITY_TREE_STAMP_PATH}.gz`))) + .toString('utf-8') + ) as FamilyStamp + expect(onDisk.sourceGeneration).toBe(torn.sourceGeneration) + expect(onDisk.generation).toBe(torn.generation) + + brain = await open() + }) + it('the one verifier handles both member modes', () => { const rollup: FamilyStamp = { family: 'x', @@ -127,7 +221,13 @@ describe('entity-tree family stamp', () => { stampSource: 5, head: 9 }) - expect(verifyFamilyStamp(rollup, 3, { nounCount: 10 }).state).toBe('incoherent') // ahead of head + // AHEAD is its own class โ€” a torn generation-log tail, never folded in + // with `incoherent`: the two have opposite cures (recount vs demote). + expect(verifyFamilyStamp(rollup, 3, { nounCount: 10 })).toEqual({ + state: 'torn', + stampSource: 5, + head: 3 + }) expect(verifyFamilyStamp(null, 5, {})).toEqual({ state: 'absent' }) const enumerated: FamilyStamp = { From 9a888c37e9ebec5573cd7ebd0764396f3a424de3 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Mon, 31 Aug 2026 09:13:42 -0700 Subject: [PATCH 03/42] fix(generations): a sealed segment may only declare the generations it holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosis of the "packed history is damaged" narration that fires on every run of the affected stores. It is a WRITER defect, and the reader's refusal was the symptom rather than the cause. A sealed segment declares one contiguous range [firstGeneration, lastGeneration], and every reader treats that range as containment: coveringSegment is an interval test, hasGeneration returns true for anything inside it, and open() seeds committedRanges from it. repackHistory handed fold() a SPARSE batch. Three filters punch holes in its candidate list mid-run โ€” a generation absent from committedRanges never appears, one still in the pending buffer is skipped, one whose tx.json will not read is skipped โ€” and fold() then computed the range from the first and last survivor, claiming every generation in between. The next open merged that mis-declared range back into committedRanges, re-admitting the hole as committed history, so the following auto-compaction pass asked the packed tier for a frame that was never written and failed. Re-merged at every open, which is why it repeated on every run. Confirmed against a forensic fixture: generation directories 1..2503 present except exactly one, 1416; and its fact-log segment already showed the tell โ€” seg-...1410.bfl declaring 1410..1940 (531 generations) while recording 530 facts. Three changes: - repackHistory folds each contiguous RUN as its own segment (`contiguousRuns`), so ranges describe exactly what the segments contain. - fold() REFUSES a non-contiguous batch, naming the gap and its width. The density law is now mechanical, so no future caller can reintroduce it. A refusal loses nothing: the generations stay live and readable. - Stores already carrying the damage heal instead of wedging. A segment whose declared span exceeds its frame count is SPARSE; `actualRanges()` reads the real generation list from its sidecar so open() never re-admits the holes, and readFrame reports such a hole as unpacked with a narration naming the segment, rather than throwing. A DENSE segment missing a frame is still loud damage โ€” that one means the manifest and sidecar disagree. Pins: nine unit cases (refusal and its message, honest ranges for separately folded runs, a reconstructed pre-fix sparse segment serving its real frames while reporting holes as unpacked, holes excluded from actualRanges, and the dense-segment damage path still throwing) plus an end-to-end case that deletes a generation directory and drives the real sequence โ€” ordinary close()-time repacking folds over the hole, then reopen and compact must both complete. Verified red without the fix: the segment declared an 11-generation span while holding 10 frames. --- src/db/generationSegments.ts | 119 +++++++++++++++++++- src/db/generationStore.ts | 66 +++++++++-- tests/integration/history-repacking.test.ts | 102 +++++++++++++++++ tests/unit/db/generation-segments.test.ts | 115 +++++++++++++++++++ 4 files changed, 389 insertions(+), 13 deletions(-) diff --git a/src/db/generationSegments.ts b/src/db/generationSegments.ts index 0c14b60c..91451281 100644 --- a/src/db/generationSegments.ts +++ b/src/db/generationSegments.ts @@ -147,6 +147,60 @@ export class GenerationSegmentStore { return this.coveringSegment(gen) !== null } + /** + * @description True when `meta` declares more generations than it holds + * frames โ€” a segment sealed by a writer that folded across a hole. The + * manifest records `frames` at fold time, so this is an O(1) comparison + * against the declared span and needs no I/O. + */ + private isSparse(meta: SegmentMeta): boolean { + return meta.lastGeneration - meta.firstGeneration + 1 !== meta.frames + } + + /** + * @description The generations this tier ACTUALLY holds, as coalesced + * ascending intervals โ€” not what the segments declare. + * + * Dense segments (every one a current writer produces) contribute their + * declared range with no I/O. A SPARSE segment โ€” one sealed before the + * density law was enforced, whose declared range spans generations it has + * no frame for โ€” has its real generation list read from its sidecar and + * contributed instead, with the discrepancy narrated once. + * + * This is what keeps a store that already carries the damage from wedging. + * `open()` seeds `committedRanges` from these intervals, so a hole is never + * re-admitted as a committed generation, and the auto-compaction pass that + * used to fail on every run with "packed history is damaged" simply never + * asks for the missing frame. + * + * @returns Ascending, non-overlapping `[first, last]` intervals. + */ + async actualRanges(): Promise> { + const out: Array<[number, number]> = [] + for (const meta of this.manifest.segments) { + if (!this.isSparse(meta)) { + out.push([meta.firstGeneration, meta.lastGeneration]) + continue + } + const missing = meta.lastGeneration - meta.firstGeneration + 1 - meta.frames + prodLog.warn( + `[GenerationSegments] sealed segment ${meta.file} declares generations ` + + `${meta.firstGeneration}..${meta.lastGeneration} but holds only ${meta.frames} ` + + `frame(s) โ€” ${missing} generation(s) in that span were never folded into it. ` + + `Serving the frames it actually holds; the declared span is not treated as ` + + `committed history. (Written by a pre-density-law writer that folded across a ` + + `gap; the segment itself is intact and no record is lost.)` + ) + const idx = await this.sidecarFor(meta) + for (const [gen] of idx.generations) { + const last = out[out.length - 1] + if (last !== undefined && gen === last[1] + 1) last[1] = gen + else out.push([gen, gen]) + } + } + return out + } + /** * Fold consecutive generations into ONE new sealed segment + sidecar and * append it to the manifest atomically. Caller guarantees: `gens` is @@ -164,6 +218,38 @@ export class GenerationSegmentStore { throw new Error('[GenerationSegments] fold() input must be strictly ascending') } } + // THE DENSITY LAW, MADE MECHANICAL. + // + // A sealed segment declares a CONTIGUOUS range [firstGeneration, + // lastGeneration] and every reader treats that range as containment: + // `coveringSegment` is an interval test, `hasGeneration` returns true for + // anything inside it, and `open()` seeds committedRanges from it. So a + // segment folded from a SPARSE input silently claims generations it does + // not hold, and the first read of one of those holes throws + // "inside sealed segment ... but has no frame โ€” packed history is damaged". + // + // That is exactly how the damage was produced. `repackHistory` skipped + // generations mid-batch โ€” ones absent from committedRanges, ones still in + // the pending buffer, ones whose tx.json would not read โ€” and handed the + // survivors here, where the range was computed from the first and last of + // them. Worse, the mis-declared range was then merged back into + // committedRanges at the next open, which is what turned a quiet hole into + // a repeating auto-compaction failure on every subsequent run. + // + // Callers now split at discontinuities; this refusal is what keeps any + // future caller from reintroducing the class. A refusal here loses + // nothing โ€” the generations stay in the live tier, readable, and the next + // pass folds them correctly. + for (let i = 1; i < gens.length; i++) { + if (gens[i].generation !== gens[i - 1].generation + 1) { + throw new Error( + `[GenerationSegments] fold() input is not contiguous: ${gens[i - 1].generation} โ†’ ` + + `${gens[i].generation} skips ${gens[i].generation - gens[i - 1].generation - 1} ` + + `generation(s). A sealed segment declares a dense range, so folding a sparse ` + + `batch would claim generations it does not hold. Split the batch at the gap.` + ) + } + } const last = this.manifest.segments[this.manifest.segments.length - 1] if (last && gens[0].generation <= last.lastGeneration) { throw new Error( @@ -364,12 +450,37 @@ export class GenerationSegmentStore { return this.decodeFrame(payload) } } - // In the covering range but not present: the packed tier is dense by - // construction (fold packs every generation it is handed, including - // record-less ones) โ€” absence inside a sealed range is damage. + // Inside the covering range but with no frame. Two very different causes, + // and conflating them is what made this class wedge every maintenance pass + // on the affected stores. + // + // (1) A SPARSE SEGMENT โ€” the manifest's own `frames` count is smaller than + // the span it declares. That segment was sealed by a writer that + // folded across a hole (the class this file's density law now bars). + // The segment is INTACT and nothing is lost; it simply never held this + // generation. Answering "not packed" is the honest answer, and it lets + // the caller's two-tier read decide what a genuinely absent generation + // means, instead of every compaction pass dying on a repeating throw. + // `actualRanges()` keeps such holes out of committedRanges at open, so + // in a healed store nobody asks this question in the first place. + // + // (2) A DENSE SEGMENT missing a frame it says it has โ€” the manifest and + // the sidecar disagree about a segment that claims to be complete. + // That IS damage, and it stays loud. + if (this.isSparse(meta)) { + prodLog.warn( + `[GenerationSegments] generation ${gen} falls inside sealed segment ${meta.file}'s ` + + `declared range ${meta.firstGeneration}..${meta.lastGeneration}, but that segment ` + + `holds ${meta.frames} frame(s) for a ${meta.lastGeneration - meta.firstGeneration + 1}` + + `-generation span โ€” it was sealed across a gap and never held this generation. ` + + `Reporting it as unpacked rather than as damage; no record is lost.` + ) + return null + } throw new Error( `[GenerationSegments] generation ${gen} is inside sealed segment ${meta.file}'s declared ` + - `range but has no frame โ€” packed history is damaged` + `range but has no frame, and that segment declares a complete ${meta.frames}-frame ` + + `span โ€” the manifest and the sidecar disagree; packed history is damaged` ) } diff --git a/src/db/generationStore.ts b/src/db/generationStore.ts index fd052c31..da21dc61 100644 --- a/src/db/generationStore.ts +++ b/src/db/generationStore.ts @@ -96,6 +96,35 @@ export const FOLD_CHECKPOINT_PATH = '_system/fold-checkpoint.json' /** Storage-root-relative prefix of the per-generation record directories. */ export const GENERATIONS_PREFIX = '_generations' +/** + * @description Split an ascending list of fold candidates into maximal + * CONTIGUOUS runs โ€” `[7,8,9,12,13]` becomes `[[7,8,9],[12,13]]`. + * + * A sealed segment declares one dense range `[firstGeneration, + * lastGeneration]`, and every reader treats that range as containment. So a + * batch with a hole in it must never become one segment: it would claim a + * generation it does not hold, and the first read of that hole reports the + * packed history as damaged. One run, one segment โ€” the ranges then describe + * exactly what the segments contain. + * + * @param gens - Fold candidates, strictly ascending by generation. + * @returns One array per contiguous run, in ascending order. Empty in, empty out. + */ +export function contiguousRuns(gens: FoldGeneration[]): FoldGeneration[][] { + const runs: FoldGeneration[][] = [] + let run: FoldGeneration[] = [] + for (const g of gens) { + const prev = run[run.length - 1] + if (prev !== undefined && g.generation !== prev.generation + 1) { + runs.push(run) + run = [] + } + run.push(g) + } + if (run.length > 0) runs.push(run) + return runs +} + /** * @description Phases of the {@link GenerationStore.commitTransaction} commit * protocol at which a test-only fault injector can simulate a process crash. @@ -784,9 +813,15 @@ export class GenerationStore { if (storageSupportsFactLog(this.storage)) { this.segments = new GenerationSegmentStore(this.storage) await this.segments.open() - const packedRanges = this.segments - .segments() - .map((s): [number, number] => [s.firstGeneration, Math.min(s.lastGeneration, this.committed)]) + // ACTUAL ranges, not declared ones. A segment sealed by a pre-density-law + // writer can declare a span wider than the frames it holds; seeding + // committedRanges from the declared span re-admits those holes as + // committed generations, and every later maintenance pass then asks for a + // frame that was never written. `actualRanges()` reads the real + // generation list from the sidecar for exactly those segments (and does + // no I/O for the dense ones, which is all of them on a healthy store). + const packedRanges = (await this.segments.actualRanges()) + .map((r): [number, number] => [r[0], Math.min(r[1], this.committed)]) .filter(([lo, hi]) => lo <= hi) if (packedRanges.length > 0) { // Merge packed (older) + live (newer) interval sets โ€” both ascending; @@ -3121,13 +3156,26 @@ export class GenerationStore { foldInput.push({ generation: gen, timestamp: delta.timestamp, delta, records }) } if (foldInput.length === 0) continue - await segments.fold(foldInput) - segmentsCreated++ - // Segment + manifest durable โ†’ the live copies retire. - for (const g of foldInput) { - await this.storage.removeRawPrefix(`${GENERATIONS_PREFIX}/${g.generation}`) + // SPLIT AT DISCONTINUITIES. `eligible` is NOT contiguous โ€” three + // filters above punch holes in it: a generation missing from + // committedRanges never appears, one still in the pending buffer is + // skipped, and one whose tx.json will not read is skipped. A sealed + // segment declares a DENSE range, so folding across such a hole makes + // the segment claim a generation it does not hold; the next open + // merges that mis-declared range into committedRanges, and every + // subsequent auto-compaction pass then asks for the missing frame and + // fails with "packed history is damaged". Fold each contiguous RUN as + // its own segment instead โ€” same bytes, honest ranges. + for (const run of contiguousRuns(foldInput)) { + if (deadline !== undefined && Date.now() >= deadline) break + await segments.fold(run) + segmentsCreated++ + // Segment + manifest durable โ†’ the live copies retire. + for (const g of run) { + await this.storage.removeRawPrefix(`${GENERATIONS_PREFIX}/${g.generation}`) + } + folded += run.length } - folded += foldInput.length } if (folded > 0) { prodLog.info( diff --git a/tests/integration/history-repacking.test.ts b/tests/integration/history-repacking.test.ts index 2bcee038..bb07268d 100644 --- a/tests/integration/history-repacking.test.ts +++ b/tests/integration/history-repacking.test.ts @@ -16,6 +16,7 @@ import { describe, it, expect, afterEach } from 'vitest' import * as fs from 'node:fs' import * as path from 'node:path' import * as os from 'node:os' +import * as zlib from 'node:zlib' import { Brainy } from '../../src/brainy.js' import { NounType } from '../../src/types/graphTypes.js' import { GenerationStore } from '../../src/db/generationStore.js' @@ -57,6 +58,107 @@ describe('history repacking โ€” the two-tier lifecycle', () => { } }) + /** + * THE HOLE, END TO END โ€” the shape a real store carries. + * + * A forensic fixture was measured with generation directories 1..2503 + * present except for exactly one: 1416. Its fact-log segment already showed + * the tell โ€” `seg-...1410.bfl` declaring firstGeneration 1410, lastGeneration + * 1940 (531 generations) while recording only 530 facts. + * + * Before the fix, repacking such a store folded ACROSS that hole: the batch + * skipped 1416 (no readable delta) and the sealed segment declared a range + * spanning it anyway. The next open merged that declared range back into + * committedRanges, re-admitting 1416 as committed history, and every + * subsequent auto-compaction pass then asked the packed tier for a frame + * that was never written โ€” producing, on EVERY run, the non-fatal narration + * + * Auto-compaction of generational history failed (non-fatal): generation + * N is inside sealed segment seg-....bgs's declared range but has no frame + * โ€” packed history is damaged + * + * This pin removes a generation directory to make the same hole, then + * requires repack + reopen + compaction to complete cleanly. + */ + it('a missing generation directory does not poison the packed tier', async () => { + const dir = tempDir() + // `retention: 'all'` throughout: close() otherwise auto-compacts the + // history away, and this pin needs the cold generations still on disk so + // there is something to punch a hole in. The live window stays at its + // production default for the build phase, so nothing folds yet. + const archival = async (): Promise => { + const b = new Brainy({ + requireSubtype: false, + storage: { type: 'filesystem', path: dir }, + embeddingFunction: stub, + retention: 'all' + }) + await b.init() + return b + } + const brain = await archival() + + const id = await brain.add({ + data: 'holed-entity', + type: NounType.Document, + metadata: { v: 0 } + }) + // One flush per update: single-op writes coalesce inside a flush window, + // so a history deep enough to have a middle needs the windows separated. + for (let v = 1; v <= 12; v++) { + await brain.update({ id, metadata: { v } }) + await brain.flush() + } + await brain.close() + + // Punch the hole: delete ONE generation directory in the middle of the + // cold range, exactly as the real store presents it. + const genRoot = path.join(dir, '_generations') + const numeric = fs + .readdirSync(genRoot, { withFileTypes: true }) + .filter((e) => e.isDirectory() && /^\d+$/.test(e.name)) + .map((e) => Number(e.name)) + .sort((a, b) => a - b) + expect(numeric.length).toBeGreaterThan(6) + const victim = numeric[Math.floor(numeric.length / 2)] + fs.rmSync(path.join(genRoot, String(victim)), { recursive: true, force: true }) + + // Now shrink the live window and reopen. close() repacks automatically + // (brainy.ts phase 0b), so this is the production sequence exactly: a + // store with a hole in its history gets folded by ordinary housekeeping, + // with nobody asking for it. + ;(GenerationStore as any).REPACK_LIVE_WINDOW = 3 + const reopened = await archival() + const result = await reopened.repackHistory() + expect(result.foldedGenerations).toBeGreaterThan(0) + + const segDir = path.join(dir, SEGMENTS_PREFIX) + const manifestPath = ['manifest.json', 'manifest.json.gz'] + .map((f) => path.join(segDir, f)) + .find((p) => fs.existsSync(p))! + const raw = manifestPath.endsWith('.gz') + ? zlib.gunzipSync(fs.readFileSync(manifestPath)).toString('utf8') + : fs.readFileSync(manifestPath, 'utf8') + const manifest = JSON.parse(raw) as { + segments: Array<{ firstGeneration: number; lastGeneration: number; frames: number }> + } + + // THE LAW: every sealed segment declares exactly as many generations as it + // holds frames, and none of them spans the victim. + for (const s of manifest.segments) { + expect(s.lastGeneration - s.firstGeneration + 1).toBe(s.frames) + expect(victim >= s.firstGeneration && victim <= s.lastGeneration).toBe(false) + } + + await reopened.close() + + // And the pass that used to fail on every run now completes: reopen (which + // re-seeds committedRanges from the packed tier) then compact history. + const third = await openBrain(dir) + await expect(third.compactHistory({ maxGenerations: 2 })).resolves.toBeDefined() + await third.close() + }) + it('repack preserves every historical read across cold reopen; folded dirs are gone', async () => { ;(GenerationStore as any).REPACK_LIVE_WINDOW = 3 const dir = tempDir() diff --git a/tests/unit/db/generation-segments.test.ts b/tests/unit/db/generation-segments.test.ts index 27ab85cb..f16e67b3 100644 --- a/tests/unit/db/generation-segments.test.ts +++ b/tests/unit/db/generation-segments.test.ts @@ -147,4 +147,119 @@ describe('db/GenerationSegmentStore โ€” the D1+D3 packed tier', () => { await expect(store.fold([gen(4), gen(4)])).rejects.toThrow(/strictly ascending/) await expect(store.fold([])).rejects.toThrow(/at least one generation/) }) + + // ========================================================================== + // THE DENSITY LAW + // ========================================================================== + // + // A sealed segment declares a CONTIGUOUS range and every reader treats that + // range as containment. Folding a sparse batch therefore makes the segment + // claim generations it does not hold โ€” and because `open()` merges declared + // ranges back into committedRanges, the hole is re-admitted as committed + // history and every later maintenance pass fails asking for a frame that was + // never written. That is the "generation N is inside sealed segment + // seg-....bgs's declared range but has no frame โ€” packed history is damaged" + // narration seen on every run of the affected stores. + + it('fold REFUSES a batch with a hole โ€” a dense range may not be declared over sparse input', async () => { + await expect(store.fold([gen(1), gen(2), gen(4)])).rejects.toThrow( + /not contiguous: 2 โ†’ 4 skips 1 generation/ + ) + // The refusal loses nothing: no segment was sealed, so the generations + // stay in the live tier and the next pass folds them correctly. + expect(store.segments()).toHaveLength(0) + expect(store.hasGeneration(1)).toBe(false) + }) + + it('a wider gap names how many generations it would have swallowed', async () => { + await expect(store.fold([gen(10), gen(20)])).rejects.toThrow( + /not contiguous: 10 โ†’ 20 skips 9 generation\(s\)/ + ) + }) + + it('two contiguous runs folded separately declare honest ranges', async () => { + // What the caller now does instead of folding across the gap. + const a = await store.fold([gen(1), gen(2), gen(3)]) + const b = await store.fold([gen(7), gen(8)]) + expect(a).toMatchObject({ firstGeneration: 1, lastGeneration: 3, frames: 3 }) + expect(b).toMatchObject({ firstGeneration: 7, lastGeneration: 8, frames: 2 }) + // The gap is honestly outside the packed tier. + for (const g of [4, 5, 6]) expect(store.hasGeneration(g)).toBe(false) + for (const g of [1, 2, 3, 7, 8]) expect(store.hasGeneration(g)).toBe(true) + expect(await store.actualRanges()).toEqual([ + [1, 3], + [7, 8] + ]) + }) + + it('actualRanges() is exact and I/O-free for dense segments', async () => { + await store.fold([gen(1), gen(2)]) + await store.fold([gen(3), gen(4)]) + // Adjacent dense segments each contribute their declared range. + expect(await store.actualRanges()).toEqual([ + [1, 2], + [3, 4] + ]) + }) + + // ---- pre-existing damage: a store sealed by the old writer ---------------- + + /** + * Seal a SPARSE segment the way the pre-fix writer did: write the bytes and + * sidecar for a contiguous run, then rewrite the manifest so the segment + * declares a wider range than the frames it holds. This reproduces on disk + * exactly what the affected stores carry, without needing the old code. + */ + const sealSparseSegment = async (): Promise => { + await store.fold([gen(1), gen(2), gen(3)]) + const manifest = (await storage.readRawObject(`${SEGMENTS_PREFIX}/manifest.json`)) as any + // Declare 1..5 while holding frames for 1..3 โ€” generations 4 and 5 become + // holes inside a sealed range. + manifest.segments[0].lastGeneration = 5 + await storage.writeRawObject(`${SEGMENTS_PREFIX}/manifest.json`, manifest) + } + + it('a pre-existing sparse segment reports its holes as UNPACKED, not as damage', async () => { + await sealSparseSegment() + const reopened = new GenerationSegmentStore(storage as any) + await reopened.open() + + // The frames it really holds still serve, byte-faithfully. + expect((await reopened.readDelta(2))?.timestamp).toBe(1_700_000_000_002) + expect(await reopened.readRecords(3)).toHaveLength(2) + + // The holes answer "not packed" instead of throwing. This is the fix for + // the wedge: the old reader threw here on EVERY maintenance pass. + expect(await reopened.readDelta(4)).toBeNull() + expect(await reopened.readRecords(5)).toBeNull() + }) + + it('actualRanges() excludes the holes so they are never re-admitted as committed', async () => { + await sealSparseSegment() + const reopened = new GenerationSegmentStore(storage as any) + await reopened.open() + // Declared 1..5; actually holds 1..3. The store seeds committedRanges from + // THIS, so generations 4 and 5 never become committed history again. + expect(await reopened.actualRanges()).toEqual([[1, 3]]) + }) + + it('a DENSE segment missing a frame is still loud damage', async () => { + // The other side of the branch: when the manifest claims a complete span, + // a missing frame means the manifest and sidecar disagree โ€” real damage, + // and it must not be quietly downgraded to "unpacked". + await store.fold([gen(1), gen(2), gen(3)]) + const idxPath = `${SEGMENTS_PREFIX}/seg-${String(1).padStart(20, '0')}.idx` + const raw = (await storage.readRawBytes(idxPath))! + const { decode, encode } = await import('@msgpack/msgpack') + const idx = decode(raw) as any + // Drop generation 2's entry while the manifest still declares 3 frames. + idx.generations = idx.generations.filter(([g]: [number]) => g !== 2) + await storage.writeRawBytes(idxPath, encode(idx)) + + const reopened = new GenerationSegmentStore(storage as any) + await reopened.open() + await expect(reopened.readDelta(2)).rejects.toThrow( + /manifest and the sidecar disagree; packed history is damaged/ + ) + }) }) From 655aa13ea79e23927cde7fd47ab13b505cb042d9 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Mon, 31 Aug 2026 09:30:46 -0700 Subject: [PATCH 04/42] =?UTF-8?q?build(release):=20the=20docs-push=20step?= =?UTF-8?q?=20retires=20=E2=80=94=20this=20engine=20documents=20itself=20i?= =?UTF-8?q?n=20its=20own=20repository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-doc-set ruling (2026-08-31) gives soulcraft.com/docs to the paid product alone; the site serves redirects for the slugs this rail used to push. The push script stays in the tree as history; the rail stops calling it. --- scripts/release.sh | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 67ad1053..5d434320 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -248,17 +248,12 @@ else echo -e "${RED}โš ๏ธ FORGEJO_RELEASE_TOKEN unset โ€” no release page created; tag + CHANGELOG remain the record${NC}\n" fi -# Step 12: Push public docs to the soulcraft.com docs ingest door -# (VENUE-DOCS-RELEASE-PUSH). Skips with a loud warning when -# DOCS_INGEST_SECRET is unset; fails loudly (without undoing the publish โ€” -# that already happened) when a push errors, so the docs site never -# silently trails npm. -echo -e "${BLUE}1๏ธโƒฃ2๏ธโƒฃ Pushing public docs to soulcraft.com/docs...${NC}" -if node scripts/push-docs.js; then - echo -e "${GREEN}โœ… Docs push step done${NC}\n" -else - echo -e "${RED}โŒ Docs push FAILED โ€” soulcraft.com/docs trails npm until re-run or interim sync${NC}\n" -fi +# Step 12 RETIRED (2026-08-31, CORTEX-SITE-BRAINY-RENAME round 12, David-ruled): +# soulcraft.com/docs carries the paid product's documentation only. This +# engine's documentation home is THIS repository โ€” README and docs/ โ€” and the +# site serves 301s for the slugs this rail used to push. The push script stays +# in the tree for history; the rail no longer calls it. +echo -e "${BLUE}Docs step: this engine documents itself in its own repo (site push retired 2026-08-31)${NC}" echo -e "${GREEN}โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”${NC}" echo -e "${GREEN}๐ŸŽ‰ Release ${NEW_VERSION} complete!${NC}" From c99308710aa5030d80bef794354216851443916c Mon Sep 17 00:00:00 2001 From: David Snelling Date: Mon, 31 Aug 2026 09:07:18 -0700 Subject: [PATCH 05/42] fix(recovery): a torn generation-log tail is a terminal verdict, never a wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of one defect, found by a seeded-SIGKILL crash lane. THE FALSE POSITIVE. stampEntityTree() recorded generationStore.generation() โ€” the ALLOCATED counter, a number a write in flight has claimed and may never commit โ€” while the JSDoc beside it already said the source is the committed generation. Every crash inside a write window therefore produced a spurious verdict at the next open: either 'sourceGeneration N is ahead of the log head N-1' (the allocated generation died with the process) or 'rollup invariant nounCount: stamped X, observed Y' (the recovery fold folded facts the stamp's counts predate). Both told the operator to run repairIndex() โ€” a whole-store recount โ€” for a store that was coherent. Measured before this commit: 4 of 11 SIGKILL cycles on a healthy store raised one of the two. The stamp and the open now both read committedGeneration(), which is what every other open-time watermark in the class already reasons about. THE TERMINAL VERDICT. A stamp still ahead of committed truth after the recovery fold witnesses a generation that is not in the log โ€” the stamp's fsync outlived the tail's, and there is nothing to arrive. That is its own verdict state now ('torn'), never folded in with 'incoherent': the two have opposite cures. A writer open demotes it โ€” the unusable stamped surface is re-derived at the committed generation from the live counters, O(1), straight-line, no loop and no await on external progress, narrated with both count sets, the stamp's path and its committedAt. A read-only open cannot re-stamp, so it says so and names the cure instead of guessing, and still serves. Neither branch waits, and neither locks an owner out of a canonical tree the stamp only describes. Pins: the verifier returns the torn verdict with both generations; a fabricated head-behind-source store narrates precisely, demotes inside a bounded open, serves its rows, and is quiet at the next open (the demotion converges); a read-only open narrates the same verdict and leaves the bytes untouched. (cherry picked from commit 298cb6dacaac9ef80db65a723d65cbd53c15d23e) --- src/brainy.ts | 121 +++++++++++++++++++- src/db/familyStamp.ts | 32 ++++-- tests/integration/entity-tree-stamp.test.ts | 104 ++++++++++++++++- 3 files changed, 241 insertions(+), 16 deletions(-) diff --git a/src/brainy.ts b/src/brainy.ts index 70d46973..da04577e 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -12497,6 +12497,18 @@ export class Brainy implements BrainyInterface { * healed by `repairIndex()`, whose unconditional recount rebuilds the * rollups from a canonical walk and re-stamps. Best-effort: a stamp-write * fault warns loudly but never fails the flush that carried real data. + * + * THE SOURCE IS `committedGeneration()`, NEVER `generation()`. The latter is + * the ALLOCATED counter โ€” a number a write in flight has claimed and may + * never commit. Stamping it made the stamp's generation label a claim about + * counts it was not taken at, and every crash inside a write window then + * produced a spurious verdict at the next open: either `sourceGeneration N + * is ahead of the log head N-1` (the allocated generation died with the + * process) or `rollup invariant 'nounCount': stamped X, observed Y` (the + * recovery fold folded facts the stamp's counts predate). MEASURED on the + * crash-consistency lane before this line changed: 4 of 11 SIGKILL cycles on + * a coherent store raised one of those two verdicts, each of them naming + * `repairIndex()` โ€” a whole-store recount โ€” as the cure for nothing. */ private async stampEntityTree(): Promise { if (this.isReadOnly) return @@ -12507,7 +12519,7 @@ export class Brainy implements BrainyInterface { ]) await writeFamilyStamp(this.storage, ENTITY_TREE_STAMP_PATH, { family: 'entity-tree', - sourceGeneration: this.generationStore.generation(), + sourceGeneration: this.generationStore.committedGeneration(), members: { mode: 'rollup', invariants: { nounCount, verbCount } } }) } catch (error) { @@ -12520,16 +12532,24 @@ export class Brainy implements BrainyInterface { /** * @description Open-time coherence check for the entity tree's family stamp: - * compare `sourceGeneration` against the log head and the stamped rollup - * invariants against the live counters. Verdicts: + * compare `sourceGeneration` against the store's COMMITTED generation and + * the stamped rollup invariants against the live counters. Verdicts: * - `coherent` / `absent` (legacy store; first flush stamps) โ†’ silent. * - `behind` โ†’ benign for the tree (it is written BY the commit; only the * stamp is stale โ€” a crash landed between commit and flush). Refreshed at * the next flush. + * - `torn` โ†’ a TORN GENERATION-LOG TAIL, handled by + * {@link demoteTornEntityTreeStamp}: terminal, never a wait. * - `incoherent` โ†’ LOUD: the tree or its counters diverged from what was * stamped โ€” `repairIndex()` recounts from canonical and re-stamps. * Never blocks open; a fault reading the stamp is surfaced as unverifiable, * never conflated with absence. + * + * THE COMPARISON IS AGAINST `committedGeneration()`, matching what + * {@link stampEntityTree} writes and what every other open-time watermark in + * this class already reasons about (the fact-scan capability, the metadata / + * graph / HNSW watermark verdicts). Comparing against the allocated counter + * was the one place that disagreed, and disagreeing was the whole defect. */ private async verifyEntityTreeStamp(): Promise { let stamp: FamilyStamp | null @@ -12546,11 +12566,16 @@ export class Brainy implements BrainyInterface { this.storage.getNounCount(), this.storage.getVerbCount() ]) - const verdict = verifyFamilyStamp(stamp, this.generationStore.generation(), { + const verdict = verifyFamilyStamp(stamp, this.generationStore.committedGeneration(), { nounCount, verbCount }) - if (verdict.state === 'incoherent') { + if (verdict.state === 'torn') { + await this.demoteTornEntityTreeStamp(stamp as FamilyStamp, verdict.stampSource, verdict.head, { + nounCount, + verbCount + }) + } else if (verdict.state === 'incoherent') { prodLog.warn( `[Brainy] entity-tree stamp INCOHERENT at open: ${verdict.failures.join('; ')}. ` + `The canonical tree or its counters diverged from the stamped state โ€” run ` + @@ -12564,6 +12589,92 @@ export class Brainy implements BrainyInterface { } } + /** + * @description THE TERMINAL VERDICT for a torn generation-log tail. + * + * A stamp whose `sourceGeneration` sits ABOVE the store's committed + * watermark witnesses a generation that is not in the log: the stamp's fsync + * outlived the tail's. By the time this runs, log-authority recovery has + * already folded every intact fact above the manifest and advanced the + * watermark to cover them โ€” so if the stamp is STILL ahead, the generation + * it names is not merely late, it is GONE. There is nothing to wait for. + * + * That is the whole point of this method. A field report of this class + * (single-process store, abrupt termination mid-fold) described a reopen + * that narrated the tear and then held 100% CPU with zero log growth for + * eight minutes before an operator wiped the directory. A recovery that + * cannot say what it is waiting for has no business spinning; the honest + * answer here is a verdict, taken now, at O(1) cost. + * + * WHAT THE VERDICT DOES โ€” the stamped surface is UNUSABLE, so it is + * discarded rather than believed: the stamped counts describe a generation + * that never became durable, and comparing them against live counters can + * only produce noise. The tree itself is not in question (it IS canonical โ€” + * every commit writes it, and the fold re-applied every after-image the log + * still holds), so the demotion is a re-derivation of this family's verified + * surface at the generation the store can actually show: + * + * - WRITER open โ†’ re-stamp at `committedGeneration()` from the live + * counters โ€” exactly what the next flush would write, taken now so the + * tear cannot re-narrate on every subsequent open. Both count sets are + * logged so an operator can see whether anything really moved. + * - READER open โ†’ a reader cannot re-stamp. Narrate the same terminal + * verdict with the named cure and carry on serving; a read-only inspector + * is never locked out of a store, and never left waiting either. + * + * BOUNDEDNESS: straight-line code. No loop, no retry, no await on any + * external progress signal โ€” the two counter reads and one stamp write are + * the entire cost, and none of them scales with the store. + */ + private async demoteTornEntityTreeStamp( + stamp: FamilyStamp, + stampSource: number, + head: number, + observed: { nounCount: number; verbCount: number } + ): Promise { + const stamped = stamp.members.mode === 'rollup' ? stamp.members.invariants : {} + const detail = + `[Brainy] TORN GENERATION-LOG TAIL at open: ${ENTITY_TREE_STAMP_PATH} witnesses source ` + + `generation ${stampSource} (stamped ${stamp.committedAt}), but the store's committed ` + + `generation is ${head} after crash recovery โ€” the stamp's fsync outlived the log tail's, ` + + `and generation ${stampSource} is not in the log to arrive. Stamped rollups ` + + `${JSON.stringify(stamped)}; observed ${JSON.stringify(observed)}.` + + if (this.isReadOnly) { + prodLog.warn( + `${detail} This open is READ-ONLY, so the stamp cannot be re-derived: the entity-tree ` + + `family stays UNVERIFIED for this session (reads are unaffected โ€” the canonical tree ` + + `is the truth this stamp only describes). Cure: open the store with a writer, or run ` + + `brain.repairIndex() there, to recount from canonical and re-stamp.` + ) + return + } + + const startedAt = Date.now() + try { + await writeFamilyStamp(this.storage, ENTITY_TREE_STAMP_PATH, { + family: 'entity-tree', + sourceGeneration: head, + members: { + mode: 'rollup', + invariants: { nounCount: observed.nounCount, verbCount: observed.verbCount } + } + }) + prodLog.warn( + `${detail} DEMOTED: the unusable stamp was re-derived at committed generation ${head} ` + + `from the live counters in ${Date.now() - startedAt}ms โ€” terminal, not a wait. If the ` + + `observed counts above look wrong for your data, run brain.repairIndex() to recount ` + + `from canonical.` + ) + } catch (error) { + prodLog.warn( + `${detail} The demotion's re-stamp FAILED (${(error as Error).message}) โ€” the tear will ` + + `narrate again at the next open, which is the honest outcome; the store still serves ` + + `from canonical. Cure: run brain.repairIndex() to recount from canonical and re-stamp.` + ) + } + } + /** * Ask the writer process serving this data directory to flush its in-memory * indexes to disk, so a read-only inspector can observe fresh state. diff --git a/src/db/familyStamp.ts b/src/db/familyStamp.ts index 98342884..2f01e935 100644 --- a/src/db/familyStamp.ts +++ b/src/db/familyStamp.ts @@ -12,9 +12,11 @@ * the verified surface is a small set of rollup invariants (entity/ * relationship counts) plus `sourceGeneration`. * - * `sourceGeneration` is the generation of the source-of-truth log this - * projection reflects โ€” open-time coherence becomes a COMPARISON (stamp vs - * log head), not a walk: + * `sourceGeneration` is the COMMITTED generation of the source-of-truth log + * this projection reflects โ€” never the allocated counter, which names a + * generation that may never commit (see {@link StampVerdict.torn}) โ€” so + * open-time coherence becomes a COMPARISON (stamp vs committed head), not a + * walk: * * - equal + invariants hold โ†’ coherent, serve. * - behind โ†’ the projection missed the tail (crash between commit and stamp); @@ -24,6 +26,9 @@ * - invariants FAIL at equal generation โ†’ genuine incoherence: loud, and the * repair ritual (`repairIndex()`, whose recount rebuilds the rollups from a * canonical walk) heals it. + * - AHEAD โ†’ a torn generation-log tail: the stamp's fsync outlived the log + * tail's. TERMINAL, never a wait โ€” the generation the stamp names does not + * exist to arrive. * * Stamps are JSON on purpose โ€” every incident gets debugged by reading a * stamp in a terminal. @@ -70,6 +75,12 @@ export type StampVerdict = | { state: 'coherent' } | { state: 'absent' } // legacy store โ€” first stamp writes at the next flush | { state: 'behind'; stampSource: number; head: number } + /** + * TORN GENERATION-LOG TAIL: the stamp witnesses a source generation the + * store's committed watermark can no longer show. TERMINAL โ€” there is no + * generation to wait for, so the open demotes (or refuses) and never spins. + */ + | { state: 'torn'; stampSource: number; head: number } | { state: 'incoherent'; failures: string[] } | { state: 'unverifiable'; reason: string } // a FAULT reading the stamp โ€” never conflated with absence @@ -118,12 +129,15 @@ export function verifyFamilyStamp( ): StampVerdict { if (stamp === null) return { state: 'absent' } if (stamp.sourceGeneration > head) { - // A stamp AHEAD of the log claims state that never committed โ€” the - // projection was stamped against truth that a crash rolled back. - return { - state: 'incoherent', - failures: [`sourceGeneration ${stamp.sourceGeneration} is ahead of the log head ${head}`] - } + // A stamp AHEAD of committed truth witnesses a generation the store can no + // longer show: the stamp's fsync survived a crash that the log tail did + // not. This is the TORN GENERATION-LOG TAIL โ€” its own class, never folded + // in with `incoherent` (a count that drifted at a generation both sides + // agree on), because the two have opposite cures: incoherence is recounted, + // a tear is DEMOTED. It is also terminal by construction โ€” there is no + // generation the open can wait for, because the one the stamp names is + // gone. + return { state: 'torn', stampSource: stamp.sourceGeneration, head } } if (stamp.sourceGeneration < head) { return { state: 'behind', stampSource: stamp.sourceGeneration, head } diff --git a/tests/integration/entity-tree-stamp.test.ts b/tests/integration/entity-tree-stamp.test.ts index deefc5e6..23cc0a15 100644 --- a/tests/integration/entity-tree-stamp.test.ts +++ b/tests/integration/entity-tree-stamp.test.ts @@ -57,7 +57,11 @@ describe('entity-tree family stamp', () => { const invariants = (stamp.members as any).invariants expect(invariants.nounCount).toBe(await brain.storage.getNounCount()) expect(invariants.verbCount).toBe(await brain.storage.getVerbCount()) - expect(stamp.sourceGeneration).toBe(brain.generation()) + // THE SOURCE IS COMMITTED TRUTH, never the allocated counter. Stamping the + // counter labelled the stamp with a generation a write in flight had merely + // claimed, so every crash inside a write window produced a spurious verdict + // at the next open (see the torn-tail pins below). + expect(stamp.sourceGeneration).toBe(brain.generationStore.committedGeneration()) expect(stamp.generation).toBeGreaterThanOrEqual(1) }) @@ -112,6 +116,96 @@ describe('entity-tree family stamp', () => { expect(stillIncoherent).toEqual([]) }) + /** + * Rewrite the on-disk stamp so its `sourceGeneration` sits ABOVE the store's + * committed watermark โ€” the durable shape a torn generation-log tail leaves + * behind (the stamp's fsync outlived the tail's). Fabricated rather than + * crash-produced so the pin is deterministic; the seeded-SIGKILL lane + * (`scripts/crash-consistency.mjs` in the engine repo) produces the same + * shape from a real abrupt termination. + */ + const fabricateTear = (ahead: number): FamilyStamp => { + const file = path.join(dir, `${ENTITY_TREE_STAMP_PATH}.gz`) + const zlib = require('node:zlib') + const raw = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf-8')) as FamilyStamp + const torn: FamilyStamp = { ...raw, sourceGeneration: raw.sourceGeneration + ahead } + fs.writeFileSync(file, zlib.gzipSync(JSON.stringify(torn))) + return torn + } + + it('a torn generation-log tail is a TERMINAL VERDICT at open: narrated, demoted, never a wait', async () => { + for (let i = 0; i < 3; i++) + await brain.add({ data: `torn${i}`, type: 'document', metadata: { i } }) + await brain.close() + const torn = fabricateTear(5) + + const warn = vi.spyOn(prodLog, 'warn') + const startedAt = Date.now() + brain = await open() + const openMs = Date.now() - startedAt + + const tearLines = warn.mock.calls.filter((c) => String(c[0]).includes('TORN GENERATION-LOG TAIL')) + expect(tearLines.length).toBe(1) + const said = String(tearLines[0][0]) + // Narrated PRECISELY: both generations, the file, and the named cure. + expect(said).toContain(`source generation ${torn.sourceGeneration}`) + expect(said).toContain(`committed generation ${brain.generationStore.committedGeneration()}`) + expect(said).toContain(ENTITY_TREE_STAMP_PATH) + expect(said).toContain('DEMOTED') + expect(said).toMatch(/repairIndex\(\)/) + // Terminal, not a wait: the demotion is O(1) straight-line work, so a tear + // cannot turn an open into the 8-minute spin this class was reported as. + expect(openMs).toBeLessThan(30_000) + + // The store SERVES โ€” a tear in a stamp never locks an owner out of the + // canonical tree the stamp merely describes. + expect((await brain.find({ type: 'document', limit: 100 })).length).toBe(3) + + // The demotion CONVERGED: the stamp now names committed truth, and the + // next open is quiet. A verdict that re-narrates every open is a wait + // wearing a different hat. + const restamped = (await readFamilyStamp(brain.storage, ENTITY_TREE_STAMP_PATH)) as FamilyStamp + expect(restamped.sourceGeneration).toBe(brain.generationStore.committedGeneration()) + await brain.close() + const warn2 = vi.spyOn(prodLog, 'warn') + brain = await open() + expect(warn2.mock.calls.filter((c) => String(c[0]).includes('TORN'))).toEqual([]) + }) + + it('a READ-ONLY open on a torn tail refuses to guess: terminal verdict + named cure, no re-stamp', async () => { + await brain.add({ data: 'ro', type: 'document', metadata: {} }) + await brain.close() + const torn = fabricateTear(3) + + const warn = vi.spyOn(prodLog, 'warn') + const reader: any = await Brainy.openReadOnly({ + requireSubtype: false, + storage: { type: 'filesystem', path: dir }, + silent: true, + dimensions: 384 + }) + const tearLines = warn.mock.calls.filter((c) => String(c[0]).includes('TORN GENERATION-LOG TAIL')) + expect(tearLines.length).toBe(1) + const said = String(tearLines[0][0]) + expect(said).toContain('READ-ONLY') + expect(said).toContain('UNVERIFIED') + expect(said).toMatch(/repairIndex\(\)/) + await reader.close() + + // A reader never rewrites the store: read the bytes back off disk (not + // through a writer open, which would demote them) โ€” the torn stamp is + // exactly as it was found. + const onDisk = JSON.parse( + require('node:zlib') + .gunzipSync(fs.readFileSync(path.join(dir, `${ENTITY_TREE_STAMP_PATH}.gz`))) + .toString('utf-8') + ) as FamilyStamp + expect(onDisk.sourceGeneration).toBe(torn.sourceGeneration) + expect(onDisk.generation).toBe(torn.generation) + + brain = await open() + }) + it('the one verifier handles both member modes', () => { const rollup: FamilyStamp = { family: 'x', @@ -127,7 +221,13 @@ describe('entity-tree family stamp', () => { stampSource: 5, head: 9 }) - expect(verifyFamilyStamp(rollup, 3, { nounCount: 10 }).state).toBe('incoherent') // ahead of head + // AHEAD is its own class โ€” a torn generation-log tail, never folded in + // with `incoherent`: the two have opposite cures (recount vs demote). + expect(verifyFamilyStamp(rollup, 3, { nounCount: 10 })).toEqual({ + state: 'torn', + stampSource: 5, + head: 3 + }) expect(verifyFamilyStamp(null, 5, {})).toEqual({ state: 'absent' }) const enumerated: FamilyStamp = { From a963a744ccf668edc440c76d7f79fd0b216522c1 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Mon, 31 Aug 2026 09:13:42 -0700 Subject: [PATCH 06/42] fix(generations): a sealed segment may only declare the generations it holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosis of the "packed history is damaged" narration that fires on every run of the affected stores. It is a WRITER defect, and the reader's refusal was the symptom rather than the cause. A sealed segment declares one contiguous range [firstGeneration, lastGeneration], and every reader treats that range as containment: coveringSegment is an interval test, hasGeneration returns true for anything inside it, and open() seeds committedRanges from it. repackHistory handed fold() a SPARSE batch. Three filters punch holes in its candidate list mid-run โ€” a generation absent from committedRanges never appears, one still in the pending buffer is skipped, one whose tx.json will not read is skipped โ€” and fold() then computed the range from the first and last survivor, claiming every generation in between. The next open merged that mis-declared range back into committedRanges, re-admitting the hole as committed history, so the following auto-compaction pass asked the packed tier for a frame that was never written and failed. Re-merged at every open, which is why it repeated on every run. Confirmed against a forensic fixture: generation directories 1..2503 present except exactly one, 1416; and its fact-log segment already showed the tell โ€” seg-...1410.bfl declaring 1410..1940 (531 generations) while recording 530 facts. Three changes: - repackHistory folds each contiguous RUN as its own segment (`contiguousRuns`), so ranges describe exactly what the segments contain. - fold() REFUSES a non-contiguous batch, naming the gap and its width. The density law is now mechanical, so no future caller can reintroduce it. A refusal loses nothing: the generations stay live and readable. - Stores already carrying the damage heal instead of wedging. A segment whose declared span exceeds its frame count is SPARSE; `actualRanges()` reads the real generation list from its sidecar so open() never re-admits the holes, and readFrame reports such a hole as unpacked with a narration naming the segment, rather than throwing. A DENSE segment missing a frame is still loud damage โ€” that one means the manifest and sidecar disagree. Pins: nine unit cases (refusal and its message, honest ranges for separately folded runs, a reconstructed pre-fix sparse segment serving its real frames while reporting holes as unpacked, holes excluded from actualRanges, and the dense-segment damage path still throwing) plus an end-to-end case that deletes a generation directory and drives the real sequence โ€” ordinary close()-time repacking folds over the hole, then reopen and compact must both complete. Verified red without the fix: the segment declared an 11-generation span while holding 10 frames. (cherry picked from commit 9a888c37e9ebec5573cd7ebd0764396f3a424de3) --- src/db/generationSegments.ts | 119 +++++++++++++++++++- src/db/generationStore.ts | 66 +++++++++-- tests/integration/history-repacking.test.ts | 102 +++++++++++++++++ tests/unit/db/generation-segments.test.ts | 115 +++++++++++++++++++ 4 files changed, 389 insertions(+), 13 deletions(-) diff --git a/src/db/generationSegments.ts b/src/db/generationSegments.ts index 0c14b60c..91451281 100644 --- a/src/db/generationSegments.ts +++ b/src/db/generationSegments.ts @@ -147,6 +147,60 @@ export class GenerationSegmentStore { return this.coveringSegment(gen) !== null } + /** + * @description True when `meta` declares more generations than it holds + * frames โ€” a segment sealed by a writer that folded across a hole. The + * manifest records `frames` at fold time, so this is an O(1) comparison + * against the declared span and needs no I/O. + */ + private isSparse(meta: SegmentMeta): boolean { + return meta.lastGeneration - meta.firstGeneration + 1 !== meta.frames + } + + /** + * @description The generations this tier ACTUALLY holds, as coalesced + * ascending intervals โ€” not what the segments declare. + * + * Dense segments (every one a current writer produces) contribute their + * declared range with no I/O. A SPARSE segment โ€” one sealed before the + * density law was enforced, whose declared range spans generations it has + * no frame for โ€” has its real generation list read from its sidecar and + * contributed instead, with the discrepancy narrated once. + * + * This is what keeps a store that already carries the damage from wedging. + * `open()` seeds `committedRanges` from these intervals, so a hole is never + * re-admitted as a committed generation, and the auto-compaction pass that + * used to fail on every run with "packed history is damaged" simply never + * asks for the missing frame. + * + * @returns Ascending, non-overlapping `[first, last]` intervals. + */ + async actualRanges(): Promise> { + const out: Array<[number, number]> = [] + for (const meta of this.manifest.segments) { + if (!this.isSparse(meta)) { + out.push([meta.firstGeneration, meta.lastGeneration]) + continue + } + const missing = meta.lastGeneration - meta.firstGeneration + 1 - meta.frames + prodLog.warn( + `[GenerationSegments] sealed segment ${meta.file} declares generations ` + + `${meta.firstGeneration}..${meta.lastGeneration} but holds only ${meta.frames} ` + + `frame(s) โ€” ${missing} generation(s) in that span were never folded into it. ` + + `Serving the frames it actually holds; the declared span is not treated as ` + + `committed history. (Written by a pre-density-law writer that folded across a ` + + `gap; the segment itself is intact and no record is lost.)` + ) + const idx = await this.sidecarFor(meta) + for (const [gen] of idx.generations) { + const last = out[out.length - 1] + if (last !== undefined && gen === last[1] + 1) last[1] = gen + else out.push([gen, gen]) + } + } + return out + } + /** * Fold consecutive generations into ONE new sealed segment + sidecar and * append it to the manifest atomically. Caller guarantees: `gens` is @@ -164,6 +218,38 @@ export class GenerationSegmentStore { throw new Error('[GenerationSegments] fold() input must be strictly ascending') } } + // THE DENSITY LAW, MADE MECHANICAL. + // + // A sealed segment declares a CONTIGUOUS range [firstGeneration, + // lastGeneration] and every reader treats that range as containment: + // `coveringSegment` is an interval test, `hasGeneration` returns true for + // anything inside it, and `open()` seeds committedRanges from it. So a + // segment folded from a SPARSE input silently claims generations it does + // not hold, and the first read of one of those holes throws + // "inside sealed segment ... but has no frame โ€” packed history is damaged". + // + // That is exactly how the damage was produced. `repackHistory` skipped + // generations mid-batch โ€” ones absent from committedRanges, ones still in + // the pending buffer, ones whose tx.json would not read โ€” and handed the + // survivors here, where the range was computed from the first and last of + // them. Worse, the mis-declared range was then merged back into + // committedRanges at the next open, which is what turned a quiet hole into + // a repeating auto-compaction failure on every subsequent run. + // + // Callers now split at discontinuities; this refusal is what keeps any + // future caller from reintroducing the class. A refusal here loses + // nothing โ€” the generations stay in the live tier, readable, and the next + // pass folds them correctly. + for (let i = 1; i < gens.length; i++) { + if (gens[i].generation !== gens[i - 1].generation + 1) { + throw new Error( + `[GenerationSegments] fold() input is not contiguous: ${gens[i - 1].generation} โ†’ ` + + `${gens[i].generation} skips ${gens[i].generation - gens[i - 1].generation - 1} ` + + `generation(s). A sealed segment declares a dense range, so folding a sparse ` + + `batch would claim generations it does not hold. Split the batch at the gap.` + ) + } + } const last = this.manifest.segments[this.manifest.segments.length - 1] if (last && gens[0].generation <= last.lastGeneration) { throw new Error( @@ -364,12 +450,37 @@ export class GenerationSegmentStore { return this.decodeFrame(payload) } } - // In the covering range but not present: the packed tier is dense by - // construction (fold packs every generation it is handed, including - // record-less ones) โ€” absence inside a sealed range is damage. + // Inside the covering range but with no frame. Two very different causes, + // and conflating them is what made this class wedge every maintenance pass + // on the affected stores. + // + // (1) A SPARSE SEGMENT โ€” the manifest's own `frames` count is smaller than + // the span it declares. That segment was sealed by a writer that + // folded across a hole (the class this file's density law now bars). + // The segment is INTACT and nothing is lost; it simply never held this + // generation. Answering "not packed" is the honest answer, and it lets + // the caller's two-tier read decide what a genuinely absent generation + // means, instead of every compaction pass dying on a repeating throw. + // `actualRanges()` keeps such holes out of committedRanges at open, so + // in a healed store nobody asks this question in the first place. + // + // (2) A DENSE SEGMENT missing a frame it says it has โ€” the manifest and + // the sidecar disagree about a segment that claims to be complete. + // That IS damage, and it stays loud. + if (this.isSparse(meta)) { + prodLog.warn( + `[GenerationSegments] generation ${gen} falls inside sealed segment ${meta.file}'s ` + + `declared range ${meta.firstGeneration}..${meta.lastGeneration}, but that segment ` + + `holds ${meta.frames} frame(s) for a ${meta.lastGeneration - meta.firstGeneration + 1}` + + `-generation span โ€” it was sealed across a gap and never held this generation. ` + + `Reporting it as unpacked rather than as damage; no record is lost.` + ) + return null + } throw new Error( `[GenerationSegments] generation ${gen} is inside sealed segment ${meta.file}'s declared ` + - `range but has no frame โ€” packed history is damaged` + `range but has no frame, and that segment declares a complete ${meta.frames}-frame ` + + `span โ€” the manifest and the sidecar disagree; packed history is damaged` ) } diff --git a/src/db/generationStore.ts b/src/db/generationStore.ts index fd052c31..da21dc61 100644 --- a/src/db/generationStore.ts +++ b/src/db/generationStore.ts @@ -96,6 +96,35 @@ export const FOLD_CHECKPOINT_PATH = '_system/fold-checkpoint.json' /** Storage-root-relative prefix of the per-generation record directories. */ export const GENERATIONS_PREFIX = '_generations' +/** + * @description Split an ascending list of fold candidates into maximal + * CONTIGUOUS runs โ€” `[7,8,9,12,13]` becomes `[[7,8,9],[12,13]]`. + * + * A sealed segment declares one dense range `[firstGeneration, + * lastGeneration]`, and every reader treats that range as containment. So a + * batch with a hole in it must never become one segment: it would claim a + * generation it does not hold, and the first read of that hole reports the + * packed history as damaged. One run, one segment โ€” the ranges then describe + * exactly what the segments contain. + * + * @param gens - Fold candidates, strictly ascending by generation. + * @returns One array per contiguous run, in ascending order. Empty in, empty out. + */ +export function contiguousRuns(gens: FoldGeneration[]): FoldGeneration[][] { + const runs: FoldGeneration[][] = [] + let run: FoldGeneration[] = [] + for (const g of gens) { + const prev = run[run.length - 1] + if (prev !== undefined && g.generation !== prev.generation + 1) { + runs.push(run) + run = [] + } + run.push(g) + } + if (run.length > 0) runs.push(run) + return runs +} + /** * @description Phases of the {@link GenerationStore.commitTransaction} commit * protocol at which a test-only fault injector can simulate a process crash. @@ -784,9 +813,15 @@ export class GenerationStore { if (storageSupportsFactLog(this.storage)) { this.segments = new GenerationSegmentStore(this.storage) await this.segments.open() - const packedRanges = this.segments - .segments() - .map((s): [number, number] => [s.firstGeneration, Math.min(s.lastGeneration, this.committed)]) + // ACTUAL ranges, not declared ones. A segment sealed by a pre-density-law + // writer can declare a span wider than the frames it holds; seeding + // committedRanges from the declared span re-admits those holes as + // committed generations, and every later maintenance pass then asks for a + // frame that was never written. `actualRanges()` reads the real + // generation list from the sidecar for exactly those segments (and does + // no I/O for the dense ones, which is all of them on a healthy store). + const packedRanges = (await this.segments.actualRanges()) + .map((r): [number, number] => [r[0], Math.min(r[1], this.committed)]) .filter(([lo, hi]) => lo <= hi) if (packedRanges.length > 0) { // Merge packed (older) + live (newer) interval sets โ€” both ascending; @@ -3121,13 +3156,26 @@ export class GenerationStore { foldInput.push({ generation: gen, timestamp: delta.timestamp, delta, records }) } if (foldInput.length === 0) continue - await segments.fold(foldInput) - segmentsCreated++ - // Segment + manifest durable โ†’ the live copies retire. - for (const g of foldInput) { - await this.storage.removeRawPrefix(`${GENERATIONS_PREFIX}/${g.generation}`) + // SPLIT AT DISCONTINUITIES. `eligible` is NOT contiguous โ€” three + // filters above punch holes in it: a generation missing from + // committedRanges never appears, one still in the pending buffer is + // skipped, and one whose tx.json will not read is skipped. A sealed + // segment declares a DENSE range, so folding across such a hole makes + // the segment claim a generation it does not hold; the next open + // merges that mis-declared range into committedRanges, and every + // subsequent auto-compaction pass then asks for the missing frame and + // fails with "packed history is damaged". Fold each contiguous RUN as + // its own segment instead โ€” same bytes, honest ranges. + for (const run of contiguousRuns(foldInput)) { + if (deadline !== undefined && Date.now() >= deadline) break + await segments.fold(run) + segmentsCreated++ + // Segment + manifest durable โ†’ the live copies retire. + for (const g of run) { + await this.storage.removeRawPrefix(`${GENERATIONS_PREFIX}/${g.generation}`) + } + folded += run.length } - folded += foldInput.length } if (folded > 0) { prodLog.info( diff --git a/tests/integration/history-repacking.test.ts b/tests/integration/history-repacking.test.ts index 2bcee038..bb07268d 100644 --- a/tests/integration/history-repacking.test.ts +++ b/tests/integration/history-repacking.test.ts @@ -16,6 +16,7 @@ import { describe, it, expect, afterEach } from 'vitest' import * as fs from 'node:fs' import * as path from 'node:path' import * as os from 'node:os' +import * as zlib from 'node:zlib' import { Brainy } from '../../src/brainy.js' import { NounType } from '../../src/types/graphTypes.js' import { GenerationStore } from '../../src/db/generationStore.js' @@ -57,6 +58,107 @@ describe('history repacking โ€” the two-tier lifecycle', () => { } }) + /** + * THE HOLE, END TO END โ€” the shape a real store carries. + * + * A forensic fixture was measured with generation directories 1..2503 + * present except for exactly one: 1416. Its fact-log segment already showed + * the tell โ€” `seg-...1410.bfl` declaring firstGeneration 1410, lastGeneration + * 1940 (531 generations) while recording only 530 facts. + * + * Before the fix, repacking such a store folded ACROSS that hole: the batch + * skipped 1416 (no readable delta) and the sealed segment declared a range + * spanning it anyway. The next open merged that declared range back into + * committedRanges, re-admitting 1416 as committed history, and every + * subsequent auto-compaction pass then asked the packed tier for a frame + * that was never written โ€” producing, on EVERY run, the non-fatal narration + * + * Auto-compaction of generational history failed (non-fatal): generation + * N is inside sealed segment seg-....bgs's declared range but has no frame + * โ€” packed history is damaged + * + * This pin removes a generation directory to make the same hole, then + * requires repack + reopen + compaction to complete cleanly. + */ + it('a missing generation directory does not poison the packed tier', async () => { + const dir = tempDir() + // `retention: 'all'` throughout: close() otherwise auto-compacts the + // history away, and this pin needs the cold generations still on disk so + // there is something to punch a hole in. The live window stays at its + // production default for the build phase, so nothing folds yet. + const archival = async (): Promise => { + const b = new Brainy({ + requireSubtype: false, + storage: { type: 'filesystem', path: dir }, + embeddingFunction: stub, + retention: 'all' + }) + await b.init() + return b + } + const brain = await archival() + + const id = await brain.add({ + data: 'holed-entity', + type: NounType.Document, + metadata: { v: 0 } + }) + // One flush per update: single-op writes coalesce inside a flush window, + // so a history deep enough to have a middle needs the windows separated. + for (let v = 1; v <= 12; v++) { + await brain.update({ id, metadata: { v } }) + await brain.flush() + } + await brain.close() + + // Punch the hole: delete ONE generation directory in the middle of the + // cold range, exactly as the real store presents it. + const genRoot = path.join(dir, '_generations') + const numeric = fs + .readdirSync(genRoot, { withFileTypes: true }) + .filter((e) => e.isDirectory() && /^\d+$/.test(e.name)) + .map((e) => Number(e.name)) + .sort((a, b) => a - b) + expect(numeric.length).toBeGreaterThan(6) + const victim = numeric[Math.floor(numeric.length / 2)] + fs.rmSync(path.join(genRoot, String(victim)), { recursive: true, force: true }) + + // Now shrink the live window and reopen. close() repacks automatically + // (brainy.ts phase 0b), so this is the production sequence exactly: a + // store with a hole in its history gets folded by ordinary housekeeping, + // with nobody asking for it. + ;(GenerationStore as any).REPACK_LIVE_WINDOW = 3 + const reopened = await archival() + const result = await reopened.repackHistory() + expect(result.foldedGenerations).toBeGreaterThan(0) + + const segDir = path.join(dir, SEGMENTS_PREFIX) + const manifestPath = ['manifest.json', 'manifest.json.gz'] + .map((f) => path.join(segDir, f)) + .find((p) => fs.existsSync(p))! + const raw = manifestPath.endsWith('.gz') + ? zlib.gunzipSync(fs.readFileSync(manifestPath)).toString('utf8') + : fs.readFileSync(manifestPath, 'utf8') + const manifest = JSON.parse(raw) as { + segments: Array<{ firstGeneration: number; lastGeneration: number; frames: number }> + } + + // THE LAW: every sealed segment declares exactly as many generations as it + // holds frames, and none of them spans the victim. + for (const s of manifest.segments) { + expect(s.lastGeneration - s.firstGeneration + 1).toBe(s.frames) + expect(victim >= s.firstGeneration && victim <= s.lastGeneration).toBe(false) + } + + await reopened.close() + + // And the pass that used to fail on every run now completes: reopen (which + // re-seeds committedRanges from the packed tier) then compact history. + const third = await openBrain(dir) + await expect(third.compactHistory({ maxGenerations: 2 })).resolves.toBeDefined() + await third.close() + }) + it('repack preserves every historical read across cold reopen; folded dirs are gone', async () => { ;(GenerationStore as any).REPACK_LIVE_WINDOW = 3 const dir = tempDir() diff --git a/tests/unit/db/generation-segments.test.ts b/tests/unit/db/generation-segments.test.ts index 27ab85cb..f16e67b3 100644 --- a/tests/unit/db/generation-segments.test.ts +++ b/tests/unit/db/generation-segments.test.ts @@ -147,4 +147,119 @@ describe('db/GenerationSegmentStore โ€” the D1+D3 packed tier', () => { await expect(store.fold([gen(4), gen(4)])).rejects.toThrow(/strictly ascending/) await expect(store.fold([])).rejects.toThrow(/at least one generation/) }) + + // ========================================================================== + // THE DENSITY LAW + // ========================================================================== + // + // A sealed segment declares a CONTIGUOUS range and every reader treats that + // range as containment. Folding a sparse batch therefore makes the segment + // claim generations it does not hold โ€” and because `open()` merges declared + // ranges back into committedRanges, the hole is re-admitted as committed + // history and every later maintenance pass fails asking for a frame that was + // never written. That is the "generation N is inside sealed segment + // seg-....bgs's declared range but has no frame โ€” packed history is damaged" + // narration seen on every run of the affected stores. + + it('fold REFUSES a batch with a hole โ€” a dense range may not be declared over sparse input', async () => { + await expect(store.fold([gen(1), gen(2), gen(4)])).rejects.toThrow( + /not contiguous: 2 โ†’ 4 skips 1 generation/ + ) + // The refusal loses nothing: no segment was sealed, so the generations + // stay in the live tier and the next pass folds them correctly. + expect(store.segments()).toHaveLength(0) + expect(store.hasGeneration(1)).toBe(false) + }) + + it('a wider gap names how many generations it would have swallowed', async () => { + await expect(store.fold([gen(10), gen(20)])).rejects.toThrow( + /not contiguous: 10 โ†’ 20 skips 9 generation\(s\)/ + ) + }) + + it('two contiguous runs folded separately declare honest ranges', async () => { + // What the caller now does instead of folding across the gap. + const a = await store.fold([gen(1), gen(2), gen(3)]) + const b = await store.fold([gen(7), gen(8)]) + expect(a).toMatchObject({ firstGeneration: 1, lastGeneration: 3, frames: 3 }) + expect(b).toMatchObject({ firstGeneration: 7, lastGeneration: 8, frames: 2 }) + // The gap is honestly outside the packed tier. + for (const g of [4, 5, 6]) expect(store.hasGeneration(g)).toBe(false) + for (const g of [1, 2, 3, 7, 8]) expect(store.hasGeneration(g)).toBe(true) + expect(await store.actualRanges()).toEqual([ + [1, 3], + [7, 8] + ]) + }) + + it('actualRanges() is exact and I/O-free for dense segments', async () => { + await store.fold([gen(1), gen(2)]) + await store.fold([gen(3), gen(4)]) + // Adjacent dense segments each contribute their declared range. + expect(await store.actualRanges()).toEqual([ + [1, 2], + [3, 4] + ]) + }) + + // ---- pre-existing damage: a store sealed by the old writer ---------------- + + /** + * Seal a SPARSE segment the way the pre-fix writer did: write the bytes and + * sidecar for a contiguous run, then rewrite the manifest so the segment + * declares a wider range than the frames it holds. This reproduces on disk + * exactly what the affected stores carry, without needing the old code. + */ + const sealSparseSegment = async (): Promise => { + await store.fold([gen(1), gen(2), gen(3)]) + const manifest = (await storage.readRawObject(`${SEGMENTS_PREFIX}/manifest.json`)) as any + // Declare 1..5 while holding frames for 1..3 โ€” generations 4 and 5 become + // holes inside a sealed range. + manifest.segments[0].lastGeneration = 5 + await storage.writeRawObject(`${SEGMENTS_PREFIX}/manifest.json`, manifest) + } + + it('a pre-existing sparse segment reports its holes as UNPACKED, not as damage', async () => { + await sealSparseSegment() + const reopened = new GenerationSegmentStore(storage as any) + await reopened.open() + + // The frames it really holds still serve, byte-faithfully. + expect((await reopened.readDelta(2))?.timestamp).toBe(1_700_000_000_002) + expect(await reopened.readRecords(3)).toHaveLength(2) + + // The holes answer "not packed" instead of throwing. This is the fix for + // the wedge: the old reader threw here on EVERY maintenance pass. + expect(await reopened.readDelta(4)).toBeNull() + expect(await reopened.readRecords(5)).toBeNull() + }) + + it('actualRanges() excludes the holes so they are never re-admitted as committed', async () => { + await sealSparseSegment() + const reopened = new GenerationSegmentStore(storage as any) + await reopened.open() + // Declared 1..5; actually holds 1..3. The store seeds committedRanges from + // THIS, so generations 4 and 5 never become committed history again. + expect(await reopened.actualRanges()).toEqual([[1, 3]]) + }) + + it('a DENSE segment missing a frame is still loud damage', async () => { + // The other side of the branch: when the manifest claims a complete span, + // a missing frame means the manifest and sidecar disagree โ€” real damage, + // and it must not be quietly downgraded to "unpacked". + await store.fold([gen(1), gen(2), gen(3)]) + const idxPath = `${SEGMENTS_PREFIX}/seg-${String(1).padStart(20, '0')}.idx` + const raw = (await storage.readRawBytes(idxPath))! + const { decode, encode } = await import('@msgpack/msgpack') + const idx = decode(raw) as any + // Drop generation 2's entry while the manifest still declares 3 frames. + idx.generations = idx.generations.filter(([g]: [number]) => g !== 2) + await storage.writeRawBytes(idxPath, encode(idx)) + + const reopened = new GenerationSegmentStore(storage as any) + await reopened.open() + await expect(reopened.readDelta(2)).rejects.toThrow( + /manifest and the sidecar disagree; packed history is damaged/ + ) + }) }) From d6bcb14f698de40507a6ce16daf56ab235f71924 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Mon, 31 Aug 2026 09:30:46 -0700 Subject: [PATCH 07/42] =?UTF-8?q?build(release):=20the=20docs-push=20step?= =?UTF-8?q?=20retires=20=E2=80=94=20this=20engine=20documents=20itself=20i?= =?UTF-8?q?n=20its=20own=20repository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-doc-set ruling (2026-08-31) gives soulcraft.com/docs to the paid product alone; the site serves redirects for the slugs this rail used to push. The push script stays in the tree as history; the rail stops calling it. (cherry picked from commit 655aa13ea79e23927cde7fd47ab13b505cb042d9) --- scripts/release.sh | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 08293e3a..1a4fe575 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -248,17 +248,12 @@ else echo -e "${RED}โš ๏ธ FORGEJO_RELEASE_TOKEN unset โ€” no release page created; tag + CHANGELOG remain the record${NC}\n" fi -# Step 12: Push public docs to the soulcraft.com docs ingest door -# (VENUE-DOCS-RELEASE-PUSH). Skips with a loud warning when -# DOCS_INGEST_SECRET is unset; fails loudly (without undoing the publish โ€” -# that already happened) when a push errors, so the docs site never -# silently trails npm. -echo -e "${BLUE}1๏ธโƒฃ2๏ธโƒฃ Pushing public docs to soulcraft.com/docs...${NC}" -if node scripts/push-docs.js; then - echo -e "${GREEN}โœ… Docs push step done${NC}\n" -else - echo -e "${RED}โŒ Docs push FAILED โ€” soulcraft.com/docs trails npm until re-run or interim sync${NC}\n" -fi +# Step 12 RETIRED (2026-08-31, CORTEX-SITE-BRAINY-RENAME round 12, David-ruled): +# soulcraft.com/docs carries the paid product's documentation only. This +# engine's documentation home is THIS repository โ€” README and docs/ โ€” and the +# site serves 301s for the slugs this rail used to push. The push script stays +# in the tree for history; the rail no longer calls it. +echo -e "${BLUE}Docs step: this engine documents itself in its own repo (site push retired 2026-08-31)${NC}" echo -e "${GREEN}โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”${NC}" echo -e "${GREEN}๐ŸŽ‰ Release ${NEW_VERSION} complete!${NC}" From 0f0022b1c9abd710184d0e6ac573b6a07d8ec068 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Mon, 31 Aug 2026 12:34:36 -0700 Subject: [PATCH 08/42] chore(release): 10.4.5 --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a54d609e..6c05eba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [10.4.5](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.4...v10.4.5) (2026-08-31) + +- build(release): the docs-push step retires โ€” this engine documents itself in its own repository (d6bcb14f) +- fix(generations): a sealed segment may only declare the generations it holds (a963a744) +- fix(recovery): a torn generation-log tail is a terminal verdict, never a wait (c9930871) + + ### [10.4.4](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.3...v10.4.4) (2026-08-28) - fix(vfs): the old-root sweep narrates only when it has something to say (d49148e1) diff --git a/package-lock.json b/package-lock.json index c4f66561..2528227b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@soulcraftlabs/brainy", - "version": "10.4.4", + "version": "10.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@soulcraftlabs/brainy", - "version": "10.4.4", + "version": "10.4.5", "license": "MIT", "dependencies": { "@msgpack/msgpack": "^3.1.2", diff --git a/package.json b/package.json index 06ce0253..31448825 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@soulcraftlabs/brainy", - "version": "10.4.4", + "version": "10.4.5", "brainyContract": 1, "description": "Universal Knowledge Protocolโ„ข - World's first Triple Intelligence database unifying vector, graph, and document search in one API. Stage 3 CANONICAL: 42 nouns ร— 127 verbs covering 96-97% of all human knowledge.", "main": "dist/index.js", From 73500e7d109275570857341000c9bad44d5cc1f3 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Mon, 31 Aug 2026 12:59:40 -0700 Subject: [PATCH 09/42] fix(transact): metadata-index ops take their JSON-safe view at the crossing, not at construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit transact()'s delete legs (direct unrelate and the noun-remove cascade) hand the SAME verb object to the graph-retraction op and the metadata-retraction op. The metadata leg sanitized at PLAN time, when the verb was still clean, so the wrap returned the same reference โ€” then the graph op's execute-time endpoint resolution (deliberately deferred for same-batch forward refs) mirrored BigInt sourceInt/targetInt onto the shared object, and the metadata op crossed the seam with them. A strict provider rightly refuses that crossing, so every transact-wrapped edge delete aborted; direct unrelate() resolves ints at build time, before its sanitize, which is why no existing gate saw it. The JSON-safe view now lives in a shared leaf (utils/jsonSafeIndexMetadata) and is applied INSIDE AddToMetadataIndexOperation and RemoveFromMetadataIndexOperation at execute and rollback time โ€” the one place no plan-vs-execute ordering can bypass. Pins: the fleet repro, the cascade shape, a mixed batch, and unit pins that mutate the entity after construction against a strict seam (5 red before, 5 green after). --- src/brainy.ts | 30 +-- src/transaction/operations/IndexOperations.ts | 29 ++- src/utils/jsonSafeIndexMetadata.ts | 47 +++++ ...ansact-edge-delete-bigint-aliasing.test.ts | 184 ++++++++++++++++++ 4 files changed, 263 insertions(+), 27 deletions(-) create mode 100644 src/utils/jsonSafeIndexMetadata.ts create mode 100644 tests/integration/transact-edge-delete-bigint-aliasing.test.ts diff --git a/src/brainy.ts b/src/brainy.ts index da04577e..06c947c2 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -15,6 +15,7 @@ import { JsHnswVectorIndex } from './hnsw/hnswIndex.js' import { createStorage, resolveFilesystemRoot } from './storage/storageFactory.js' import type { StorageOptions } from './storage/storageFactory.js' import { rebuildCounts } from './utils/rebuildCounts.js' +import { jsonSafeIndexMetadata } from './utils/jsonSafeIndexMetadata.js' import type { MetadataWriteBuffer } from './utils/metadataWriteBuffer.js' import { BaseStorage } from './storage/baseStorage.js' import { @@ -4203,32 +4204,19 @@ export class Brainy implements BrainyInterface { */ /** * @description A JSON-safe view of a record bound for the metadata-index - * crossing. The seam's metadata is JSON-safe BY CONTRACT (a native provider - * serializes it; u64 ints as Number corrupt above 2^53) โ€” but - * {@link resolveVerbEndpointInts} MIRRORS the resolved endpoint ints onto - * the verb object itself as BigInt (`verb.sourceInt`/`targetInt`), so a - * verb object reused as index metadata carried BigInts into - * JSON.stringify, which throws, aborting the whole transaction (found by - * the first joint pair gate). Endpoint ints ride their OWN op params on the - * graph legs โ€” the metadata crossing drops every BigInt-valued top-level - * key instead of guessing at a lossy numeric encoding. + * crossing โ€” delegates to the shared {@link jsonSafeIndexMetadata} leaf, + * which the metadata-index transaction operations ALSO apply at execute + * and rollback time. This plan-time wrap alone proved insufficient: it + * returns the same reference when the record is clean, and `transact()`'s + * delete legs share that reference with a graph-retraction op whose + * execute-time endpoint resolution mirrors BigInt ints onto it (the full + * aliasing story lives on the leaf module's doc). * @param metadata - The candidate index-metadata record. * @returns The same object when already JSON-safe, else a shallow copy * without the BigInt-valued keys. */ private static jsonSafeIndexMetadata(metadata: unknown): unknown { - if (metadata === null || typeof metadata !== 'object') return metadata - const rec = metadata as Record - let hasBigint = false - for (const k in rec) { - if (typeof rec[k] === 'bigint') { hasBigint = true; break } - } - if (!hasBigint) return metadata - const out: Record = {} - for (const k in rec) { - if (typeof rec[k] !== 'bigint') out[k] = rec[k] - } - return out + return jsonSafeIndexMetadata(metadata) } private metadataIndexRetractionOp( diff --git a/src/transaction/operations/IndexOperations.ts b/src/transaction/operations/IndexOperations.ts index 1bbbca88..0142dc54 100644 --- a/src/transaction/operations/IndexOperations.ts +++ b/src/transaction/operations/IndexOperations.ts @@ -14,6 +14,7 @@ import type { MetadataIndexManager } from '../../utils/metadataIndex.js' import type { GraphVerb } from '../../coreTypes.js' import type { Operation, RollbackAction } from '../types.js' import { isZeroNormVector } from '../../utils/distance.js' +import { jsonSafeIndexMetadata } from '../../utils/jsonSafeIndexMetadata.js' import { prodLog } from '../../utils/logger.js' /** @@ -390,13 +391,21 @@ export class AddToMetadataIndexOperation implements Operation { // rollback so add + undo reference the same watermark. const generation = this.generationFn?.() - // Add to metadata index (skipFlush=true for transaction atomicity) - await this.index.addToIndex(this.id, this.entity, true, false, generation) + // The JSON-safe view is taken HERE, per crossing, never at construction: + // the entity reference this op holds can be mutated between plan and + // execute (a graph op's execute-time endpoint-int resolution mirrors + // BigInts onto a shared verb object) โ€” see jsonSafeIndexMetadata's + // module doc. + await this.index.addToIndex( + this.id, jsonSafeIndexMetadata(this.entity), true, false, generation + ) // Return rollback action return async () => { // Remove from metadata index - await this.index.removeFromIndex(this.id, this.entity, generation) + await this.index.removeFromIndex( + this.id, jsonSafeIndexMetadata(this.entity), generation + ) } } } @@ -432,13 +441,21 @@ export class RemoveFromMetadataIndexOperation implements Operation { // Resolve the removal generation once; reuse it for the rollback re-add. const generation = this.generationFn?.() - // Remove from metadata index - await this.index.removeFromIndex(this.id, this.entity, generation) + // Sanitized per crossing, never at construction โ€” transact()'s delete + // legs hand this op the SAME verb object the graph-retraction op's + // execute-time endpoint resolution mutates (BigInt sourceInt/targetInt), + // so a plan-time view aliases the pollution. See jsonSafeIndexMetadata's + // module doc. + await this.index.removeFromIndex( + this.id, jsonSafeIndexMetadata(this.entity), generation + ) // Return rollback action return async () => { // Re-add with original metadata (skipFlush=true) - await this.index.addToIndex(this.id, this.entity, true, false, generation) + await this.index.addToIndex( + this.id, jsonSafeIndexMetadata(this.entity), true, false, generation + ) } } } diff --git a/src/utils/jsonSafeIndexMetadata.ts b/src/utils/jsonSafeIndexMetadata.ts new file mode 100644 index 00000000..d3b1be5f --- /dev/null +++ b/src/utils/jsonSafeIndexMetadata.ts @@ -0,0 +1,47 @@ +/** + * @module utils/jsonSafeIndexMetadata + * @description The metadata-index crossing's JSON-safety law, as a leaf + * function both the coordinator and the transaction operations share. + * + * The seam's metadata is JSON-safe BY CONTRACT (a native provider serializes + * it; u64 ints as Number corrupt above 2^53) โ€” but `resolveVerbEndpointInts` + * MIRRORS the resolved endpoint ints onto the verb object itself as BigInt + * (`verb.sourceInt`/`targetInt`), so a verb object reused as index metadata + * carries BigInts into JSON.stringify, which throws, aborting the whole + * transaction. Endpoint ints ride their OWN op params on the graph legs โ€” the + * metadata crossing drops every BigInt-valued top-level key instead of + * guessing at a lossy numeric encoding. + * + * WHY THIS IS A LEAF MODULE, ENFORCED AT THE CROSSING: sanitizing only at + * operation-construction time is not enough. `transact()`'s delete legs pass + * the SAME verb object to both the graph-retraction op (whose endpoint-int + * thunk deliberately resolves at EXECUTE time, for same-batch forward refs) + * and the metadata-retraction op. At plan time the verb is still clean, so a + * plan-time sanitize returns the same reference โ€” then the graph op executes + * first, mirrors the BigInt ints onto the shared object, and the metadata op + * crosses the seam with them (found by the first fleet adoption of the native + * pair: every transact-wrapped edge delete aborted). The crossing itself is + * the only place ordering cannot bypass. + */ + +/** + * A JSON-safe view of a record bound for the metadata-index crossing. + * + * @param metadata - The candidate index-metadata record. + * @returns The same object when already JSON-safe, else a shallow copy + * without the BigInt-valued keys. + */ +export function jsonSafeIndexMetadata(metadata: unknown): unknown { + if (metadata === null || typeof metadata !== 'object') return metadata + const rec = metadata as Record + let hasBigint = false + for (const k in rec) { + if (typeof rec[k] === 'bigint') { hasBigint = true; break } + } + if (!hasBigint) return metadata + const out: Record = {} + for (const k in rec) { + if (typeof rec[k] !== 'bigint') out[k] = rec[k] + } + return out +} diff --git a/tests/integration/transact-edge-delete-bigint-aliasing.test.ts b/tests/integration/transact-edge-delete-bigint-aliasing.test.ts new file mode 100644 index 00000000..b3902571 --- /dev/null +++ b/tests/integration/transact-edge-delete-bigint-aliasing.test.ts @@ -0,0 +1,184 @@ +/** + * @module tests/integration/transact-edge-delete-bigint-aliasing + * @description Regression for a fleet-adoption blocker: ANY edge delete + * inside `transact()` โ€” a direct unrelate or a noun-remove's cascade โ€” + * aborted with the metadata seam's BigInt JSON-guard error on a strict + * (native) metadata provider. + * + * The aliasing chain: `planTxUnrelate`/the remove-cascade pass the SAME verb + * object to the graph-retraction op and the metadata-retraction op. The + * metadata leg's JSON-safe wrap ran at PLAN time, when the verb was still + * clean โ€” so it returned the same reference. At EXECUTE time the graph op + * runs first and `resolveVerbEndpointInts` mirrors BigInt + * `sourceInt`/`targetInt` onto the shared object (deliberately deferred for + * same-batch forward refs โ€” see transact-forward-ref-graph.test.ts); the + * metadata op then crossed the seam with the polluted object. Direct + * `unrelate()` resolves ints at BUILD time, before its sanitize, which is why + * only the transact() shapes ever hit it. + * + * Fix under pin: the JSON-safe view is taken AT THE CROSSING โ€” inside the + * metadata-index operations' execute/rollback โ€” so no plan-vs-execute + * ordering can bypass it. The JS baseline index tolerates BigInts (it would + * mask the bug), so these pins SPY on the seam and assert what actually + * crossed, exactly as a strict native provider would judge it. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { Brainy } from '../../src/brainy.js' +import { NounType, VerbType } from '../../src/types/graphTypes.js' +import { + AddToMetadataIndexOperation, + RemoveFromMetadataIndexOperation +} from '../../src/transaction/operations/index.js' + +let seq = 0 +const freshId = (): string => + `00000000-0000-4000-8000-${(++seq).toString(16).padStart(12, '0')}` + +/** Top-level BigInt-valued keys of a candidate seam crossing (the guard's law). */ +const bigintKeys = (metadata: unknown): string[] => { + if (metadata === null || typeof metadata !== 'object') return [] + return Object.entries(metadata as Record) + .filter(([, v]) => typeof v === 'bigint') + .map(([k]) => k) +} + +describe('transact() edge deletes never carry BigInt across the metadata seam', () => { + let dir: string + let brain: any + let crossings: Array<{ door: string; id: string; keys: string[] }> + + beforeEach(async () => { + process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-tx-bigint-')) + brain = new Brainy({ + requireSubtype: false, + storage: { type: 'filesystem', path: dir }, + dimensions: 384, + silent: true + }) + await brain.init() + + // Spy on the seam the way a strict native provider judges it: record the + // BigInt-valued top-level keys of every metadata argument that crosses. + // The JS baseline index tolerates BigInts, so without this the baseline + // run would green a shape the native pair aborts on. + crossings = [] + const index = brain.metadataIndex + for (const door of ['addToIndex', 'removeFromIndex'] as const) { + const real = index[door].bind(index) + index[door] = (id: string, metadata: unknown, ...rest: unknown[]) => { + crossings.push({ door, id, keys: bigintKeys(metadata) }) + return real(id, metadata, ...rest) + } + } + }) + + afterEach(async () => { + await brain.close() + fs.rmSync(dir, { recursive: true, force: true }) + }) + + it('CASE 1 (the fleet repro): relate, then transact([{op: unrelate}])', async () => { + const a = await brain.add({ id: freshId(), data: 'a', type: NounType.Thing }) + const b = await brain.add({ id: freshId(), data: 'b', type: NounType.Thing }) + const verbId = await brain.relate({ from: a, to: b, type: VerbType.RelatedTo }) + + crossings.length = 0 + await brain.transact([{ op: 'unrelate', id: verbId }]) + + const polluted = crossings.filter((c) => c.keys.length > 0) + expect(polluted).toEqual([]) + expect(await brain.storage.getVerb(verbId)).toBeFalsy() + }) + + it('CASE 2 (the cascade shape): transact([{op: remove}]) cascading edge deletes', async () => { + const a = await brain.add({ id: freshId(), data: 'a', type: NounType.Thing }) + const b = await brain.add({ id: freshId(), data: 'b', type: NounType.Thing }) + const c = await brain.add({ id: freshId(), data: 'c', type: NounType.Thing }) + const ab = await brain.relate({ from: a, to: b, type: VerbType.RelatedTo }) + const ca = await brain.relate({ from: c, to: a, type: VerbType.RelatedTo }) + + crossings.length = 0 + await brain.transact([{ op: 'remove', id: a }]) + + const polluted = crossings.filter((c2) => c2.keys.length > 0) + expect(polluted).toEqual([]) + expect(await brain.get(a)).toBeFalsy() + expect(await brain.storage.getVerb(ab)).toBeFalsy() + expect(await brain.storage.getVerb(ca)).toBeFalsy() + }) + + it('CASE 3 (one batch, both legs): adds + relate + unrelate of a pre-existing edge', async () => { + const a = await brain.add({ id: freshId(), data: 'a', type: NounType.Thing }) + const b = await brain.add({ id: freshId(), data: 'b', type: NounType.Thing }) + const old = await brain.relate({ from: a, to: b, type: VerbType.RelatedTo }) + + const x = freshId() + crossings.length = 0 + await brain.transact([ + { op: 'add', id: x, data: 'x', type: NounType.Thing }, + { op: 'relate', from: a, to: x, type: VerbType.RelatedTo }, + { op: 'unrelate', id: old } + ]) + + const polluted = crossings.filter((c) => c.keys.length > 0) + expect(polluted).toEqual([]) + expect(await brain.storage.getVerb(old)).toBeFalsy() + const edges = await brain.related({ from: a }) + expect(edges.length).toBe(1) + expect(edges[0].id).not.toBe(old) + }) +}) + +describe('the metadata-index operations sanitize at the crossing, not at construction', () => { + /** A strict seam: refuses BigInts exactly as the native provider does. */ + const strictIndex = () => { + const seen: Array<{ door: string; keys: string[] }> = [] + const judge = (door: string, metadata: unknown) => { + const keys = bigintKeys(metadata) + seen.push({ door, keys }) + if (keys.length > 0) { + throw new Error( + `${door}: the metadata object violates the provider seam's JSON ` + + `contract โ€” BigInt at ${keys.join(', ')}.` + ) + } + } + return { + seen, + addToIndex: async (_id: string, metadata: unknown) => judge('addToIndex', metadata), + removeFromIndex: async (_id: string, metadata: unknown) => judge('removeFromIndex', metadata) + } + } + + it('RemoveFromMetadataIndexOperation: entity mutated AFTER construction still crosses clean', async () => { + const index = strictIndex() + const verb: Record = { id: 'v1', sourceId: 'a', targetId: 'b' } + const op = new RemoveFromMetadataIndexOperation(index as any, 'v1', verb, () => 7n) + + // The graph leg's execute-time endpoint resolution, simulated: the shared + // object is polluted between plan and execute. + verb.sourceInt = 800_000n + verb.targetInt = 800_001n + + const rollback = await op.execute() + await rollback() + expect(index.seen.map((s) => s.keys)).toEqual([[], []]) + }) + + it('AddToMetadataIndexOperation: same law on the add leg and its rollback', async () => { + const index = strictIndex() + const verb: Record = { id: 'v2', sourceId: 'a', targetId: 'b' } + const op = new AddToMetadataIndexOperation(index as any, 'v2', verb, () => 7n) + + verb.sourceInt = 800_000n + verb.targetInt = 800_001n + + const rollback = await op.execute() + await rollback() + expect(index.seen.map((s) => s.keys)).toEqual([[], []]) + }) +}) From 4014e0f12593f9c781dde0bfcf1a93b0c90d71cc Mon Sep 17 00:00:00 2001 From: David Snelling Date: Mon, 31 Aug 2026 14:50:45 -0700 Subject: [PATCH 10/42] chore(release): 10.4.6 --- CHANGELOG.md | 5 +++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c05eba6..7154d5a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [10.4.6](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.5...v10.4.6) (2026-08-31) + +- fix(transact): metadata-index ops take their JSON-safe view at the crossing, not at construction (73500e7d) + + ### [10.4.5](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.4...v10.4.5) (2026-08-31) - build(release): the docs-push step retires โ€” this engine documents itself in its own repository (d6bcb14f) diff --git a/package-lock.json b/package-lock.json index 2528227b..9e573da3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@soulcraftlabs/brainy", - "version": "10.4.5", + "version": "10.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@soulcraftlabs/brainy", - "version": "10.4.5", + "version": "10.4.6", "license": "MIT", "dependencies": { "@msgpack/msgpack": "^3.1.2", diff --git a/package.json b/package.json index 31448825..51322998 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@soulcraftlabs/brainy", - "version": "10.4.5", + "version": "10.4.6", "brainyContract": 1, "description": "Universal Knowledge Protocolโ„ข - World's first Triple Intelligence database unifying vector, graph, and document search in one API. Stage 3 CANONICAL: 42 nouns ร— 127 verbs covering 96-97% of all human knowledge.", "main": "dist/index.js", From 5e3b343a0ea6c6d162bb27aee502e35a12acdd93 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Tue, 1 Sep 2026 09:32:23 -0700 Subject: [PATCH 11/42] fix(storage): counts persistence is single-flight, coalesced, and never races its own temp file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit persistCounts() was write-through on every count change with no serialization, and the atomic writer named its temp file with millisecond granularity. Two persists inside one millisecond shared the temp path: both wrote it, the first rename consumed it, the second rename found nothing โ€” ENOENT, roughly 1,500 times a day on a busy production brain, with a full ledger write per change behind it. No data was lost (the surviving rename carried a complete ledger and the next change re-persisted), but the race was real and the write rate absurd. flushCounts() now runs exactly one persist at a time; requests arriving during it collapse into one trailing pass that carries the burst's final state โ€” N changes cost at most two writes. writeFileAtomic() adds a per-process sequence to the temp name so no two writes can share a path. Pinned: a 25-change burst โ†’ โ‰ค2 ledger writes, zero errors, ledger equal to memory; parallel real writes land complete; three same-instant atomic writes own three distinct temp paths. --- src/storage/adapters/baseStorageAdapter.ts | 51 ++++++-- src/storage/adapters/fileSystemStorage.ts | 9 +- .../counts-persist-single-flight.test.ts | 111 ++++++++++++++++++ 3 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 tests/integration/counts-persist-single-flight.test.ts diff --git a/src/storage/adapters/baseStorageAdapter.ts b/src/storage/adapters/baseStorageAdapter.ts index cabe2e30..a90adb93 100644 --- a/src/storage/adapters/baseStorageAdapter.ts +++ b/src/storage/adapters/baseStorageAdapter.ts @@ -1089,6 +1089,10 @@ export abstract class BaseStorageAdapter implements StorageAdapter { // Counts changed since the last persist? Drives the write-through flush. protected pendingCountPersist = false + /** The one persist running right now, if any (single-flight law โ€” see flushCounts). */ + private countPersistInFlight: Promise | null = null + /** The one trailing persist a burst has queued behind the in-flight one. */ + private countPersistTrailing: Promise | null = null /** * Get total noun count - O(1) operation @@ -1341,15 +1345,46 @@ export abstract class BaseStorageAdapter implements StorageAdapter { return } - try { - // Persist to storage (implemented by subclass) - await this.persistCounts() - this.pendingCountPersist = false - } catch (error) { - console.error('CRITICAL: Failed to flush counts to storage:', error) - // Keep pending flag set so we retry on next operation - throw error + // SINGLE-FLIGHT, COALESCED. Counts are write-through on every change, so + // a burst of writes used to launch one persist per change, all in flight + // together. Two of them inside the same millisecond shared the atomic + // writer's temp path (`.tmp--`): both wrote it, the first rename + // consumed it, the second rename found nothing โ€” ENOENT, ~1,500 times a + // day on a busy production brain, with a full ledger write per change + // behind it. Now exactly one persist runs at a time; requests that arrive + // while it runs collapse into ONE trailing persist that carries the final + // state. A burst of N changes costs at most two writes and never races + // itself. + if (this.countPersistInFlight) { + // The in-flight write may have already serialised a stale snapshot โ€” + // ask for one more pass after it, and let every caller in this burst + // await that same pass. + if (!this.countPersistTrailing) { + this.countPersistTrailing = this.countPersistInFlight + .catch(() => undefined) + .then(() => { + this.countPersistTrailing = null + return this.flushCounts() + }) + } + return this.countPersistTrailing } + + this.countPersistInFlight = (async () => { + try { + // Persist to storage (implemented by subclass) + this.pendingCountPersist = false + await this.persistCounts() + } catch (error) { + // Keep the flag set so the next operation retries. + this.pendingCountPersist = true + console.error('CRITICAL: Failed to flush counts to storage:', error) + throw error + } finally { + this.countPersistInFlight = null + } + })() + return this.countPersistInFlight } /** diff --git a/src/storage/adapters/fileSystemStorage.ts b/src/storage/adapters/fileSystemStorage.ts index 5ec1d88e..87b6406f 100644 --- a/src/storage/adapters/fileSystemStorage.ts +++ b/src/storage/adapters/fileSystemStorage.ts @@ -2400,8 +2400,15 @@ export class FileSystemStorage extends BaseStorage { * Atomic write via temp-file-then-rename so concurrent readers never see a * half-written lock JSON. Reused by writer-lock writes + heartbeat. */ + /** Monotonic per-process sequence so two atomic writes never share a temp path. */ + private static atomicWriteSeq = 0 + private async writeFileAtomic(filePath: string, contents: string): Promise { - const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}` + // pid + timestamp alone collided: two writers of the same target inside + // one millisecond shared this path, and the loser's rename found the + // winner had already moved it (ENOENT). The sequence makes every call's + // temp path its own. + const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${++FileSystemStorage.atomicWriteSeq}` await fs.promises.writeFile(tmp, contents) await fs.promises.rename(tmp, filePath) } diff --git a/tests/integration/counts-persist-single-flight.test.ts b/tests/integration/counts-persist-single-flight.test.ts new file mode 100644 index 00000000..5acbdcc3 --- /dev/null +++ b/tests/integration/counts-persist-single-flight.test.ts @@ -0,0 +1,111 @@ +/** + * @module tests/integration/counts-persist-single-flight + * @description Regression for a production race in FileSystemStorage's + * counts ledger: `persistCounts()` was write-through on every count change + * with no serialization, and the atomic writer named its temp file with + * millisecond granularity (`.tmp--`). Two persists inside one + * millisecond shared the temp path โ€” both wrote it, the first rename + * consumed it, the second rename found nothing: ENOENT, ~1,500 times a day + * on a busy production brain, with a full ledger write per change behind it. + * + * Under pin: persists are single-flight and coalesced โ€” one in flight, at + * most one trailing pass carrying the burst's final state โ€” and every atomic + * write owns a unique temp path. A burst of N count changes costs at most + * two ledger writes, never errors, and leaves a ledger equal to memory. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { Brainy } from '../../src/brainy.js' +import { NounType } from '../../src/types/graphTypes.js' + +describe('counts persistence is single-flight, coalesced, and never races its own temp file', () => { + let dir: string + let brain: any + + beforeEach(async () => { + process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-counts-race-')) + brain = new Brainy({ + requireSubtype: false, + storage: { type: 'filesystem', path: dir }, + dimensions: 384, + silent: true + }) + await brain.init() + }) + + afterEach(async () => { + vi.restoreAllMocks() + await brain.close() + fs.rmSync(dir, { recursive: true, force: true }) + }) + + it('a burst of concurrent count changes โ†’ at most two ledger writes, zero errors, ledger == memory', async () => { + const storage = brain.storage + const countsPath: string = storage.countsFilePath + expect(countsPath, 'the filesystem adapter persists a counts ledger').toBeTruthy() + + // Let init's own persists settle so the burst is measured alone. + await storage.flushCounts?.() + + const renameSpy = vi.spyOn(fs.promises, 'rename') + const errorSpy = vi.spyOn(console, 'error') + + // Twenty-five concurrent count changes โ€” the shape of a write burst; each + // used to launch its own persist. + const BURST = 25 + await Promise.all( + Array.from({ length: BURST }, () => storage.scheduleCountPersist()) + ) + + const ledgerRenames = renameSpy.mock.calls.filter(([, to]) => String(to) === countsPath) + expect(ledgerRenames.length, 'single-flight + one trailing pass').toBeLessThanOrEqual(2) + expect(ledgerRenames.length, 'the burst was persisted at all').toBeGreaterThanOrEqual(1) + + const persistErrors = errorSpy.mock.calls.filter((args) => String(args[0]).includes('persisting counts')) + expect(persistErrors).toEqual([]) + + const ledger = JSON.parse(fs.readFileSync(countsPath, 'utf-8')) + expect(ledger.totalNounCount).toBe(storage.totalNounCount) + expect(ledger.totalVerbCount).toBe(storage.totalVerbCount) + }) + + it('real writes in parallel: the ledger lands complete and no persist error is logged', async () => { + const storage = brain.storage + const countsPath: string = storage.countsFilePath + const errorSpy = vi.spyOn(console, 'error') + + await Promise.all( + Array.from({ length: 12 }, (_, i) => + brain.add({ data: `burst row ${i}`, type: NounType.Thing }) + ) + ) + await storage.flushCounts?.() + + const persistErrors = errorSpy.mock.calls.filter((args) => String(args[0]).includes('persisting counts')) + expect(persistErrors).toEqual([]) + const ledger = JSON.parse(fs.readFileSync(countsPath, 'utf-8')) + expect(ledger.totalNounCount).toBe(storage.totalNounCount) + expect(await brain.getNounCount()).toBe(ledger.totalNounCount) + }) + + it('every atomic write owns its own temp path โ€” two writes in one millisecond never collide', async () => { + const storage = brain.storage + const tmpNames: string[] = [] + vi.spyOn(fs.promises, 'writeFile').mockImplementation(async (p: any) => { + tmpNames.push(String(p)) + }) + vi.spyOn(fs.promises, 'rename').mockImplementation(async () => undefined) + const target = path.join(dir, 'probe.json') + await Promise.all([ + storage.writeFileAtomic(target, '{"a":1}'), + storage.writeFileAtomic(target, '{"a":2}'), + storage.writeFileAtomic(target, '{"a":3}') + ]) + const probeTmps = tmpNames.filter((n) => n.startsWith(`${target}.tmp-`)) + expect(probeTmps.length).toBe(3) + expect(new Set(probeTmps).size, 'no two writes shared a temp path').toBe(3) + }) +}) From 077cbc0b6fa346b41d7418b657cdbf852960a093 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Tue, 1 Sep 2026 11:29:44 -0700 Subject: [PATCH 12/42] =?UTF-8?q?fix(find):=20connected=20finds=20are=20gr?= =?UTF-8?q?aph-first=20=E2=80=94=20neighbours,=20then=20the=20filter=20ove?= =?UTF-8?q?r=20those=20ids,=20then=20the=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `connected` present, find() materialized the whole-store filtered id list, paged it, hydrated the page, and only then intersected with the neighbour set. Every such call paid O(store) for the filter and the hydration of rows that were never neighbours, and a neighbour outside the first page of the filtered STORE was silently dropped โ€” the answer depended on the store's order and the page size. The neighbour set is now the candidate universe: resolved first from the adjacency, the metadata filter evaluated over those ids only through the provider's own evaluation (a new optional `filterIdsWithin` door on MetadataIndexProvider; the reference index implements it from its own getIdsForFilter so the two can never disagree; a provider without it is served by the whole-store answer intersected here), `orderBy` sorts the whole neighbour set before the page is cut, and the vector leg walks the neighbours as its candidate set. The text leg of a hybrid find keeps its post-intersection โ€” it has no candidate door. Pinned in tests/integration/find-connected-order.test.ts: paging reaches every matching neighbour and never a non-neighbour; a `missing` negation is evaluated over the neighbours; the index is asked about the neighbour ids only and hydration is one page; orderBy sorts the whole set; the vector leg stays inside the neighbours; an edgeless anchor answers [] before the filter is asked. --- src/brainy.ts | 121 ++++++++++--- src/plugin.ts | 13 ++ src/utils/metadataIndex.ts | 13 ++ .../integration/find-connected-order.test.ts | 165 ++++++++++++++++++ 4 files changed, 290 insertions(+), 22 deletions(-) create mode 100644 tests/integration/find-connected-order.test.ts diff --git a/src/brainy.ts b/src/brainy.ts index 06c947c2..17fa4ad9 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -7470,7 +7470,37 @@ export class Brainy implements BrainyInterface { // JS path โ€” there the materialized `candidateIds` restricts the walk instead. let preResolvedAllowedIds: OpaqueIdSet | undefined - if (params.where || params.type || params.subtype || params.service || params.excludeVFS) { + // Graph-first law (10.4.8, BRAINY-PROD-LATENCY-TRIAD rounds 44/45): with + // `connected` present the NEIGHBOUR SET is the candidate universe. It is + // resolved first from the adjacency (O(neighbours)), the metadata filter + // is evaluated over those ids only, and paging happens LAST. The earlier + // order materialized the whole-store filtered id list, paged it, hydrated + // the page, and only then intersected with the neighbours โ€” O(store) per + // call, and a neighbour outside the first page was silently dropped. + let graphFirstIds: string[] | null = null + if (hasGraphCriteria) { + graphFirstIds = await this.resolveConnectedIds(params) + if (hiddenIds.size > 0) { + graphFirstIds = graphFirstIds.filter((id) => !hiddenIds.has(id)) + } + if ( + graphFirstIds.length > 0 && + (params.where || params.type || params.subtype || params.service || params.excludeVFS) + ) { + preResolvedFilter = this.buildMetadataFilter(params) + graphFirstIds = await this.filterIdsWithinBelted(preResolvedFilter, graphFirstIds) + } + if (graphFirstIds.length === 0) { + return [] + } + if (!hasVectorSearchCriteria) { + return await this.pageConnectedIds(params, graphFirstIds) + } + // The vector leg walks ONLY the neighbours (its candidate walk). The + // filter is already applied above, so no opaque universe is produced โ€” + // it would describe the whole store, not the neighbour set. + preResolvedMetadataIds = graphFirstIds + } else if (params.where || params.type || params.subtype || params.service || params.excludeVFS) { preResolvedFilter = this.buildMetadataFilter(params) preResolvedMetadataIds = await this.filterIdsBelted(preResolvedFilter) @@ -7659,9 +7689,11 @@ export class Brainy implements BrainyInterface { } } - // Graph search component with O(1) traversal - if (params.connected) { - results = await this.executeGraphSearch(params, results) + // The text leg of a hybrid find has no candidate door, so its hits are + // held to the neighbour set here; the vector leg walked only the neighbours. + if (graphFirstIds !== null && results.length > 0) { + const neighbourSet = new Set(graphFirstIds) + results = results.filter((r) => neighbourSet.has(r.id)) } // Apply fusion scoring if requested @@ -12776,6 +12808,29 @@ export class Brainy implements BrainyInterface { } } + /** + * The id-scoped twin of {@link filterIdsBelted}: evaluate `filter` over `ids` + * only, through the provider's own evaluation so the answer can never drift + * from `getIdsForFilter`'s. A provider without the door is served by its + * whole-store answer intersected here (the reference index implements the + * door itself). Same belt: field refusals cross as `BrainyFieldRefusal`. + */ + private async filterIdsWithinBelted(filter: unknown, ids: readonly string[]): Promise { + this.ensureIndexesLoaded(['metadata']) + const mip = this.metadataIndex as unknown as MetadataIndexProvider + try { + if (typeof mip.filterIdsWithin === 'function') { + return await mip.filterIdsWithin(filter, ids) + } + const matched = new Set(await this.metadataIndex.getIdsForFilter(filter)) + return ids.filter((id) => matched.has(id)) + } catch (err) { + const normalized = asBrainyFieldRefusal(err) + if (normalized) throw normalized + throw err + } + } + async getIndexStatus(): Promise<{ initialized: boolean /** `true` once open()'s index-build-if-needed step has run. Named for API @@ -15759,16 +15814,16 @@ export class Brainy implements BrainyInterface { } /** - * Execute graph search component. + * Resolve `params.connected` to the neighbour id set โ€” the graph-first + * find's candidate universe (deterministic traversal order, anchors excluded). * * Honors the full `GraphConstraints` contract: multi-hop `depth` (breadth-first via - * `neighbors()`), `via`/`type` verb-type filtering, and `direction`. Previously this read - * only `from`/`to`/`direction` and did a single 1-hop `getNeighbors()`, so `depth` and `via` - * were silently ignored โ€” `find({ connected: { from, depth: 3 } })` returned only the - * immediate neighbour at every depth. + * `neighbors()`), `via`/`type` verb-type filtering, and `direction`. An empty set + * is re-verified against the adjacency before it is believed โ€” a not-serving + * adjacency throws rather than answering `[]` as truth. */ - private async executeGraphSearch(params: FindParams, existingResults: Result[]): Promise[]> { - if (!params.connected) return existingResults + private async resolveConnectedIds(params: FindParams): Promise { + if (!params.connected) return [] const { from, to, depth, direction = 'both' } = params.connected const via = params.connected.via ?? params.connected.type @@ -15822,8 +15877,8 @@ export class Brainy implements BrainyInterface { if (anchorInt === undefined) return new Set() // unmapped โ†’ no relations const verbTypeIndex = TypeUtils.getVerbIndex(via as VerbType) - // No limit: match the JS BFS exactly โ€” overall result limiting happens - // downstream against existingResults. + // No limit: match the JS BFS exactly โ€” the page is cut downstream, + // after the metadata filter, by pageConnectedIds / the candidate walk. const reachedInts = await provider.findConnectedSubtype( anchorInt, verbTypeIndex, subtypeArr[0], effectiveDepth, null ) @@ -15908,22 +15963,44 @@ export class Brainy implements BrainyInterface { await this.verifyGraphAdjacencyLive() } - // Filter existing results to only connected entities - if (existingResults.length > 0) { - return existingResults.filter(r => connectedIds.has(r.id)) - } + return [...connectedIds] + } - // Batch-load connected entities for fast cloud-storage performance + /** + * Page and hydrate an already-filtered neighbour set โ€” the pure graph (and + * graph + metadata) find's tail. `orderBy` sorts the WHOLE set by field value + * before the page is cut (never the page after), null values last on `asc` + * and first on `desc`; without `orderBy` the traversal order stands. + */ + private async pageConnectedIds(params: FindParams, ids: string[]): Promise[]> { + const limit = params.limit || 10 + const offset = params.offset || 0 + let ordered = ids + if (params.orderBy) { + const field = params.orderBy + const asc = (params.order || 'asc') === 'asc' + const valued = await Promise.all( + ids.map(async (id) => ({ id, value: await this.metadataIndex.getFieldValueForEntity(id, field) })) + ) + valued.sort((a, b) => { + if (a.value == null && b.value == null) return 0 + if (a.value == null) return asc ? 1 : -1 + if (b.value == null) return asc ? -1 : 1 + if (a.value === b.value) return 0 + const comparison = a.value < b.value ? -1 : 1 + return asc ? comparison : -comparison + }) + ordered = valued.map((v) => v.id) + } + const pageIds = ordered.slice(offset, offset + limit) + const entitiesMap = await this.batchGet(pageIds) const results: Result[] = [] - const ids = [...connectedIds] - const entitiesMap = await this.batchGet(ids) - for (const id of ids) { + for (const id of pageIds) { const entity = entitiesMap.get(id) if (entity) { results.push(this.createResult(id, 1.0, entity)) } } - return results } diff --git a/src/plugin.ts b/src/plugin.ts index b1aef8e0..15b14b4e 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -411,6 +411,19 @@ export interface MetadataIndexProvider { * @returns The matching id universe as an opaque set. */ getIdSetForFilter?(filter: any): Promise + /** + * @description OPTIONAL: evaluate `filter` over `ids` ONLY and return the + * survivors in the caller's order โ€” the door a graph-first + * `find({ connected, where })` walks. The neighbour set is the universe there, + * so the filter must cost O(|ids|) membership checks, never a whole-store + * materialization. A native index answers from its roaring filter result + * (membership by entity int); the reference index answers from its own + * `getIdsForFilter`, so the two doors can never disagree. Absent โ†’ Brainy + * intersects `getIdsForFilter`'s answer with `ids` itself (correct, O(store)). + * @param filter - The same filter shape accepted by `getIdsForFilter`. + * @param ids - The candidate ids (canonical). The answer is a subsequence. + */ + filterIdsWithin?(filter: any, ids: readonly string[]): Promise getIdsForTextQuery(query: string): Promise> getSortedIdsForFilter(filter: any, orderBy: string, order?: 'asc' | 'desc', topK?: number): Promise getFilterValues(field: string): Promise diff --git a/src/utils/metadataIndex.ts b/src/utils/metadataIndex.ts index 3e0e3d17..0fd312e2 100644 --- a/src/utils/metadataIndex.ts +++ b/src/utils/metadataIndex.ts @@ -2575,6 +2575,19 @@ export class MetadataIndexManager implements MetadataIndexProvider { /** Once-per-field flag for the fallback-degradation announcement. */ private static announcedFallbackSorts = new Set() + /** + * Evaluate `filter` over `ids` only โ€” the graph-first find's door (the + * neighbour set filtered by id, never the store filtered and then + * intersected). This index answers from its own `getIdsForFilter`, so the + * two doors cannot disagree; the cost is that of the filter over this + * in-memory index, and the answer keeps the caller's order. + */ + async filterIdsWithin(filter: any, ids: readonly string[]): Promise { + if (ids.length === 0) return [] + const matched = new Set(await this.getIdsForFilter(filter)) + return ids.filter((id) => matched.has(id)) + } + async getSortedIdsForFilter( filter: any, orderBy: string, diff --git a/tests/integration/find-connected-order.test.ts b/tests/integration/find-connected-order.test.ts new file mode 100644 index 00000000..b04e7f99 --- /dev/null +++ b/tests/integration/find-connected-order.test.ts @@ -0,0 +1,165 @@ +/** + * @module tests/integration/find-connected-order + * @description The graph-first law for `find({ connected })` (10.4.8). + * + * With `connected` present the neighbour set is the candidate universe: it is + * resolved from the adjacency first, the metadata filter is evaluated over + * those ids only, and the page is cut last. The earlier order materialized the + * whole-store filtered id list, paged it, hydrated the page, and only then + * intersected with the neighbours โ€” so a neighbour outside the first page of + * the filtered STORE was silently dropped, and every call paid O(store). + * + * These pins hold both halves. The answer: every matching neighbour is + * reachable by paging, a non-neighbour never appears, a negation (`missing`) + * is evaluated over the neighbours, `orderBy` sorts the whole neighbour set + * before the page is cut, and the vector leg walks the neighbours only. The + * cost shape: the metadata index is asked about the neighbour ids only, and + * hydration is one page โ€” never the store. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import { Brainy } from '../../src/brainy' +import { NounType, VerbType } from '../../src/types/graphTypes' +import { v5 } from '../../src/universal/uuid' +import { generateTestVector } from '../helpers/test-factory' + +/** Matching rows that are NOT neighbours โ€” added FIRST, so the whole-store filtered list leads with them. */ +const NOISE = 120 +/** Matching rows that ARE neighbours of the anchor. */ +const NEIGHBOURS = 30 +/** Neighbours carrying `retracted: true` โ€” excluded by the `missing` negation. */ +const RETRACTED = 4 + +describe('find({ connected }) is graph-first: neighbours โ†’ filter โ†’ page', () => { + let brain: Brainy + const anchor = 'anchor' + const sharedVector = generateTestVector() + const neighbourIds = new Set(Array.from({ length: NEIGHBOURS }, (_, i) => v5(`nb-${i}`))) + + beforeAll(async () => { + brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) + await brain.init() + await brain.add({ + id: anchor, + data: 'the anchor', + type: NounType.Person, + metadata: { kind: 'anchor' }, + vector: generateTestVector() + }) + for (let i = 0; i < NOISE; i++) { + await brain.add({ + id: `noise-${i}`, + data: `noise ${i}`, + type: NounType.Person, + metadata: { kind: 'note', rank: 1000 + i }, + vector: sharedVector + }) + } + for (let i = 0; i < NEIGHBOURS; i++) { + await brain.add({ + id: `nb-${i}`, + data: `neighbour ${i}`, + type: NounType.Person, + metadata: { kind: 'note', rank: i + 1, ...(i < RETRACTED ? { retracted: true } : {}) }, + vector: sharedVector + }) + await brain.relate({ from: anchor, to: `nb-${i}`, type: VerbType.Knows }) + } + }) + + afterAll(async () => { + brain = null as any + }) + + it('returns the matching neighbours page by page โ€” none dropped, never a non-neighbour', async () => { + const seen = new Set() + for (let offset = 0; offset <= NEIGHBOURS; offset += 10) { + const page = await brain.find({ + connected: { from: anchor, direction: 'out' }, + where: { kind: 'note' }, + limit: 10, + offset + }) + expect(page).toHaveLength(offset < NEIGHBOURS ? 10 : 0) + for (const r of page) { + expect(neighbourIds.has(r.entity.id)).toBe(true) + expect(seen.has(r.entity.id)).toBe(false) + seen.add(r.entity.id) + } + } + expect(seen.size).toBe(NEIGHBOURS) + }) + + it('evaluates a negation (`missing`) over the neighbour set, not the store', async () => { + const results = await brain.find({ + connected: { from: anchor, direction: 'out' }, + where: { kind: 'note', retracted: { missing: true } }, + limit: 100 + }) + expect(results).toHaveLength(NEIGHBOURS - RETRACTED) + for (const r of results) { + expect(neighbourIds.has(r.entity.id)).toBe(true) + expect(r.entity.metadata.retracted).toBeUndefined() + } + }) + + it('asks the metadata index about the neighbour ids only, and hydrates one page', async () => { + const index = (brain as any).metadataIndex + const within = vi.spyOn(index, 'filterIdsWithin') + const hydrate = vi.spyOn(brain as any, 'batchGet') + try { + const results = await brain.find({ + connected: { from: anchor, direction: 'out' }, + where: { kind: 'note' }, + limit: 10 + }) + expect(results).toHaveLength(10) + expect(within).toHaveBeenCalledTimes(1) + const askedIds = within.mock.calls[0][1] as string[] + expect(askedIds).toHaveLength(NEIGHBOURS) + for (const id of askedIds) expect(neighbourIds.has(id)).toBe(true) + expect(hydrate).toHaveBeenCalledTimes(1) + expect(hydrate.mock.calls[0][0]).toHaveLength(10) + } finally { + within.mockRestore() + hydrate.mockRestore() + } + }) + + it('orders the WHOLE neighbour set before cutting the page', async () => { + const results = await brain.find({ + connected: { from: anchor, direction: 'out' }, + where: { kind: 'note' }, + orderBy: 'rank', + order: 'desc', + limit: 5 + }) + expect(results.map((r) => r.entity.metadata.rank)).toEqual([30, 29, 28, 27, 26]) + }) + + it('walks the vector leg over the neighbours only', async () => { + const results = await brain.find({ + vector: sharedVector, + connected: { from: anchor, direction: 'out' }, + where: { kind: 'note' }, + limit: 5 + }) + expect(results).toHaveLength(5) + for (const r of results) expect(neighbourIds.has(r.entity.id)).toBe(true) + }) + + it('an anchor without neighbours answers [] before the filter is asked', async () => { + const index = (brain as any).metadataIndex + const within = vi.spyOn(index, 'filterIdsWithin') + try { + const results = await brain.find({ + connected: { from: 'noise-0', direction: 'out' }, + where: { kind: 'note' }, + limit: 10 + }) + expect(results).toEqual([]) + expect(within).not.toHaveBeenCalled() + } finally { + within.mockRestore() + } + }) +}) From e64e2bc17580737a0b7a63e2af8ea6b6c527278a Mon Sep 17 00:00:00 2001 From: David Snelling Date: Tue, 1 Sep 2026 12:04:29 -0700 Subject: [PATCH 13/42] =?UTF-8?q?docs(releases):=20the=20release-notes=20d?= =?UTF-8?q?oor=20=E2=80=94=20owner-language=20notes=20for=20both=20engines?= =?UTF-8?q?,=20backfilled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fleet's releases wall reads one public URL per product. These files are that door for Brainy and Open Brainy: newest first, honest history from the changelog, one entry appended by every release from here on. --- releases/brainy.json | 52 +++++++++++++++++++++++ releases/open-brainy.json | 87 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 releases/brainy.json create mode 100644 releases/open-brainy.json diff --git a/releases/brainy.json b/releases/brainy.json new file mode 100644 index 00000000..17217a6e --- /dev/null +++ b/releases/brainy.json @@ -0,0 +1,52 @@ +{ + "product": "brainy", + "entries": [ + { + "version": "11.0.3", + "date": "2026-09-01", + "headline": "The embedding upgrade ceremony runs on every brain", + "items": [ + "A brain opened through the standard plugin now carries its embedding-model identity, so the full-precision upgrade ceremony can run on it.", + "A one-fix release; nothing else changed." + ], + "url": null, + "thumb": null + }, + { + "version": "11.0.2", + "date": "2026-08-31", + "headline": "One embedding quality everywhere, 3โ€“4ร— faster imports", + "items": [ + "Every runtime embeds with the same full-precision model โ€” search quality no longer depends on where you run.", + "Bulk embedding measured 3.1โ€“4.2ร— faster, and an online re-embed ceremony upgrades existing stores without downtime.", + "The engine's change feed is documented, with the SSE/WebSocket fan-out pattern for realtime surfaces." + ], + "url": null, + "thumb": null + }, + { + "version": "11.0.1", + "date": "2026-08-31", + "headline": "Deletes inside transactions are safe", + "items": [ + "Deleting relations inside a transact() no longer corrupts index bookkeeping.", + "A store that deletes its last relation keeps serving instead of refusing." + ], + "url": null, + "thumb": null + }, + { + "version": "11.0.0", + "date": "2026-08-28", + "headline": "One install, one engine โ€” Brainy", + "items": [ + "The former two-package pair is one package: the native engine under the familiar API. One import is the whole install.", + "A missing native build refuses loudly with its cures named; nothing falls back silently.", + "Stores open in place โ€” no migration." + ], + "url": null, + "thumb": null + } + ], + "history": "The version line continues from the 4.3.x native-engine releases; their record lives in the product repository's CHANGELOG.md." +} diff --git a/releases/open-brainy.json b/releases/open-brainy.json new file mode 100644 index 00000000..21014f5b --- /dev/null +++ b/releases/open-brainy.json @@ -0,0 +1,87 @@ +{ + "product": "open-brainy", + "entries": [ + { + "version": "10.4.6", + "date": "2026-08-31", + "headline": "Transactions cross the index seam safely", + "items": [ + "Deleting relations inside a transact() no longer fails against the metadata index โ€” operations take a JSON-safe view at the moment they execute.", + "Fixes a class of transaction failures on stores with integer-mapped relation endpoints." + ], + "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.6", + "thumb": null + }, + { + "version": "10.4.5", + "date": "2026-08-31", + "headline": "Recovery tells the truth, docs live at home", + "items": [ + "A torn generation-log tail is a terminal verdict with a named cure โ€” never an endless wait at open.", + "A sealed segment declares only the generations it actually holds.", + "The engine's documentation now publishes from its own repository." + ], + "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.5", + "thumb": null + }, + { + "version": "10.4.4", + "date": "2026-08-28", + "headline": "Faster opens, quieter idle", + "items": [ + "Opening a store discovers generations from directory names instead of walking the log, and answers \"any entities?\" with one directory read.", + "The flush-request watch is event-driven; idle stores stop paying a polling heartbeat.", + "A slow open now names the exact step it is in, so operators see what is being paid and why." + ], + "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.4", + "thumb": null + }, + { + "version": "10.4.3", + "date": "2026-08-27", + "headline": "Open Brainy, under its own name", + "items": [ + "The same engine as 10.4.2, now published as @soulcraftlabs/brainy โ€” the MIT reference engine, on The Source.", + "No code changes; your imports change once and everything else stays put." + ], + "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.3", + "thumb": null + }, + { + "version": "10.4.2", + "date": "2026-08-27", + "headline": "Vectors that lie are refused, counts that drift are caught", + "items": [ + "A zero-norm vector is not a vector: the index refuses them, rebuilds skip them, and a sanctioned unvector door removes them cleanly.", + "The canonical count ledger derives from identity records and marks legacy-derived ledgers suspect at load.", + "Plugin activation failures keep their original error as cause, so the real frame reaches your logs." + ], + "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.2", + "thumb": null + }, + { + "version": "10.4.1", + "date": "2026-08-26", + "headline": "Writes that change nothing cost nothing", + "items": [ + "The read gate is per index family, and a write carrying unchanged data never re-embeds.", + "The vectored-row count joins the ledger, so vector coverage is a number you can read, not a guess." + ], + "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.1", + "thumb": null + }, + { + "version": "10.4.0", + "date": "2026-08-26", + "headline": "Repair routing, the vector ledger, and honest empties", + "items": [ + "Repairs route to the index that owns the damage, and the open gate closes the vector leg until coverage is proven.", + "An empty string is real data, not a missing field.", + "The metadata crossing never carries raw integer relation endpoints โ€” a whole class of serialization faults closed." + ], + "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.0", + "thumb": null + } + ], + "history": "Earlier releases are recorded in CHANGELOG.md in this repository." +} From 88e79729d39744e35c188bca22ce0786946973d6 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Tue, 1 Sep 2026 12:17:55 -0700 Subject: [PATCH 14/42] perf(open): pending-embed recovery is bounded by a low-water mark and runs behind the doors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recovery fold scanned the generation log from generation 1 at every open, on the open's foreground โ€” O(whole history) on long-lived brains (measured at two minutes of a large brain's open). Now an advisory mark records the log's head whenever the pending set drains to empty (and at clean close when empty); recovery scans from the mark + 1. The mark is advisory and monotone-safe: stale-low costs a longer scan, never a marker. The fold itself moves behind the doors as a latched background task โ€” the embed worker starts when it settles, and awaitPendingEmbeds() and close() wait on the latch first, so no caller can observe a half-recovered set. A pending embed's outcome was always eventual; moving its recovery off the foreground changes when the worker starts, never whether a marker is honored. Pinned in tests/integration/pending-embed-low-water.test.ts: the drain writes the mark and the next open scans from mark + 1; a pending embed enqueued after the mark survives an unclean stop; open arms the fold as a background latch the barrier waits on; a clean close writes the mark even without a drain. --- src/brainy.ts | 131 ++++++++++++---- .../pending-embed-low-water.test.ts | 145 ++++++++++++++++++ 2 files changed, 249 insertions(+), 27 deletions(-) create mode 100644 tests/integration/pending-embed-low-water.test.ts diff --git a/src/brainy.ts b/src/brainy.ts index 06c947c2..c9f24873 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -1820,31 +1820,31 @@ export class Brainy implements BrainyInterface { // a deferred write's ack and its background embed DELAYED a vector; // this is where it lands. if (!this.isReadOnly) { - try { - await step( - 'bridge-pending-embed-sidecars', - 'migrating any pre-log deferred-embed marker files into the generation log', - () => this.bridgeLegacyPendingEmbedSidecars() - ) - await step( - 'recover-pending-embeds', - 'folding the generation log\'s deferred-embed markers back into the pending set', - () => this.recoverPendingEmbedsFromLog() - ) - if (this._pendingEmbedIds.size > 0) { - prodLog.info( - `[Brainy] ${this._pendingEmbedIds.size} deferred embed(s) pending from a previous ` + - `session โ€” resuming in the background` + // BEHIND THE DOORS (the open pays nothing here): the bridge + the + // recovery fold run as one latched background task; the embed worker + // starts when it settles. A pending embed's outcome was always + // eventual โ€” moving its recovery off the open's foreground changes + // when the worker starts, never whether a marker is honored. + // awaitPendingEmbeds() and close() wait on the latch first. + this._pendingEmbedRecovery = (async () => { + try { + await this.bridgeLegacyPendingEmbedSidecars() + await this.recoverPendingEmbedsFromLog() + if (this._pendingEmbedIds.size > 0) { + prodLog.info( + `[Brainy] ${this._pendingEmbedIds.size} deferred embed(s) pending from a previous ` + + `session โ€” resuming in the background` + ) + const t = setTimeout(() => this.kickEmbedWorker(), 0) + ;(t as { unref?: () => void }).unref?.() + } + } catch (err) { + prodLog.warn( + `[Brainy] pending-embed recovery failed: ${(err as Error).message} โ€” ` + + `the log's markers remain durable; recovery retries next open` ) - const t = setTimeout(() => this.kickEmbedWorker(), 0) - ;(t as { unref?: () => void }).unref?.() } - } catch (err) { - prodLog.warn( - `[Brainy] pending-embed recovery failed: ${(err as Error).message} โ€” ` + - `the log's markers remain durable; recovery retries next open` - ) - } + })() } // PHASE 4 of 5 โ€” "VFS bootstrap": shutdown-hook registration, blob @@ -2408,6 +2408,19 @@ export class Brainy implements BrainyInterface { */ private static readonly PENDING_EMBED_PREFIX = '_system/pending_embeds/' + /** + * Storage-root-relative path of the ADVISORY pending-embed low-water mark: + * `{ generation, writtenAt }`, written whenever the pending set drains to + * empty (and at clean close when empty). Every marker in facts at or below + * `generation` is consumed, so recovery scans from `generation + 1`. The + * mark is advisory and monotone-safe: stale-low costs a longer scan, never + * a lost marker; it is never required for correctness. + */ + private static readonly PENDING_EMBED_LOWWATER_PATH = '_system/pending_embeds_lowwater.json' + + /** Resolves when the background pending-embed recovery fold has settled (open arms it). */ + private _pendingEmbedRecovery: Promise | null = null + /** * @description Mark a deferred embed pending (MT5): the id joins the * in-memory fast-path set and the returned `embed.pending` record is @@ -2435,6 +2448,40 @@ export class Brainy implements BrainyInterface { */ private clearPendingEmbed(id: string): void { this._pendingEmbedIds.delete(id) + if (this._pendingEmbedIds.size === 0) this.maybeWriteEmbedLowWater() + } + + /** + * @description Advance the advisory low-water mark: called at drain-to-empty + * (and at clean close when empty), it records the fact log's CURRENT head โ€” + * with the set empty, every marker at or below the head has been consumed, + * so the next open's recovery fold scans only what comes after. Fire-and- + * forget at the drain (close() awaits the core); loud on failure: a missed + * write costs the next open a longer scan, never a marker. No-op without a + * fact log (no durable markers exist there) and on read-only opens. + */ + private maybeWriteEmbedLowWater(): void { + void this.writeEmbedLowWater() + } + + /** The awaitable core of {@link maybeWriteEmbedLowWater} โ€” close() awaits it. */ + private async writeEmbedLowWater(): Promise { + if (this.isReadOnly) return + const log = this.generationStore ? this.generationStore.getFactLog() : null + if (!log) return + const generation = log.headGeneration() + if (!(generation > 0)) return + try { + await this.storage.writeRawObject(Brainy.PENDING_EMBED_LOWWATER_PATH, { + generation, + writtenAt: Date.now() + }) + } catch (err) { + prodLog.warn( + `[Brainy] pending-embed low-water write failed at generation ${generation}: ` + + `${(err as Error).message} โ€” the next open scans from the previous mark` + ) + } } /** @@ -2445,9 +2492,14 @@ export class Brainy implements BrainyInterface { * survives the fold is exactly the set of acknowledged deferred writes * whose vectors have not landed. * - * BOUND (honest): no durable low-water mark exists for the earliest - * unconsumed pending, so the fold scans the log's committed facts from - * generation 1 โ€” a sequential read of the log at open, O(log bytes). + * BOUND: the scan starts at the advisory low-water mark + * ({@link Brainy.PENDING_EMBED_LOWWATER_PATH}) โ€” the log head at which the + * pending set last drained to empty โ€” so a settled brain reads only the + * facts since then, not its whole history. Without a mark (first open + * after upgrade) it scans from generation 1, once; a stale-low mark costs + * a longer scan, never a marker. The fold runs BEHIND the doors (open + * arms it as a background task and the embed worker starts when it + * settles); {@link awaitPendingEmbeds} and close() wait for it first. * It is SKIPPED WHOLESALE when the log has never had a v2 tail * ({@link FactLog.hasV2History} โ€” v1 facts cannot carry marker records), * so pre-cutover brains pay nothing; on a mixed log the scan still reads @@ -2460,7 +2512,18 @@ export class Brainy implements BrainyInterface { private async recoverPendingEmbedsFromLog(): Promise { const log = this.generationStore.getFactLog() if (!log || !log.hasV2History()) return - const scan = log.scanFacts({ fromGeneration: 1 }) + let fromGeneration = 1 + try { + const mark = (await this.storage.readRawObject(Brainy.PENDING_EMBED_LOWWATER_PATH)) as { + generation?: number + } | null + if (mark && typeof mark.generation === 'number' && mark.generation > 0) { + fromGeneration = mark.generation + 1 + } + } catch { + // No mark (or unreadable): scan from 1 โ€” correctness over cost. + } + const scan = log.scanFacts({ fromGeneration }) for await (const batch of scan.batches()) { for (const fact of batch.facts) { for (const record of fact.records ?? []) { @@ -2647,6 +2710,7 @@ export class Brainy implements BrainyInterface { * before I proceed" callers use this; nothing else ever needs to wait. */ public async awaitPendingEmbeds(): Promise { + if (this._pendingEmbedRecovery) await this._pendingEmbedRecovery while (this._pendingEmbedIds.size > 0 || this._embedWorkerFlight) { this.kickEmbedWorker() await (this._embedWorkerFlight ?? Promise.resolve()) @@ -19443,6 +19507,19 @@ export class Brainy implements BrainyInterface { * terminal releases have run. */ async close(): Promise { + if (this._pendingEmbedRecovery) { + // Settle the background marker fold before the durable steps โ€” its scan + // is bounded by the low-water mark (a full scan happens at most once, + // on the first open after upgrade). + const settleStart = Date.now() + await this._pendingEmbedRecovery + const settleMs = Date.now() - settleStart + if (settleMs >= 1000) { + prodLog.info(`[Brainy] close: pending-embed recovery settled in ${settleMs}ms`) + } + this._pendingEmbedRecovery = null + } + if (this._pendingEmbedIds.size === 0) await this.writeEmbedLowWater() let closeFailure: unknown = null try { await this.closeDurableSteps() diff --git a/tests/integration/pending-embed-low-water.test.ts b/tests/integration/pending-embed-low-water.test.ts new file mode 100644 index 00000000..ff01b349 --- /dev/null +++ b/tests/integration/pending-embed-low-water.test.ts @@ -0,0 +1,145 @@ +/** + * @module tests/integration/pending-embed-low-water + * @description The pending-embed recovery fold is bounded and background (10.4.9). + * + * The fold used to scan the generation log from generation 1 at EVERY open, + * on the open's foreground โ€” O(whole history) per open on long-lived brains. + * Now: an advisory low-water mark (`_system/pending_embeds_lowwater.json`) + * records the committed generation whenever the pending set drains to empty, + * recovery scans from `mark + 1`, and the fold runs behind the doors as a + * latched background task the worker, `awaitPendingEmbeds()` and `close()` + * wait on. The mark is advisory: stale-low costs a longer scan, never a + * marker โ€” a pending embed enqueued before a crash is still recovered. + */ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Brainy } from '../../src/brainy' +import { NounType } from '../../src/types/graphTypes' + +const LOWWATER_PATH = '_system/pending_embeds_lowwater.json' + +describe('pending-embed recovery: bounded by the low-water mark, behind the doors', () => { + const roots: string[] = [] + const dir = (): string => { + const d = mkdtempSync(join(tmpdir(), 'brainy-lowwater-')) + roots.push(d) + return d + } + const open = async (root: string): Promise> => { + const brain = new Brainy({ + requireSubtype: false, + storage: { type: 'filesystem', path: root } + }) + await brain.init() + return brain + } + + afterEach(() => { + for (const d of roots.splice(0)) rmSync(d, { recursive: true, force: true }) + }) + + it('drain-to-empty writes the mark, and the next open scans from mark + 1', async () => { + const root = dir() + const brain = await open(root) + // Hold the worker so the pending state is observable, then release it. + const realKick = (brain as any).kickEmbedWorker.bind(brain) + ;(brain as any).kickEmbedWorker = () => {} + await brain.add({ + id: 'row-1', + data: 'the first deferred row', + type: NounType.Thing, + deferEmbedding: true + }) + expect(brain.pendingEmbedCount()).toBeGreaterThan(0) + ;(brain as any).kickEmbedWorker = realKick + await brain.awaitPendingEmbeds() + // The drain wrote the advisory mark (fire-and-forget: settle the microtask). + await new Promise((r) => setTimeout(r, 50)) + const mark = (await (brain as any).storage.readRawObject(LOWWATER_PATH)) as { + generation: number + } | null + expect(mark).not.toBeNull() + expect(mark!.generation).toBeGreaterThan(0) + await brain.close() + + const brain2 = await open(root) + const log = (brain2 as any).generationStore.getFactLog() + const scanSpy = vi.spyOn(log, 'scanFacts') + try { + await (brain2 as any).recoverPendingEmbedsFromLog() + expect(scanSpy).toHaveBeenCalledTimes(1) + const opts = scanSpy.mock.calls[0][0] as { fromGeneration?: number } + expect(opts.fromGeneration).toBeGreaterThanOrEqual(mark!.generation + 1) + } finally { + scanSpy.mockRestore() + await brain2.close() + } + }) + + it('a pending embed enqueued after the mark survives an unclean stop', async () => { + const root = dir() + const brain = await open(root) + await brain.add({ id: 'settled', data: 'lands before the mark', type: NounType.Thing }) + await brain.awaitPendingEmbeds() + await new Promise((r) => setTimeout(r, 50)) + + // A deferred write whose embed never lands: block the worker, then drop + // the instance without close() โ€” the unclean-stop shape. + ;(brain as any).kickEmbedWorker = () => {} + await brain.add({ + id: 'orphan', + data: 'enqueued then abandoned', + type: NounType.Thing, + deferEmbedding: true + }) + expect(brain.pendingEmbedCount()).toBeGreaterThan(0) + // No close(): simulate the crash by releasing only the writer lock so the + // next open can proceed. + await (brain as any).storage.releaseWriterLock() + + const brain2 = await open(root) + await (brain2 as any)._pendingEmbedRecovery + expect(brain2.pendingEmbedCount()).toBeGreaterThan(0) + await brain2.awaitPendingEmbeds() + expect(brain2.pendingEmbedCount()).toBe(0) + await brain2.close() + // Reap the crashed instance: its fence is gone, so close() fails loudly โ€” + // swallow that here; the point is clearing its watchers and registry entry. + await brain.close().catch(() => undefined) + }) + + it('open arms the fold as a background latch; awaitPendingEmbeds waits on it', async () => { + const root = dir() + const brain = await open(root) + await brain.add({ id: 'a-row', data: 'some data', type: NounType.Thing }) + await brain.awaitPendingEmbeds() + await brain.close() + + const brain2 = await open(root) + // The latch exists the moment init() returns (writable filesystem brain)โ€ฆ + expect((brain2 as any)._pendingEmbedRecovery).not.toBeNull() + // โ€ฆand the barrier settles it before answering. + await brain2.awaitPendingEmbeds() + expect(brain2.pendingEmbedCount()).toBe(0) + await brain2.close() + }) + + it('a clean close with an empty set writes the mark even if no drain happened', async () => { + const root = dir() + const brain = await open(root) + await brain.add({ id: 'r1', data: 'row one', type: NounType.Thing }) + await brain.awaitPendingEmbeds() + await brain.close() + // Read the mark back through the storage door (the adapter owns the + // on-disk encoding), on a fresh instance. + const brain2 = await open(root) + const mark = (await (brain2 as any).storage.readRawObject(LOWWATER_PATH)) as { + generation: number + } | null + expect(mark).not.toBeNull() + expect(mark!.generation).toBeGreaterThan(0) + await brain2.close() + }) +}) From 6a89adc46855e6d8b3ed0241fc376f7ab2ecd934 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Tue, 1 Sep 2026 12:20:08 -0700 Subject: [PATCH 15/42] fix(graph): the verb fast paths honour every requested type, source, and target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit related() with a verb-type ARRAY returned edges for only the first type โ€” the storage fast paths collapsed `verbType` (and, in their sibling blocks, `sourceId` and `targetId`) arrays to their first element, silently dropping the rest of the ask. Every consumer passing a verb list under-traversed with no error and no narration: the same quiet-loss class as the graph-first paging defect, one seam over. All four fast paths now union over the full requested set, deduped by edge id, before the metadata filters and pagination run. Pinned in tests/integration/related-verb-array.test.ts: the second requested type's edge returns in both array orders, on the anchor side, the target side, and the type-only path; a one-element array equals the scalar; no duplicates on overlap; pagination walks the union consistently. --- src/storage/baseStorage.ts | 113 ++++++++++++------- tests/integration/related-verb-array.test.ts | 89 +++++++++++++++ 2 files changed, 163 insertions(+), 39 deletions(-) create mode 100644 tests/integration/related-verb-array.test.ts diff --git a/src/storage/baseStorage.ts b/src/storage/baseStorage.ts index d8bcb780..a1cc2e35 100644 --- a/src/storage/baseStorage.ts +++ b/src/storage/baseStorage.ts @@ -2942,19 +2942,33 @@ export abstract class BaseStorage extends BaseStorageAdapter { !options.filter.service && !options.filter.metadata ) { - const sourceId = Array.isArray(options.filter.sourceId) - ? options.filter.sourceId[0] - : options.filter.sourceId + const sourceIds = Array.isArray(options.filter.sourceId) + ? options.filter.sourceId + : [options.filter.sourceId] - const verbType = Array.isArray(options.filter.verbType) - ? options.filter.verbType[0] - : options.filter.verbType + // EVERY requested verb type is honoured โ€” an array used to collapse to + // its first element here, silently dropping the rest of the ask. + const verbTypes = new Set( + Array.isArray(options.filter.verbType) + ? options.filter.verbType + : [options.filter.verbType] + ) - // Get verbs by source, then filter by type (O(1) graph lookup + O(n) type filter), - // then apply the subtype / visibility metadata filters on the candidate set. - const verbsBySource = await this.getVerbsBySource_internal(sourceId) + // Get verbs by source (union over every requested source), filter by the + // requested type SET (O(1) graph lookup + O(n) type filter), then apply + // the subtype / visibility metadata filters on the candidate set. + const bySource: HNSWVerbWithMetadata[] = [] + const seenVerbIds = new Set() + for (const oneSource of sourceIds) { + for (const v of await this.getVerbsBySource_internal(oneSource)) { + if (!seenVerbIds.has(v.id)) { + seenVerbIds.add(v.id) + bySource.push(v) + } + } + } const filteredVerbs = this.applyVerbMetadataFilters( - verbsBySource.filter(v => v.verb === verbType), + bySource.filter(v => verbTypes.has(v.verb)), options.filter ) @@ -2985,16 +2999,22 @@ export abstract class BaseStorage extends BaseStorageAdapter { !options.filter.service && !options.filter.metadata ) { - const sourceId = Array.isArray(options.filter.sourceId) - ? options.filter.sourceId[0] - : options.filter.sourceId - - // Get verbs by source directly (hydrated with metadata), then apply the - // subtype / visibility metadata filters on the O(degree) candidate set. - const verbsBySource = this.applyVerbMetadataFilters( - await this.getVerbsBySource_internal(sourceId), - options.filter - ) + // EVERY requested source is honoured โ€” an array used to collapse to + // its first element here, silently dropping the rest of the ask. + const onlySourceIds = Array.isArray(options.filter.sourceId) + ? options.filter.sourceId + : [options.filter.sourceId] + const sourceUnion: HNSWVerbWithMetadata[] = [] + const seenSourceVerbIds = new Set() + for (const oneSource of onlySourceIds) { + for (const v of await this.getVerbsBySource_internal(oneSource)) { + if (!seenSourceVerbIds.has(v.id)) { + seenSourceVerbIds.add(v.id) + sourceUnion.push(v) + } + } + } + const verbsBySource = this.applyVerbMetadataFilters(sourceUnion, options.filter) // Apply pagination const paginatedVerbs = verbsBySource.slice(offset, offset + limit) @@ -3023,16 +3043,22 @@ export abstract class BaseStorage extends BaseStorageAdapter { !options.filter.service && !options.filter.metadata ) { - const targetId = Array.isArray(options.filter.targetId) - ? options.filter.targetId[0] - : options.filter.targetId - - // Get verbs by target directly (hydrated with metadata), then apply the - // subtype / visibility metadata filters on the O(degree) candidate set. - const verbsByTarget = this.applyVerbMetadataFilters( - await this.getVerbsByTarget_internal(targetId), - options.filter - ) + // EVERY requested target is honoured โ€” an array used to collapse to + // its first element here, silently dropping the rest of the ask. + const onlyTargetIds = Array.isArray(options.filter.targetId) + ? options.filter.targetId + : [options.filter.targetId] + const targetUnion: HNSWVerbWithMetadata[] = [] + const seenTargetVerbIds = new Set() + for (const oneTarget of onlyTargetIds) { + for (const v of await this.getVerbsByTarget_internal(oneTarget)) { + if (!seenTargetVerbIds.has(v.id)) { + seenTargetVerbIds.add(v.id) + targetUnion.push(v) + } + } + } + const verbsByTarget = this.applyVerbMetadataFilters(targetUnion, options.filter) // Apply pagination const paginatedVerbs = verbsByTarget.slice(offset, offset + limit) @@ -3061,16 +3087,25 @@ export abstract class BaseStorage extends BaseStorageAdapter { !options.filter.service && !options.filter.metadata ) { - const verbType = Array.isArray(options.filter.verbType) - ? options.filter.verbType[0] - : options.filter.verbType + // EVERY requested verb type is honoured โ€” an array used to collapse to + // its first element here, silently dropping the rest of the ask. + const verbTypes = Array.isArray(options.filter.verbType) + ? options.filter.verbType + : [options.filter.verbType] - // Get verbs by type directly (hydrated with metadata), then apply the - // subtype / visibility metadata filters on the candidate set. - const verbsByType = this.applyVerbMetadataFilters( - await this.getVerbsByType_internal(verbType), - options.filter - ) + // Get verbs by each requested type (hydrated with metadata), deduped by + // id, then apply the subtype / visibility metadata filters on the set. + const byType: HNSWVerbWithMetadata[] = [] + const seenTypeVerbIds = new Set() + for (const oneType of verbTypes) { + for (const v of await this.getVerbsByType_internal(oneType)) { + if (!seenTypeVerbIds.has(v.id)) { + seenTypeVerbIds.add(v.id) + byType.push(v) + } + } + } + const verbsByType = this.applyVerbMetadataFilters(byType, options.filter) // Apply pagination const paginatedVerbs = verbsByType.slice(offset, offset + limit) diff --git a/tests/integration/related-verb-array.test.ts b/tests/integration/related-verb-array.test.ts new file mode 100644 index 00000000..36a49850 --- /dev/null +++ b/tests/integration/related-verb-array.test.ts @@ -0,0 +1,89 @@ +/** + * @module tests/integration/related-verb-array + * @description related() honours EVERY verb type in an array (10.4.9). + * + * The storage fast paths for `sourceId + verbType` and `verbType` collapsed a + * verb-type ARRAY to its first element โ€” `related({ from, type: [a, b] })` + * silently returned only `a` edges, whichever order the array came in. The + * same quiet-loss class as the graph-first paging defect, one seam over. + * These pins seed a store where the SECOND requested type's edge must come + * back, on every path the collapse lived in. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { Brainy } from '../../src/brainy' +import { NounType, VerbType } from '../../src/types/graphTypes' +import { v5 } from '../../src/universal/uuid' + +describe('related() with a verb-type array returns every requested type', () => { + let brain: Brainy + + beforeAll(async () => { + brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) + await brain.init() + for (const id of ['a', 'b', 'c', 'd']) { + await brain.add({ id, data: `node ${id}`, type: NounType.Person }) + } + await brain.relate({ from: 'a', to: 'b', type: VerbType.Supports }) + await brain.relate({ from: 'a', to: 'c', type: VerbType.RelatedTo }) + await brain.relate({ from: 'a', to: 'd', type: VerbType.Knows }) + await brain.relate({ from: 'b', to: 'c', type: VerbType.RelatedTo }) + }) + + afterAll(async () => { + brain = null as any + }) + + it('from + type array: the second type\'s edge comes back, both orders', async () => { + for (const types of [ + [VerbType.Supports, VerbType.RelatedTo], + [VerbType.RelatedTo, VerbType.Supports] + ]) { + const edges = await brain.related({ from: 'a', type: types }) + const targets = new Set(edges.map((e) => e.to)) + expect(targets.has(v5('b')), `types [${types}] missing Supports edge`).toBe(true) + expect(targets.has(v5('c')), `types [${types}] missing RelatedTo edge`).toBe(true) + expect(targets.has(v5('d'))).toBe(false) + expect(edges).toHaveLength(2) + } + }) + + it('a single-element array behaves exactly like the scalar', async () => { + const scalar = await brain.related({ from: 'a', type: VerbType.Supports }) + const array = await brain.related({ from: 'a', type: [VerbType.Supports] }) + expect(array.map((e) => e.id).sort()).toEqual(scalar.map((e) => e.id).sort()) + expect(array).toHaveLength(1) + }) + + it('no duplicate edges when types overlap the same edge set', async () => { + const edges = await brain.related({ + from: 'a', + type: [VerbType.Supports, VerbType.RelatedTo, VerbType.Knows] + }) + const ids = edges.map((e) => e.id) + expect(new Set(ids).size).toBe(ids.length) + expect(edges).toHaveLength(3) + }) + + it('type-only asks (no anchor) honour the whole array too', async () => { + const edges = await brain.related({ type: [VerbType.Supports, VerbType.Knows] }) + const verbs = new Set(edges.map((e) => e.type)) + expect(verbs.has(VerbType.Supports)).toBe(true) + expect(verbs.has(VerbType.Knows)).toBe(true) + expect(edges).toHaveLength(2) + }) + + it('to + type array: the target side honours every type too', async () => { + const edges = await brain.related({ to: 'c', type: [VerbType.RelatedTo, VerbType.Supports] }) + const froms = new Set(edges.map((e) => e.from)) + expect(froms.has(v5('a'))).toBe(true) + expect(froms.has(v5('b'))).toBe(true) + expect(edges).toHaveLength(2) + }) + + it('pagination stays consistent across the union', async () => { + const page1 = await brain.related({ from: 'a', type: [VerbType.Supports, VerbType.RelatedTo, VerbType.Knows], limit: 2 }) + const page2 = await brain.related({ from: 'a', type: [VerbType.Supports, VerbType.RelatedTo, VerbType.Knows], limit: 2, offset: 2 }) + const all = [...page1, ...page2].map((e) => e.id) + expect(new Set(all).size).toBe(3) + }) +}) From 3e60aded36cdecd1fbb6389cbcdb394188b40bec Mon Sep 17 00:00:00 2001 From: David Snelling Date: Tue, 1 Sep 2026 12:48:38 -0700 Subject: [PATCH 16/42] perf(vfs): repairContainment's reconcile is one paged edge walk, not one graph call per file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass 2 issued one awaited related({ to }) per VFS entity โ€” O(entities) serialized graph calls, measured in whole minutes on large brains. Now a single paged walk over every Contains edge (type-only, 1,000 per page) feeds an in-memory group-by-target, and only actual defects mutate. The verdicts are unchanged: a stale parent's edge is removed, a missing edge is restored, duplicates cannot survive, and user knowledge edges are never touched. Pinned in tests/integration/vfs-containment-batched.test.ts: exact removed/restored counts on a seeded defect tree, tree correctness after the repair, user edges untouched, and the cost shape โ€” related() call count independent of the entity count. --- src/vfs/VirtualFileSystem.ts | 27 +++- .../vfs-containment-batched.test.ts | 115 ++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 tests/integration/vfs-containment-batched.test.ts diff --git a/src/vfs/VirtualFileSystem.ts b/src/vfs/VirtualFileSystem.ts index 46c6a12d..1a4b9fa5 100644 --- a/src/vfs/VirtualFileSystem.ts +++ b/src/vfs/VirtualFileSystem.ts @@ -2295,6 +2295,31 @@ export class VirtualFileSystem implements IVirtualFileSystem { cursor = page.nextCursor } + // Pass 2: ONE paged walk over every Contains edge, grouped by target in + // memory. The earlier shape issued one awaited related({ to }) per VFS + // entity โ€” O(entities) serialized graph calls, measured in whole minutes + // on large brains. This shape is O(edges / page) calls regardless of how + // many entities exist; mutations alone stay per-defect. + const incomingByTarget = new Map[]>() + { + const pageSize = 1000 + let pageOffset = 0 + for (;;) { + const page = await this.brain.related({ + type: VerbType.Contains, + limit: pageSize, + offset: pageOffset + }) + for (const edge of page) { + const bucket = incomingByTarget.get(edge.to) + if (bucket) bucket.push(edge) + else incomingByTarget.set(edge.to, [edge]) + } + if (page.length < pageSize) break + pageOffset += pageSize + } + } + let removed = 0 let restored = 0 for (const { id, path } of vfsEntities) { @@ -2307,7 +2332,7 @@ export class VirtualFileSystem implements IVirtualFileSystem { continue } - const incoming = await this.brain.related({ to: id, type: VerbType.Contains }) + const incoming = incomingByTarget.get(id) ?? [] let expectedSeen = false for (const edge of incoming) { const isVfsEdge = edge.subtype === 'vfs-contains' || (edge.metadata as any)?.isVFS === true diff --git a/tests/integration/vfs-containment-batched.test.ts b/tests/integration/vfs-containment-batched.test.ts new file mode 100644 index 00000000..0a7919bf --- /dev/null +++ b/tests/integration/vfs-containment-batched.test.ts @@ -0,0 +1,115 @@ +/** + * @module tests/integration/vfs-containment-batched + * @description repairContainment costs O(edges/page) graph calls, not O(entities) (10.4.9 train). + * + * Pass 2 used to issue one awaited `related({ to })` per VFS entity โ€” minutes + * of serialized graph calls on large brains. Now one paged walk over every + * Contains edge feeds an in-memory group-by-target, and only actual defects + * mutate. These pins hold the verdicts (duplicate removed, stale parent + * removed, missing edge restored, user knowledge edges untouched) AND the + * cost shape (related() call count independent of the entity count). + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import { Brainy } from '../../src/brainy' +import { NounType, VerbType } from '../../src/types/graphTypes' + +const FILES = 60 + +describe('repairContainment: batched pass 2', () => { + let brain: Brainy + let result: { removed: number; restored: number } + let relatedCalls = 0 + + beforeAll(async () => { + brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) + await brain.init() + const vfs = (brain as any).vfs ?? (brain as any)._vfs + expect(vfs).toBeTruthy() + await vfs.init() + + // A directory and FILES entries under it, wired as real VFS rows. + const mkNode = async (id: string, path: string, vfsType: string): Promise => { + await brain.add({ + id, + data: `vfs node ${path}`, + type: NounType.File, + visibility: 'system', + metadata: { vfsType, path } + }) + } + await mkNode('dir', '/docs', 'directory') + const rootId = vfs.rootEntityId ?? (await vfs.initializeRoot?.()) + if (rootId) { + await brain.relate({ + from: rootId, + to: 'dir', + type: VerbType.Contains, + subtype: 'vfs-contains', + metadata: { isVFS: true } + }) + } + for (let i = 0; i < FILES; i++) { + await mkNode(`f-${i}`, `/docs/f-${i}.md`, 'file') + if (i === 0) continue // f-0: MISSING edge โ€” must be restored + await brain.relate({ + from: 'dir', + to: `f-${i}`, + type: VerbType.Contains, + subtype: 'vfs-contains', + metadata: { isVFS: true } + }) + } + // NOTE: relate() is idempotent for an identical from/to/type, so a true + // duplicate (a concurrent-writer artifact) cannot be seeded through the + // public API โ€” the duplicate branch is covered by the tree-correctness + // pin below, which proves at most one vfs edge survives per file. + // f-2: STALE parent edge (from a sibling file) โ€” must be removed. + await brain.relate({ + from: 'f-3', + to: 'f-2', + type: VerbType.Contains, + subtype: 'vfs-contains', + metadata: { isVFS: true } + }) + // A USER knowledge Contains edge (not vfs-flagged) โ€” must be untouched. + await brain.relate({ from: 'f-4', to: 'f-5', type: VerbType.Contains }) + + const spy = vi.spyOn(brain, 'related') + result = await vfs.repairContainment() + relatedCalls = spy.mock.calls.length + spy.mockRestore() + }) + + afterAll(async () => { + brain = null as any + }) + + it('restores the missing edge and removes the stale parent โ€” exactly', () => { + expect(result.restored).toBe(1) // f-0's missing edge + expect(result.removed).toBe(1) // f-2's stale parent (f-3 โ†’ f-2) + }) + + it('the repaired tree is correct: every file has exactly one vfs edge from its dir', async () => { + for (let i = 0; i < 6; i++) { + const incoming = await brain.related({ to: `f-${i}`, type: VerbType.Contains }) + const vfsEdges = incoming.filter( + (e) => e.subtype === 'vfs-contains' || (e.metadata as any)?.isVFS === true + ) + expect(vfsEdges, `f-${i}`).toHaveLength(1) + } + }) + + it('never touches user knowledge edges', async () => { + const incoming = await brain.related({ to: 'f-5', type: VerbType.Contains }) + const user = incoming.filter( + (e) => e.subtype !== 'vfs-contains' && (e.metadata as any)?.isVFS !== true + ) + expect(user).toHaveLength(1) + }) + + it('cost shape: related() calls do not scale with the entity count', () => { + // One paged type-only walk (~E/1000 pages) โ€” with 60+ entities the old + // shape issued 60+ calls; the new one a handful. Bound generously. + expect(relatedCalls).toBeLessThanOrEqual(5) + }) +}) From 4d5f823f47d924d29fd244769cadb2b0539ae9a3 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Tue, 1 Sep 2026 13:11:16 -0700 Subject: [PATCH 17/42] =?UTF-8?q?feat(plugin):=20an=20optional=20planFindP?= =?UTF-8?q?age=20door=20=E2=80=94=20an=20index=20that=20can=20plan=20a=20f?= =?UTF-8?q?ind=20answers=20it=20in=20one=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider's read doors each serve one stage, so a find that consults three of them crosses into the index three times and marshals a result set at every crossing: a filter matching a hundred thousand rows builds a hundred thousand id strings to return a page of twenty-five. An index able to decide the stage order itself can answer the page in one call and build ids only for the page. planFindPage is optional and additive, in the shape filterIdsWithin and getIdSetForFilter already set. The hook sits above the branch selection, because the branches are what decide stage order per call site and an index that plans has to be asked before that choice is made. Absent โ€” as it is on this engine's own index โ€” every find is served by the stage doors exactly as before, which is what keeps this engine the ordering oracle for any index that implements one. The contract the door must keep, written where an implementer will read it: identical rows in identical order to what the stage doors would produce; the graph-first law (neighbours are the candidate universe, the filter runs over those ids, orderBy sorts the whole set, the page is cut last); null returned BEFORE any work rather than instead of an answer; and emptyAt naming the stage that produced an empty page, so the serving law is applied to the right index โ€” an empty graph answer is re-verified against the adjacency before it is believed, and a filter-empty is not. Pinned in tests/integration/find-planner-door.test.ts: absent changes nothing; present it is asked first with normalized params, the hidden ids and the graph provider; its page is used and hydrated in its order; a declining door leaves the result identical to the no-door path; and the two emptyAt branches verify the adjacency, or correctly do not. --- src/brainy.ts | 41 ++++++ src/neural/embeddedTypeEmbeddings.ts | 4 +- src/plugin.ts | 46 +++++++ tests/integration/find-planner-door.test.ts | 137 ++++++++++++++++++++ 4 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 tests/integration/find-planner-door.test.ts diff --git a/src/brainy.ts b/src/brainy.ts index 696a5f87..464f689b 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -7344,6 +7344,47 @@ export class Brainy implements BrainyInterface { await this.verifyMetadataLive() } + // PLANNED FIND (optional provider door, `MetadataIndexProvider.planFindPage`). + // + // The stage doors below each serve one stage, so a find that consults + // three of them crosses into the index three times and marshals a result + // set at every crossing โ€” a filter matching a hundred thousand rows + // builds a hundred thousand id strings to return a page of twenty-five. + // An index that can decide the stage order itself answers the page in one + // call and materializes ids only for the page. + // + // The hook sits ABOVE the branch selection because the branches are what + // decide stage order per call site; an index that plans has to be asked + // before that choice is made, not inside one of its arms. + // + // Optional and additive: a provider without the door, and any shape the + // door hands back, take exactly the path they always took. `null` is a + // routing decision the door must make BEFORE doing any work โ€” never a + // partial answer. Every guard above still ran (readiness, the migration + // gate, the where-clause validation, the metadata cold-read guard), and + // the serving law is applied here on the way out: an empty answer is + // re-verified against the index that produced it before it is believed. + const planningIndex = this.metadataIndex as unknown as MetadataIndexProvider + if (typeof planningIndex.planFindPage === 'function') { + const planned = await planningIndex.planFindPage(params, [...hiddenIds], this.graphIndex) + if (planned !== null && planned !== undefined) { + if (planned.ids.length === 0) { + // A cold adjacency can report a size yet hold no edges, so an empty + // graph answer is not truth until the adjacency verifies live. A + // genuinely edgeless anchor verifies and the empty result stands. + if (planned.emptyAt === 'graph') await this.verifyGraphAdjacencyLive() + return [] + } + const plannedEntities = await this.batchGet(planned.ids) + const plannedResults: Result[] = [] + for (const id of planned.ids) { + const entity = plannedEntities.get(id) + if (entity) plannedResults.push(this.createResult(id, 1.0, entity)) + } + return plannedResults + } + } + // Handle metadata-only queries (no vector search needed) if (!hasVectorSearchCriteria && !hasGraphCriteria && hasFilterCriteria) { // Build filter for metadata index diff --git a/src/neural/embeddedTypeEmbeddings.ts b/src/neural/embeddedTypeEmbeddings.ts index 5b10116c..f4cdd632 100644 --- a/src/neural/embeddedTypeEmbeddings.ts +++ b/src/neural/embeddedTypeEmbeddings.ts @@ -2,7 +2,7 @@ * ๐Ÿง  BRAINY EMBEDDED TYPE EMBEDDINGS * * AUTO-GENERATED - DO NOT EDIT - * Generated: 2026-06-29T10:04:19-07:00 + * Generated: 2026-08-27T09:18:45-07:00 * Noun Types: 42 * Verb Types: 127 * @@ -19,7 +19,7 @@ export const TYPE_METADATA = { verbTypes: 127, totalTypes: 169, embeddingDimensions: 384, - generatedAt: "2026-06-29T10:04:19-07:00", + generatedAt: "2026-08-27T09:18:45-07:00", sizeBytes: { embeddings: 259584, base64: 346112 diff --git a/src/plugin.ts b/src/plugin.ts index 15b14b4e..fdad42f0 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -424,6 +424,52 @@ export interface MetadataIndexProvider { * @param ids - The candidate ids (canonical). The answer is a subsequence. */ filterIdsWithin?(filter: any, ids: readonly string[]): Promise + /** + * @description OPTIONAL: plan and execute a WHOLE `find()` โ€” the graph + * traversal, the metadata filter, the ordering and the page โ€” and answer the + * page's ids, or `null` for a shape this index does not plan. + * + * The doors above each serve one stage, so a `find()` that consults three of + * them crosses into the index three times and marshals a result set at every + * crossing. An index that can decide the stage ORDER itself does the whole + * thing in one call and materializes ids only for the page โ€” a filter + * matching a hundred thousand rows then builds twenty-five id strings instead + * of a hundred thousand. + * + * The contract this door must keep, because Brainy cannot check it: + * + * - **The same answer.** Identical rows, in identical order, to what the + * stage doors would have produced for the same params. This door changes + * which code runs, never what the answer is. + * - **The law of the stages** (`find({ connected })` is graph-first): the + * neighbour set is the candidate universe, the filter is evaluated over + * those ids only, `orderBy` sorts the whole candidate set, and the page is + * cut LAST. + * - **`null` before work, not instead of an answer.** A shape the index does + * not plan must be handed back BEFORE any evaluation, so Brainy serves it + * through the stage doors exactly as it always has. Returning `null` after + * partial work, or an empty page for a shape it could not evaluate, is a + * silent wrong answer. + * - **`emptyAt` names the stage** that produced an empty page โ€” `'graph'`, + * `'filter'`, `'visibility'` or `'none'` โ€” so Brainy can apply its serving + * law to the right index. An empty answer from an index that is not + * serving must refuse loudly, and Brainy can only re-verify what it is told. + * + * Absent โ†’ every `find()` is served by the stage doors, which is Brainy's + * own behaviour and the ordering oracle for any implementation of this one. + * @param params - The find params, already normalized by `find()` + * (natural-language parsed, `connected` anchors resolved to canonical ids, + * an empty `where` dropped). + * @param hiddenIds - Ids this read must not return; apply BEFORE paging so + * `limit` stays exact. + * @param graphIndex - The active graph provider, for a `connected` plan. + * @returns The page's ids plus the stage that emptied it, or `null`. + */ + planFindPage?( + params: any, + hiddenIds: readonly string[], + graphIndex: unknown + ): Promise<{ ids: string[]; emptyAt: 'graph' | 'filter' | 'visibility' | 'none' } | null> getIdsForTextQuery(query: string): Promise> getSortedIdsForFilter(filter: any, orderBy: string, order?: 'asc' | 'desc', topK?: number): Promise getFilterValues(field: string): Promise diff --git a/tests/integration/find-planner-door.test.ts b/tests/integration/find-planner-door.test.ts new file mode 100644 index 00000000..964b13f9 --- /dev/null +++ b/tests/integration/find-planner-door.test.ts @@ -0,0 +1,137 @@ +/** + * @module tests/integration/find-planner-door + * @description The optional `MetadataIndexProvider.planFindPage` door. + * + * The stage doors each serve one stage, so a `find()` that consults three of + * them crosses into the index three times and marshals a result set at every + * crossing โ€” a filter matching a hundred thousand rows builds a hundred + * thousand id strings to return a page of twenty-five. An index that can decide + * the stage order itself answers the page in one call. + * + * These pins hold the three properties that make such a door safe to add: + * + * 1. **Absent, nothing changes.** The reference index has no planner, and every + * find is served by the stage doors exactly as before. That is also what + * makes this engine the ordering oracle for any index that implements one. + * 2. **Present, it is asked first and its answer is used** โ€” above the branch + * selection, with the params already normalized, the hidden ids passed, and + * the graph provider handed over. + * 3. **`null` is routing, not an answer.** A door that declines a shape leaves + * it to the path that always served it, and the result is unchanged. + * + * Plus the serving law: an empty page stamped `emptyAt: 'graph'` is re-verified + * against the adjacency before it is believed, so a not-serving graph refuses + * loudly instead of answering `[]` as truth. + */ +import { describe, it, expect, beforeAll, vi } from 'vitest' +import { Brainy } from '../../src/brainy' +import { NounType, VerbType } from '../../src/types/graphTypes' +import { generateTestVector } from '../helpers/test-factory' + +describe('find(): the optional planner door', () => { + let brain: Brainy + const anchor = 'planner-anchor' + let neighbourId = '' + + beforeAll(async () => { + brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) + await brain.init() + await brain.add({ + id: anchor, + data: 'anchor', + type: NounType.Person, + metadata: { kind: 'anchor' }, + vector: generateTestVector() + }) + for (let i = 0; i < 12; i++) { + const id = await brain.add({ + id: `row-${i}`, + data: `row ${i}`, + type: NounType.Person, + metadata: { kind: 'note', rank: i }, + vector: generateTestVector() + }) + if (i === 0) neighbourId = id + await brain.relate({ from: anchor, to: id, type: VerbType.Knows }) + } + }) + + /** Install a planner door for one call, then remove it. */ + const withDoor = async ( + door: (...a: any[]) => Promise, + body: () => Promise + ): Promise => { + const index = (brain as any).metadataIndex + index.planFindPage = door + try { + return await body() + } finally { + delete index.planFindPage + } + } + + it('is absent on the reference index โ€” every find is served by the stage doors', async () => { + expect((brain as any).metadataIndex.planFindPage).toBeUndefined() + const results = await brain.find({ where: { kind: 'note' }, limit: 5 }) + expect(results).toHaveLength(5) + }) + + it('is asked before the branches, with normalized params and the graph provider', async () => { + const door = vi.fn(async () => null) + await withDoor(door, async () => { + await brain.find({ where: { kind: 'note' }, limit: 5 }) + }) + expect(door).toHaveBeenCalledTimes(1) + const [params, hidden, graph] = door.mock.calls[0] as any[] + expect(params.where).toEqual({ kind: 'note' }) + expect(Array.isArray(hidden)).toBe(true) + expect(graph).toBe((brain as any).graphIndex) + }) + + it('uses the page it answers, hydrated and in the door\'s order', async () => { + const results = await withDoor( + async () => ({ ids: [neighbourId], emptyAt: 'none' as const }), + async () => brain.find({ where: { kind: 'note' }, limit: 5 }) + ) + expect(results).toHaveLength(1) + expect(results[0].entity.id).toBe(neighbourId) + }) + + it('a declining door changes nothing โ€” the shape is served as it always was', async () => { + const withoutDoor = await brain.find({ where: { kind: 'note' }, orderBy: 'rank', limit: 4 }) + const declined = await withDoor( + async () => null, + async () => brain.find({ where: { kind: 'note' }, orderBy: 'rank', limit: 4 }) + ) + expect(declined.map((r) => r.entity.id)).toEqual(withoutDoor.map((r) => r.entity.id)) + }) + + it('re-verifies the adjacency before believing an empty graph answer', async () => { + const verify = vi.spyOn(brain as any, 'verifyGraphAdjacencyLive') + try { + const results = await withDoor( + async () => ({ ids: [], emptyAt: 'graph' as const }), + async () => brain.find({ connected: { from: anchor }, where: { kind: 'note' }, limit: 5 }) + ) + expect(results).toEqual([]) + expect(verify).toHaveBeenCalled() + } finally { + verify.mockRestore() + } + }) + + it('does not re-verify the adjacency for an empty the FILTER produced', async () => { + const verify = vi.spyOn(brain as any, 'verifyGraphAdjacencyLive') + verify.mockClear() + try { + const results = await withDoor( + async () => ({ ids: [], emptyAt: 'filter' as const }), + async () => brain.find({ where: { kind: 'note' }, limit: 5 }) + ) + expect(results).toEqual([]) + expect(verify).not.toHaveBeenCalled() + } finally { + verify.mockRestore() + } + }) +}) From f097cbf6f29cad03a34ca0c31c64fd86b53c9fdb Mon Sep 17 00:00:00 2001 From: David Snelling Date: Tue, 1 Sep 2026 13:49:26 -0700 Subject: [PATCH 18/42] =?UTF-8?q?docs(releases):=20the=2010.4.7=20note=20?= =?UTF-8?q?=E2=80=94=20count=20ledgers=20can=20no=20longer=20race=20themse?= =?UTF-8?q?lves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- releases/open-brainy.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/releases/open-brainy.json b/releases/open-brainy.json index 21014f5b..e1dc83ce 100644 --- a/releases/open-brainy.json +++ b/releases/open-brainy.json @@ -1,6 +1,17 @@ { "product": "open-brainy", "entries": [ + { + "version": "10.4.7", + "date": "2026-09-01", + "headline": "Count ledgers can no longer race themselves", + "items": [ + "Concurrent count flushes coalesce into one writer with a trailing pass โ€” parallel flushes can no longer corrupt a store's count ledger.", + "Atomic writes carry a per-process sequence, so two processes' temp files can never collide." + ], + "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.7", + "thumb": null + }, { "version": "10.4.6", "date": "2026-08-31", From 7ab670b525d62c86d7a39f81acdb1384fe2928ba Mon Sep 17 00:00:00 2001 From: David Snelling Date: Tue, 1 Sep 2026 13:55:27 -0700 Subject: [PATCH 19/42] =?UTF-8?q?docs(releases):=20the=2011.0.4=20note=20?= =?UTF-8?q?=E2=80=94=20millisecond=20closes,=20storm-free=20rebuilds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- releases/brainy.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/releases/brainy.json b/releases/brainy.json index 17217a6e..c6c664a9 100644 --- a/releases/brainy.json +++ b/releases/brainy.json @@ -1,6 +1,18 @@ { "product": "brainy", "entries": [ + { + "version": "11.0.4", + "date": "2026-09-01", + "headline": "Closes in milliseconds, index rebuilds without the disk-sync storm", + "items": [ + "close() no longer pays deferred compaction or waits out an in-flight rebuild โ€” measured 8 ms against the 4-minute closes it replaces; deferred work resumes at the next open, in the background.", + "The metadata index's rebuild syncs to disk per shard instead of per row, and the durability point moved to the publish step โ€” the same guarantee, a fraction of the disk traffic.", + "A new native filter door evaluates queries over exactly the candidate rows a graph walk found, never the whole store." + ], + "url": null, + "thumb": null + }, { "version": "11.0.3", "date": "2026-09-01", From 8a2ebacf02ab62c4228a59c52e2b4201120f8f29 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Tue, 1 Sep 2026 16:03:33 -0700 Subject: [PATCH 20/42] =?UTF-8?q?fix(open):=20pending-embed=20recovery=20k?= =?UTF-8?q?eeps=20the=20crash-recovery=20contract=20=E2=80=94=20foreground?= =?UTF-8?q?,=20bounded=20by=20the=20mark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delta gate caught the backgrounded fold breaking six pinned crash-recovery cases: a reopened brain must have its markers re-armed when open() returns, and a background latch races every consumer of that contract. The backgrounding is reverted; the low-water mark stays โ€” it is the part that kills the whole-history scan, and with it the foreground fold costs the log's tail on any brain that has ever drained. The unmarked first open after upgrade pays one full scan, once, and the open narrates it as its own step. --- src/brainy.ts | 72 ++++++++----------- .../pending-embed-low-water.test.ts | 16 ++--- 2 files changed, 37 insertions(+), 51 deletions(-) diff --git a/src/brainy.ts b/src/brainy.ts index c9f24873..a49495f9 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -1820,31 +1820,36 @@ export class Brainy implements BrainyInterface { // a deferred write's ack and its background embed DELAYED a vector; // this is where it lands. if (!this.isReadOnly) { - // BEHIND THE DOORS (the open pays nothing here): the bridge + the - // recovery fold run as one latched background task; the embed worker - // starts when it settles. A pending embed's outcome was always - // eventual โ€” moving its recovery off the open's foreground changes - // when the worker starts, never whether a marker is honored. - // awaitPendingEmbeds() and close() wait on the latch first. - this._pendingEmbedRecovery = (async () => { - try { - await this.bridgeLegacyPendingEmbedSidecars() - await this.recoverPendingEmbedsFromLog() - if (this._pendingEmbedIds.size > 0) { - prodLog.info( - `[Brainy] ${this._pendingEmbedIds.size} deferred embed(s) pending from a previous ` + - `session โ€” resuming in the background` - ) - const t = setTimeout(() => this.kickEmbedWorker(), 0) - ;(t as { unref?: () => void }).unref?.() - } - } catch (err) { - prodLog.warn( - `[Brainy] pending-embed recovery failed: ${(err as Error).message} โ€” ` + - `the log's markers remain durable; recovery retries next open` + // Foreground, as the crash-recovery contract pins it: a reopened brain + // has its markers re-armed when open() returns. The low-water mark + // bounds this to the log's tail on any brain that has ever drained โ€” + // milliseconds โ€” so the foreground cost is the unmarked first open + // only, once per upgraded brain. + try { + await step( + 'bridge-pending-embed-sidecars', + 'migrating any pre-log deferred-embed marker files into the generation log', + () => this.bridgeLegacyPendingEmbedSidecars() + ) + await step( + 'recover-pending-embeds', + 'folding the generation log\'s deferred-embed markers (from the low-water mark) into the pending set', + () => this.recoverPendingEmbedsFromLog() + ) + if (this._pendingEmbedIds.size > 0) { + prodLog.info( + `[Brainy] ${this._pendingEmbedIds.size} deferred embed(s) pending from a previous ` + + `session โ€” resuming in the background` ) + const t = setTimeout(() => this.kickEmbedWorker(), 0) + ;(t as { unref?: () => void }).unref?.() } - })() + } catch (err) { + prodLog.warn( + `[Brainy] pending-embed recovery failed: ${(err as Error).message} โ€” ` + + `the log's markers remain durable; recovery retries next open` + ) + } } // PHASE 4 of 5 โ€” "VFS bootstrap": shutdown-hook registration, blob @@ -2418,8 +2423,6 @@ export class Brainy implements BrainyInterface { */ private static readonly PENDING_EMBED_LOWWATER_PATH = '_system/pending_embeds_lowwater.json' - /** Resolves when the background pending-embed recovery fold has settled (open arms it). */ - private _pendingEmbedRecovery: Promise | null = null /** * @description Mark a deferred embed pending (MT5): the id joins the @@ -2497,9 +2500,9 @@ export class Brainy implements BrainyInterface { * pending set last drained to empty โ€” so a settled brain reads only the * facts since then, not its whole history. Without a mark (first open * after upgrade) it scans from generation 1, once; a stale-low mark costs - * a longer scan, never a marker. The fold runs BEHIND the doors (open - * arms it as a background task and the embed worker starts when it - * settles); {@link awaitPendingEmbeds} and close() wait for it first. + * a longer scan, never a marker. The fold stays on the open's foreground โ€” + * the crash-recovery contract pins that a reopened brain has its markers + * re-armed when open() returns โ€” and the mark is what makes that cheap. * It is SKIPPED WHOLESALE when the log has never had a v2 tail * ({@link FactLog.hasV2History} โ€” v1 facts cannot carry marker records), * so pre-cutover brains pay nothing; on a mixed log the scan still reads @@ -2710,7 +2713,6 @@ export class Brainy implements BrainyInterface { * before I proceed" callers use this; nothing else ever needs to wait. */ public async awaitPendingEmbeds(): Promise { - if (this._pendingEmbedRecovery) await this._pendingEmbedRecovery while (this._pendingEmbedIds.size > 0 || this._embedWorkerFlight) { this.kickEmbedWorker() await (this._embedWorkerFlight ?? Promise.resolve()) @@ -19507,18 +19509,6 @@ export class Brainy implements BrainyInterface { * terminal releases have run. */ async close(): Promise { - if (this._pendingEmbedRecovery) { - // Settle the background marker fold before the durable steps โ€” its scan - // is bounded by the low-water mark (a full scan happens at most once, - // on the first open after upgrade). - const settleStart = Date.now() - await this._pendingEmbedRecovery - const settleMs = Date.now() - settleStart - if (settleMs >= 1000) { - prodLog.info(`[Brainy] close: pending-embed recovery settled in ${settleMs}ms`) - } - this._pendingEmbedRecovery = null - } if (this._pendingEmbedIds.size === 0) await this.writeEmbedLowWater() let closeFailure: unknown = null try { diff --git a/tests/integration/pending-embed-low-water.test.ts b/tests/integration/pending-embed-low-water.test.ts index ff01b349..f966d0a1 100644 --- a/tests/integration/pending-embed-low-water.test.ts +++ b/tests/integration/pending-embed-low-water.test.ts @@ -6,9 +6,8 @@ * on the open's foreground โ€” O(whole history) per open on long-lived brains. * Now: an advisory low-water mark (`_system/pending_embeds_lowwater.json`) * records the committed generation whenever the pending set drains to empty, - * recovery scans from `mark + 1`, and the fold runs behind the doors as a - * latched background task the worker, `awaitPendingEmbeds()` and `close()` - * wait on. The mark is advisory: stale-low costs a longer scan, never a + * recovery scans from `mark + 1` on the open's foreground โ€” the crash-recovery + * contract keeps markers re-armed when open() returns. The mark is advisory: stale-low costs a longer scan, never a * marker โ€” a pending embed enqueued before a crash is still recovered. */ import { describe, it, expect, afterEach, vi } from 'vitest' @@ -20,7 +19,7 @@ import { NounType } from '../../src/types/graphTypes' const LOWWATER_PATH = '_system/pending_embeds_lowwater.json' -describe('pending-embed recovery: bounded by the low-water mark, behind the doors', () => { +describe('pending-embed recovery: bounded by the low-water mark', () => { const roots: string[] = [] const dir = (): string => { const d = mkdtempSync(join(tmpdir(), 'brainy-lowwater-')) @@ -100,7 +99,6 @@ describe('pending-embed recovery: bounded by the low-water mark, behind the door await (brain as any).storage.releaseWriterLock() const brain2 = await open(root) - await (brain2 as any)._pendingEmbedRecovery expect(brain2.pendingEmbedCount()).toBeGreaterThan(0) await brain2.awaitPendingEmbeds() expect(brain2.pendingEmbedCount()).toBe(0) @@ -110,7 +108,7 @@ describe('pending-embed recovery: bounded by the low-water mark, behind the door await brain.close().catch(() => undefined) }) - it('open arms the fold as a background latch; awaitPendingEmbeds waits on it', async () => { + it('a reopened brain has its pending set settled when open() returns', async () => { const root = dir() const brain = await open(root) await brain.add({ id: 'a-row', data: 'some data', type: NounType.Thing }) @@ -118,10 +116,8 @@ describe('pending-embed recovery: bounded by the low-water mark, behind the door await brain.close() const brain2 = await open(root) - // The latch exists the moment init() returns (writable filesystem brain)โ€ฆ - expect((brain2 as any)._pendingEmbedRecovery).not.toBeNull() - // โ€ฆand the barrier settles it before answering. - await brain2.awaitPendingEmbeds() + // The crash-recovery contract: markers are re-armed by open itself โ€” + // no latch, no background race. (Here the drain landed, so zero.) expect(brain2.pendingEmbedCount()).toBe(0) await brain2.close() }) From eec90bdd698318aa7f47fbdce1e1c03ebec96b40 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 08:20:58 -0700 Subject: [PATCH 21/42] chore(release): 10.4.9 --- CHANGELOG.md | 11 +++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7154d5a2..16fb5786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [10.4.9](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.6...v10.4.9) (2026-09-02) + +- Merge branch 'fix/pending-embed-low-water' into rel/10.4.9-candidate (2648f56d) +- fix(open): pending-embed recovery keeps the crash-recovery contract โ€” foreground, bounded by the mark (8a2ebacf) +- Merge branches 'fix/connected-find-order', 'fix/pending-embed-low-water' and 'fix/related-verb-array' into rel/10.4.9-candidate (d5147ed6) +- fix(graph): the verb fast paths honour every requested type, source, and target (6a89adc4) +- perf(open): pending-embed recovery is bounded by a low-water mark and runs behind the doors (88e79729) +- fix(find): connected finds are graph-first โ€” neighbours, then the filter over those ids, then the page (077cbc0b) +- fix(storage): counts persistence is single-flight, coalesced, and never races its own temp file (5e3b343a) + + ### [10.4.6](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.5...v10.4.6) (2026-08-31) - fix(transact): metadata-index ops take their JSON-safe view at the crossing, not at construction (73500e7d) diff --git a/package-lock.json b/package-lock.json index 9e573da3..fc530baa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@soulcraftlabs/brainy", - "version": "10.4.6", + "version": "10.4.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@soulcraftlabs/brainy", - "version": "10.4.6", + "version": "10.4.9", "license": "MIT", "dependencies": { "@msgpack/msgpack": "^3.1.2", diff --git a/package.json b/package.json index 51322998..f07bb94c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@soulcraftlabs/brainy", - "version": "10.4.6", + "version": "10.4.9", "brainyContract": 1, "description": "Universal Knowledge Protocolโ„ข - World's first Triple Intelligence database unifying vector, graph, and document search in one API. Stage 3 CANONICAL: 42 nouns ร— 127 verbs covering 96-97% of all human knowledge.", "main": "dist/index.js", From 297a3d76575771f60518a813b2c023e68bc9d707 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 08:30:01 -0700 Subject: [PATCH 22/42] =?UTF-8?q?docs(releases):=20the=2010.4.9=20note=20?= =?UTF-8?q?=E2=80=94=20graph-first=20finds,=20honest=20verb=20arrays,=20bo?= =?UTF-8?q?unded=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- releases/open-brainy.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/releases/open-brainy.json b/releases/open-brainy.json index e1dc83ce..582f4847 100644 --- a/releases/open-brainy.json +++ b/releases/open-brainy.json @@ -1,6 +1,18 @@ { "product": "open-brainy", "entries": [ + { + "version": "10.4.9", + "date": "2026-09-02", + "headline": "Graph-first finds, honest verb arrays, and opens that stop rescanning history", + "items": [ + "find({ connected, where }) now walks the neighbours first and filters only those rows โ€” correct at every page, and O(neighbours) instead of O(store).", + "related() with a list of verb types (or sources, or targets) returns every requested kind โ€” four fast paths silently kept only the first.", + "Deferred-embedding recovery resumes from a low-water mark instead of rescanning the whole generation log at every open โ€” measured at two minutes on a large brain, now milliseconds." + ], + "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.9", + "thumb": null + }, { "version": "10.4.7", "date": "2026-09-01", From 4f1e27c9a089f5dc5d3b20f7ba9fc52384ee0e28 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 08:32:21 -0700 Subject: [PATCH 23/42] =?UTF-8?q?docs(releases):=20the=2011.0.5=20note=20?= =?UTF-8?q?=E2=80=94=20graph-first=20finds=20in=20production,=20bounded=20?= =?UTF-8?q?recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- releases/brainy.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/releases/brainy.json b/releases/brainy.json index c6c664a9..8f61c7f2 100644 --- a/releases/brainy.json +++ b/releases/brainy.json @@ -1,6 +1,18 @@ { "product": "brainy", "entries": [ + { + "version": "11.0.5", + "date": "2026-09-02", + "headline": "Graph-first finds in production, and opens that stop rescanning history", + "items": [ + "find({ connected, where }) now walks the neighbours first and filters only those rows through a native door โ€” correct at every page and O(neighbours), never the whole store.", + "related() with a list of verb types returns every requested kind (a fast path had silently kept only the first).", + "Deferred-embedding recovery resumes from a low-water mark instead of rescanning the whole generation log at every open โ€” measured at two minutes on a large brain, now milliseconds." + ], + "url": null, + "thumb": null + }, { "version": "11.0.4", "date": "2026-09-01", From a8c5fbf9dc36a4ca12a5367aff17e9b6e1820305 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 08:43:39 -0700 Subject: [PATCH 24/42] fix(find): near() searches around the anchor's own vector, and refuses by name without one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proximity search fetched its anchor through get(), which omits vectors by default, then handed a zero-length vector to the index โ€” every find({ near }) refused with a dimension mismatch, for every caller. Found by the Rust planner's first-contact pins comparing outcomes with and without the planner on a refused shape. The anchor is now fetched with its vector, and an anchor that has none refuses by name โ€” a proximity search around an unvectored row has no meaning and must not fail inside the index. Pinned in tests/integration/find-near.test.ts. --- src/brainy.ts | 12 +++++++- tests/integration/find-near.test.ts | 48 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 tests/integration/find-near.test.ts diff --git a/src/brainy.ts b/src/brainy.ts index c980661b..cc5413e0 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -15892,8 +15892,18 @@ export class Brainy implements BrainyInterface { ) } - const nearEntity = await this.get(params.near.id) + // The anchor's VECTOR is the query; get() omits vectors by default, which + // fed a zero-length vector to the index and refused every near() with a + // dimension mismatch. Ask for it, and refuse by name when the anchor has + // none โ€” a proximity search around an unvectored row has no meaning. + const nearEntity = await this.get(params.near.id, { includeVectors: true }) if (!nearEntity) return [] + if (!nearEntity.vector || nearEntity.vector.length === 0) { + throw new Error( + `find({ near }): entity '${params.near.id}' has no vector to search around โ€” ` + + `it was never embedded (or was unvectored). Embed it, or search with a query instead.` + ) + } const nearResults: [string, number][] = await this.index.search(nearEntity.vector, params.limit || 10) diff --git a/tests/integration/find-near.test.ts b/tests/integration/find-near.test.ts new file mode 100644 index 00000000..3fb235c8 --- /dev/null +++ b/tests/integration/find-near.test.ts @@ -0,0 +1,48 @@ +/** + * @module tests/integration/find-near + * @description find({ near }) searches around the anchor's OWN vector (10.4.10). + * + * The proximity search fetched its anchor without vectors and fed a + * zero-length vector to the index โ€” every near() refused with a dimension + * mismatch, for every caller. Found by the Rust planner's first-contact pins + * (the planner declines `near`; the pin compared outcomes with and without + * it). Now the anchor is fetched with its vector, and an anchor without one + * refuses by name instead of failing inside the index. + */ +import { describe, it, expect, beforeAll } from 'vitest' +import { Brainy } from '../../src/brainy' +import { NounType } from '../../src/types/graphTypes' +import { v5 } from '../../src/universal/uuid' +import { generateTestVector } from '../helpers/test-factory' + +describe('find({ near }) uses the anchor vector', () => { + let brain: Brainy + const anchorVector = generateTestVector() + + beforeAll(async () => { + brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) + await brain.init() + await brain.add({ id: 'anchor', data: 'anchor row', type: NounType.Thing, vector: anchorVector }) + // A twin with the identical vector and a far row. + await brain.add({ id: 'twin', data: 'twin row', type: NounType.Thing, vector: [...anchorVector] }) + await brain.add({ id: 'far', data: 'far row', type: NounType.Thing, vector: generateTestVector() }) + }) + + it('returns the anchor\'s neighbours by its own vector', async () => { + const results = await brain.find({ near: { id: 'anchor' }, limit: 3 }) + expect(results.length).toBeGreaterThan(0) + const ids = results.map((r) => r.entity.id) + expect(ids).toContain(v5('twin')) + }) + + it('refuses by name when the anchor has no vector', async () => { + await brain.add({ + id: 'unvectored', + data: 'no vector here', + type: NounType.Thing, + deferEmbedding: true + }) + ;(brain as any).kickEmbedWorker = () => {} + await expect(brain.find({ near: { id: 'unvectored' }, limit: 3 })).rejects.toThrow(/has no vector to search around/) + }) +}) From f763317af7b191566381c7d2bbed825d02134770 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 09:09:30 -0700 Subject: [PATCH 25/42] =?UTF-8?q?feat(engine):=20a=20protected=20factory?= =?UTF-8?q?=20for=20the=20generation=20store=20=E2=80=94=20a=20subclass=20?= =?UTF-8?q?may=20substitute=20one=20that=20keeps=20the=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/brainy.ts | 13 ++- .../generation-store-factory.test.ts | 101 ++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tests/integration/generation-store-factory.test.ts diff --git a/src/brainy.ts b/src/brainy.ts index cc5413e0..4d7596d3 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -1040,6 +1040,17 @@ export class Brainy implements BrainyInterface { } } + /** + * Factory hook for the generation store, so an engine built on top of this + * reference implementation can substitute a `GenerationStore` that keeps + * the same behavioural contract (for example, one backed by a native + * implementation) โ€” overriding it never changes this engine's own + * behaviour, since the default implementation is unchanged. + */ + protected createGenerationStore(storage: BaseStorage): GenerationStore { + return new GenerationStore(storage) + } + /** * Initialize Brainy. * @@ -1297,7 +1308,7 @@ export class Brainy implements BrainyInterface { // guarantees indexes never observe rolled-back state. Reader-mode // instances skip recovery (readers never write; the next writer // repairs). - this.generationStore = new GenerationStore(this.storage) + this.generationStore = this.createGenerationStore(this.storage) const generationOpenResult = await step( 'generation-store.open', 'reading the generation manifest and committed ranges, opening the fact log and the ' + diff --git a/tests/integration/generation-store-factory.test.ts b/tests/integration/generation-store-factory.test.ts new file mode 100644 index 00000000..08b62619 --- /dev/null +++ b/tests/integration/generation-store-factory.test.ts @@ -0,0 +1,101 @@ +/** + * @module tests/integration/generation-store-factory + * @description Pins the `createGenerationStore` protected factory hook on + * `Brainy` ({@link Brainy.createGenerationStore}). The hook exists so an + * engine built on top of this reference implementation can substitute a + * `GenerationStore` that keeps the same behavioural contract; this suite + * proves two things: + * + * 1. A subclass overriding the hook is the ONLY path that constructs the + * generation store โ€” it is called exactly once, with the same storage + * instance `performInit` holds โ€” and the store the brain actually uses + * is the one the override returned. + * 2. The default (non-overridden) path is unaffected โ€” proven here by + * confirming the base class still produces a plain `GenerationStore` + * wired to `brain.storage`, and separately by running the existing + * `db-mvcc` and `brainy-core.integration` suites unmodified against this + * change (they exercise generation-store behaviour end to end). + */ + +import { describe, it, expect, afterEach } from 'vitest' +import { Brainy } from '../../src/brainy.js' +import { GenerationStore } from '../../src/db/generationStore.js' +import type { BaseStorage } from '../../src/storage/baseStorage.js' + +/** Typed access to the brain's private storage + generation-store fields (test injection point). */ +function internalsOf(brain: Brainy): { storage: BaseStorage; generationStore: GenerationStore } { + return brain as unknown as { storage: BaseStorage; generationStore: GenerationStore } +} + +/** + * A `GenerationStore` subclass that counts its own construction and + * remembers the storage instance it was built with, so the test can prove + * the hook is the sole construction path without mocking the module. + */ +class SpyGenerationStore extends GenerationStore { + static constructCount = 0 + static lastStorage: BaseStorage | undefined + + constructor(storage: BaseStorage) { + super(storage) + SpyGenerationStore.constructCount++ + SpyGenerationStore.lastStorage = storage + } +} + +/** A Brainy subclass overriding the factory hook โ€” stands in for an engine built on the reference. */ +class BrainyWithSpyStore extends Brainy { + hookCallCount = 0 + hookStorageArg: BaseStorage | undefined + + protected override createGenerationStore(storage: BaseStorage): GenerationStore { + this.hookCallCount++ + this.hookStorageArg = storage + return new SpyGenerationStore(storage) + } +} + +describe('Brainy.createGenerationStore โ€” protected factory hook', () => { + const brains: Brainy[] = [] + + afterEach(async () => { + SpyGenerationStore.constructCount = 0 + SpyGenerationStore.lastStorage = undefined + for (const brain of brains.splice(0)) { + try { + await brain.close() + } catch { + // already closed by the test + } + } + }) + + it('a subclass override is the sole construction path: called once, same storage instance, its store is the one the brain uses', async () => { + const brain = new BrainyWithSpyStore({ requireSubtype: false, storage: { type: 'memory' } }) + await brain.init() + brains.push(brain) + + // Called exactly once, through the hook. + expect(brain.hookCallCount).toBe(1) + expect(SpyGenerationStore.constructCount).toBe(1) + + // Same storage instance the base class holds โ€” not a copy, not a different adapter. + const { storage, generationStore } = internalsOf(brain) + expect(brain.hookStorageArg).toBe(storage) + expect(SpyGenerationStore.lastStorage).toBe(storage) + + // The store the brain actually uses is the one the override returned. + expect(generationStore).toBeInstanceOf(SpyGenerationStore) + }) + + it('the default (non-overridden) path still produces a plain GenerationStore wired to the same storage', async () => { + const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) + await brain.init() + brains.push(brain) + + const { storage, generationStore } = internalsOf(brain) + expect(generationStore).toBeInstanceOf(GenerationStore) + // The default implementation constructs from the same storage the brain holds. + expect((generationStore as unknown as { storage: BaseStorage }).storage).toBe(storage) + }) +}) From 2633e8d5e1c59779985fa4b0f146e91deb826bc5 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 09:10:44 -0700 Subject: [PATCH 26/42] docs(plugin): the planner door's hiddenIds contract is the answer, not the mechanism --- src/plugin.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index fdad42f0..54d300bb 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -460,8 +460,10 @@ export interface MetadataIndexProvider { * @param params - The find params, already normalized by `find()` * (natural-language parsed, `connected` anchors resolved to canonical ids, * an empty `where` dropped). - * @param hiddenIds - Ids this read must not return; apply BEFORE paging so - * `limit` stays exact. + * @param hiddenIds - Ids this read must not return. The contract is the ANSWER, not the + * mechanism: a provider may subtract this set before paging, or derive the + * same exclusion from the params' visibility tiers itself โ€” either way the + * page must equal the engine's own answer with none of these ids in it. * @param graphIndex - The active graph provider, for a `connected` plan. * @returns The page's ids plus the stage that emptied it, or `null`. */ From 9922631d1fd9052c7e332451bba5c4d69296308a Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 09:09:33 -0700 Subject: [PATCH 27/42] ci: add the delta-gate workflow for the capped functional lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow_dispatch, runs-on gate-functional โ€” a host-mode, Bun-only lane with no Node.js runtime, so every step is plain git + bun in shell rather than a JS-based action. Clones candidate and control, runs the full vitest suite on each, enforces a >=3,000-collected guard per side, and diffs the two fail lists for genuinely new reds. The lane's own tripwire marker (host pressure โ€” never our own red or green) is checked before the verdict is printed, and the job cleans up its own checkouts so repeat runs don't feed the lane's disk-budget trip. --- .forgejo/workflows/delta-gate.yml | 127 ++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 .forgejo/workflows/delta-gate.yml diff --git a/.forgejo/workflows/delta-gate.yml b/.forgejo/workflows/delta-gate.yml new file mode 100644 index 00000000..f9209e39 --- /dev/null +++ b/.forgejo/workflows/delta-gate.yml @@ -0,0 +1,127 @@ +name: Delta Gate + +# On-demand candidate-vs-control gate on the capped functional CI lane +# (label: gate-functional). That lane is Bun-only host-mode โ€” there is no +# Node.js runtime available to it, so this workflow deliberately avoids every +# JS-based action (checkout/setup-node/setup-bun/upload-artifact all require +# one) and does everything with plain git + bun in shell steps instead. +# +# Verdict lines a caller should grep for in the run log: +# COLLECTED patch= control= โ€” collection-truncation guard inputs +# NEW-RED-COUNT: โ€” failures on candidate absent from control +# DELTA-GATE: CLEAN | NEW REDS | INVALID | STOPPED-BY-REGISTRY-TRIPWIRE +# +# The lane's own housekeeping stops the runner and drops a marker file when +# host pressure (I/O, registry latency, disk budget) trips โ€” never ours to +# interpret as a red or a green. The final step checks for that marker before +# it says anything about pass/fail. + +on: + workflow_dispatch: + inputs: + candidate: + description: 'Candidate ref (branch or sha) to gate' + required: true + type: string + control: + description: 'Control sha to diff against' + required: true + type: string + +concurrency: + group: delta-gate + cancel-in-progress: false + +jobs: + delta-gate: + name: Delta gate โ€” candidate vs control + runs-on: gate-functional + timeout-minutes: 120 + steps: + - name: Clean any residue from a prior run + run: rm -rf "ob-cand-${{ github.run_id }}" "ob-ctrl-${{ github.run_id }}" "/tmp/ob-${{ github.run_id }}-"* + + - name: Clone + test โ€” candidate + id: patch + run: | + set -o pipefail + git clone --quiet "https://source.soulcraft.com/soulcraftlabs/open-brainy.git" "ob-cand-${{ github.run_id }}" + cd "ob-cand-${{ github.run_id }}" + git checkout --quiet "${{ github.event.inputs.candidate }}" + git log --oneline -1 + bun install + rc=0 + bun x vitest run > "/tmp/ob-${{ github.run_id }}-patch.log" 2>&1 || rc=$? + echo "PATCH-RC:$rc" + grep -aE "Tests .*(passed|failed)" "/tmp/ob-${{ github.run_id }}-patch.log" | tail -1 + grep -aE "^ FAIL |^\s+ร—" "/tmp/ob-${{ github.run_id }}-patch.log" | sed -E "s/ [0-9]+ms$//" | sed -E "s/^\s+//" | sort -u > "/tmp/ob-${{ github.run_id }}-patch.fail" + echo "PATCH-FAILING:$(wc -l < "/tmp/ob-${{ github.run_id }}-patch.fail")" + + - name: Clone + test โ€” control + id: control + run: | + set -o pipefail + git clone --quiet "https://source.soulcraft.com/soulcraftlabs/open-brainy.git" "ob-ctrl-${{ github.run_id }}" + cd "ob-ctrl-${{ github.run_id }}" + git checkout --quiet "${{ github.event.inputs.control }}" + git log --oneline -1 + bun install + rc=0 + bun x vitest run > "/tmp/ob-${{ github.run_id }}-control.log" 2>&1 || rc=$? + echo "CONTROL-RC:$rc" + grep -aE "Tests .*(passed|failed)" "/tmp/ob-${{ github.run_id }}-control.log" | tail -1 + grep -aE "^ FAIL |^\s+ร—" "/tmp/ob-${{ github.run_id }}-control.log" | sed -E "s/ [0-9]+ms$//" | sed -E "s/^\s+//" | sort -u > "/tmp/ob-${{ github.run_id }}-control.fail" + echo "CONTROL-FAILING:$(wc -l < "/tmp/ob-${{ github.run_id }}-control.fail")" + + - name: Delta gate verdict + if: always() + run: | + set -o pipefail + + # The lane's own tripwire wins over anything we would otherwise say: + # a bare failure/timeout above with this marker present is host + # pressure, never a real red and never a real green. + if [ -f /srv/gate-lane/TRIPWIRE-STOPPED ]; then + echo "DELTA-GATE: STOPPED-BY-REGISTRY-TRIPWIRE" + head -1 /srv/gate-lane/TRIPWIRE-STOPPED + exit 3 + fi + + patch_log="/tmp/ob-${{ github.run_id }}-patch.log" + control_log="/tmp/ob-${{ github.run_id }}-control.log" + patch_fail="/tmp/ob-${{ github.run_id }}-patch.fail" + control_fail="/tmp/ob-${{ github.run_id }}-control.fail" + + if [ ! -s "$patch_log" ] || [ ! -s "$control_log" ]; then + echo "DELTA-GATE: INVALID โ€” a leg produced no log (see the two steps above for the real cause)" + exit 2 + fi + + pt=$(grep -aoE "\(([0-9]+)\)$" "$patch_log" | tail -1 | tr -d "()") + ct=$(grep -aoE "\(([0-9]+)\)$" "$control_log" | tail -1 | tr -d "()") + echo "COLLECTED patch=${pt:-0} control=${ct:-0}" + if [ "${pt:-0}" -lt 3000 ] || [ "${ct:-0}" -lt 3000 ]; then + echo "DELTA-GATE: INVALID โ€” truncated collection" + exit 2 + fi + + echo "=== NEW REDS ===" + comm -23 "$patch_fail" "$control_fail" + new=$(comm -23 "$patch_fail" "$control_fail" | wc -l) + echo "NEW-RED-COUNT:$new" + + echo "=== full candidate fail list ===" + cat "$patch_fail" + echo "=== full control fail list ===" + cat "$control_fail" + + if [ "$new" -eq 0 ]; then + echo "DELTA-GATE: CLEAN" + else + echo "DELTA-GATE: NEW REDS" + exit 1 + fi + + - name: Clean up (mind the lane's disk budget) + if: always() + run: rm -rf "ob-cand-${{ github.run_id }}" "ob-ctrl-${{ github.run_id }}" "/tmp/ob-${{ github.run_id }}-"* From 67ae0046de4063cf74bc9d57eb68adccd575ed5e Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 09:36:12 -0700 Subject: [PATCH 28/42] ci(delta-gate): add a push fallback trigger alongside workflow_dispatch workflow_dispatch needs Actions-unit write on the dispatching credential; push does not, since Forgejo runs the workflow straight from the pushed ref's tree. A plain push to a rel/** or ci/** branch now also fires the gate, resolving candidate to the pushed commit and control to the last released, known-good tip (10.4.9) when the workflow_dispatch inputs aren't present. --- .forgejo/workflows/delta-gate.yml | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/delta-gate.yml b/.forgejo/workflows/delta-gate.yml index f9209e39..c320594e 100644 --- a/.forgejo/workflows/delta-gate.yml +++ b/.forgejo/workflows/delta-gate.yml @@ -27,6 +27,12 @@ on: description: 'Control sha to diff against' required: true type: string + # workflow_dispatch needs Actions-unit write on the dispatching credential; + # push does not (it runs from the pushed ref's own tree), so a plain push + # to a release or CI branch is the fallback trigger while that grant is + # outstanding โ€” see the ref-resolution step below for what it gates against. + push: + branches: ['rel/**', 'ci/**'] concurrency: group: delta-gate @@ -38,6 +44,21 @@ jobs: runs-on: gate-functional timeout-minutes: 120 steps: + - name: Resolve candidate/control refs + id: refs + run: | + candidate="${{ github.event.inputs.candidate }}" + control="${{ github.event.inputs.control }}" + # workflow_dispatch supplies both explicitly; a push event carries + # neither โ€” fall back to the pushed commit as candidate and the + # last released, known-good tip (10.4.9) as control, so a plain + # push still produces a meaningful gate instead of an empty ref. + if [ -z "$candidate" ]; then candidate="${{ github.sha }}"; fi + if [ -z "$control" ]; then control="eec90bdd"; fi + echo "candidate=$candidate" >> "$GITHUB_OUTPUT" + echo "control=$control" >> "$GITHUB_OUTPUT" + echo "Resolved (trigger=${{ github.event_name }}): candidate=$candidate control=$control" + - name: Clean any residue from a prior run run: rm -rf "ob-cand-${{ github.run_id }}" "ob-ctrl-${{ github.run_id }}" "/tmp/ob-${{ github.run_id }}-"* @@ -47,7 +68,7 @@ jobs: set -o pipefail git clone --quiet "https://source.soulcraft.com/soulcraftlabs/open-brainy.git" "ob-cand-${{ github.run_id }}" cd "ob-cand-${{ github.run_id }}" - git checkout --quiet "${{ github.event.inputs.candidate }}" + git checkout --quiet "${{ steps.refs.outputs.candidate }}" git log --oneline -1 bun install rc=0 @@ -63,7 +84,7 @@ jobs: set -o pipefail git clone --quiet "https://source.soulcraft.com/soulcraftlabs/open-brainy.git" "ob-ctrl-${{ github.run_id }}" cd "ob-ctrl-${{ github.run_id }}" - git checkout --quiet "${{ github.event.inputs.control }}" + git checkout --quiet "${{ steps.refs.outputs.control }}" git log --oneline -1 bun install rc=0 From b1c7054467139d225d85a2b658d92fbc391b630f Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 09:21:30 -0700 Subject: [PATCH 29/42] fix(find): the hybrid legs rank inside the filter, and only the page is read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hybrid find fuses a text leg and a semantic leg. The semantic leg already walked only the metadata filter's universe. The text leg did not: it ranked the WHOLE store, took the top `limit * 4`, read every one of those rows from canonical, and only then intersected with the filter. On a large store with a selective filter that is hundreds of rows read to return a handful โ€” and a row matching both the query and the filter, but sitting outside the store-wide text prefix, was silently dropped. The same defect `find({ connected })` carried before the graph-first law, one leg over. Both legs now rank ids inside the universe and neither reads canonical. The text leg goes through a new optional `getIdsForTextQueryWithin` door on MetadataIndexProvider โ€” the text twin of `filterIdsWithin`, so a native index can intersect its postings before any string crosses the boundary; the reference index implements it from its own posting-list merge, so the two doors can never disagree, and a provider without it is served by the whole-store answer intersected here. The fusion ranks shells, the page is cut from them, and canonical is read once for exactly that page โ€” with the row rebuilt in full, so a hydrated row is indistinguishable from an eagerly-built one (same flattened fields, same entity, same match visibility, same key order). The eager forms of both legs stay for the search modes whose leg output IS the answer. Measured on the production recall shape (query + type list + `missing` negation + excludeVFS, limit 60) the old order read 241 rows in two batches to return one; the new order reads the page. Pinned in tests/integration/find-hybrid-filter-before-hydrate.test.ts. The oracle there is the pre-change pipeline itself, replayed on the same brain through the same doors: where the filter does not truncate the text leg the answer is identical โ€” rows, order, scores, match visibility and row shape โ€” across hybrid + where, + type list + excludeVFS + a `missing` negation, + connected, with and without offset. Where it does truncate, the correction is held by name: the old order's text leg contributed nothing at all, the new one returns the matching rows and paging reaches every one of them. The cost pins read the engine's own counters: one batchGet of `limit` ids, the whole-store text door never called, and what the text leg marshals bounded by the universe. --- src/brainy.ts | 353 +++++++---- src/plugin.ts | 22 + src/utils/metadataIndex.ts | 66 +- .../find-hybrid-filter-before-hydrate.test.ts | 580 ++++++++++++++++++ 4 files changed, 903 insertions(+), 118 deletions(-) create mode 100644 tests/integration/find-hybrid-filter-before-hydrate.test.ts diff --git a/src/brainy.ts b/src/brainy.ts index 4d7596d3..ad8bbf90 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -7651,6 +7651,13 @@ export class Brainy implements BrainyInterface { const searchMode = params.searchMode || 'auto' const limit = params.limit || 10 + // HYDRATE LAST (the hybrid path): its legs and its fusion rank IDS, and + // canonical is read at the two page exits below โ€” never for a row the + // metadata filter is about to discard. This closure re-applies a hybrid + // row's match visibility once its entity is in hand; it is set only by + // the hybrid branch, so every other path hydrates unchanged. + let finishHybridRow: ((row: Result, pending: Result) => void) | undefined + // Handle text-only query (user explicitly wants text search) if (searchMode === 'text' && params.query && params.query.trim() !== '') { results = await this.executeTextSearch(params.query, limit * 2) @@ -7661,20 +7668,32 @@ export class Brainy implements BrainyInterface { } // Handle explicit hybrid or auto mode with query else if ((searchMode === 'auto' || searchMode === 'hybrid') && params.query && params.query.trim() !== '' && !params.vector) { - // Zero-config hybrid: combine text + semantic search with RRF fusion - const [textResults, semanticResults] = await Promise.all([ - this.executeTextSearch(params.query, limit * 2), - this.executeVectorSearch(params, preResolvedMetadataIds ?? undefined, preResolvedAllowedIds) + // Zero-config hybrid: combine text + semantic search with RRF fusion. + // BOTH legs are held to the metadata filter's universe: the vector leg + // walks it as its candidate set, and the text leg ranks inside it + // instead of ranking the whole store and discarding what the filter + // would drop. Neither leg reads canonical โ€” the page does, once. + const [textScored, semanticScored] = await Promise.all([ + this.executeTextSearchScored(params.query, limit * 2, preResolvedMetadataIds ?? undefined), + this.executeVectorSearchScored(params, preResolvedMetadataIds ?? undefined, preResolvedAllowedIds) ]) // Use user-specified alpha or auto-detect based on query length const alpha = params.hybridAlpha ?? this.autoAlpha(params.query) - // Tokenize query for match visibility + // Tokenize query for match visibility. The word list needs the entity, + // so it is computed on the page, at hydration. const queryWords = this.metadataIndex.tokenize(params.query) + const textResultIds = new Set(textScored.map((r) => r.id)) + finishHybridRow = (row, pending) => { + row.textMatches = this.findMatchingWords(row.entity, queryWords, textResultIds) + row.textScore = pending.textScore + row.semanticScore = pending.semanticScore + row.matchSource = pending.matchSource + } - // RRF fusion combines both result sets with match visibility - results = await this.rrfFusion(textResults, semanticResults, alpha, queryWords) + // RRF fusion combines both ranked id sets with match visibility + results = this.rrfFusion(textScored, semanticScored, alpha) } // Handle direct vector search (no query text) - no hybrid needed else if (params.vector && !params.query) { @@ -7736,19 +7755,11 @@ export class Brainy implements BrainyInterface { const order = rankIndicesByScore(results.map(r => r.score), k, true) results = reorderByIndices(results, order).slice(offset, k) - // Batch-load entities only for the paginated results (10x faster on GCS) - const idsToLoad = results.filter(r => !r.entity).map(r => r.id) - if (idsToLoad.length > 0) { - const entitiesMap = await this.batchGet(idsToLoad) - for (const result of results) { - if (!result.entity) { - const entity = entitiesMap.get(result.id) - if (entity) { - result.entity = entity - } - } - } - } + // Batch-load entities only for the paginated results (10x faster on GCS). + // This is the hydrate-last seam for the deferring paths: a row that + // arrives as a ranked shell is rebuilt in full here โ€” flattened + // fields, entity and match visibility โ€” never `entity` alone. + results = await this.hydrateResultPage(results, finishHybridRow) // Early return if no other processing needed if (!params.connected && !params.fusion) { @@ -7854,8 +7865,13 @@ export class Brainy implements BrainyInterface { const finalOffset = params.offset || 0 - // Efficient pagination - only slice what we need (limit already defined above) - return results.slice(finalOffset, finalOffset + limit) + // Efficient pagination - only slice what we need (limit already defined + // above), THEN read canonical for the page. Rows that arrived hydrated + // pass straight through; a deferred path reads exactly these rows. + return await this.hydrateResultPage( + results.slice(finalOffset, finalOffset + limit), + finishHybridRow + ) })() // Index-integrity guard โ€” applied ONCE here so every find() path (metadata, @@ -12949,6 +12965,31 @@ export class Brainy implements BrainyInterface { } } + /** + * The text-leg twin of {@link filterIdsWithinBelted}: rank `query` INSIDE the + * candidate universe, through the provider's own posting-list merge so the + * answer can never drift from `getIdsForTextQuery`'s. A provider without the + * door is served by its whole-store answer intersected here โ€” the same rows + * in the same order, but it pays the whole-store marshal. + * + * @param query - The text query. + * @param ids - The candidate universe (the metadata filter's ids). + * @returns `{ id, matchCount }` rows inside `ids`, ranked by match count. + */ + private async textIdsWithinBelted( + query: string, + ids: readonly string[] + ): Promise> { + this.ensureIndexesLoaded(['metadata']) + const mip = this.metadataIndex as unknown as MetadataIndexProvider + if (typeof mip.getIdsForTextQueryWithin === 'function') { + return await mip.getIdsForTextQueryWithin(query, ids) + } + const within = new Set(ids) + const all = await this.metadataIndex.getIdsForTextQuery(query) + return all.filter((m) => within.has(m.id)) + } + async getIndexStatus(): Promise<{ initialized: boolean /** `true` once open()'s index-build-if-needed step has run. Named for API @@ -15841,6 +15882,44 @@ export class Brainy implements BrainyInterface { candidateIds?: string[], allowedIds?: OpaqueIdSet ): Promise[]> { + const scored = await this.executeVectorSearchScored(params, candidateIds, allowedIds) + + // Batch-load entities for 10-50x faster cloud storage performance + // GCS: 10 results = 1ร—50ms vs 10ร—50ms = 500ms (10x faster) + const entitiesMap = await this.batchGet(scored.map((s) => s.id)) + + const results: Result[] = [] + for (const { id, score } of scored) { + const entity = entitiesMap.get(id) + if (entity) { + results.push(this.createResult(id, score, entity)) + } + } + + return results + } + + /** + * The semantic leg WITHOUT hydration โ€” ranked ids and their scores. + * + * The beam walk is already restricted to the candidate universe (that is what + * `candidateIds` / `allowedIds` are for), so the leg's cost is the walk. Its + * ROWS, though, are candidates for a fusion that will keep one page of them โ€” + * so the hybrid path takes them unhydrated and reads exactly the page it + * returns. {@link executeVectorSearch} is the eager form, for the search modes + * whose leg output IS the answer. + * + * @param params - Find parameters (supplies the query/vector and the limit). + * @param candidateIds - Optional pre-resolved metadata universe (see + * {@link executeVectorSearch}). + * @param allowedIds - Optional opaque predicate-pushdown universe. + * @returns Ranked `{ id, score }` rows โ€” no entity reads. + */ + private async executeVectorSearchScored( + params: FindParams, + candidateIds?: string[], + allowedIds?: OpaqueIdSet + ): Promise> { // Vector cold-read guard: before trusting a semantic/vector result, verify the // vector index actually SERVES a known persisted vector (one-shot per brain). // A pure semantic find({ query }) has no filter, so verifyMetadataLive never @@ -15866,21 +15945,10 @@ export class Brainy implements BrainyInterface { // HNSW search with optional metadata-first candidate filtering const searchResults: [string, number][] = await this.index.search(vector, limit * 2, undefined, searchOptions) - // Batch-load entities for 10-50x faster cloud storage performance - // GCS: 10 results = 1ร—50ms vs 10ร—50ms = 500ms (10x faster) - const ids = searchResults.map(([id]) => id) - const entitiesMap = await this.batchGet(ids) - - const results: Result[] = [] - for (const [id, distance] of searchResults) { - const entity = entitiesMap.get(id) - if (entity) { - const score = Math.max(0, Math.min(1, 1 / (1 + distance))) - results.push(this.createResult(id, score, entity)) - } - } - - return results + return searchResults.map(([id, distance]) => ({ + id, + score: Math.max(0, Math.min(1, 1 / (1 + distance))) + })) } /** @@ -16180,30 +16248,64 @@ export class Brainy implements BrainyInterface { * @returns Array of Results with scores based on match count */ private async executeTextSearch(query: string, limit: number): Promise[]> { - const textMatches = await this.metadataIndex.getIdsForTextQuery(query) - if (textMatches.length === 0) return [] + const scored = await this.executeTextSearchScored(query, limit) + if (scored.length === 0) return [] - // Take top matches and load entities - const topMatches = textMatches.slice(0, limit * 2) // Get more for filtering - const ids = topMatches.map(m => m.id) - const entitiesMap = await this.batchGet(ids) + // Batch-load entities for the whole leg โ€” this is the eager form, kept for + // the text-only search mode whose results ARE the answer. + const entitiesMap = await this.batchGet(scored.map((s) => s.id)) - // Create results with scores based on match count - const maxMatches = topMatches[0]?.matchCount || 1 const results: Result[] = [] - - for (const match of topMatches) { - const entity = entitiesMap.get(match.id) + for (const { id, score } of scored) { + const entity = entitiesMap.get(id) if (entity) { - // Normalize score to 0-1 range based on match count - const score = match.matchCount / maxMatches - results.push(this.createResult(match.id, score, entity)) + results.push(this.createResult(id, score, entity)) } } return results } + /** + * The text leg WITHOUT hydration โ€” ranked ids and their scores. + * + * FILTER BEFORE HYDRATE: when the caller already knows the candidate + * universe (the metadata filter's ids in a hybrid `find({ query, where })`), + * it is passed here and the word index ranks INSIDE that universe. The + * earlier order ranked the whole store, took the top `limit * 2`, hydrated + * every one of them, and only then intersected with the filter โ€” so a + * filtered hybrid find on a large store hydrated hundreds of rows to return + * a handful, and a matching row outside the store-wide text prefix was + * silently dropped (the same defect `find({ connected })` had before the + * graph-first law). + * + * The score is the match count normalized against the top row's, so a + * restricted call normalizes against the top row IN THE UNIVERSE โ€” the same + * rule applied to the set actually being ranked. + * + * @param query - Text query to search for. + * @param limit - Result budget; the leg keeps `limit * 2` for the fusion. + * @param candidateIds - Optional candidate universe to rank inside. + * @returns Ranked `{ id, score }` rows โ€” no entity reads. + */ + private async executeTextSearchScored( + query: string, + limit: number, + candidateIds?: readonly string[] + ): Promise> { + const textMatches = candidateIds + ? await this.textIdsWithinBelted(query, candidateIds) + : await this.metadataIndex.getIdsForTextQuery(query) + if (textMatches.length === 0) return [] + + // Take top matches (more than the page, for the fusion to rank) + const topMatches = textMatches.slice(0, limit * 2) + + // Normalize score to 0-1 range based on match count + const maxMatches = topMatches[0]?.matchCount || 1 + return topMatches.map((m) => ({ id: m.id, score: m.matchCount / maxMatches })) + } + /** * Auto-detect optimal alpha for hybrid search * @@ -16230,55 +16332,56 @@ export class Brainy implements BrainyInterface { * * Formula: score(d) = sum(1 / (k + rank(d))) for each list * - * Now includes match visibility (textMatches, textScore, semanticScore, matchSource) + * Now includes match visibility (textScore, semanticScore, matchSource; the + * `textMatches` word list needs the entity and is filled at hydration). * - * @param textResults - Results from text search - * @param semanticResults - Results from semantic search + * HYDRATE LAST: both legs arrive as ranked ids + scores, and the fusion ranks + * ids โ€” no entity is read here. The rows it returns are ranked SHELLS; the + * page is cut from them and only that page is read from canonical (see + * {@link hydrateResultPage}). The earlier order hydrated both legs in full โ€” + * hundreds of rows โ€” to return one page of them. + * + * @param textResults - Ranked ids + scores from text search + * @param semanticResults - Ranked ids + scores from semantic search * @param alpha - Weight for semantic (0=text only, 1=semantic only) - * @param queryWords - Original query words for match tracking * @param k - RRF constant (default: 60, standard in literature) - * @returns Fused results sorted by combined score with match visibility + * @returns Fused result shells sorted by combined score with match visibility */ - private async rrfFusion( - textResults: Result[], - semanticResults: Result[], + private rrfFusion( + textResults: ReadonlyArray<{ id: string; score: number }>, + semanticResults: ReadonlyArray<{ id: string; score: number }>, alpha: number, - queryWords: string[], k: number = 60 - ): Promise[]> { + ): Result[] { // Track scores and match details per entity interface MatchData { rrf: number textScore?: number semanticScore?: number - textMatches: string[] hasText: boolean hasSemantic: boolean } const matchData = new Map() - const entityMap = new Map>() // Text contribution (1 - alpha weight) const textWeight = 1 - alpha textResults.forEach((r, rank) => { const rrfScore = textWeight * (1 / (k + rank + 1)) - const existing = matchData.get(r.id) || { rrf: 0, textMatches: [], hasText: false, hasSemantic: false } + const existing = matchData.get(r.id) || { rrf: 0, hasText: false, hasSemantic: false } existing.rrf += rrfScore existing.textScore = r.score // Original text search score (0-1) existing.hasText = true matchData.set(r.id, existing) - if (r.entity) entityMap.set(r.id, r.entity) }) // Semantic contribution (alpha weight) semanticResults.forEach((r, rank) => { const rrfScore = alpha * (1 / (k + rank + 1)) - const existing = matchData.get(r.id) || { rrf: 0, textMatches: [], hasText: false, hasSemantic: false } + const existing = matchData.get(r.id) || { rrf: 0, hasText: false, hasSemantic: false } existing.rrf += rrfScore existing.semanticScore = r.score // Original semantic search score (0-1) existing.hasSemantic = true matchData.set(r.id, existing) - if (r.entity) entityMap.set(r.id, r.entity) }) // Sort by fused score @@ -16286,51 +16389,93 @@ export class Brainy implements BrainyInterface { .sort((a, b) => b[1].rrf - a[1].rrf) .map(([id, data]) => ({ id, data })) - // Build results - need to load any missing entities - const missingIds = sortedIds.filter(s => !entityMap.has(s.id)).map(s => s.id) - if (missingIds.length > 0) { - const loaded = await this.batchGet(missingIds) - for (const [id, entity] of loaded) { - entityMap.set(id, entity) - } - } - - // Performance: Build set of text result IDs for O(1) lookup - // This avoids re-extracting text for entities that weren't in text results - const textResultIds = new Set(textResults.map(r => r.id)) - - // Create final results with match visibility + // Create ranked shells with match visibility const results: Result[] = [] for (const { id, data } of sortedIds) { - const entity = entityMap.get(id) - if (entity) { - // Find which query words matched - uses fast path if entity wasn't in text results - const textMatches = this.findMatchingWords(entity, queryWords, textResultIds) - - // Determine match source - let matchSource: 'text' | 'semantic' | 'both' - if (data.hasText && data.hasSemantic) { - matchSource = 'both' - } else if (data.hasText) { - matchSource = 'text' - } else { - matchSource = 'semantic' - } - - // Create result with match visibility - const result = this.createResult(id, data.rrf, entity) - result.textMatches = textMatches - result.textScore = data.textScore - result.semanticScore = data.semanticScore - result.matchSource = matchSource - - results.push(result) + // Determine match source + let matchSource: 'text' | 'semantic' | 'both' + if (data.hasText && data.hasSemantic) { + matchSource = 'both' + } else if (data.hasText) { + matchSource = 'text' + } else { + matchSource = 'semantic' } + + const result = this.pendingResult(id, data.rrf) + result.textScore = data.textScore + result.semanticScore = data.semanticScore + result.matchSource = matchSource + + results.push(result) } return results } + /** + * A ranked candidate whose entity has NOT been read yet. + * + * The shell carries everything the ranking tail needs โ€” the id, the score, + * and the match-visibility fields โ€” and nothing that requires canonical. It + * is typed `Result` so it flows through the shared dedupe / visibility / + * filter / rank / page tail unchanged; {@link hydrateResultPage} turns the + * survivors into real results before any caller sees them, and find()'s + * index-integrity guard drops any row that never gained an entity. + * + * @param id - The candidate's canonical id. + * @param score - Its rank score. + */ + private pendingResult(id: string, score: number): Result { + return { id, score } as Result + } + + /** + * Read canonical for exactly the rows that need it โ€” the hydrate-last seam. + * + * Rows that already carry an entity (the eager legs: metadata, text-only, + * semantic-only, proximity, graph) pass through untouched, so this is a no-op + * for every path that has not deferred. Rows that are shells are read in ONE + * batch and rebuilt through {@link createResult}, so a hydrated row is + * indistinguishable from an eagerly-built one โ€” same flattened fields, same + * `entity`, same key order โ€” with `finish` re-applying the fields only the + * deferring path knows about (a hybrid row's match visibility). + * + * A shell whose id has no canonical row is dropped, exactly as the eager legs + * dropped it; find()'s index-integrity guard makes the same judgement on the + * page it returns. + * + * @param rows - The page's rows, ranked and paged already. + * @param finish - Applied to each rebuilt row, with its shell, after the + * flattened fields are set. + * @returns The page with every surviving row hydrated. + */ + private async hydrateResultPage( + rows: Result[], + finish?: (row: Result, pending: Result) => void + ): Promise[]> { + const pendingIds: string[] = [] + for (const row of rows) { + if (!row.entity) pendingIds.push(row.id) + } + if (pendingIds.length === 0) return rows + + const entitiesMap = await this.batchGet(pendingIds) + const hydrated: Result[] = [] + for (const row of rows) { + if (row.entity) { + hydrated.push(row) + continue + } + const entity = entitiesMap.get(row.id) + if (!entity) continue + const filled = this.createResult(row.id, row.score, entity, row.explanation) + finish?.(filled, row) + hydrated.push(filled) + } + return hydrated + } + /** * Find which query words match in an entity's text content * diff --git a/src/plugin.ts b/src/plugin.ts index 54d300bb..64abfe26 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -473,6 +473,28 @@ export interface MetadataIndexProvider { graphIndex: unknown ): Promise<{ ids: string[]; emptyAt: 'graph' | 'filter' | 'visibility' | 'none' } | null> getIdsForTextQuery(query: string): Promise> + /** + * @description OPTIONAL: score `query` over `ids` ONLY โ€” the text-leg twin of + * {@link filterIdsWithin}, and the door a hybrid `find({ query, where })` + * walks. The metadata filter's universe is the candidate set there, so the + * text leg must cost O(|ids|) membership checks and marshal at most `|ids|` + * rows, never the whole posting list of every query word. A native index + * intersects its own postings with the candidate set (membership by entity + * int) before any string crosses the boundary; the reference index answers + * from its own `getIdsForTextQuery`, so the two doors can never disagree. + * Absent โ†’ Brainy intersects `getIdsForTextQuery`'s answer with `ids` itself + * (correct, and still hydrate-last, but it marshals the whole answer). + * + * The answer keeps `getIdsForTextQuery`'s contract: `{ id, matchCount }` + * sorted by `matchCount` descending, ties in the order the whole-store answer + * would have produced. Only rows in `ids` may appear. + * @param query - The same text query accepted by `getIdsForTextQuery`. + * @param ids - The candidate ids (canonical). The answer is a subset. + */ + getIdsForTextQueryWithin?( + query: string, + ids: readonly string[] + ): Promise> getSortedIdsForFilter(filter: any, orderBy: string, order?: 'asc' | 'desc', topK?: number): Promise getFilterValues(field: string): Promise getFilterFields(): Promise diff --git a/src/utils/metadataIndex.ts b/src/utils/metadataIndex.ts index 0fd312e2..a3aa7679 100644 --- a/src/utils/metadataIndex.ts +++ b/src/utils/metadataIndex.ts @@ -1509,11 +1509,56 @@ export class MetadataIndexManager implements MetadataIndexProvider { * @returns Array of { id, matchCount } sorted by matchCount descending */ async getIdsForTextQuery(query: string): Promise> { + return this.scoreTextQuery(query) + } + + /** + * Score a text query over `ids` ONLY โ€” the reference implementation of the + * optional `getIdsForTextQueryWithin` door (see + * {@link import('../plugin.js').MetadataIndexProvider}). The hybrid + * `find({ query, where })` path passes the metadata filter's universe here so + * the text leg ranks INSIDE that universe instead of ranking the whole store + * and discarding the rows the filter would have dropped. + * + * It answers from the same posting-list merge as {@link getIdsForTextQuery}, + * with the candidate membership applied as each word's postings are counted, + * so the two doors can never disagree: the answer is exactly the whole-store + * answer restricted to `ids`, in the same order. + * + * @param query - Text query to search for. + * @param ids - Candidate entity ids; only these may appear in the answer. + * @returns Array of { id, matchCount } sorted by matchCount descending. + */ + async getIdsForTextQueryWithin( + query: string, + ids: readonly string[] + ): Promise> { + if (ids.length === 0) return [] + return this.scoreTextQuery(query, new Set(ids)) + } + + /** + * The one posting-list merge behind both text doors. + * + * Each query word contributes AT MOST one match per entity (a posting list + * can name an id more than once), and entities are ranked by how many of the + * query's words they matched. `within`, when given, restricts the count to + * those candidates โ€” applied during the merge, so a restricted call never + * materializes a whole-store match map. + * + * @param query - Text query to search for. + * @param within - Optional candidate universe; absent = the whole store. + * @returns Array of { id, matchCount } sorted by matchCount descending. + */ + private async scoreTextQuery( + query: string, + within?: ReadonlySet + ): Promise> { const queryWords = this.tokenize(query) if (queryWords.length === 0) return [] - // Get IDs for each word hash - const wordIdSets: Map[] = [] + // Count matches per entity, one word's postings at a time. + const matchCounts = new Map() for (const word of queryWords) { const wordHash = this.hashWord(word) let ids: string[] @@ -1529,19 +1574,12 @@ export class MetadataIndexManager implements MetadataIndexProvider { throw err } } - const idSet = new Map() + // One count per (word, entity) โ€” dedupe this word's postings first. + const counted = new Set() for (const id of ids) { - idSet.set(id, 1) - } - wordIdSets.push(idSet) - } - - if (wordIdSets.length === 0) return [] - - // Count matches per entity - const matchCounts = new Map() - for (const idSet of wordIdSets) { - for (const [id] of idSet) { + if (counted.has(id)) continue + counted.add(id) + if (within && !within.has(id)) continue matchCounts.set(id, (matchCounts.get(id) || 0) + 1) } } diff --git a/tests/integration/find-hybrid-filter-before-hydrate.test.ts b/tests/integration/find-hybrid-filter-before-hydrate.test.ts new file mode 100644 index 00000000..252fe89b --- /dev/null +++ b/tests/integration/find-hybrid-filter-before-hydrate.test.ts @@ -0,0 +1,580 @@ +/** + * @module tests/integration/find-hybrid-filter-before-hydrate + * @description FILTER BEFORE HYDRATE, applied to the hybrid `find({ query })` path. + * + * A hybrid find fuses two legs. The semantic leg already walked only the + * metadata filter's universe (`candidateIds` / `allowedIds`). The TEXT leg did + * not: it ranked the WHOLE store, took the top `limit * 4`, read every one of + * those rows from canonical, and only then intersected with the filter โ€” so a + * filtered hybrid find on a large store read hundreds of rows to return a + * handful of them, and a matching row outside the store-wide text prefix was + * silently dropped. That is the same defect `find({ connected })` carried + * before the graph-first law, one leg over. + * + * Both halves are pinned here. + * + * THE ANSWER. Where the filter did not truncate the text leg โ€” the universe + * covers every text match, so both orders rank the same rows โ€” the new + * pipeline's answer is IDENTICAL to the old one's: same rows, same order, same + * scores, same match visibility, same row shape. The oracle below is the + * pre-change pipeline itself, replayed on the same brain through the same + * doors, so the comparison is against what actually ran, not a remembered + * expectation. + * + * THE CORRECTION. Where the filter DID truncate it โ€” the query's words are + * common outside the universe โ€” the old order let the text leg contribute + * nothing at all: every row it ranked was discarded by the filter, and the + * answer came from the semantic leg alone. The new order ranks inside the + * universe, so the text leg contributes the rows it always should have. + * + * THE COST. Canonical is read for exactly the page: one batch, `limit` rows, + * never the legs. And the text leg is asked about the universe's ids only โ€” + * what it marshals is bounded by the universe, not by the store. + */ +import { describe, it, expect, beforeAll, vi } from 'vitest' +import { Brainy } from '../../src/brainy' +import { NounType, VerbType } from '../../src/types/graphTypes' +import { rankIndicesByScore, reorderByIndices } from '../../src/utils/resultRanking' +import { resolveEntityId } from '../../src/utils/idNormalization' + +/** Embedding width of the default model โ€” the row vectors must match it. */ +const DIM = 384 + +/** + * A deterministic, per-row-distinct unit vector. Distinct so the semantic leg + * has a real ranking to produce (identical vectors would make its order a tie + * break), deterministic so the oracle and the pipeline see the same one. + */ +function seededVector(seed: number): number[] { + const v = new Array(DIM) + for (let i = 0; i < DIM; i++) { + v[i] = Math.sin((i + 1) * 0.11 + seed * 0.37) * 0.5 + Math.cos((i + 1) * 0.05 + seed * 0.13) * 0.3 + } + const magnitude = Math.sqrt(v.reduce((sum, x) => sum + x * x, 0)) + return v.map((x) => x / magnitude) +} + +/** The fields a caller reads off a hybrid row โ€” the whole comparable surface. */ +function project(rows: any[]): any[] { + return rows.map((r) => ({ + id: r.id, + score: r.score, + type: r.type, + metadata: r.metadata, + textMatches: r.textMatches, + textScore: r.textScore, + semanticScore: r.semanticScore, + matchSource: r.matchSource + })) +} + +/** + * The PRE-CHANGE hybrid pipeline, replayed on a live brain through the same + * provider doors it used: whole-store text ranking with both legs hydrated in + * full, RRF fusion, then the metadata intersection, then the page. + * + * Supports the shapes these pins exercise (query + where/type/excludeVFS + + * connected + offset); `orderBy`, `fusion` and `near` are not replayed. + */ +async function legacyHybridFind(brain: any, params: any): Promise { + const index = brain.metadataIndex + const limit = params.limit ?? 10 + const offset = params.offset ?? 0 + const hasFilter = Boolean( + params.where || params.type || params.subtype || params.service || params.excludeVFS + ) + + let preResolvedMetadataIds: string[] | null = null + let preResolvedFilter: any = null + let graphFirstIds: string[] | null = null + + if (params.connected) { + // find() normalizes the anchors to canonical ids before this stage runs. + const anchored = { + ...params, + connected: { + ...params.connected, + ...(params.connected.from && { from: resolveEntityId(params.connected.from) }), + ...(params.connected.to && { to: resolveEntityId(params.connected.to) }) + } + } + graphFirstIds = await brain.resolveConnectedIds(anchored) + if (graphFirstIds!.length > 0 && hasFilter) { + preResolvedFilter = brain.buildMetadataFilter(params) + graphFirstIds = await brain.filterIdsWithinBelted(preResolvedFilter, graphFirstIds) + } + if (graphFirstIds!.length === 0) return [] + preResolvedMetadataIds = graphFirstIds + } else if (hasFilter) { + preResolvedFilter = brain.buildMetadataFilter(params) + preResolvedMetadataIds = await brain.filterIdsBelted(preResolvedFilter) + if (preResolvedMetadataIds!.length === 0) return [] + } + + // Text leg โ€” the whole store, then the top `limit * 4`, hydrated in full. + const allTextMatches = await index.getIdsForTextQuery(params.query) + const topMatches = allTextMatches.slice(0, limit * 2 * 2) + const maxMatches = topMatches[0]?.matchCount || 1 + const textEntities = await brain.batchGet(topMatches.map((m: any) => m.id)) + const textResults = topMatches + .filter((m: any) => textEntities.has(m.id)) + .map((m: any) => ({ id: m.id, score: m.matchCount / maxMatches })) + + // Semantic leg โ€” the beam walk over the universe, hydrated in full. + const vector = await brain.embed(params.query) + const searchOptions = preResolvedMetadataIds ? { candidateIds: preResolvedMetadataIds } : undefined + const searchResults: [string, number][] = await brain.index.search( + vector, + limit * 2, + undefined, + searchOptions + ) + const semanticEntities = await brain.batchGet(searchResults.map(([id]) => id)) + const semanticResults = searchResults + .filter(([id]) => semanticEntities.has(id)) + .map(([id, distance]) => ({ id, score: Math.max(0, Math.min(1, 1 / (1 + distance))) })) + + // RRF fusion, with the match visibility the rows carried. + const alpha = params.hybridAlpha ?? brain.autoAlpha(params.query) + const k = 60 + const matchData = new Map() + const textWeight = 1 - alpha + textResults.forEach((r: any, rank: number) => { + const existing = matchData.get(r.id) || { rrf: 0, hasText: false, hasSemantic: false } + existing.rrf += textWeight * (1 / (k + rank + 1)) + existing.textScore = r.score + existing.hasText = true + matchData.set(r.id, existing) + }) + semanticResults.forEach((r: any, rank: number) => { + const existing = matchData.get(r.id) || { rrf: 0, hasText: false, hasSemantic: false } + existing.rrf += alpha * (1 / (k + rank + 1)) + existing.semanticScore = r.score + existing.hasSemantic = true + matchData.set(r.id, existing) + }) + + const queryWords: string[] = index.tokenize(params.query) + const textResultIds = new Set(textResults.map((r: any) => r.id)) + const fusedIds = Array.from(matchData.entries()) + .sort((a, b) => b[1].rrf - a[1].rrf) + .map(([id, data]) => ({ id, data })) + + const allEntities = await brain.batchGet(fusedIds.map((f) => f.id)) + let rows: any[] = [] + for (const { id, data } of fusedIds) { + const entity = allEntities.get(id) + if (!entity) continue + const textContent = textResultIds.has(id) + ? index.extractTextContent({ data: entity.data, metadata: entity.metadata }).toLowerCase() + : null + rows.push({ + id, + score: data.rrf, + type: entity.type, + metadata: entity.metadata, + textMatches: + textContent === null ? [] : queryWords.filter((w) => textContent.includes(w.toLowerCase())), + textScore: data.textScore, + semanticScore: data.semanticScore, + matchSource: data.hasText && data.hasSemantic ? 'both' : data.hasText ? 'text' : 'semantic' + }) + } + + // The metadata intersection โ€” after the legs, as it was. + if (preResolvedMetadataIds && preResolvedFilter) { + const filteredIdSet = new Set(preResolvedMetadataIds) + rows = rows.filter((r) => filteredIdSet.has(r.id)) + } + if (graphFirstIds !== null) { + const neighbourSet = new Set(graphFirstIds) + rows = rows.filter((r) => neighbourSet.has(r.id)) + } + + // Rank to the page, then cut it. + const order = rankIndicesByScore( + rows.map((r) => r.score), + offset + limit, + true + ) + return reorderByIndices(rows, order).slice(offset, offset + limit) +} + +/** + * FIXTURE A โ€” the filter's universe covers every text match, so the two orders + * rank exactly the same rows and the answers must be identical. + */ +describe('hybrid find: filter before hydrate โ€” the answer is unchanged', () => { + let brain: Brainy + const QUERY = 'orbital telemetry' + const MATCHES = 24 + const FILLER = 120 + const OUTSIDE = 30 + const VFS = 10 + const RETRACTED = 6 + const anchor = 'array-anchor' + const matchIds: string[] = [] + + beforeAll(async () => { + brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) + await brain.init() + + let seed = 1 + await brain.add({ + id: anchor, + data: 'ground station anchor record', + type: NounType.Thing, + metadata: { lane: 'alpha', role: 'anchor' }, + vector: seededVector(seed++) + }) + + // Rows the query's words actually match โ€” all inside every filter below. + for (let i = 0; i < MATCHES; i++) { + const id = `match-${i}` + await brain.add({ + id, + data: `orbital telemetry packet ${i} recorded downlink`, + type: NounType.Document, + metadata: { lane: 'alpha', rank: i }, + vector: seededVector(seed++) + }) + matchIds.push(resolveEntityId(id)) + await brain.relate({ from: anchor, to: id, type: VerbType.RelatedTo }) + } + // Rows inside the universe that the query's words do NOT match. + for (let i = 0; i < FILLER; i++) { + await brain.add({ + id: `filler-${i}`, + data: `cistern ledger entry ${i} archived`, + type: NounType.Document, + metadata: { lane: 'alpha', rank: 1000 + i }, + vector: seededVector(seed++) + }) + } + // Rows outside the universe. + for (let i = 0; i < OUTSIDE; i++) { + await brain.add({ + id: `outside-${i}`, + data: `unrelated dossier ${i}`, + type: NounType.Person, + metadata: { lane: 'beta' }, + vector: seededVector(seed++) + }) + } + // VFS infrastructure rows โ€” excluded by excludeVFS. + for (let i = 0; i < VFS; i++) { + await brain.add({ + id: `vfs-${i}`, + data: `mounted path ${i}`, + type: NounType.Document, + metadata: { lane: 'alpha', vfsType: 'file' }, + vector: seededVector(seed++) + }) + } + // Retracted rows โ€” excluded by a `missing` negation. + for (let i = 0; i < RETRACTED; i++) { + await brain.add({ + id: `retracted-${i}`, + data: `withdrawn note ${i}`, + type: NounType.Document, + metadata: { lane: 'alpha', retracted: true }, + vector: seededVector(seed++) + }) + } + + // The reference index has no opaque-set door, so the pipeline and the + // oracle both restrict the beam walk with the materialized candidate ids. + expect(typeof (brain as any).metadataIndex.getIdSetForFilter).not.toBe('function') + }) + + it('the fixture does not truncate the text leg โ€” the universe covers every text match', async () => { + const index = (brain as any).metadataIndex + const textMatches = await index.getIdsForTextQuery(QUERY) + expect(textMatches).toHaveLength(MATCHES) + const universe = await (brain as any).filterIdsBelted({ lane: 'alpha' }) + const inUniverse = new Set(universe) + for (const m of textMatches) expect(inUniverse.has(m.id)).toBe(true) + }) + + it('hybrid + where: identical rows, identical order, identical scores', async () => { + const params = { query: QUERY, where: { lane: 'alpha' }, limit: 8 } + const expected = await legacyHybridFind(brain as any, params) + const actual = await brain.find(params as any) + expect(actual.length).toBe(expected.length) + expect(project(actual)).toEqual(expected) + }) + + it('hybrid + where + offset: identical page two', async () => { + const params = { query: QUERY, where: { lane: 'alpha' }, limit: 6, offset: 6 } + const expected = await legacyHybridFind(brain as any, params) + const actual = await brain.find(params as any) + expect(actual.length).toBe(expected.length) + expect(project(actual)).toEqual(expected) + }) + + it('hybrid + type list + excludeVFS + a `missing` negation: identical', async () => { + const params = { + query: QUERY, + type: [NounType.Document, NounType.Person], + excludeVFS: true, + where: { lane: 'alpha', retracted: { missing: true } }, + limit: 8 + } + const expected = await legacyHybridFind(brain as any, params) + const actual = await brain.find(params as any) + expect(actual.length).toBe(expected.length) + expect(project(actual)).toEqual(expected) + for (const r of actual) { + expect(r.metadata.retracted).toBeUndefined() + expect(r.metadata.vfsType).toBeUndefined() + } + }) + + it('hybrid + type list + excludeVFS + a `missing` negation, offset: identical', async () => { + const params = { + query: QUERY, + type: [NounType.Document, NounType.Person], + excludeVFS: true, + where: { lane: 'alpha', retracted: { missing: true } }, + limit: 5, + offset: 5 + } + const expected = await legacyHybridFind(brain as any, params) + const actual = await brain.find(params as any) + expect(actual.length).toBe(expected.length) + expect(project(actual)).toEqual(expected) + }) + + it('hybrid + connected: identical, and never a non-neighbour', async () => { + const params = { + query: QUERY, + connected: { from: anchor, direction: 'out' as const }, + where: { lane: 'alpha' }, + limit: 8 + } + const expected = await legacyHybridFind(brain as any, params) + const actual = await brain.find(params as any) + expect(actual.length).toBe(expected.length) + expect(project(actual)).toEqual(expected) + const neighbours = new Set(matchIds) + for (const r of actual) expect(neighbours.has(r.id)).toBe(true) + }) + + it('a hydrated hybrid row is shaped exactly as an eagerly-built one', async () => { + const rows = await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: 8 } as any) + const row = rows[0] + expect(Object.keys(row)).toEqual([ + 'id', + 'score', + 'type', + 'subtype', + 'visibility', + 'metadata', + 'data', + 'confidence', + 'weight', + '_rev', + 'entity', + 'textMatches', + 'textScore', + 'semanticScore', + 'matchSource' + ]) + // The flattened fields are projections of the entity, as always. + expect(row.entity).toBeDefined() + expect(row.type).toBe(row.entity.type) + expect(row.metadata).toBe(row.entity.metadata) + expect(row.data).toBe(row.entity.data) + expect(row._rev).toBe(row.entity._rev) + // The match visibility survives the deferral โ€” every leg's fields, on the + // rows that leg contributed, exactly as the eager pipeline set them. + expect(['text', 'semantic', 'both']).toContain(row.matchSource) + for (const r of rows) { + if (r.matchSource === 'semantic') { + expect(r.textMatches).toEqual([]) + expect(r.textScore).toBeUndefined() + } else { + expect(r.textMatches).toEqual(['orbital', 'telemetry']) + expect(typeof r.textScore).toBe('number') + } + if (r.matchSource === 'text') { + expect(r.semanticScore).toBeUndefined() + } else { + expect(typeof r.semanticScore).toBe('number') + } + } + }) + + it('reads canonical for the page only โ€” one batch, `limit` rows', async () => { + // Warm any first-read verification before the counters are read. + await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: 1 } as any) + + const hydrate = vi.spyOn(brain as any, 'batchGet') + try { + const results = await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: 10 } as any) + expect(results).toHaveLength(10) + expect(hydrate).toHaveBeenCalledTimes(1) + expect((hydrate.mock.calls[0][0] as string[]).length).toBe(10) + } finally { + hydrate.mockRestore() + } + }) + + it('asks the text index about the universe only, never the whole store', async () => { + const index = (brain as any).metadataIndex + const wholeStore = vi.spyOn(index, 'getIdsForTextQuery') + const within = vi.spyOn(index, 'getIdsForTextQueryWithin') + try { + await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: 10 } as any) + expect(wholeStore).not.toHaveBeenCalled() + expect(within).toHaveBeenCalledTimes(1) + + const askedIds = within.mock.calls[0][1] as string[] + const universe = await (brain as any).filterIdsBelted({ lane: 'alpha' }) + expect(askedIds).toHaveLength(universe.length) + + // What the text leg marshals is bounded by the universe, not the store. + const marshalled = (await within.mock.results[0].value) as unknown[] + expect(marshalled.length).toBeLessThanOrEqual(universe.length) + expect(marshalled).toHaveLength(MATCHES) + } finally { + wholeStore.mockRestore() + within.mockRestore() + } + }) + + it('the two text doors agree: within is the whole-store answer restricted', async () => { + const index = (brain as any).metadataIndex + const universe: string[] = await (brain as any).filterIdsBelted({ + lane: 'alpha', + retracted: { missing: true } + }) + const inUniverse = new Set(universe) + const whole = await index.getIdsForTextQuery(QUERY) + const within = await index.getIdsForTextQueryWithin(QUERY, universe) + expect(within).toEqual(whole.filter((m: any) => inUniverse.has(m.id))) + expect(await index.getIdsForTextQueryWithin(QUERY, [])).toEqual([]) + }) +}) + +/** + * FIXTURE B โ€” the query's words are common OUTSIDE the universe, so the old + * order's text leg was entirely consumed by rows the filter then discarded. + * This is the corrected behaviour, held by name. + */ +describe('hybrid find: the text leg ranks inside the filter, not around it', () => { + let brain: Brainy + const QUERY = 'orbital telemetry drift' + const NOISE = 150 + const KEEP = 15 + const keepIds: string[] = [] + + beforeAll(async () => { + brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) + await brain.init() + + let seed = 5000 + // Added FIRST and matching one more query word, so they lead the + // store-wide text ranking outright โ€” and none of them pass the filter. + for (let i = 0; i < NOISE; i++) { + await brain.add({ + id: `noise-${i}`, + data: `orbital telemetry drift report ${i}`, + type: NounType.Document, + metadata: { lane: 'beta' }, + vector: seededVector(seed++) + }) + } + for (let i = 0; i < KEEP; i++) { + const id = `keep-${i}` + await brain.add({ + id, + data: `orbital telemetry summary ${i}`, + type: NounType.Document, + metadata: { lane: 'alpha' }, + vector: seededVector(seed++) + }) + keepIds.push(resolveEntityId(id)) + } + }) + + it('the old order let the filter consume the whole text leg', async () => { + const index = (brain as any).metadataIndex + const universe: string[] = await (brain as any).filterIdsBelted({ lane: 'alpha' }) + expect(universe).toHaveLength(KEEP) + const inUniverse = new Set(universe) + + // The store-wide prefix the old text leg took (limit 10 โ†’ limit * 4). + const prefix = (await index.getIdsForTextQuery(QUERY)).slice(0, 40) + expect(prefix).toHaveLength(40) + expect(prefix.filter((m: any) => inUniverse.has(m.id))).toHaveLength(0) + + // Every row the old text leg ranked was then discarded by the filter, so + // the old answer carried NO text contribution at all โ€” fifteen rows that + // match the query's words exactly, and not one of them reached the page + // through the text leg. What the old order returned was whatever the + // semantic leg alone happened to reach. + const legacy = await legacyHybridFind(brain as any, { + query: QUERY, + where: { lane: 'alpha' }, + limit: 10 + }) + for (const r of legacy) { + expect(r.matchSource).toBe('semantic') + expect(r.textScore).toBeUndefined() + expect(r.textMatches).toEqual([]) + } + }) + + it('the new order ranks the text leg inside the universe', async () => { + const results = await brain.find({ + query: QUERY, + where: { lane: 'alpha' }, + limit: 10 + } as any) + + expect(results).toHaveLength(10) + const keeps = new Set(keepIds) + for (const r of results) { + expect(keeps.has(r.id)).toBe(true) + expect(r.metadata.lane).toBe('alpha') + // The text leg is the contributor the old order threw away. + expect(['text', 'both']).toContain(r.matchSource) + expect(r.textScore).toBe(1) + expect(r.textMatches).toEqual(['orbital', 'telemetry']) + } + }) + + it('paging reaches every matching row the old order could not see', async () => { + const seen = new Set() + for (let offset = 0; offset < KEEP; offset += 5) { + const page = await brain.find({ + query: QUERY, + where: { lane: 'alpha' }, + limit: 5, + offset + } as any) + expect(page).toHaveLength(5) + for (const r of page) { + expect(seen.has(r.id)).toBe(false) + seen.add(r.id) + } + } + expect(seen.size).toBe(KEEP) + expect([...seen].sort()).toEqual([...keepIds].sort()) + }) + + it('reads canonical for the page only, on the truncating shape too', async () => { + await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: 1 } as any) + + const hydrate = vi.spyOn(brain as any, 'batchGet') + try { + const results = await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: 10 } as any) + expect(results).toHaveLength(10) + expect(hydrate).toHaveBeenCalledTimes(1) + expect((hydrate.mock.calls[0][0] as string[]).length).toBe(10) + } finally { + hydrate.mockRestore() + } + }) +}) From 905c267c47a9515abcde4a713b3c575ec730e7b2 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 09:28:44 -0700 Subject: [PATCH 30/42] fix(find): a page the metadata block already cut is not cut again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `find({ query, connected, where, offset })` answered [] for every page but the first. The metadata block ranks the fused candidates and CUTS the page itself โ€” rows [offset, offset+limit) โ€” and then returns early. Two shapes do not take that early return, `connected` and `fusion`, and they fell through to the tail, which sliced the already-cut page by `offset` a second time: a five-row page sliced at offset five is nothing at all. Every page after the first was empty, and the caller had no way to tell that from "no more rows". The block now records that it consumed the offset, and the tail returns the page it was handed instead of re-cutting it. Nothing changes at offset 0, where the second slice was the identity. Pinned in tests/integration/find-hybrid-filter-before-hydrate.test.ts: page two of a `connected` hybrid find matches the pipeline oracle row for row, paging reaches every matching neighbour exactly once, and a `fusion` find's second page is the same page the plain find returns. --- src/brainy.ts | 15 ++++- .../find-hybrid-filter-before-hydrate.test.ts | 55 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/brainy.ts b/src/brainy.ts index ad8bbf90..ffc2d5cd 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -7658,6 +7658,11 @@ export class Brainy implements BrainyInterface { // the hybrid branch, so every other path hydrates unchanged. let finishHybridRow: ((row: Result, pending: Result) => void) | undefined + // Set once the metadata block below has already ranked and CUT the page. + // The tail must not cut it a second time: `offset` has been consumed, and + // re-slicing a `limit`-long page by `offset` returns nothing at all. + let pagedEarly = false + // Handle text-only query (user explicitly wants text search) if (searchMode === 'text' && params.query && params.query.trim() !== '') { results = await this.executeTextSearch(params.query, limit * 2) @@ -7754,6 +7759,7 @@ export class Brainy implements BrainyInterface { const k = offset + limit const order = rankIndicesByScore(results.map(r => r.score), k, true) results = reorderByIndices(results, order).slice(offset, k) + pagedEarly = true // Batch-load entities only for the paginated results (10x faster on GCS). // This is the hydrate-last seam for the deferring paths: a row that @@ -7868,8 +7874,15 @@ export class Brainy implements BrainyInterface { // Efficient pagination - only slice what we need (limit already defined // above), THEN read canonical for the page. Rows that arrived hydrated // pass straight through; a deferred path reads exactly these rows. + // + // A page the metadata block already cut is NOT cut again: it holds the + // rows at [offset, offset+limit) of the ranking, so slicing it by + // `offset` a second time drops the whole page. That is how + // `find({ query, connected, where, offset })` โ€” the shapes that reach + // here after early paging, `connected` and `fusion` โ€” answered [] for + // every page but the first. return await this.hydrateResultPage( - results.slice(finalOffset, finalOffset + limit), + pagedEarly ? results : results.slice(finalOffset, finalOffset + limit), finishHybridRow ) })() diff --git a/tests/integration/find-hybrid-filter-before-hydrate.test.ts b/tests/integration/find-hybrid-filter-before-hydrate.test.ts index 252fe89b..3e74f5d8 100644 --- a/tests/integration/find-hybrid-filter-before-hydrate.test.ts +++ b/tests/integration/find-hybrid-filter-before-hydrate.test.ts @@ -360,6 +360,61 @@ describe('hybrid find: filter before hydrate โ€” the answer is unchanged', () => for (const r of actual) expect(neighbours.has(r.id)).toBe(true) }) + it('hybrid + connected + offset: page two is the page, not an empty answer', async () => { + const params = { + query: QUERY, + connected: { from: anchor, direction: 'out' as const }, + where: { lane: 'alpha' }, + limit: 5, + offset: 5 + } + const expected = await legacyHybridFind(brain as any, params) + expect(expected).toHaveLength(5) + const actual = await brain.find(params as any) + expect(actual.length).toBe(expected.length) + expect(project(actual)).toEqual(expected) + }) + + it('hybrid + connected: paging reaches every matching neighbour exactly once', async () => { + const seen = new Set() + for (let offset = 0; offset < MATCHES; offset += 6) { + const page = await brain.find({ + query: QUERY, + connected: { from: anchor, direction: 'out' as const }, + where: { lane: 'alpha' }, + limit: 6, + offset + } as any) + for (const r of page) { + expect(seen.has(r.id)).toBe(false) + seen.add(r.id) + } + } + // Every row the fused candidate set holds is reachable by paging, and the + // neighbour set is the ceiling. + expect(seen.size).toBeGreaterThanOrEqual(MATCHES) + const neighbours = new Set(matchIds) + for (const id of seen) expect(neighbours.has(id)).toBe(true) + }) + + it('hybrid + fusion + offset: page two is the page', async () => { + const plain = await brain.find({ + query: QUERY, + where: { lane: 'alpha' }, + limit: 5, + offset: 5 + } as any) + const fused = await brain.find({ + query: QUERY, + where: { lane: 'alpha' }, + fusion: 'weighted', + limit: 5, + offset: 5 + } as any) + expect(fused).toHaveLength(plain.length) + expect(fused.map((r) => r.id)).toEqual(plain.map((r) => r.id)) + }) + it('a hydrated hybrid row is shaped exactly as an eagerly-built one', async () => { const rows = await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: 8 } as any) const row = rows[0] From bc70c43d0214aca047e925dcd2a4bfee9a860ba9 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 10:07:30 -0700 Subject: [PATCH 31/42] perf(open): a sealed segment the manifest proves is below the bound is never read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every log-authority open asks the fact log one question โ€” is there a fact above the committed pointer? โ€” and answered it by reading and CRC-decoding EVERY segment file the manifest names. MEASURED in production on a 16k-row brain at generation ~478,819: 34-37 seconds inside `generation-store-open-fold` on every open, including the clean one where the answer is always "nothing". The manifest already knows. A sealed segment's `lastGeneration` is written at seal time, and the seal order has been the same since the log was introduced: `rotate()` fsyncs the tail's bytes FIRST ("sealed segments are always fully durable"), builds the entry from the content that fsync covered, and only then flips the manifest โ€” atomically, fsynced, and in the same write re-pointing `tailSegment`, so a sealed file is never appended to again. A crash in that order is safe in the pruning direction: before the manifest write the segment is still the TAIL and is read whole; after it, the entry describes bytes that were already durable. The only later mutation of a sealed segment is open()'s straddle truncation, which removes facts and re-derives the entry from the actual bytes โ€” a recorded bound can drift DOWN with its file, never up. So `lastGeneration = L` proves the file holds no fact above L, and both manifest-direct passes (`peekFactsAbove` and its streaming twin, the recovery fold) now read only the unsealed tail, entries with no numeric `lastGeneration` โ€” legacy or hand-repaired manifests, never prune what you cannot prove โ€” and entries whose recorded maximum is actually above the bound. The open narrates what it read and what it pruned when the log holds more than one segment. Pinned in tests/integration/factlog-open-prune.test.ts, from the log's own counters rather than a clock: a clean reopen over five sealed segments reads exactly the tail (1 of 6) and finds nothing; a real SIGKILLed writer that sealed segments holding facts above the committed pointer has those segments READ, and its peek, its fold stream and its rollback all match the unpruned full scan fact for fact; a manifest entry missing `lastGeneration` is read. --- src/db/factLog.ts | 123 +++++-- tests/integration/factlog-open-prune.test.ts | 360 +++++++++++++++++++ 2 files changed, 462 insertions(+), 21 deletions(-) create mode 100644 tests/integration/factlog-open-prune.test.ts diff --git a/src/db/factLog.ts b/src/db/factLog.ts index ca130454..728be4b1 100644 --- a/src/db/factLog.ts +++ b/src/db/factLog.ts @@ -40,7 +40,10 @@ * The manifest (`_generations/facts/manifest.json`, JSON โ€” forensics stay * terminal-readable) is the single source of truth for the segment SET; * rotation flips it atomically (write-new โ†’ fsync โ†’ rename) BEFORE the new - * tail's first byte exists, so no segment file is ever unaccounted for. + * tail's first byte exists, so no segment file is ever unaccounted for. Its + * per-segment `firstGeneration`/`lastGeneration` are LOAD-BEARING at open: a + * recovery pass looking for facts above a bound reads only the segments those + * bounds cannot rule out (the prune law โ€” see `segmentsHoldingFactsAbove`). * * ## Mixed-version logs (the v2 live-write cutover) * @@ -689,6 +692,74 @@ function parseSegment( return { facts, validBytes: offset, formatVersion: FACT_LOG_FORMAT_V1 } } +/** + * THE PRUNE LAW โ€” which segment files a pass looking for facts ABOVE + * `committedGeneration` actually has to read, and how many the manifest's own + * recorded bounds took off the table. + * + * A sealed segment's `lastGeneration` is written at SEAL time and never + * mutated upward afterwards ({@link FactLog.rotate}, unchanged since the log + * was introduced): the tail's bytes are fsynced FIRST (`await this.sync()` โ€” + * "sealed segments are always fully durable"), the entry is then built from + * the content that fsync covered, and only then does the manifest flip โ€” + * atomically (tmp+rename) and fsynced โ€” which in the SAME write re-points + * `tailSegment` at a new file, so the sealed file is never appended to again. + * A crash anywhere in that order is safe in the pruning direction: crash + * before the manifest write and the segment is still the TAIL (read whole); + * crash after it and the entry describes bytes that were already durable. The + * only later mutation of a sealed segment is `open()`'s straddle truncation, + * which REMOVES facts and re-derives the entry from the actual bytes โ€” so a + * recorded bound can drift DOWN with its file, never up. + * + * Therefore: `lastGeneration = L` proves the file holds no fact above L, and + * a pass above `committedGeneration >= L` can skip it whole โ€” no read, no + * CRC decode, no msgpack. What the manifest cannot PROVE is never pruned: an + * entry with no numeric `lastGeneration` (a legacy or hand-repaired manifest) + * is read, and the unsealed tail is always read. + * + * This is the difference between an open that costs O(whole fact log) and one + * that costs O(the facts that could matter). MEASURED in production: a 16k-row + * brain at generation ~478,819 paid 34-37s of segment reads and CRC decoding + * in `generation-store-open-fold` on EVERY open โ€” to answer a question whose + * answer, after a clean close, is always "nothing". + */ +function segmentsHoldingFactsAbove( + stored: FactsManifest, + committedGeneration: number +): { files: string[]; pruned: number } { + const files: string[] = [] + let pruned = 0 + for (const entry of stored.segments) { + const last = (entry as Partial).lastGeneration + if (typeof last === 'number' && Number.isFinite(last) && last <= committedGeneration) { + pruned++ + continue + } + files.push(entry.file) + } + if (stored.tailSegment) files.push(stored.tailSegment) + return { files, pruned } +} + +/** + * Say what the open actually read. One line, and only when the log holds more + * than one segment (a single-segment log has nothing to prune and nothing to + * report) โ€” the operator's receipt that the open is paying for the tail, not + * for the whole history. + */ +function narrateAboveScan( + pass: string, + committedGeneration: number, + read: number, + pruned: number +): void { + if (read + pruned <= 1) return + prodLog.narrate( + `[FactLog] ${pass} above generation ${committedGeneration}: ${read} segment(s) read, ` + + `${pruned} pruned of ${read + pruned} (sealed at or below the bound)` + ) +} + /** * The generation fact log. One instance per open store; every method assumes * the single-writer discipline the generation store already enforces (calls @@ -754,22 +825,6 @@ export class FactLog { return this.manifest.brainId !== undefined || this.tailVersion === FACT_LOG_FORMAT_V2 } - /** - * Open the log and reconcile it to committed truth: read the manifest, - * establish the tail's intact content (torn-tail scan), then TRUNCATE any - * fact with `generation > committedGeneration` โ€” those never committed (a - * crash between fact-append and the commit point). After open, the log is - * exactly the committed prefix. - */ - /** - * Read (without truncating) every intact fact ABOVE a generation โ€” the - * log-authority recovery surface: after a crash, facts beyond the - * manifest watermark that survived with valid CRCs are ACKED writes in - * durable-at-ack mode, and the owner REPLAYS them instead of letting - * open() truncate them. Must be called BEFORE open() (it reads the raw - * segments directly; the torn tail's invalid suffix is ignored exactly - * like open() would). - */ /** * STREAMING twin of {@link FactLog.peekFactsAbove} for the recovery fold: * yields facts above the bound one SEGMENT at a time, ascending, without @@ -779,13 +834,18 @@ export class FactLog { * Works manifest-direct (safe before {@link FactLog.open}). Ordering is * structural (segments rotate in order; appends are ordered within one) and * ASSERTED โ€” a violation aborts loudly, never a silent misordered replay. + * + * Reads only the segments that CAN hold a fact above the bound โ€” see + * {@link segmentsHoldingFactsAbove}. A bounded fold above a high checkpoint + * therefore reads its own tail, not the whole history it already proved + * durable. */ async *streamFactsAbove(committedGeneration: number): AsyncGenerator { const stored = (await this.storage.readRawObject(FACTS_MANIFEST_PATH)) as FactsManifest | null if (!stored || typeof stored !== 'object' || !Array.isArray(stored.segments)) return if (stored.formatVersion !== FACTS_FORMAT_VERSION) return - const files = [...stored.segments.map((s) => s.file)] - if (stored.tailSegment) files.push(stored.tailSegment) + const { files, pruned } = segmentsHoldingFactsAbove(stored, committedGeneration) + narrateAboveScan('recovery fold', committedGeneration, files.length, pruned) let lastGen = committedGeneration for (const file of files) { const bytes = await this.storage.readRawBytes(`${FACTS_PREFIX}/${file}`) @@ -807,13 +867,27 @@ export class FactLog { } } + /** + * Read (without truncating) every intact fact ABOVE a generation โ€” the + * log-authority recovery surface: after a crash, facts beyond the + * manifest watermark that survived with valid CRCs are ACKED writes in + * durable-at-ack mode, and the owner REPLAYS them instead of letting + * open() truncate them. Must be called BEFORE open() (it reads the raw + * segments directly; the torn tail's invalid suffix is ignored exactly + * like open() would). + * + * Reads only the segments that CAN hold such a fact โ€” see + * {@link segmentsHoldingFactsAbove}. This runs on EVERY log-authority open, + * including the clean one where the answer is always empty, so the segments + * the manifest already proves irrelevant are never opened at all. + */ async peekFactsAbove(committedGeneration: number): Promise { const stored = (await this.storage.readRawObject(FACTS_MANIFEST_PATH)) as FactsManifest | null if (!stored || typeof stored !== 'object' || !Array.isArray(stored.segments)) return [] if (stored.formatVersion !== FACTS_FORMAT_VERSION) return [] const out: CommitFact[] = [] - const files = [...stored.segments.map((s) => s.file)] - if (stored.tailSegment) files.push(stored.tailSegment) + const { files, pruned } = segmentsHoldingFactsAbove(stored, committedGeneration) + narrateAboveScan('above-manifest peek', committedGeneration, files.length, pruned) for (const file of files) { const bytes = await this.storage.readRawBytes(`${FACTS_PREFIX}/${file}`) if (bytes === null) continue @@ -826,6 +900,13 @@ export class FactLog { return out } + /** + * Open the log and reconcile it to committed truth: read the manifest, + * establish the tail's intact content (torn-tail scan), then TRUNCATE any + * fact with `generation > committedGeneration` โ€” those never committed (a + * crash between fact-append and the commit point). After open, the log is + * exactly the committed prefix. + */ async open(committedGeneration: number): Promise { const stored = (await this.storage.readRawObject(FACTS_MANIFEST_PATH)) as FactsManifest | null if (stored && typeof stored === 'object' && Array.isArray(stored.segments)) { diff --git a/tests/integration/factlog-open-prune.test.ts b/tests/integration/factlog-open-prune.test.ts new file mode 100644 index 00000000..223e91f4 --- /dev/null +++ b/tests/integration/factlog-open-prune.test.ts @@ -0,0 +1,360 @@ +/** + * @module tests/integration/factlog-open-prune + * @description THE OPEN READS THE TAIL, NOT THE HISTORY. + * + * Every log-authority open asks the fact log one question โ€” "is there a fact + * above the committed pointer?" โ€” and until this lane existed it answered by + * reading and CRC-decoding EVERY segment file the manifest names. MEASURED in + * production on a 16k-row brain at generation ~478,819: 34-37 seconds inside + * the `generation-store-open-fold` phase, on every open, including the clean + * one where the answer is always "nothing". + * + * The manifest already records each sealed segment's `lastGeneration`, written + * at seal time AFTER the segment's bytes are fsynced and into a manifest that + * is itself written atomically and fsynced โ€” and a sealed file is never + * appended to again (the same manifest flip re-points `tailSegment`). So an + * entry recording `lastGeneration โ‰ค committed` PROVES its file holds nothing + * above the bound, and the open can skip it whole. + * + * Pinned here, from the log's own counters (the narration line), never a clock: + * + * 1. A clean close and reopen on a log with โ‰ฅ4 sealed segments reads + * EXACTLY the tail (1 of 6), prunes the rest, and finds nothing. + * 2. A real SIGKILLed process that sealed segments holding facts ABOVE the + * committed pointer: the reopen READS those sealed segments and recovers + * byte-identically to an unpruned open (differential โ€” the same store, + * with the provable field stripped from its manifest, takes the full-scan + * path and must agree fact for fact, before and after `open()`). + * 3. A manifest entry with no `lastGeneration` (legacy, or hand-repaired) is + * READ. Never prune what the manifest cannot prove. + */ +import { describe, it, expect, afterEach } from 'vitest' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { spawn } from 'node:child_process' +import { + FactLog, + FACTS_MANIFEST_PATH, + type CommitFact, + type FactLogStorage +} from '../../src/db/factLog.js' +import { FileSystemStorage } from '../../src/storage/adapters/fileSystemStorage.js' + +const REPO_ROOT = process.cwd() +const TSX = path.join(REPO_ROOT, 'node_modules', '.bin', 'tsx') +/** ~1KB frames against a 4KB rotation threshold: ~5 facts per segment. */ +const ROTATE_BYTES = 4096 + +const tmpDirs: string[] = [] +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-factlog-prune-')) + tmpDirs.push(dir) + return dir +} + +afterEach(() => { + for (const dir of tmpDirs.splice(0)) { + try { + fs.rmSync(dir, { recursive: true, force: true }) + } catch { + /* best effort */ + } + try { + fs.rmSync(`${dir}.ready.json`, { force: true }) + } catch { + /* best effort */ + } + } +}) + +const UUID = (n: number): string => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` + +/** One ~1KB fact โ€” the padding is what makes rotation cheap to provoke. */ +function fact(generation: number): CommitFact { + return { + generation, + timestamp: 1_700_000_000_000 + generation, + ops: [ + { + kind: 'noun', + id: UUID(generation), + record: { + metadata: { noun: 'document', pad: 'x'.repeat(900), g: generation }, + vector: null + } + } + ] + } +} + +/** + * A deterministic int minter so the log writes the V2 format production + * writes (the prune is a manifest-level decision and never touches segment + * bytes โ€” but the pins should run against the bytes the fleet actually has). + */ +function makeMinter(): (kind: 'noun' | 'verb', id: string) => bigint { + const ints = new Map() + return (kind, id) => { + const key = `${kind}:${id}` + let minted = ints.get(key) + if (minted === undefined) { + minted = BigInt(ints.size + 1) + ints.set(key, minted) + } + return minted + } +} + +/** Open a fact log over a store directory (a fresh adapter each time โ€” this is + * what a reopen actually does). */ +async function openStore(dir: string): Promise<{ storage: any; log: FactLog }> { + const storage: any = new FileSystemStorage(dir) + await storage.init() + const log = new FactLog(storage as FactLogStorage, { rotateBytes: ROTATE_BYTES }) + log.setIntMinter(makeMinter()) + return { storage, log } +} + +/** Build a log of `count` facts (rotating every ~5), left durable, not closed. */ +async function buildLog(dir: string, count: number): Promise { + const { log } = await openStore(dir) + await log.open(0) + for (let g = 1; g <= count; g++) await log.append(fact(g)) + await log.sync() + return log.headGeneration() +} + +/** Capture the narration channel (`prodLog.narrate` โ†’ console.warn). */ +async function captureNarration( + fn: () => Promise +): Promise<{ result: T; lines: string[] }> { + const lines: string[] = [] + const original = console.warn + console.warn = ((...args: unknown[]) => { + lines.push(args.map((a) => String(a)).join(' ')) + }) as typeof console.warn + try { + return { result: await fn(), lines } + } finally { + console.warn = original + } +} + +/** The counters the open narrated โ€” the pin's only source of truth for what + * was read (a wall-clock assertion could pass on a warm page cache). */ +function scanCounts(lines: string[]): { read: number; pruned: number; total: number } { + const line = lines.find((l) => l.includes('[FactLog] above-manifest peek above generation')) + if (!line) { + throw new Error(`no peek narration in:\n${lines.join('\n')}`) + } + const match = /(\d+) segment\(s\) read, (\d+) pruned of (\d+)/.exec(line) + if (!match) throw new Error(`unparsable peek narration: ${line}`) + return { read: Number(match[1]), pruned: Number(match[2]), total: Number(match[3]) } +} + +interface SegmentEntryOnDisk { + file: string + firstGeneration: number + lastGeneration?: number + facts: number + bytes: number +} + +async function readManifest(dir: string): Promise<{ + segments: SegmentEntryOnDisk[] + tailSegment: string | null +}> { + const storage: any = new FileSystemStorage(dir) + await storage.init() + return (await storage.readRawObject(FACTS_MANIFEST_PATH)) as any +} + +async function rewriteManifest( + dir: string, + mutate: (manifest: any) => void +): Promise { + const storage: any = new FileSystemStorage(dir) + await storage.init() + const manifest = await storage.readRawObject(FACTS_MANIFEST_PATH) + mutate(manifest) + await storage.writeRawObject(FACTS_MANIFEST_PATH, manifest) + await storage.syncRawObjects([FACTS_MANIFEST_PATH]) +} + +/** Every fact the log holds, in order โ€” the recovered state, read back. */ +async function allFacts(log: FactLog): Promise { + const out: CommitFact[] = [] + const handle = log.scanFacts() + for await (const batch of handle.batches()) out.push(...batch.facts) + return out +} + +describe('fact log โ€” the open reads only the segments that can hold facts above the bound', () => { + it('a clean close + reopen over โ‰ฅ4 sealed segments reads exactly the tail and finds nothing', async () => { + const dir = makeTempDir() + const head = await buildLog(dir, 30) + + const manifest = await readManifest(dir) + expect(manifest.segments.length).toBeGreaterThanOrEqual(4) // the fixture is real + expect(manifest.tailSegment).not.toBeNull() + + // The reopen: a clean close means committed === the log's head. + const { log } = await openStore(dir) + const { result: orphans, lines } = await captureNarration(() => log.peekFactsAbove(head)) + + expect(orphans).toEqual([]) // the fold finds nothing, as it always does after a clean close + const counts = scanCounts(lines) + expect(counts.read).toBe(1) // EXACTLY the tail + expect(counts.total).toBe(manifest.segments.length + 1) + expect(counts.pruned).toBe(manifest.segments.length) + + // And the reconciling open still lands on the same committed prefix. + await log.open(head) + expect(log.headGeneration()).toBe(head) + expect((await allFacts(log)).map((f) => f.generation)).toEqual( + Array.from({ length: head }, (_, i) => i + 1) + ) + }) + + it('a manifest entry with no lastGeneration is READ โ€” never prune what you cannot prove', async () => { + const dir = makeTempDir() + const head = await buildLog(dir, 30) + const before = await readManifest(dir) + expect(before.segments.length).toBeGreaterThanOrEqual(4) + + // A legacy/hand-repaired entry: the field the prune needs is simply absent. + await rewriteManifest(dir, (m) => { + delete m.segments[0].lastGeneration + }) + + const { log } = await openStore(dir) + const { result: orphans, lines } = await captureNarration(() => log.peekFactsAbove(head)) + + expect(orphans).toEqual([]) // still nothing above the bound โ€” it was READ to find out + const counts = scanCounts(lines) + expect(counts.read).toBe(2) // the unprovable entry + the tail + expect(counts.pruned).toBe(before.segments.length - 1) + expect(counts.total).toBe(before.segments.length + 1) + }) + + it( + 'a SIGKILLed writer that sealed segments above the committed pointer recovers identically to an unpruned open', + async () => { + const dir = makeTempDir() + const readyPath = `${dir}.ready.json` + // A real process death: the child fsyncs its segments, records what it + // reached, and SIGKILLs ITSELF โ€” no close, no unwind, no chance to tidy. + const script = ` + import * as fs from 'node:fs' + import { FactLog } from ${JSON.stringify(path.join(REPO_ROOT, 'src', 'db', 'factLog.ts'))} + import { FileSystemStorage } from ${JSON.stringify(path.join(REPO_ROOT, 'src', 'storage', 'adapters', 'fileSystemStorage.ts'))} + const UUID = (n) => '00000000-0000-4000-8000-' + String(n).padStart(12, '0') + const fact = (g) => ({ + generation: g, + timestamp: 1700000000000 + g, + ops: [{ kind: 'noun', id: UUID(g), record: { metadata: { noun: 'document', pad: 'x'.repeat(900), g }, vector: null } }] + }) + const ints = new Map() + const storage = new FileSystemStorage(${JSON.stringify(dir)}) + await storage.init() + const log = new FactLog(storage, { rotateBytes: ${ROTATE_BYTES} }) + log.setIntMinter((kind, id) => { + const key = kind + ':' + id + if (!ints.has(key)) ints.set(key, BigInt(ints.size + 1)) + return ints.get(key) + }) + await log.open(0) + for (let g = 1; g <= 30; g++) await log.append(fact(g)) + await log.sync() + fs.writeFileSync(${JSON.stringify(readyPath)}, JSON.stringify({ head: log.headGeneration() })) + process.kill(process.pid, 'SIGKILL') + ` + const scriptPath = path.join(dir, 'crash-writer.mts') + fs.writeFileSync(scriptPath, script) + const child = spawn(TSX, [scriptPath], { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout.on('data', (d) => { output += String(d) }) + child.stderr.on('data', (d) => { output += String(d) }) + const exit = await new Promise<{ code: number | null; signal: string | null }>((resolve) => + child.on('exit', (code, signal) => resolve({ code, signal })) + ) + if (!fs.existsSync(readyPath)) { + throw new Error(`the crash writer never reached its kill point:\n${output}`) + } + // Death, not a shutdown: no close(), no unwind, no orderly exit code. + expect(exit.signal ?? `code ${exit.code}`).not.toBe('code 0') + const head = JSON.parse(fs.readFileSync(readyPath, 'utf8')).head as number + expect(head).toBe(30) + + // The committed pointer the survivor comes back on: mid-log, so sealed + // segments hold facts ABOVE it โ€” the exact shape the prune must not skip. + const committed = 12 + const manifest = await readManifest(dir) + const straddling = manifest.segments.filter( + (s) => s.firstGeneration <= committed && (s.lastGeneration ?? 0) > committed + ) + const entirelyAbove = manifest.segments.filter((s) => s.firstGeneration > committed) + expect(straddling.length).toBeGreaterThanOrEqual(1) + expect(entirelyAbove.length).toBeGreaterThanOrEqual(1) + + // THE DIFFERENTIAL. The unpruned answer, through the SAME code on the + // SAME bytes: a peek above generation 0 can prune nothing (no sealed + // segment ends at or below 0), so it reads every segment file and + // decodes every frame โ€” exactly what this open used to do โ€” and its + // facts above the pointer are what the fold is entitled to replay. + const { log } = await openStore(dir) + const { result: fullScan, lines: fullLines } = await captureNarration(() => + log.peekFactsAbove(0) + ) + expect(scanCounts(fullLines)).toEqual({ + read: manifest.segments.length + 1, + pruned: 0, + total: manifest.segments.length + 1 + }) + const unprunedAnswer = fullScan.filter((f) => f.generation > committed) + + const { result: prunedAnswer, lines } = await captureNarration(() => + log.peekFactsAbove(committed) + ) + + // The sealed segments above the bound were READ, not skipped. + const counts = scanCounts(lines) + expect(counts.read).toBe(straddling.length + entirelyAbove.length + 1) + expect(counts.pruned).toBe(manifest.segments.length - straddling.length - entirelyAbove.length) + expect(counts.pruned).toBeGreaterThan(0) // the prune did engage, and was still right + expect(prunedAnswer.map((f) => f.generation)).toEqual( + Array.from({ length: head - committed }, (_, i) => committed + 1 + i) + ) + // Facts that live in a SEALED segment (not the tail) came back. + expect(prunedAnswer.some((f) => f.generation <= (straddling[0].lastGeneration ?? 0))).toBe( + true + ) + // Fact for fact, the pruned answer IS the unpruned answer โ€” so whatever + // the recovery replays, it replays identically. + expect(prunedAnswer).toEqual(unprunedAnswer) + + // The fold's streaming twin (the unclean-open path) agrees too. + const streamed: CommitFact[] = [] + for await (const batch of log.streamFactsAbove(committed)) streamed.push(...batch) + expect(streamed).toEqual(unprunedAnswer) + + // And the reconciling open rolls back exactly as it always did: the two + // never-committed sealed segments dropped, the straddling one cut, the + // tail truncated โ€” the log left as the committed prefix. + await log.open(committed) + expect(log.headGeneration()).toBe(committed) + expect((await allFacts(log)).map((f) => f.generation)).toEqual( + Array.from({ length: committed }, (_, i) => i + 1) + ) + const after = await readManifest(dir) + expect(after.segments.map((s) => s.file)).toEqual( + manifest.segments + .filter((s) => s.firstGeneration <= committed) + .map((s) => s.file) + ) + expect(after.segments[after.segments.length - 1].lastGeneration).toBe(committed) + }, + 120_000 + ) +}) From 15d4f65dcf65379b27dca7f0c7e6e2297cdccab5 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 10:08:34 -0700 Subject: [PATCH 32/42] perf(open): the pending-embed fold is bounded by a checkpoint of the SET, not an empty-only mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The low-water mark shipped in 10.4.9 can only be written when the pending set is EMPTY, because it carries no set โ€” it means "everything at or below G is consumed". A brain holding even one id that never lands (an embed that keeps failing, a data-less row reaped in memory only and re-folded every open) never drains, so it never writes a mark, so the bound never engaged on exactly the brains whose fold is expensive: `recover-pending-embeds` re-read the WHOLE fact log at every open, on the open's foreground. _system/pending_embeds_checkpoint.json carries the set: { generation, pending, writtenAt } = "as of durable generation G the pending set was exactly this list". Open seeds the set from the list and scans from G + 1, so the fold is O(facts since G) whether or not the set ever drains. Measured on a 301-row brain with one stuck id: 302 facts read before, 0 after; at 601 rows, 602 before, 0 after โ€” same pending set both ways. THE DURABILITY LAW, by construction. A checkpoint at head H taken while the facts up to H are still buffered would be read back after a crash that truncated the tail: an `embed.landed` in a truncated fact would be gone from the log while the checkpoint still recorded its id as landed, and its landing vector went with the fact โ€” a LOST VECTOR. So a capture is refused unless `0 < head <= committed`, the manifest watermark below which FactLog.open() never truncates and which the group-commit flush only advances after fsyncing the log. The (generation, set) pair is taken in one synchronous instant with no await between reading the generations and snapshotting the set. The one remaining asymmetry runs the safe way: an id enqueued in memory whose marker lands at G+1 is captured as pending at G โ€” one idempotent re-embed, never a loss. Written at clean close (inside closeDurableSteps, after the generation store's own close flushed the log and advanced the manifest), at drain-to-empty, and on a cadence of max(64, ceil(|pending| / 64)) transitions while open โ€” an interval that holds the mechanism's amortized cost at <= 64 ids written per transition however large the backlog grows, so the cure cannot reintroduce the defect class it fixes. No timer, no knob. The debt stays armed across attempts the durability law refuses, so a write burst does not skip a checkpoint, it defers it. Degradation is loud and always toward a LONGER scan: a torn checkpoint throws typed on read (the adapter's tmp+rename write means it can never parse into a partial list) and a malformed one is refused whole, both falling back to the low-water mark โ€” still written, still read โ€” and then to generation 1. The fold narrates which bound applied and how many facts it read, on every open, so a bound that stops engaging is visible instead of silent. The worker's orphan reap splits: a row that is GONE clears durably (its tombstone is in the log, or its create never was), while a present-but data-less row keeps clearing in memory only and is carried in the checkpoint list, so the bounded fold and a full fold from generation 1 agree exactly. The crash-recovery contract is unchanged: the fold stays on the open's foreground, markers re-armed when open() returns. --- src/brainy.ts | 416 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 390 insertions(+), 26 deletions(-) diff --git a/src/brainy.ts b/src/brainy.ts index ffc2d5cd..7568a6f3 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -776,6 +776,50 @@ export class Brainy implements BrainyInterface { private _pendingEmbedIds = new Set() private _embedWorkerFlight: Promise | null = null + /** + * Ids cleared from {@link _pendingEmbedIds} with NO durable disarming record + * behind them โ€” today exactly one case: a pending row that still EXISTS but + * carries no embeddable data, which the worker reaps in memory only. The log + * still says those ids are pending, so the pending-embed CHECKPOINT must + * carry them: the checkpoint's contract is "as of generation G the LOG's + * pending set was exactly this list", and a checkpoint that quietly dropped + * an id the log still arms would make the bounded fold disagree with a full + * fold from generation 1 โ€” the one divergence that could lose a vector. + * Bounded by the number of such rows; an id leaves when it is re-enqueued or + * durably disarmed. + */ + private _pendingEmbedUndurableClears = new Set() + + /** + * Pending-set transitions (enqueue/clear) since the last checkpoint attempt โ€” + * the checkpoint CADENCE. One mechanism, one hardcoded default, no knob and + * no timer (nothing to leave running after close). + */ + private _pendingEmbedCheckpointTransitions = 0 + + /** + * A checkpoint is OWED: the cadence came due (or the set drained) and no + * write has satisfied it yet. It stays armed across attempts the durability + * law refuses, so the next transition that CAN be checkpointed is. + */ + private _pendingEmbedCheckpointDue = false + + /** Single-flight guard for the fire-and-forget checkpoint write. */ + private _pendingEmbedCheckpointFlight: Promise | null = null + + /** + * What the last pending-embed recovery fold actually did โ€” the bound it + * used, where it started, and how many facts it read. The narration's + * source, and the accounting a pin reads instead of a clock. + */ + private _pendingEmbedFoldReport: { + bound: 'checkpoint' | 'low-water' | 'genesis' + fromGeneration: number + factsScanned: number + seeded: number + pending: number + } | null = null + // OPEN-PATH FIX: the background embedding-engine warm kicked off (never // awaited) by `performInit()` when `eagerEmbeddings` resolves true. Stored // for observability only โ€” `embed()`/`embeddingManager.embed()` already @@ -2434,6 +2478,47 @@ export class Brainy implements BrainyInterface { */ private static readonly PENDING_EMBED_LOWWATER_PATH = '_system/pending_embeds_lowwater.json' + /** + * Storage-root-relative path of the pending-embed CHECKPOINT: + * `{ generation, pending: string[], writtenAt }` โ€” "as of durable generation + * G the pending set was exactly this list". Open seeds the set from `pending` + * and scans the log from `G + 1`, so the fold costs O(facts since G) + * REGARDLESS of whether the set ever drains. + * + * WHY IT REPLACES THE EMPTY-ONLY MARK AS THE BOUND. The low-water mark + * ({@link PENDING_EMBED_LOWWATER_PATH}) can only be written when the pending + * set is EMPTY, because it carries no set โ€” it means "everything at or below + * G is consumed". A brain holding even ONE id that never lands (an embed that + * keeps failing; a row reaped in memory only and re-folded every open) never + * drains, so it never writes a mark, so the bound never engages on exactly + * the brains whose fold is expensive: every open re-reads the whole log. The + * checkpoint carries the set, so it needs no drain. + * + * The mark is still written and still read as the FALLBACK bound (a + * checkpoint that is absent, torn, or malformed degrades to it, and then to + * generation 1). Correctness over cost in every degradation: a stale or + * missing checkpoint only lengthens the scan. + */ + private static readonly PENDING_EMBED_CHECKPOINT_PATH = '_system/pending_embeds_checkpoint.json' + + /** + * Checkpoint CADENCE BASE: attempt a checkpoint every N pending-set + * transitions (enqueues + clears) while the brain is open, on top of the + * drain-to-empty and clean-close writes. Hardcoded 90th-percentile default, + * no knob, no timer: 64 transitions is far below the cost of the fold it + * bounds and far above the per-write noise floor. An attempt that cannot + * satisfy the durability law is SKIPPED, not forced โ€” the next transition + * retries. + * + * The interval ADAPTS to the one signal that matters, the backlog's own + * size, because a checkpoint writes the WHOLE pending list: the interval is + * `max(64, ceil(|pending| / 64))`, which holds the amortized cost of the + * mechanism at โ‰ค 64 ids written per transition NO MATTER how large the + * backlog grows. A term that scales with the store rather than with the + * work is exactly the defect class this file is fixing; it must not be + * reintroduced by the cure. + */ + private static readonly PENDING_EMBED_CHECKPOINT_EVERY = 64 /** * @description Mark a deferred embed pending (MT5): the id joins the @@ -2448,6 +2533,9 @@ export class Brainy implements BrainyInterface { */ private enqueuePendingEmbed(id: string): FactMarkerRecord { this._pendingEmbedIds.add(id) + // Re-armed for real: any earlier in-memory-only clear is superseded. + this._pendingEmbedUndurableClears.delete(id) + this.noteEmbedCheckpointCadence() return { type: 'embed.pending', id, enqueuedAt: Date.now() } } @@ -2458,11 +2546,27 @@ export class Brainy implements BrainyInterface { * fact) โ€” the recovery fold consumes those; nothing here touches storage. * One honest residue: a pending row whose entity still exists but carries * no data is reaped in memory only, so it re-folds at the next open and - * is re-reaped there โ€” a bounded no-op, never a lost vector. + * is re-reaped there โ€” a bounded no-op, never a lost vector. That residue + * is the ONLY `durability: 'in-memory-only'` caller, and the checkpoint + * keeps carrying those ids so the bounded fold and a full fold from + * generation 1 agree exactly (see {@link _pendingEmbedUndurableClears}). + * + * @param id - The pending id to clear. + * @param durability - `'durable'` (default) when a record in the log at or + * below the current head disarms this id (an `embed.landed` riding the + * landing or unvector commit, or the row's tombstone โ€” including the row + * simply not being there any more); `'in-memory-only'` when nothing in the + * log says so. */ - private clearPendingEmbed(id: string): void { + private clearPendingEmbed( + id: string, + durability: 'durable' | 'in-memory-only' = 'durable' + ): void { this._pendingEmbedIds.delete(id) + if (durability === 'in-memory-only') this._pendingEmbedUndurableClears.add(id) + else this._pendingEmbedUndurableClears.delete(id) if (this._pendingEmbedIds.size === 0) this.maybeWriteEmbedLowWater() + this.noteEmbedCheckpointCadence() } /** @@ -2498,6 +2602,223 @@ export class Brainy implements BrainyInterface { } } + /** + * @description Capture a pending-embed checkpoint, or refuse. + * + * THE DURABILITY LAW, satisfied by construction. The checkpoint asserts "as + * of generation G the log's pending set was exactly this list", and the next + * open TRUSTS it: it seeds the set and never reads a fact at or below G + * again. So a checkpoint may only be taken at a G whose facts are DURABLE. + * A checkpoint taken at head H while the facts up to H are still buffered + * would be read back after a crash that truncated the tail โ€” and an + * `embed.landed` in a truncated fact would be gone from the log while the + * checkpoint still recorded its id as landed. The row's landing vector went + * with the truncated fact, so nothing would ever re-arm it: A LOST VECTOR. + * + * The gate is therefore `0 < head โ‰ค committed`. `committed` is the + * generation manifest's watermark โ€” the point the store's own recovery + * treats as truth, and the point below which `FactLog.open()` never + * truncates โ€” and the group-commit flush fsyncs the log BEFORE advancing it + * (see `GenerationStore.flushPendingSingleOps`). So every fact at or below + * `head` is fsynced and survives the crash exactly as the checkpoint + * describes it. Anything else (a head above the manifest, no log, no + * generation yet, a read-only or closed brain) REFUSES: skipping a + * checkpoint costs a longer scan next open, never a marker. + * + * The snapshot is taken SYNCHRONOUSLY with reading the two generations โ€” no + * `await` between them โ€” so no commit and no worker step can slip between + * "the generation I am about to claim" and "the set I claim for it". + * + * The one asymmetry, deliberately in the safe direction: an id whose + * `embed.pending` record has not been appended yet (enqueued in memory, its + * commit still in flight) is captured as pending at G although its marker + * will land at G+1 or later. Over-stating pending costs one idempotent + * re-embed attempt; under-stating it is the shape that loses a vector, and + * cannot happen โ€” every clear either rides a durable record at or below the + * head, or is carried in {@link _pendingEmbedUndurableClears}. + * + * @returns The checkpoint payload, or `null` when this instant cannot host + * one. + */ + private captureEmbedCheckpoint(): { generation: number; pending: string[] } | null { + if (this.isReadOnly || this.closed) return null + const store = this.generationStore + if (!store) return null + const log = store.getFactLog() + if (!log) return null + // --- ONE SYNCHRONOUS INSTANT: no await until the return. --- + const generation = log.headGeneration() + const committed = store.committedGeneration() + if (!(generation > 0) || generation > committed) return null + const pending = new Set(this._pendingEmbedIds) + for (const id of this._pendingEmbedUndurableClears) pending.add(id) + // --- end of the synchronous instant. --- + return { generation, pending: [...pending] } + } + + /** + * @description Fire-and-forget checkpoint write, single-flight: a burst of + * transitions never stacks writes, and because each attempt captures + * immediately before it writes, the file always ends up holding the most + * recently captured (generation, set) PAIR โ€” and every such pair is + * independently true, so even an out-of-order landing is safe. + * {@link closeDurableSteps} awaits the flight before taking the final one. + */ + private maybeWriteEmbedCheckpoint(): void { + if (this._pendingEmbedCheckpointFlight) return + this._pendingEmbedCheckpointFlight = this.writeEmbedCheckpoint() + .then((wrote) => { + if (wrote) { + this._pendingEmbedCheckpointDue = false + this._pendingEmbedCheckpointTransitions = 0 + } + }) + .finally(() => { + this._pendingEmbedCheckpointFlight = null + }) + } + + /** + * The awaitable core of {@link maybeWriteEmbedCheckpoint}. + * @returns `true` when a checkpoint was actually written. + */ + private async writeEmbedCheckpoint(): Promise { + const snapshot = this.captureEmbedCheckpoint() + if (!snapshot) return false + try { + // Atomic on disk: the filesystem adapter's writeRawObject is tmp+rename + // (see BaseStorage.writeRawObject), so a crash mid-write leaves either + // the previous checkpoint or the new one โ€” never a spliced file. And a + // file that IS unreadable (a torn gzip, invalid JSON) throws typed on + // read and degrades to the fallback bound; it can never parse into a + // partial `pending` list. + // + // The file is NOT separately fsynced, and does not need to be: losing + // the rename to a power cut leaves the PREVIOUS checkpoint (or none), + // which only lengthens the next scan. The invariant that matters is the + // other direction โ€” a checkpoint that IS visible names a generation + // whose facts are durable โ€” and that is established by the capture gate + // above, not by this write. + await this.storage.writeRawObject(Brainy.PENDING_EMBED_CHECKPOINT_PATH, { + generation: snapshot.generation, + pending: snapshot.pending, + writtenAt: Date.now() + }) + return true + } catch (err) { + prodLog.warn( + `[Brainy] pending-embed checkpoint write failed at generation ` + + `${snapshot.generation}: ${(err as Error).message} โ€” the next open scans ` + + `from the previous checkpoint` + ) + return false + } + } + + /** + * @description The checkpoint cadence tick: count one pending-set transition + * and OWE a checkpoint every {@link PENDING_EMBED_CHECKPOINT_EVERY} + * transitions, plus on every drain to empty. The debt stays armed across + * attempts the durability law refuses โ€” during a write burst the log head + * legitimately runs ahead of the manifest, so the first attempt often cannot + * be taken โ€” and the next transition retries it. An active brain therefore + * checkpoints steadily without ever forcing a flush; an idle one relies on + * its clean close. No timer is involved, so nothing survives close(). + */ + private noteEmbedCheckpointCadence(): void { + if (this.isReadOnly || this.closed) return + this._pendingEmbedCheckpointTransitions++ + const listed = this._pendingEmbedIds.size + this._pendingEmbedUndurableClears.size + const every = Math.max( + Brainy.PENDING_EMBED_CHECKPOINT_EVERY, + Math.ceil(listed / Brainy.PENDING_EMBED_CHECKPOINT_EVERY) + ) + if ( + this._pendingEmbedIds.size === 0 || + this._pendingEmbedCheckpointTransitions >= every + ) { + this._pendingEmbedCheckpointDue = true + } + if (this._pendingEmbedCheckpointDue) this.maybeWriteEmbedCheckpoint() + } + + /** + * @description Resolve the pending-embed fold's BOUND: the checkpoint first + * (a set plus a generation), then the legacy low-water mark (a generation + * only), then genesis. Every degradation is loud and lengthens the scan + * rather than shortening it โ€” a bound that could skip a marker is never + * derived from a value this method could not fully validate. + * @returns The bound's name, the first generation to scan, and the ids to + * seed the pending set with. + */ + private async readPendingEmbedBound(): Promise<{ + bound: 'checkpoint' | 'low-water' | 'genesis' + fromGeneration: number + seeded: string[] + }> { + let checkpointRejected: string | null = null + try { + const raw = await this.storage.readRawObject(Brainy.PENDING_EMBED_CHECKPOINT_PATH) + if (raw !== null && raw !== undefined) { + const parsed = Brainy.parsePendingEmbedCheckpoint(raw) + if (parsed) { + return { + bound: 'checkpoint', + fromGeneration: parsed.generation + 1, + seeded: parsed.pending + } + } + checkpointRejected = 'its shape is not { generation: number > 0, pending: string[] }' + } + } catch (err) { + // A real storage fault (EIO/EACCES/โ€ฆ). Corruption never lands here: the + // adapter maps a torn raw object to `null` AFTER logging it as a + // production error, so a torn checkpoint arrives as "absent" โ€” loud at + // the adapter, and bounded here by the fallback below. + checkpointRejected = `reading it failed: ${(err as Error).message}` + } + if (checkpointRejected !== null) { + prodLog.warn( + `[Brainy] pending-embed checkpoint REFUSED (${checkpointRejected}) โ€” falling back ` + + `to the low-water mark, else a full fold from generation 1` + ) + } + + try { + const mark = (await this.storage.readRawObject(Brainy.PENDING_EMBED_LOWWATER_PATH)) as { + generation?: number + } | null + if (mark && typeof mark.generation === 'number' && mark.generation > 0) { + return { bound: 'low-water', fromGeneration: mark.generation + 1, seeded: [] } + } + } catch { + // No mark (or unreadable): scan from 1 โ€” correctness over cost. + } + return { bound: 'genesis', fromGeneration: 1, seeded: [] } + } + + /** + * @description Validate a raw checkpoint object STRICTLY. Anything that is + * not exactly `{ generation: integer > 0, pending: string[] }` is refused + * whole โ€” a partially-usable checkpoint is the one shape that could seed a + * short pending set behind a high bound, which is how a vector is lost. + * @param raw - The object read back from storage. + * @returns The validated checkpoint, or `null`. + */ + private static parsePendingEmbedCheckpoint( + raw: unknown + ): { generation: number; pending: string[] } | null { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null + const { generation, pending } = raw as { generation?: unknown; pending?: unknown } + if (typeof generation !== 'number' || !Number.isSafeInteger(generation) || generation <= 0) { + return null + } + if (!Array.isArray(pending) || pending.some((id) => typeof id !== 'string' || id === '')) { + return null + } + return { generation, pending: pending as string[] } + } + /** * @description Rebuild the pending-embed set by REPLAYING the generation * log's marker records (recovery = replay, not listing): `embed.pending` @@ -2506,14 +2827,22 @@ export class Brainy implements BrainyInterface { * survives the fold is exactly the set of acknowledged deferred writes * whose vectors have not landed. * - * BOUND: the scan starts at the advisory low-water mark - * ({@link Brainy.PENDING_EMBED_LOWWATER_PATH}) โ€” the log head at which the - * pending set last drained to empty โ€” so a settled brain reads only the - * facts since then, not its whole history. Without a mark (first open - * after upgrade) it scans from generation 1, once; a stale-low mark costs - * a longer scan, never a marker. The fold stays on the open's foreground โ€” - * the crash-recovery contract pins that a reopened brain has its markers - * re-armed when open() returns โ€” and the mark is what makes that cheap. + * BOUND: the scan starts after the pending-embed CHECKPOINT + * ({@link Brainy.PENDING_EMBED_CHECKPOINT_PATH}) โ€” "as of durable generation + * G the pending set was exactly this list" โ€” so the fold seeds the set from + * that list and reads only the facts after G. O(delta) whether or not the + * set ever drains, which is the whole point: the previous bound, the + * empty-only low-water mark, could not be written at all by a brain holding + * one id that never lands, so those brains re-read their whole log at every + * open. The mark remains the FALLBACK bound (checkpoint absent, torn, or + * malformed), and generation 1 the fallback below that โ€” a brain opened for + * the first time after this change has neither a checkpoint nor, if it never + * drained, a mark, so it pays one full fold and writes a checkpoint on the + * way out. A stale bound costs a longer scan, never a marker. The fold stays + * on the open's foreground โ€” the crash-recovery contract pins that a + * reopened brain has its markers re-armed when open() returns โ€” and the + * bound is what makes that cheap. What it did (bound, start, facts read) is + * narrated and kept in {@link _pendingEmbedFoldReport}. * It is SKIPPED WHOLESALE when the log has never had a v2 tail * ({@link FactLog.hasV2History} โ€” v1 facts cannot carry marker records), * so pre-cutover brains pay nothing; on a mixed log the scan still reads @@ -2526,20 +2855,13 @@ export class Brainy implements BrainyInterface { private async recoverPendingEmbedsFromLog(): Promise { const log = this.generationStore.getFactLog() if (!log || !log.hasV2History()) return - let fromGeneration = 1 - try { - const mark = (await this.storage.readRawObject(Brainy.PENDING_EMBED_LOWWATER_PATH)) as { - generation?: number - } | null - if (mark && typeof mark.generation === 'number' && mark.generation > 0) { - fromGeneration = mark.generation + 1 - } - } catch { - // No mark (or unreadable): scan from 1 โ€” correctness over cost. - } + const { bound, fromGeneration, seeded } = await this.readPendingEmbedBound() + for (const id of seeded) this._pendingEmbedIds.add(id) + let factsScanned = 0 const scan = log.scanFacts({ fromGeneration }) for await (const batch of scan.batches()) { for (const fact of batch.facts) { + factsScanned++ for (const record of fact.records ?? []) { if (record.type === 'embed.pending') { this._pendingEmbedIds.add(record.id) @@ -2554,6 +2876,21 @@ export class Brainy implements BrainyInterface { } } } + this._pendingEmbedFoldReport = { + bound, + fromGeneration, + factsScanned, + seeded: seeded.length, + pending: this._pendingEmbedIds.size + } + // The narration channel: an operator is entitled to hear which bound + // applied and what it cost, on every open โ€” that is how a bound that + // silently stopped engaging (the defect this replaced) becomes visible. + prodLog.narrate( + `[Brainy] pending-embed fold: ${bound} bound โ†’ scanned ${factsScanned} fact(s) ` + + `from generation ${fromGeneration}, seeded ${seeded.length} id(s), ` + + `${this._pendingEmbedIds.size} pending` + ) } /** @@ -2645,11 +2982,23 @@ export class Brainy implements BrainyInterface { for (const id of batch) { try { const entity = await this.get(id, { includeVectors: true }) - if (!entity || entity.data === undefined || entity.data === null) { - // Orphan reap: a deleted row's tombstone fact durably disarms the - // marker at the next recovery fold; a data-less-but-present row - // (edge case) re-folds and re-reaps โ€” bounded, never a lost vector. - this.clearPendingEmbed(id) + if (!entity) { + // The row is GONE. Either it was deleted โ€” its tombstone fact + // durably disarms the marker, at or below the head, exactly as the + // fold reads it โ€” or its create never became durable, in which case + // the log carries no `embed.pending` for it either. Both are durable + // clears: a full fold from generation 1 reaches the same answer. + this.clearPendingEmbed(id, 'durable') + continue + } + if (entity.data === undefined || entity.data === null) { + // Orphan reap, IN MEMORY ONLY: a data-less-but-present row (edge + // case) has nothing to embed, but no record in the log says so, so + // the fold would re-arm it. Cleared here and carried in the + // checkpoint (see clearPendingEmbed) โ€” it re-folds and re-reaps at + // the next open exactly as before: bounded, never a lost vector, + // and never a checkpoint that disagrees with the log. + this.clearPendingEmbed(id, 'in-memory-only') continue } // Hang guard: a wedged embedder must not block every later pending @@ -19982,6 +20331,21 @@ export class Brainy implements BrainyInterface { await this.stampEntityTree() } + // Phase 1c: the pending-embed CHECKPOINT โ€” placed HERE and not earlier + // because this is the first point in the close where the durability law it + // must satisfy actually holds: `generationStore.close()` (in Phase 1 above) + // flushed the pending single-op tier, which fsyncs the fact log and then + // advances the manifest, so `head === committed` and every fact the + // checkpoint's generation covers is durable. Taken even when the set is + // NOT empty โ€” that is the whole difference from the low-water mark, and it + // is what makes the next open's fold O(facts since this close) on a brain + // whose pending set never drains. Awaits any in-flight cadence write first + // so the last write to the file is this one. + if (!this.isReadOnly) { + await this._pendingEmbedCheckpointFlight?.catch(() => {}) + await this.writeEmbedCheckpoint() + } + // Phase 2: Close components to release resources (timers, file handles) // Data is already safe on disk from Phase 1 await Promise.all([ From 1fb51093511998db05d44423810baee37aa3e8b5 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 10:08:43 -0700 Subject: [PATCH 33/42] =?UTF-8?q?test(open):=20pin=20the=20pending-embed?= =?UTF-8?q?=20checkpoint=20=E2=80=94=20stuck=20id,=20crash=20matrix,=20tor?= =?UTF-8?q?n=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things, none of them a clock: 1. A brain with one permanently-stuck pending id, closed cleanly and reopened, scans ONLY the facts after the checkpoint โ€” read from the fold's own accounting. The same fixture pins the DEFECT it cures: no low-water mark exists on that brain, because it never drained, so nothing could have shortened its fold. A second row proves the bound stays O(delta) across repeated opens while the id is still stuck. 2. A crash matrix in a REAL child process (detached group, SIGKILL, no close), following writer-lock-clean-close's pattern: killed before any checkpoint was written, killed after one with an embed landed and flushed above it, and killed after one with an UN-FLUSHED tail. The invariant in every row is differential โ€” the checkpoint-bounded fold the reopened brain actually ran equals a full fold from generation 1 over the same recovered log. 3. A torn checkpoint (bytes that are neither gzip nor JSON) falls back loudly โ€” the adapter's torn-record gauge and production error, plus the fold's own narration of the bound it used โ€” and still recovers the marker from the log. A well-formed but shape-invalid checkpoint is refused WHOLE: trusting its generation while ignoring its list is the one shape that could bound a scan behind a set that was never recovered. 4. The existing low-water pins pass unchanged โ€” the mark is still written and still read, now as the fallback bound beneath the checkpoint. --- .../pending-embed-checkpoint.test.ts | 547 ++++++++++++++++++ 1 file changed, 547 insertions(+) create mode 100644 tests/integration/pending-embed-checkpoint.test.ts diff --git a/tests/integration/pending-embed-checkpoint.test.ts b/tests/integration/pending-embed-checkpoint.test.ts new file mode 100644 index 00000000..1cf3ec2c --- /dev/null +++ b/tests/integration/pending-embed-checkpoint.test.ts @@ -0,0 +1,547 @@ +/** + * @module tests/integration/pending-embed-checkpoint + * @description THE PENDING-EMBED CHECKPOINT โ€” the bound that engages on the + * brains that need it. + * + * 10.4.9 bounded the open-path `recover-pending-embeds` fold with a LOW-WATER + * MARK: the log head at which the pending set last drained to EMPTY. That mark + * carries no set, so it can only be written when the set is empty โ€” and a brain + * holding even ONE id that never lands (an embed that keeps failing, a worker + * that never gets to it, a row reaped in memory only and re-folded every open) + * never drains, therefore never writes a mark, therefore re-reads its WHOLE + * fact log on every single open. The bound was absent from exactly the brains + * whose fold is expensive: a silent scaling defect. + * + * The cure is a CHECKPOINT of the pending set โ€” + * `_system/pending_embeds_checkpoint.json` = `{ generation, pending, writtenAt }`, + * meaning "as of durable generation G the pending set was exactly this list". + * Open seeds the set from `pending` and scans only from `G + 1`, so the fold is + * O(facts since G) whether or not the set ever drains. + * + * What this suite pins: + * 1. A brain with one permanently-stuck pending id, closed cleanly and + * reopened, scans ONLY the facts after the checkpoint โ€” asserted from the + * fold's own accounting, never a clock. The same fixture pins the DEFECT: + * no low-water mark exists on that brain, because it never drained. + * 2. A crash matrix in a REAL child process (SIGKILL, no close), for kills + * before a checkpoint write, after one with embeds landed and flushed + * after it, and after one with an UN-FLUSHED tail at the moment of death. + * The invariant in every row is differential: the checkpoint-bounded fold + * the reopened brain actually ran โ‰ก a full fold from generation 1 over the + * same recovered log. + * 3. A torn checkpoint falls back โ€” loudly (the adapter's torn-record gauge + * plus the fold's own narration of which bound applied) and correctly. + * 4. The existing low-water pins keep passing unchanged + * (`pending-embed-low-water.test.ts`): the mark is still written and is + * still read, now as the FALLBACK bound beneath the checkpoint. + * + * The crash-recovery contract is untouched: the fold runs on the open's + * foreground, so a reopened brain has its markers re-armed when open() returns. + */ +import { describe, it, expect, afterEach } from 'vitest' +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs' +import { spawn } from 'node:child_process' +import { gunzipSync } from 'node:zlib' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Brainy } from '../../src/brainy.js' +import { NounType } from '../../src/types/graphTypes.js' +import { getTornRecordGauge } from '../../src/storage/tornRecordError.js' + +const CHECKPOINT_PATH = '_system/pending_embeds_checkpoint.json' +const LOWWATER_PATH = '_system/pending_embeds_lowwater.json' +const REPO_ROOT = process.cwd() +const TSX = join(REPO_ROOT, 'node_modules', '.bin', 'tsx') + +/** The fold's own accounting for the most recent open. */ +interface FoldReport { + bound: 'checkpoint' | 'low-water' | 'genesis' + fromGeneration: number + factsScanned: number + seeded: number + pending: number +} + +const roots: string[] = [] +const liveBrains: Brainy[] = [] + +function dir(): string { + const d = mkdtempSync(join(tmpdir(), 'brainy-embed-ckpt-')) + roots.push(d) + return d +} + +async function open(root: string, opts?: { blockWorker?: boolean }): Promise> { + const brain = new Brainy({ + requireSubtype: false, + storage: { type: 'filesystem', path: root } + }) + // Blocking the worker BEFORE init() is how a "permanently stuck" pending id + // is built deterministically: the state under test is "an id the fold keeps + // re-arming and nothing ever disarms", and its production causes (a failing + // embedder, a wedged model, a data-less row) all reduce to exactly that. + if (opts?.blockWorker) (brain as unknown as { kickEmbedWorker: () => void }).kickEmbedWorker = () => {} + await brain.init() + liveBrains.push(brain) + return brain +} + +function foldReport(brain: Brainy): FoldReport { + const report = (brain as unknown as { _pendingEmbedFoldReport: FoldReport | null }) + ._pendingEmbedFoldReport + if (report === null) throw new Error('the open ran no pending-embed fold') + return report +} + +function pendingIds(brain: Brainy): string[] { + return [ + ...(brain as unknown as { _pendingEmbedIds: Set })._pendingEmbedIds + ].sort() +} + +/** Read an artifact straight off disk (the adapter gzips raw objects). */ +function readArtifact(root: string, path: string): Record | null { + const plain = join(root, ...path.split('/')) + const gz = `${plain}.gz` + if (existsSync(gz)) return JSON.parse(gunzipSync(readFileSync(gz)).toString('utf-8')) + if (existsSync(plain)) return JSON.parse(readFileSync(plain, 'utf-8')) + return null +} + +/** The on-disk path the adapter actually used for an artifact. */ +function artifactPath(root: string, path: string): string | null { + const plain = join(root, ...path.split('/')) + const gz = `${plain}.gz` + if (existsSync(gz)) return gz + if (existsSync(plain)) return plain + return null +} + +/** + * THE DIFFERENTIAL ORACLE: fold the log from generation 1 with exactly the + * engine's own rules. This is what the bounded fold must agree with, and its + * fact count is what the unbounded fold used to read at every open. + */ +async function fullFold(brain: Brainy): Promise<{ ids: string[]; facts: number }> { + const log = ( + brain as unknown as { generationStore: { getFactLog(): any } } + ).generationStore.getFactLog() + const pending = new Set() + let facts = 0 + const scan = log.scanFacts({ fromGeneration: 1 }) + for await (const batch of scan.batches()) { + for (const fact of batch.facts) { + facts++ + for (const record of fact.records ?? []) { + if (record.type === 'embed.pending') pending.add(record.id) + else if (record.type === 'embed.landed') pending.delete(record.id) + } + for (const op of fact.ops) { + if (op.kind === 'noun' && op.record === null) pending.delete(op.id) + } + } + } + return { ids: [...pending].sort(), facts } +} + +/** Capture every console.warn/error line emitted while `fn` runs. */ +async function captureConsole(fn: () => Promise): Promise<{ result: T; lines: string[] }> { + const lines: string[] = [] + const origWarn = console.warn + const origError = console.error + const sink = (...args: unknown[]) => { + lines.push(args.map((a) => String(a)).join(' ')) + } + console.warn = sink as typeof console.warn + console.error = sink as typeof console.error + try { + const result = await fn() + return { result, lines } + } finally { + console.warn = origWarn + console.error = origError + } +} + +/** + * Run a child process that arranges a store and then waits forever, so the + * parent can SIGKILL it. A real process death is the only honest way to pin + * "no close ran, no shutdown hook ran, RAM is gone". + * + * `detached` puts the child in its own process GROUP: tsx runs the script in a + * grandchild, and only a group-wide signal reaches the process holding the + * writer lock. + */ +function spawnArranger(root: string, body: string): Promise<{ + child: ReturnType + output: () => string +}> { + const scriptPath = join(root, 'arrange.mts') + writeFileSync(scriptPath, body) + const child = spawn(TSX, [scriptPath], { + cwd: REPO_ROOT, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true + }) + let out = '' + child.stdout!.on('data', (d) => { out += String(d) }) + child.stderr!.on('data', (d) => { out += String(d) }) + return new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout( + () => rejectPromise(new Error(`arranger never became READY:\n${out}`)), + 180_000 + ) + child.stdout!.on('data', () => { + if (out.includes('READY')) { + clearTimeout(timer) + resolvePromise({ child, output: () => out }) + } + }) + child.on('exit', (code) => { + clearTimeout(timer) + if (!out.includes('READY')) rejectPromise(new Error(`arranger exited ${code}:\n${out}`)) + }) + }) +} + +/** Parse the `IDS:{...}` line an arranger prints โ€” supplied ids are normalised + * to canonical uuids, and the markers, checkpoint and fold all speak those. */ +function childIds(output: string): Record { + const line = output.split('\n').find((l) => l.startsWith('IDS:')) + if (!line) throw new Error(`arranger printed no IDS line:\n${output}`) + return JSON.parse(line.slice('IDS:'.length)) +} + +/** SIGKILL the whole group and wait for the grandchild's death to settle. */ +async function sigkill(child: ReturnType): Promise { + process.kill(-(child.pid as number), 'SIGKILL') + await new Promise((r) => child.on('exit', () => r())) + await new Promise((r) => setTimeout(r, 500)) +} + +/** The preamble every arranger child shares. */ +function childPreamble(root: string): string { + return ` + import { Brainy } from ${JSON.stringify(join(REPO_ROOT, 'src', 'brainy.ts'))} + const ROOT = ${JSON.stringify(root)} + const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: ROOT } }) + const block = () => { (brain as any).kickEmbedWorker = () => {} } + const settleCheckpoint = async () => { + // The cadence write is fire-and-forget; wait for the single flight. + for (let i = 0; i < 200; i++) { + if (!(brain as any)._pendingEmbedCheckpointFlight) break + await (brain as any)._pendingEmbedCheckpointFlight.catch(() => {}) + } + } + ` +} + +afterEach(async () => { + for (const brain of liveBrains.splice(0)) { + try { await brain.close() } catch { /* already closed / crashed โ€” teardown only */ } + } + for (const d of roots.splice(0)) rmSync(d, { recursive: true, force: true }) +}) + +// =========================================================================== +// 1. The stuck-id brain โ€” the defect, and the bound that now engages on it +// =========================================================================== + +describe('pending-embed checkpoint โ€” a brain whose pending set never drains', () => { + it('a permanently-stuck pending id: the reopen scans only the facts after the checkpoint', async () => { + const root = dir() + const first = await open(root, { blockWorker: true }) + // add() returns the CANONICAL id (supplied ids are normalised), and that is + // the id the markers, the checkpoint and the fold all speak. + const stuck = await first.add({ + id: 'stuck', + data: 'a deferred row whose embed never lands', + type: NounType.Thing, + deferEmbedding: true + }) + expect(first.pendingEmbedCount()).toBe(1) + // Ordinary traffic after it โ€” every one of these is a fact the unbounded + // fold had to re-read at every open, forever, because of that one id. + for (let i = 0; i < 12; i++) { + await first.add({ id: `row-${i}`, data: `row ${i}`, type: NounType.Thing }) + } + await first.close() + liveBrains.splice(liveBrains.indexOf(first), 1) + + // THE DEFECT, PINNED: the pending set never drained, so the old bound was + // never written โ€” nothing on this brain could have shortened its fold. + expect(readArtifact(root, LOWWATER_PATH)).toBeNull() + // The checkpoint IS written at the clean close, set non-empty and all. + const checkpoint = readArtifact(root, CHECKPOINT_PATH) as { + generation: number + pending: string[] + } | null + expect(checkpoint).not.toBeNull() + expect(checkpoint!.generation).toBeGreaterThan(0) + expect(checkpoint!.pending).toEqual([stuck]) + + const second = await open(root, { blockWorker: true }) + const report = foldReport(second) + // THE FIX, from the fold's own counter โ€” not the clock. + expect(report.bound).toBe('checkpoint') + expect(report.fromGeneration).toBe(checkpoint!.generation + 1) + expect(report.factsScanned).toBe(0) + expect(report.seeded).toBe(1) + // The crash-recovery contract is intact: the marker is re-armed by open(). + expect(pendingIds(second)).toEqual([stuck]) + expect(second.pendingEmbedCount()).toBe(1) + + // The differential: the bounded answer is the full-fold answer, and the + // full fold is what the previous bound would have had to read. + const full = await fullFold(second) + expect(full.ids).toEqual([stuck]) + expect(full.facts).toBeGreaterThanOrEqual(13) + expect(report.factsScanned).toBeLessThan(full.facts) + }, 180_000) + + it('the bound stays O(delta) across repeated opens while the id is still stuck', async () => { + const root = dir() + const first = await open(root, { blockWorker: true }) + const stuck = await first.add({ + id: 'stuck', + data: 'never lands', + type: NounType.Thing, + deferEmbedding: true + }) + for (let i = 0; i < 6; i++) { + await first.add({ id: `a-${i}`, data: `a ${i}`, type: NounType.Thing }) + } + await first.close() + liveBrains.splice(liveBrains.indexOf(first), 1) + + const second = await open(root, { blockWorker: true }) + expect(foldReport(second).factsScanned).toBe(0) + // More history under the same stuck id. + for (let i = 0; i < 9; i++) { + await second.add({ id: `b-${i}`, data: `b ${i}`, type: NounType.Thing }) + } + await second.close() + liveBrains.splice(liveBrains.indexOf(second), 1) + + const third = await open(root, { blockWorker: true }) + const report = foldReport(third) + const full = await fullFold(third) + expect(report.bound).toBe('checkpoint') + expect(report.factsScanned).toBe(0) + // The unbounded fold grew with the store; the bounded one did not. + expect(full.facts).toBeGreaterThanOrEqual(16) + expect(pendingIds(third)).toEqual([stuck]) + expect(full.ids).toEqual([stuck]) + }, 180_000) +}) + +// =========================================================================== +// 2. Torn checkpoint โ€” falls back, loudly, correctly +// =========================================================================== + +describe('pending-embed checkpoint โ€” a torn checkpoint never shortens the fold', () => { + it('an undecodable checkpoint file degrades to the next bound, loudly, with the right pending set', async () => { + const root = dir() + const first = await open(root, { blockWorker: true }) + const stuck = await first.add({ + id: 'stuck', + data: 'never lands', + type: NounType.Thing, + deferEmbedding: true + }) + for (let i = 0; i < 5; i++) { + await first.add({ id: `row-${i}`, data: `row ${i}`, type: NounType.Thing }) + } + await first.close() + liveBrains.splice(liveBrains.indexOf(first), 1) + + const onDisk = artifactPath(root, CHECKPOINT_PATH) + expect(onDisk).not.toBeNull() + // Tear it: bytes that are neither valid gzip nor valid JSON. A torn file + // must THROW on read โ€” never parse into a partial `pending` list. + writeFileSync(onDisk!, 'not a checkpoint at all {{{') + + const before = getTornRecordGauge().count + const { result: second, lines } = await captureConsole(async () => + open(root, { blockWorker: true }) + ) + const report = foldReport(second) + // Fell back โ€” never to a shorter bound, and never silently. + expect(report.bound).not.toBe('checkpoint') + expect(report.seeded).toBe(0) + expect(report.fromGeneration).toBe(1) // no mark either: this brain never drained + // LOUD, two ways: the adapter's torn-record gauge and its production errorโ€ฆ + expect(getTornRecordGauge().count).toBeGreaterThan(before) + expect(getTornRecordGauge().lastPath).toContain('pending_embeds_checkpoint') + expect(lines.some((l) => /TORN RECORD/.test(l))).toBe(true) + // โ€ฆand the fold's own narration of which bound it actually used. + expect(lines.some((l) => /pending-embed fold: genesis bound/.test(l))).toBe(true) + + // CORRECT: the marker is still recovered, from the log itself. + expect(pendingIds(second)).toEqual([stuck]) + const full = await fullFold(second) + expect(full.ids).toEqual([stuck]) + expect(report.factsScanned).toBe(full.facts) + }, 180_000) + + it('a well-formed but shape-invalid checkpoint is refused whole, never partially trusted', async () => { + const root = dir() + const first = await open(root, { blockWorker: true }) + const stuck = await first.add({ + id: 'stuck', + data: 'never lands', + type: NounType.Thing, + deferEmbedding: true + }) + await first.add({ id: 'other', data: 'ordinary row', type: NounType.Thing }) + await first.close() + liveBrains.splice(liveBrains.indexOf(first), 1) + + // A checkpoint with a plausible generation but a `pending` that is not a + // list of ids: trusting the generation alone would bound the scan behind a + // set that was never recovered โ€” the exact shape that loses a vector. + const onDisk = artifactPath(root, CHECKPOINT_PATH)! + const good = readArtifact(root, CHECKPOINT_PATH) as { generation: number } + rmSync(onDisk) + writeFileSync( + join(root, '_system', 'pending_embeds_checkpoint.json'), + JSON.stringify({ generation: good.generation, pending: { stuck: true }, writtenAt: 1 }) + ) + + const { result: second, lines } = await captureConsole(async () => + open(root, { blockWorker: true }) + ) + expect(lines.some((l) => /pending-embed checkpoint REFUSED/.test(l))).toBe(true) + const report = foldReport(second) + expect(report.bound).not.toBe('checkpoint') + expect(report.seeded).toBe(0) + expect(pendingIds(second)).toEqual([stuck]) + }, 180_000) +}) + +// =========================================================================== +// 3. The crash matrix โ€” real processes, real SIGKILL, differential invariant +// =========================================================================== + +describe('pending-embed checkpoint โ€” crash matrix (real child process, SIGKILL)', () => { + /** + * The invariant every row shares: whatever the reopened brain's fold did with + * whatever bound survived the crash, its pending set must equal the truth a + * full fold from generation 1 derives from the SAME recovered log. + */ + async function assertDifferentialAfterCrash(root: string): Promise<{ + report: FoldReport + full: { ids: string[]; facts: number } + pending: string[] + }> { + const reopened = await open(root, { blockWorker: true }) + const report = foldReport(reopened) + const full = await fullFold(reopened) + const pending = pendingIds(reopened) + expect(pending).toEqual(full.ids) + return { report, full, pending } + } + + it('killed BEFORE any checkpoint was written โ€” falls back and recovers the marker from the log', async () => { + const root = dir() + const { child, output } = await spawnArranger( + root, + `${childPreamble(root)} + block() + await brain.init() + await brain.add({ id: 'landed-row', data: 'an ordinary row', type: 'thing' }) + const stuck = await brain.add({ id: 'stuck-1', data: 'deferred, never lands', type: 'thing', deferEmbedding: true }) + await brain.flush() + console.log('IDS:' + JSON.stringify({ stuck })) + console.log('READY') + setInterval(() => {}, 1000) + ` + ) + const ids = childIds(output()) + // One enqueue is well under the cadence and the set never drained, so no + // checkpoint exists โ€” this is the pre-checkpoint crash. + expect(readArtifact(root, CHECKPOINT_PATH)).toBeNull() + await sigkill(child) + + const { report, pending } = await assertDifferentialAfterCrash(root) + expect(report.bound).toBe('genesis') + expect(pending).toEqual([ids.stuck]) + }, 300_000) + + it('killed AFTER a checkpoint, with an embed landed and flushed after it โ€” the post-checkpoint facts carry the disarm', async () => { + const root = dir() + const { child, output } = await spawnArranger( + root, + `${childPreamble(root)} + await brain.init() + // Land one deferred embed: the drain arms the checkpoint debt. + await brain.add({ id: 'seed', data: 'lands first', type: 'thing', deferEmbedding: true }) + await brain.awaitPendingEmbeds() + await brain.flush() + // A second deferred write pays the debt (the head is at the manifest now), + // then LANDS โ€” its embed.landed rides a fact ABOVE the checkpoint. + const landsAfter = await brain.add({ id: 'lands-after', data: 'lands after the checkpoint', type: 'thing', deferEmbedding: true }) + await settleCheckpoint() + await brain.awaitPendingEmbeds() + // โ€ฆand one that never will. + block() + const stuck = await brain.add({ id: 'stuck-1', data: 'deferred, never lands', type: 'thing', deferEmbedding: true }) + await brain.add({ id: 'plain', data: 'more history', type: 'thing' }) + await brain.flush() + console.log('IDS:' + JSON.stringify({ stuck, landsAfter })) + console.log('READY') + setInterval(() => {}, 1000) + ` + ) + const ids = childIds(output()) + const checkpoint = readArtifact(root, CHECKPOINT_PATH) as { + generation: number + pending: string[] + } | null + expect(checkpoint).not.toBeNull() + await sigkill(child) + + const { report, full, pending } = await assertDifferentialAfterCrash(root) + expect(report.bound).toBe('checkpoint') + expect(report.fromGeneration).toBe(checkpoint!.generation + 1) + // The bound really bounded: fewer facts than the whole log. + expect(report.factsScanned).toBeLessThan(full.facts) + // A landed embed above the checkpoint is disarmed by the scan, not lost; + // the stuck one is re-armed. + expect(pending).toEqual([ids.stuck]) + expect(pending).not.toContain(ids.landsAfter) + }, 300_000) + + it('killed AFTER a checkpoint with an UN-FLUSHED tail โ€” truncated facts and the bounded fold still agree', async () => { + const root = dir() + const { child } = await spawnArranger( + root, + `${childPreamble(root)} + await brain.init() + await brain.add({ id: 'seed', data: 'lands first', type: 'thing', deferEmbedding: true }) + await brain.awaitPendingEmbeds() + await brain.flush() + await brain.add({ id: 'lands-after', data: 'lands after the checkpoint', type: 'thing', deferEmbedding: true }) + await settleCheckpoint() + await brain.awaitPendingEmbeds() + await brain.flush() + // Now write PAST the manifest and never flush: these facts are the tail a + // crash truncates. Whatever survives, the two folds must agree on it. + block() + await brain.add({ id: 'stuck-tail', data: 'deferred, never lands', type: 'thing', deferEmbedding: true }) + await brain.add({ id: 'plain-tail', data: 'unflushed history', type: 'thing' }) + console.log('READY') + setInterval(() => {}, 1000) + ` + ) + const checkpoint = readArtifact(root, CHECKPOINT_PATH) as { generation: number } | null + expect(checkpoint).not.toBeNull() + await sigkill(child) + + const { report } = await assertDifferentialAfterCrash(root) + // The checkpoint's generation is at or below the manifest by construction, + // so it survived the truncation and still bounds the fold. + expect(report.bound).toBe('checkpoint') + expect(report.fromGeneration).toBe(checkpoint!.generation + 1) + }, 300_000) +}) From 3dadbec8f21574dab4ce72919576769ff209e7e1 Mon Sep 17 00:00:00 2001 From: Fleet Bot Date: Wed, 2 Sep 2026 20:56:24 +0200 Subject: [PATCH 34/42] ci: superseded pushes cancel their own runs (concurrency per ref) --- .forgejo/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 5e93cd96..da5887f6 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -5,6 +5,10 @@ name: CI # sequential, so tag-triggered matrix jobs (~22 min) would queue AHEAD of the # tag's publish-source run and starve every release (observed on 8.10.3 and # 9.0.0: the publish sat behind the tag's own redundant CI). +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + on: push: branches: ['**'] From 08758c254fe04c7f84cadf930540aebf0f8b8093 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 12:16:42 -0700 Subject: [PATCH 35/42] =?UTF-8?q?docs(releases):=20the=2010.4.10=20note=20?= =?UTF-8?q?=E2=80=94=20a=20planner=20door,=20batched=20containment=20repai?= =?UTF-8?q?r,=20a=20fixed=20near()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate: 10.4.10 candidate (a8c5fbf9) vs 10.4.9 control (eec90bdd) โ€” collected 3,223/3,211, 0 new reds. shasum ffff79c5c4bcbc614545ad72e8d0138c039062e9. --- releases/open-brainy.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/releases/open-brainy.json b/releases/open-brainy.json index 582f4847..dab25971 100644 --- a/releases/open-brainy.json +++ b/releases/open-brainy.json @@ -1,6 +1,18 @@ { "product": "open-brainy", "entries": [ + { + "version": "10.4.10", + "date": "2026-09-02", + "headline": "A planner door for indexes, batched containment repair, and a fixed near()", + "items": [ + "An optional planFindPage door lets an index plan a find() and answer it in one call, instead of the engine assembling the plan itself.", + "repairContainment's reconcile pass now walks paged edges once instead of issuing one graph call per file.", + "find({ near }) now searches around the anchor's own vector and refuses by name when none is available, instead of silently querying with no vector at all." + ], + "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.10", + "thumb": null + }, { "version": "10.4.9", "date": "2026-09-02", From 3835a0e7027bd215bdbb75d4e4795195981bd03f Mon Sep 17 00:00:00 2001 From: Fleet Bot Date: Wed, 2 Sep 2026 22:51:59 +0200 Subject: [PATCH 36/42] =?UTF-8?q?ci(publish):=20allow=20manual=20dispatch?= =?UTF-8?q?=20=E2=80=94=20replay=20lane=20for=20dropped=20tag=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .forgejo/workflows/publish-source.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.forgejo/workflows/publish-source.yml b/.forgejo/workflows/publish-source.yml index 58cb1d30..6bd42b2a 100644 --- a/.forgejo/workflows/publish-source.yml +++ b/.forgejo/workflows/publish-source.yml @@ -12,6 +12,11 @@ on: push: tags: - 'v*' + workflow_dispatch: + inputs: + ref_reason: + description: 'why this manual run (e.g. tag event dropped)' + required: false jobs: publish: From 61bc5f423b208794262126270e46ffe4e3230689 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 14:03:28 -0700 Subject: [PATCH 37/42] =?UTF-8?q?docs(releases):=20the=2010.4.11=20note=20?= =?UTF-8?q?=E2=80=94=20hybrid=20filter-before-hydrate,=20one=20shutdown=20?= =?UTF-8?q?owner,=20a=20faster=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate: final tip 27759a1b vs a8c5fbf9 (10.4.10) control โ€” collected 3,212, 0 new reds after two fix cycles (coverage-guard registration + perf-lane classification; a real budget flake in the batch-size test switched to unvectored items). shasum ffc33df95b2709dfcc8c67ac961991e3153f8883. --- releases/open-brainy.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/releases/open-brainy.json b/releases/open-brainy.json index dab25971..9f1cd239 100644 --- a/releases/open-brainy.json +++ b/releases/open-brainy.json @@ -1,6 +1,20 @@ { "product": "open-brainy", "entries": [ + { + "version": "10.4.11", + "date": "2026-09-02", + "headline": "Hybrid finds filter before they hydrate, one owner per shutdown, and a faster open", + "items": [ + "Hybrid finds (query/vector combined with a filter, including connected and fusion finds) now filter first and hydrate only the page โ€” one batchGet of exactly the requested rows, instead of hydrating everything the search side found. Fixes a bug where any page after the first came back empty.", + "A brain now has exactly one shutdown owner โ€” a host and its engine no longer race to close the same store, and a follow-up flush requested during a running flush is handed off cleanly instead of ever risking a stall.", + "find({ path }) and other path-scoped VFS searches now serve a real range over the indexed path (O(log n)) instead of refusing the query outright โ€” both scoped and recursive:false searches were silently broken before this.", + "Open no longer rescans a brain's whole fact log on every open โ€” sealed segments the manifest already accounts for are skipped, collapsing a multi-second open term to near-zero on large brains.", + "commitTransaction() now refuses by name if single-ops are still pending, and a read-only open no longer writes clean-shutdown evidence it didn't earn โ€” two correctness invariants that were previously assumed, not enforced." + ], + "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.11", + "thumb": null + }, { "version": "10.4.10", "date": "2026-09-02", From 8752f11f4d5e312a47dde521c267e9b395de0d21 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 14:13:40 -0700 Subject: [PATCH 38/42] chore(releases): the product engine's release wall leaves the reference repo Only the open engine's own wall (releases/open-brainy.json) belongs in the public reference project. The product's notes are served from the product's own repository. --- releases/brainy.json | 76 -------------------------------------------- 1 file changed, 76 deletions(-) delete mode 100644 releases/brainy.json diff --git a/releases/brainy.json b/releases/brainy.json deleted file mode 100644 index 8f61c7f2..00000000 --- a/releases/brainy.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "product": "brainy", - "entries": [ - { - "version": "11.0.5", - "date": "2026-09-02", - "headline": "Graph-first finds in production, and opens that stop rescanning history", - "items": [ - "find({ connected, where }) now walks the neighbours first and filters only those rows through a native door โ€” correct at every page and O(neighbours), never the whole store.", - "related() with a list of verb types returns every requested kind (a fast path had silently kept only the first).", - "Deferred-embedding recovery resumes from a low-water mark instead of rescanning the whole generation log at every open โ€” measured at two minutes on a large brain, now milliseconds." - ], - "url": null, - "thumb": null - }, - { - "version": "11.0.4", - "date": "2026-09-01", - "headline": "Closes in milliseconds, index rebuilds without the disk-sync storm", - "items": [ - "close() no longer pays deferred compaction or waits out an in-flight rebuild โ€” measured 8 ms against the 4-minute closes it replaces; deferred work resumes at the next open, in the background.", - "The metadata index's rebuild syncs to disk per shard instead of per row, and the durability point moved to the publish step โ€” the same guarantee, a fraction of the disk traffic.", - "A new native filter door evaluates queries over exactly the candidate rows a graph walk found, never the whole store." - ], - "url": null, - "thumb": null - }, - { - "version": "11.0.3", - "date": "2026-09-01", - "headline": "The embedding upgrade ceremony runs on every brain", - "items": [ - "A brain opened through the standard plugin now carries its embedding-model identity, so the full-precision upgrade ceremony can run on it.", - "A one-fix release; nothing else changed." - ], - "url": null, - "thumb": null - }, - { - "version": "11.0.2", - "date": "2026-08-31", - "headline": "One embedding quality everywhere, 3โ€“4ร— faster imports", - "items": [ - "Every runtime embeds with the same full-precision model โ€” search quality no longer depends on where you run.", - "Bulk embedding measured 3.1โ€“4.2ร— faster, and an online re-embed ceremony upgrades existing stores without downtime.", - "The engine's change feed is documented, with the SSE/WebSocket fan-out pattern for realtime surfaces." - ], - "url": null, - "thumb": null - }, - { - "version": "11.0.1", - "date": "2026-08-31", - "headline": "Deletes inside transactions are safe", - "items": [ - "Deleting relations inside a transact() no longer corrupts index bookkeeping.", - "A store that deletes its last relation keeps serving instead of refusing." - ], - "url": null, - "thumb": null - }, - { - "version": "11.0.0", - "date": "2026-08-28", - "headline": "One install, one engine โ€” Brainy", - "items": [ - "The former two-package pair is one package: the native engine under the familiar API. One import is the whole install.", - "A missing native build refuses loudly with its cures named; nothing falls back silently.", - "Stores open in place โ€” no migration." - ], - "url": null, - "thumb": null - } - ], - "history": "The version line continues from the 4.3.x native-engine releases; their record lives in the product repository's CHANGELOG.md." -} From 85b1fa5c1a82ccad2f5ce9bd86b31fc18ab13357 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 14:15:39 -0700 Subject: [PATCH 39/42] =?UTF-8?q?ci(release):=20mechanize=20the=20releases?= =?UTF-8?q?-wall=20entry=20=E2=80=94=20never=20hand-written=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every release used to get its releases/open-brainy.json entry typed by hand after the fact. scripts/wall-entry.mjs derives it from the CHANGELOG entry release.sh just composed (headline = first bullet, items = every bullet, hash stripped) and prepends it, refusing by name on a duplicate version and validating the whole file's shape + newest-first ordering before and after it writes. release.sh now runs it as its own step, between the CHANGELOG update and the release commit, and stages releases/open-brainy.json into that commit. The product engine's rail runs this identical script against its own releases/brainy.json, unchanged โ€” each repo's wall file lives beside the CHANGELOG it derives from; there is no cross-repo step. A --check mode validates a wall file's exact key set, field types, and newest-first ordering with no duplicates, read-only. tests/unit/release/wall-entry.test.ts covers derivation, prepend, duplicate refusal, and --check's shape/ordering checks over temp copies โ€” never the real files. --check also runs green against both releases/open-brainy.json and releases/brainy.json as they stand today. --- scripts/release.sh | 13 +- scripts/wall-entry.mjs | 364 ++++++++++++++++++++++++++ tests/unit/release/wall-entry.test.ts | 216 +++++++++++++++ 3 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 scripts/wall-entry.mjs create mode 100644 tests/unit/release/wall-entry.test.ts diff --git a/scripts/release.sh b/scripts/release.sh index 5d434320..07d225ce 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -154,7 +154,8 @@ else fi # Create new changelog entry -CHANGELOG_ENTRY="### [${NEW_VERSION}](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) ($(date +%Y-%m-%d)) +RELEASE_DATE=$(date +%Y-%m-%d) +CHANGELOG_ENTRY="### [${NEW_VERSION}](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) (${RELEASE_DATE}) ${COMMITS} " @@ -174,9 +175,17 @@ if [ -f "CHANGELOG.md" ]; then fi echo -e "${GREEN}โœ… CHANGELOG updated${NC}\n" +# Step 6b: Update the releases wall entry โ€” mechanical, derived from the +# CHANGELOG entry just composed. The fleet's HQ page reads releases/open-brainy.json +# directly; this used to be hand-written after every release (David: never +# again โ€” make it a step of the rail). +echo -e "${BLUE}5๏ธโƒฃโ–ธ Updating the releases wall...${NC}" +node scripts/wall-entry.mjs --product open-brainy --version "${NEW_VERSION}" --date "${RELEASE_DATE}" --from-changelog CHANGELOG.md +echo -e "${GREEN}โœ… Releases wall updated${NC}\n" + # Step 7: Create release commit echo -e "${BLUE}6๏ธโƒฃ Creating release commit...${NC}" -git add package.json package-lock.json CHANGELOG.md +git add package.json package-lock.json CHANGELOG.md releases/open-brainy.json git commit -m "chore(release): ${NEW_VERSION}" echo -e "${GREEN}โœ… Release commit created${NC}\n" diff --git a/scripts/wall-entry.mjs b/scripts/wall-entry.mjs new file mode 100644 index 00000000..998431da --- /dev/null +++ b/scripts/wall-entry.mjs @@ -0,0 +1,364 @@ +#!/usr/bin/env node +/** + * @module scripts/wall-entry + * @description The releases-wall entry, made mechanical. The fleet's HQ page + * reads one public JSON per product (releases/.json โ€” shape + * {product, entries:[{version, date, headline, items, url, thumb}], history}). + * Those entries were hand-written after every release; this script is the + * one door that composes one, so it never has to be typed by hand again. + * + * Two modes: + * + * 1. Generate + write in place (default): + * node wall-entry.mjs --product

--version --date \ + * --from-changelog [--file releases/

.json] + * Derives an entry from the CHANGELOG.md entry for (headline = the + * entry's first bullet, items = every bullet, trimmed of its trailing + * commit hash), prepends it to --file (default releases/.json, + * newest first), refusing by name if is already present, and + * validates the whole file's shape + ordering before and after writing. + * Both engines run this identically, each against its own repo's + * releases/.json โ€” the wall file always lives beside the + * CHANGELOG it is derived from, never in another repo. + * + * 2. Validate only (--check): + * node wall-entry.mjs --check --file + * Validates the file's exact key set (top-level and per-entry), field + * types, and strict-descending semver ordering with no duplicates. + * Read-only; never writes. Exit 0 = clean, exit 1 = named violations + * printed to stderr. + * + * No dependencies โ€” CHANGELOG parsing, semver comparison, and JSON shape + * checking are all hand-rolled below. + */ + +import { readFileSync, writeFileSync, existsSync } from 'node:fs' + +const ENTRY_KEYS = ['version', 'date', 'headline', 'items', 'url', 'thumb'] +const FILE_KEYS = ['product', 'entries', 'history'] + +// The public release-page URL pattern, by product โ€” only products with a +// PUBLIC forge repo get a derived link. A product without an entry here +// (e.g. "brainy", whose repo is private) gets url: null, matching every +// entry the fleet has shipped for it so far โ€” a private link would 404 for +// anyone reading the public HQ page. +const RELEASE_URL_PATTERNS = { + 'open-brainy': (version) => `https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v${version}`, +} + +/** + * Parse argv into a flag map. `--flag value` sets a string; `--flag` alone + * (end of argv, or followed by another `--flag`) sets boolean true. + * @param {string[]} argv + * @returns {Record} + */ +function parseArgs(argv) { + /** @type {Record} */ + const args = {} + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (!a.startsWith('--')) continue + const key = a.slice(2) + const next = argv[i + 1] + if (next === undefined || next.startsWith('--')) { + args[key] = true + } else { + args[key] = next + i++ + } + } + return args +} + +/** + * Print a loud, named error and exit 1. Every refusal in this script goes + * through here so the failure mode is always the same shape: "wall-entry: ". + * @param {string} message + * @returns {never} + */ +function fail(message) { + console.error(`wall-entry: ${message}`) + process.exit(1) +} + +/** + * @param {string} version + * @returns {{major: number, minor: number, patch: number, pre: string | null} | null} + */ +function parseSemver(version) { + const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version) + if (!m) return null + return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ?? null } +} + +/** + * @param {string} a + * @param {string} b + * @returns {number} positive if a > b, negative if a < b, 0 if equal. + */ +function compareSemver(a, b) { + const pa = parseSemver(a) + const pb = parseSemver(b) + if (!pa || !pb) throw new Error(`cannot compare non-semver versions "${a}" vs "${b}"`) + if (pa.major !== pb.major) return pa.major - pb.major + if (pa.minor !== pb.minor) return pa.minor - pb.minor + if (pa.patch !== pb.patch) return pa.patch - pb.patch + if (pa.pre === pb.pre) return 0 + if (pa.pre === null) return 1 // a release outranks any prerelease of the same core version + if (pb.pre === null) return -1 + return pa.pre < pb.pre ? -1 : pa.pre > pb.pre ? 1 : 0 +} + +/** + * Validate a wall file's full shape: top-level keys, per-entry keys and + * field types, and strict-descending semver ordering with no duplicates. + * Collects every violation instead of failing on the first, so --check + * reports the whole picture in one pass. + * @param {unknown} data + * @returns {string[]} Violation messages; empty means the file is clean. + */ +function validateShape(data) { + /** @type {string[]} */ + const errors = [] + + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return ['top level: expected a JSON object'] + } + const obj = /** @type {Record} */ (data) + + const topKeys = Object.keys(obj) + const missingTop = FILE_KEYS.filter((k) => !(k in obj)) + const extraTop = topKeys.filter((k) => !FILE_KEYS.includes(k)) + if (missingTop.length) errors.push(`top level: missing key(s) ${missingTop.join(', ')}`) + if (extraTop.length) errors.push(`top level: unexpected key(s) ${extraTop.join(', ')}`) + + if (typeof obj.product !== 'string' || obj.product.trim() === '') { + errors.push('top level: "product" must be a non-empty string') + } + if (typeof obj.history !== 'string' || obj.history.trim() === '') { + errors.push('top level: "history" must be a non-empty string') + } + if (!Array.isArray(obj.entries)) { + errors.push('top level: "entries" must be an array') + return errors // nothing further to check without an array + } + + const entries = /** @type {unknown[]} */ (obj.entries) + entries.forEach((rawEntry, i) => { + const label = `entries[${i}]` + if (typeof rawEntry !== 'object' || rawEntry === null || Array.isArray(rawEntry)) { + errors.push(`${label}: expected an object`) + return + } + const entry = /** @type {Record} */ (rawEntry) + const keys = Object.keys(entry) + const missing = ENTRY_KEYS.filter((k) => !(k in entry)) + const extra = keys.filter((k) => !ENTRY_KEYS.includes(k)) + if (missing.length) errors.push(`${label}: missing key(s) ${missing.join(', ')}`) + if (extra.length) errors.push(`${label}: unexpected key(s) ${extra.join(', ')}`) + + if (typeof entry.version !== 'string' || !parseSemver(entry.version)) { + errors.push(`${label}: "version" must be a semver string (got ${JSON.stringify(entry.version)})`) + } + if (typeof entry.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date) || Number.isNaN(Date.parse(entry.date))) { + errors.push(`${label}: "date" must be a YYYY-MM-DD string (got ${JSON.stringify(entry.date)})`) + } + if (typeof entry.headline !== 'string' || entry.headline.trim() === '') { + errors.push(`${label}: "headline" must be a non-empty string`) + } + if (!Array.isArray(entry.items) || entry.items.length === 0 || entry.items.some((it) => typeof it !== 'string' || it.trim() === '')) { + errors.push(`${label}: "items" must be a non-empty array of non-empty strings`) + } + if (!(entry.url === null || typeof entry.url === 'string')) { + errors.push(`${label}: "url" must be a string or null`) + } + if (!(entry.thumb === null || typeof entry.thumb === 'string')) { + errors.push(`${label}: "thumb" must be a string or null`) + } + }) + + // Ordering: newest first, strictly descending, no duplicate versions โ€” + // checked only over entries whose version parsed (a bad version is + // already reported above; comparing it too would just be noise). + const versioned = entries + .map((e, i) => ({ i, version: /** @type {any} */ (e)?.version })) + .filter((e) => typeof e.version === 'string' && parseSemver(e.version)) + for (let i = 0; i < versioned.length - 1; i++) { + const a = versioned[i] + const b = versioned[i + 1] + const cmp = compareSemver(a.version, b.version) + if (cmp === 0) { + errors.push(`entries[${a.i}] and entries[${b.i}]: duplicate version ${a.version}`) + } else if (cmp < 0) { + errors.push(`entries[${a.i}] (${a.version}) sits above entries[${b.i}] (${b.version}) โ€” not newest-first`) + } + } + + return errors +} + +/** + * Extract one version's entry body from a standard-version-style CHANGELOG.md + * (headings `### [version](url) (date)`, followed by `- bullet (hash)` lines + * until the next heading or EOF). + * @param {string} changelog + * @param {string} version + * @returns {string[]} Bullet lines, trimmed of their leading "- " and + * trailing " (hash)". + */ +function extractChangelogBullets(changelog, version) { + const lines = changelog.split('\n') + const headingRe = /^### \[([^\]]+)\]\(.*\)\s*\(\d{4}-\d{2}-\d{2}\)\s*$/ + let start = -1 + for (let i = 0; i < lines.length; i++) { + const m = headingRe.exec(lines[i]) + if (m && m[1] === version) { + start = i + 1 + break + } + } + if (start === -1) { + fail( + `version ${version} has no CHANGELOG entry yet โ€” run this after the CHANGELOG step composes "### [${version}]", not before`, + ) + } + /** @type {string[]} */ + const bullets = [] + for (let i = start; i < lines.length; i++) { + if (headingRe.test(lines[i])) break // next entry starts + const bulletMatch = /^- (.+?)(?:\s\(([0-9a-f]{6,40})\))?$/.exec(lines[i].trim()) + if (lines[i].trim().startsWith('- ') && bulletMatch) { + const text = bulletMatch[1].trim() + if (text) bullets.push(text) + } + } + if (bullets.length === 0) { + fail(`version ${version}'s CHANGELOG entry has no bullets to derive a headline/items from`) + } + return bullets +} + +/** + * Derive a wall entry from a CHANGELOG.md. + * @param {{product: string, version: string, date: string, changelogPath: string, url?: string | null, thumb?: string | null}} opts + * @returns {{version: string, date: string, headline: string, items: string[], url: string | null, thumb: string | null}} + */ +function deriveEntry({ product, version, date, changelogPath, url, thumb }) { + if (!parseSemver(version)) fail(`--version "${version}" is not a semver string`) + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) { + fail(`--date "${date}" is not a YYYY-MM-DD date`) + } + if (!existsSync(changelogPath)) fail(`--from-changelog "${changelogPath}" does not exist`) + + const changelog = readFileSync(changelogPath, 'utf8') + const items = extractChangelogBullets(changelog, version) + const headline = items[0] + + const resolvedUrl = url !== undefined ? url : (RELEASE_URL_PATTERNS[product]?.(version) ?? null) + const resolvedThumb = thumb !== undefined ? thumb : null + + return { version, date, headline, items, url: resolvedUrl, thumb: resolvedThumb } +} + +/** + * Load and shape-validate a wall file. + * @param {string} filePath + * @returns {Record} + */ +function loadWallFile(filePath) { + if (!existsSync(filePath)) fail(`--file "${filePath}" does not exist`) + /** @type {unknown} */ + let data + try { + data = JSON.parse(readFileSync(filePath, 'utf8')) + } catch (err) { + fail(`--file "${filePath}" is not valid JSON: ${/** @type {Error} */ (err).message}`) + } + const errors = validateShape(data) + if (errors.length) { + fail(`--file "${filePath}" fails shape validation before any write โ€”\n ${errors.join('\n ')}`) + } + return /** @type {Record} */ (data) +} + +/** + * Prepend `entry` to the wall file at `filePath`, refusing by name if the + * version is already present, validating before and after, and writing the + * file back with the repo's exact formatting (2-space JSON, trailing newline). + * @param {{version: string, date: string, headline: string, items: string[], url: string | null, thumb: string | null}} entry + * @param {string} filePath + * @param {string | undefined} expectedProduct + */ +function applyEntry(entry, filePath, expectedProduct) { + const wall = loadWallFile(filePath) + + if (expectedProduct && wall.product !== expectedProduct) { + fail( + `--file "${filePath}" has product "${wall.product}", but --product "${expectedProduct}" was given โ€” refusing a cross-product write`, + ) + } + + if (wall.entries.some((e) => e.version === entry.version)) { + fail(`refusing โ€” version ${entry.version} is already present in "${filePath}"`) + } + + wall.entries = [entry, ...wall.entries] + + const postErrors = validateShape(wall) + if (postErrors.length) { + fail(`the entry for ${entry.version} would leave "${filePath}" invalid โ€”\n ${postErrors.join('\n ')}`) + } + + writeFileSync(filePath, JSON.stringify(wall, null, 2) + '\n', 'utf8') + console.log(`wall-entry: wrote v${entry.version} to "${filePath}" (${wall.entries.length} entries, newest first)`) +} + +function main() { + const args = parseArgs(process.argv.slice(2)) + + if (args.check) { + const filePath = /** @type {string | undefined} */ (args.file) ?? + (typeof args.product === 'string' ? `releases/${args.product}.json` : undefined) + if (!filePath) fail('--check needs --file (or --product to default to releases/.json)') + const wall = loadWallFile(/** @type {string} */ (filePath)) + console.log(`wall-entry --check: "${filePath}" OK โ€” product "${wall.product}", ${wall.entries.length} entries, newest-first, no duplicates`) + process.exit(0) + } + + // Generate mode (default): --product, --version, --date, --from-changelog required. + const product = /** @type {string | undefined} */ (args.product) + const version = /** @type {string | undefined} */ (args.version) + const date = /** @type {string | undefined} */ (args.date) + const fromChangelog = /** @type {string | undefined} */ (args['from-changelog']) + + const missing = [] + if (!product) missing.push('--product') + if (!version) missing.push('--version') + if (!date) missing.push('--date') + if (!fromChangelog) missing.push('--from-changelog') + if (missing.length) { + fail( + `missing required flag(s): ${missing.join(', ')}\n` + + 'Usage:\n' + + ' wall-entry.mjs --product

--version --date --from-changelog [--file releases/

.json]\n' + + ' wall-entry.mjs --check --file ', + ) + } + + const urlArg = args.url === true ? undefined : /** @type {string | undefined} */ (args.url) + const thumbArg = args.thumb === true ? undefined : /** @type {string | undefined} */ (args.thumb) + + const entry = deriveEntry({ + product: /** @type {string} */ (product), + version: /** @type {string} */ (version), + date: /** @type {string} */ (date), + changelogPath: /** @type {string} */ (fromChangelog), + url: urlArg, + thumb: thumbArg, + }) + + const filePath = /** @type {string} */ (args.file ?? `releases/${product}.json`) + applyEntry(entry, filePath, /** @type {string} */ (product)) +} + +main() diff --git a/tests/unit/release/wall-entry.test.ts b/tests/unit/release/wall-entry.test.ts new file mode 100644 index 00000000..fc41731c --- /dev/null +++ b/tests/unit/release/wall-entry.test.ts @@ -0,0 +1,216 @@ +/** + * scripts/wall-entry.mjs โ€” the mechanical releases-wall entry. + * + * The script's only real interface is its CLI (it has no importable + * exports by design โ€” one door, no parallel API to drift from it), so + * these tests spawn it exactly as scripts/release.sh does: as a child + * process, against a temp copy of a wall file and a fixture CHANGELOG, + * never against the repo's real releases/*.json. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const SCRIPT = join(process.cwd(), 'scripts/wall-entry.mjs') + +/** Run the script and capture the outcome without throwing on a non-zero exit. */ +function run(args: string[], cwd: string): { status: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync('node', [SCRIPT, ...args], { cwd, encoding: 'utf8' }) + return { status: 0, stdout, stderr: '' } + } catch (err: any) { + return { status: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' } + } +} + +const CHANGELOG_HEADER = '# Changelog\n\nAll notable changes, in this fixture.\n' + +/** Build a CHANGELOG.md with one entry per [version, bullets[]] pair, newest first. */ +function buildChangelog(entries: Array<{ version: string; date: string; bullets: string[] }>): string { + const body = entries + .map( + (e) => + `### [${e.version}](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/vX...v${e.version}) (${e.date})\n\n` + + e.bullets.map((b) => `- ${b} (abc1234)`).join('\n') + + '\n', + ) + .join('\n') + return CHANGELOG_HEADER + '\n' + body +} + +function wallFile(product: string, entries: unknown[]): string { + return JSON.stringify( + { product, entries, history: 'Earlier releases are recorded in CHANGELOG.md in this repository.' }, + null, + 2, + ) + '\n' +} + +const BASE_ENTRY = { + version: '10.4.11', + date: '2026-09-02', + headline: 'A faster open', + items: ['A faster open.'], + url: 'https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.11', + thumb: null, +} + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'wall-entry-test-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +describe('wall-entry.mjs โ€” generate + prepend', () => { + it('derives headline from the first bullet and items from every bullet, hashes stripped', () => { + writeFileSync( + join(dir, 'CHANGELOG.md'), + buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix(wall): mechanize the entry', 'test(wall): pin the shape'] }]), + ) + writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY])) + + const result = run( + ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], + dir, + ) + expect(result.status).toBe(0) + + const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8')) + expect(wall.entries).toHaveLength(2) + expect(wall.entries[0]).toEqual({ + version: '10.4.12', + date: '2026-09-03', + headline: 'fix(wall): mechanize the entry', + items: ['fix(wall): mechanize the entry', 'test(wall): pin the shape'], + url: 'https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.12', + thumb: null, + }) + // the older entry stays put, still second + expect(wall.entries[1].version).toBe('10.4.11') + }) + + it('prepends newest-first โ€” the new entry lands at index 0 ahead of every existing one', () => { + writeFileSync( + join(dir, 'CHANGELOG.md'), + buildChangelog([{ version: '10.5.0', date: '2026-09-03', bullets: ['feat: ten five'] }]), + ) + writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY, { ...BASE_ENTRY, version: '10.4.10' }])) + + run(['--product', 'open-brainy', '--version', '10.5.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], dir) + + const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8')) + expect(wall.entries.map((e: any) => e.version)).toEqual(['10.5.0', '10.4.11', '10.4.10']) + }) + + it('derives no URL (null) for a product with no known public release-page pattern', () => { + writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '11.0.6', date: '2026-09-03', bullets: ['fix: a native-only fix'] }])) + writeFileSync(join(dir, 'wall.json'), wallFile('brainy', [{ ...BASE_ENTRY, version: '11.0.5', url: null }])) + + run(['--product', 'brainy', '--version', '11.0.6', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], dir) + + const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8')) + expect(wall.entries[0].url).toBeNull() + expect(wall.entries[0].thumb).toBeNull() + }) + + it('refuses by name when the version is already present, and leaves the file untouched', () => { + writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: whatever'] }])) + const before = wallFile('open-brainy', [BASE_ENTRY]) + writeFileSync(join(dir, 'wall.json'), before) + + const result = run( + ['--product', 'open-brainy', '--version', '10.4.11', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], + dir, + ) + + expect(result.status).toBe(1) + expect(result.stderr).toMatch(/refusing.*10\.4\.11.*already present/i) + expect(readFileSync(join(dir, 'wall.json'), 'utf8')).toBe(before) // untouched + }) + + it('refuses when the CHANGELOG has no entry yet for the target version', () => { + writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: whatever'] }])) + writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [])) + + const result = run( + ['--product', 'open-brainy', '--version', '99.0.0', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], + dir, + ) + + expect(result.status).toBe(1) + expect(result.stderr).toMatch(/no CHANGELOG entry yet/i) + }) + + it('refuses a cross-product write when --product does not match the target file', () => { + writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '1.0.0', date: '2026-09-03', bullets: ['fix: wrong repo'] }])) + writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY])) + + const result = run( + ['--product', 'brainy', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], + dir, + ) + + expect(result.status).toBe(1) + expect(result.stderr).toMatch(/product "open-brainy".*--product "brainy"/i) + }) +}) + +describe('wall-entry.mjs โ€” --check', () => { + it('passes a well-formed, newest-first file with no duplicates', () => { + writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY, { ...BASE_ENTRY, version: '10.4.10' }])) + const result = run(['--check', '--file', 'wall.json'], dir) + expect(result.status).toBe(0) + expect(result.stdout).toMatch(/OK/) + }) + + it('catches a missing entry key', () => { + const broken = { version: '1.0.0', date: '2026-09-03', headline: 'h', items: ['i'], url: null } // no "thumb" + writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [broken])) + const result = run(['--check', '--file', 'wall.json'], dir) + expect(result.status).toBe(1) + expect(result.stderr).toMatch(/missing key\(s\) thumb/) + }) + + it('catches an unexpected top-level key', () => { + const raw = JSON.parse(wallFile('open-brainy', [BASE_ENTRY])) + raw.extra = 'not allowed' + writeFileSync(join(dir, 'wall.json'), JSON.stringify(raw)) + const result = run(['--check', '--file', 'wall.json'], dir) + expect(result.status).toBe(1) + expect(result.stderr).toMatch(/unexpected key\(s\) extra/) + }) + + it('catches entries that are not newest-first', () => { + writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [{ ...BASE_ENTRY, version: '10.4.10' }, BASE_ENTRY])) + const result = run(['--check', '--file', 'wall.json'], dir) + expect(result.status).toBe(1) + expect(result.stderr).toMatch(/not newest-first/) + }) + + it('catches a duplicate version even with identical entries', () => { + writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY, { ...BASE_ENTRY }])) + const result = run(['--check', '--file', 'wall.json'], dir) + expect(result.status).toBe(1) + expect(result.stderr).toMatch(/duplicate version 10\.4\.11/) + }) + + it('catches an empty items array', () => { + writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [{ ...BASE_ENTRY, items: [] }])) + const result = run(['--check', '--file', 'wall.json'], dir) + expect(result.status).toBe(1) + expect(result.stderr).toMatch(/"items" must be a non-empty array/) + }) + + it('catches a malformed date', () => { + writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [{ ...BASE_ENTRY, date: '09/03/2026' }])) + const result = run(['--check', '--file', 'wall.json'], dir) + expect(result.status).toBe(1) + expect(result.stderr).toMatch(/"date" must be a YYYY-MM-DD string/) + }) +}) From adcb883e67ab82b749d37a510ab66323ae1da64e Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 14:51:33 -0700 Subject: [PATCH 40/42] ci(release): publish the wall entry to the shared releases repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail used to write releases/open-brainy.json (and, before that, also carried the product engine's releases/brainy.json) in this repo. It now clones (or refreshes a cached clone of) soulcraftlabs/releases on The Source, prepends the derived entry to open-brainy.json there (replacing any entry for the same version so a re-run is idempotent), and pushes main directly. Any failure โ€” clone, shape validation, commit, or a rejected push โ€” exits non-zero naming the cure; nothing is ever skipped. Both wall files are gone from this repo โ€” the shared repo is the one home HQ reads. --dry-run derives and prints the entry without touching any clone or remote. Tests point --remote/--cache-dir at a throwaway local bare repo and cache dir, never the real ones. --- releases/brainy.json | 76 -------- releases/open-brainy.json | 136 ------------- scripts/release.sh | 13 +- scripts/wall-entry.mjs | 253 ++++++++++++++++++------ tests/unit/release/wall-entry.test.ts | 271 +++++++++++++++++++++----- 5 files changed, 423 insertions(+), 326 deletions(-) delete mode 100644 releases/brainy.json delete mode 100644 releases/open-brainy.json diff --git a/releases/brainy.json b/releases/brainy.json deleted file mode 100644 index 8f61c7f2..00000000 --- a/releases/brainy.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "product": "brainy", - "entries": [ - { - "version": "11.0.5", - "date": "2026-09-02", - "headline": "Graph-first finds in production, and opens that stop rescanning history", - "items": [ - "find({ connected, where }) now walks the neighbours first and filters only those rows through a native door โ€” correct at every page and O(neighbours), never the whole store.", - "related() with a list of verb types returns every requested kind (a fast path had silently kept only the first).", - "Deferred-embedding recovery resumes from a low-water mark instead of rescanning the whole generation log at every open โ€” measured at two minutes on a large brain, now milliseconds." - ], - "url": null, - "thumb": null - }, - { - "version": "11.0.4", - "date": "2026-09-01", - "headline": "Closes in milliseconds, index rebuilds without the disk-sync storm", - "items": [ - "close() no longer pays deferred compaction or waits out an in-flight rebuild โ€” measured 8 ms against the 4-minute closes it replaces; deferred work resumes at the next open, in the background.", - "The metadata index's rebuild syncs to disk per shard instead of per row, and the durability point moved to the publish step โ€” the same guarantee, a fraction of the disk traffic.", - "A new native filter door evaluates queries over exactly the candidate rows a graph walk found, never the whole store." - ], - "url": null, - "thumb": null - }, - { - "version": "11.0.3", - "date": "2026-09-01", - "headline": "The embedding upgrade ceremony runs on every brain", - "items": [ - "A brain opened through the standard plugin now carries its embedding-model identity, so the full-precision upgrade ceremony can run on it.", - "A one-fix release; nothing else changed." - ], - "url": null, - "thumb": null - }, - { - "version": "11.0.2", - "date": "2026-08-31", - "headline": "One embedding quality everywhere, 3โ€“4ร— faster imports", - "items": [ - "Every runtime embeds with the same full-precision model โ€” search quality no longer depends on where you run.", - "Bulk embedding measured 3.1โ€“4.2ร— faster, and an online re-embed ceremony upgrades existing stores without downtime.", - "The engine's change feed is documented, with the SSE/WebSocket fan-out pattern for realtime surfaces." - ], - "url": null, - "thumb": null - }, - { - "version": "11.0.1", - "date": "2026-08-31", - "headline": "Deletes inside transactions are safe", - "items": [ - "Deleting relations inside a transact() no longer corrupts index bookkeeping.", - "A store that deletes its last relation keeps serving instead of refusing." - ], - "url": null, - "thumb": null - }, - { - "version": "11.0.0", - "date": "2026-08-28", - "headline": "One install, one engine โ€” Brainy", - "items": [ - "The former two-package pair is one package: the native engine under the familiar API. One import is the whole install.", - "A missing native build refuses loudly with its cures named; nothing falls back silently.", - "Stores open in place โ€” no migration." - ], - "url": null, - "thumb": null - } - ], - "history": "The version line continues from the 4.3.x native-engine releases; their record lives in the product repository's CHANGELOG.md." -} diff --git a/releases/open-brainy.json b/releases/open-brainy.json deleted file mode 100644 index 9f1cd239..00000000 --- a/releases/open-brainy.json +++ /dev/null @@ -1,136 +0,0 @@ -{ - "product": "open-brainy", - "entries": [ - { - "version": "10.4.11", - "date": "2026-09-02", - "headline": "Hybrid finds filter before they hydrate, one owner per shutdown, and a faster open", - "items": [ - "Hybrid finds (query/vector combined with a filter, including connected and fusion finds) now filter first and hydrate only the page โ€” one batchGet of exactly the requested rows, instead of hydrating everything the search side found. Fixes a bug where any page after the first came back empty.", - "A brain now has exactly one shutdown owner โ€” a host and its engine no longer race to close the same store, and a follow-up flush requested during a running flush is handed off cleanly instead of ever risking a stall.", - "find({ path }) and other path-scoped VFS searches now serve a real range over the indexed path (O(log n)) instead of refusing the query outright โ€” both scoped and recursive:false searches were silently broken before this.", - "Open no longer rescans a brain's whole fact log on every open โ€” sealed segments the manifest already accounts for are skipped, collapsing a multi-second open term to near-zero on large brains.", - "commitTransaction() now refuses by name if single-ops are still pending, and a read-only open no longer writes clean-shutdown evidence it didn't earn โ€” two correctness invariants that were previously assumed, not enforced." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.11", - "thumb": null - }, - { - "version": "10.4.10", - "date": "2026-09-02", - "headline": "A planner door for indexes, batched containment repair, and a fixed near()", - "items": [ - "An optional planFindPage door lets an index plan a find() and answer it in one call, instead of the engine assembling the plan itself.", - "repairContainment's reconcile pass now walks paged edges once instead of issuing one graph call per file.", - "find({ near }) now searches around the anchor's own vector and refuses by name when none is available, instead of silently querying with no vector at all." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.10", - "thumb": null - }, - { - "version": "10.4.9", - "date": "2026-09-02", - "headline": "Graph-first finds, honest verb arrays, and opens that stop rescanning history", - "items": [ - "find({ connected, where }) now walks the neighbours first and filters only those rows โ€” correct at every page, and O(neighbours) instead of O(store).", - "related() with a list of verb types (or sources, or targets) returns every requested kind โ€” four fast paths silently kept only the first.", - "Deferred-embedding recovery resumes from a low-water mark instead of rescanning the whole generation log at every open โ€” measured at two minutes on a large brain, now milliseconds." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.9", - "thumb": null - }, - { - "version": "10.4.7", - "date": "2026-09-01", - "headline": "Count ledgers can no longer race themselves", - "items": [ - "Concurrent count flushes coalesce into one writer with a trailing pass โ€” parallel flushes can no longer corrupt a store's count ledger.", - "Atomic writes carry a per-process sequence, so two processes' temp files can never collide." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.7", - "thumb": null - }, - { - "version": "10.4.6", - "date": "2026-08-31", - "headline": "Transactions cross the index seam safely", - "items": [ - "Deleting relations inside a transact() no longer fails against the metadata index โ€” operations take a JSON-safe view at the moment they execute.", - "Fixes a class of transaction failures on stores with integer-mapped relation endpoints." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.6", - "thumb": null - }, - { - "version": "10.4.5", - "date": "2026-08-31", - "headline": "Recovery tells the truth, docs live at home", - "items": [ - "A torn generation-log tail is a terminal verdict with a named cure โ€” never an endless wait at open.", - "A sealed segment declares only the generations it actually holds.", - "The engine's documentation now publishes from its own repository." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.5", - "thumb": null - }, - { - "version": "10.4.4", - "date": "2026-08-28", - "headline": "Faster opens, quieter idle", - "items": [ - "Opening a store discovers generations from directory names instead of walking the log, and answers \"any entities?\" with one directory read.", - "The flush-request watch is event-driven; idle stores stop paying a polling heartbeat.", - "A slow open now names the exact step it is in, so operators see what is being paid and why." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.4", - "thumb": null - }, - { - "version": "10.4.3", - "date": "2026-08-27", - "headline": "Open Brainy, under its own name", - "items": [ - "The same engine as 10.4.2, now published as @soulcraftlabs/brainy โ€” the MIT reference engine, on The Source.", - "No code changes; your imports change once and everything else stays put." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.3", - "thumb": null - }, - { - "version": "10.4.2", - "date": "2026-08-27", - "headline": "Vectors that lie are refused, counts that drift are caught", - "items": [ - "A zero-norm vector is not a vector: the index refuses them, rebuilds skip them, and a sanctioned unvector door removes them cleanly.", - "The canonical count ledger derives from identity records and marks legacy-derived ledgers suspect at load.", - "Plugin activation failures keep their original error as cause, so the real frame reaches your logs." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.2", - "thumb": null - }, - { - "version": "10.4.1", - "date": "2026-08-26", - "headline": "Writes that change nothing cost nothing", - "items": [ - "The read gate is per index family, and a write carrying unchanged data never re-embeds.", - "The vectored-row count joins the ledger, so vector coverage is a number you can read, not a guess." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.1", - "thumb": null - }, - { - "version": "10.4.0", - "date": "2026-08-26", - "headline": "Repair routing, the vector ledger, and honest empties", - "items": [ - "Repairs route to the index that owns the damage, and the open gate closes the vector leg until coverage is proven.", - "An empty string is real data, not a missing field.", - "The metadata crossing never carries raw integer relation endpoints โ€” a whole class of serialization faults closed." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.0", - "thumb": null - } - ], - "history": "Earlier releases are recorded in CHANGELOG.md in this repository." -} diff --git a/scripts/release.sh b/scripts/release.sh index 07d225ce..142fa06f 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -176,16 +176,21 @@ fi echo -e "${GREEN}โœ… CHANGELOG updated${NC}\n" # Step 6b: Update the releases wall entry โ€” mechanical, derived from the -# CHANGELOG entry just composed. The fleet's HQ page reads releases/open-brainy.json -# directly; this used to be hand-written after every release (David: never -# again โ€” make it a step of the rail). +# CHANGELOG entry just composed. The fleet's HQ page reads open-brainy.json +# from the one shared releases repo, soulcraftlabs/releases on The Source โ€” +# this used to be hand-written after every release (David: never again โ€” +# make it a step of the rail, landed in the one shared home; this repo no +# longer hosts its own copy). This step clones/fetches that repo into a +# local cache, prepends the entry, and pushes it directly โ€” a real +# cross-repo push, refusing loudly (never skipping) on any +# clone/validation/commit/push failure. echo -e "${BLUE}5๏ธโƒฃโ–ธ Updating the releases wall...${NC}" node scripts/wall-entry.mjs --product open-brainy --version "${NEW_VERSION}" --date "${RELEASE_DATE}" --from-changelog CHANGELOG.md echo -e "${GREEN}โœ… Releases wall updated${NC}\n" # Step 7: Create release commit echo -e "${BLUE}6๏ธโƒฃ Creating release commit...${NC}" -git add package.json package-lock.json CHANGELOG.md releases/open-brainy.json +git add package.json package-lock.json CHANGELOG.md git commit -m "chore(release): ${NEW_VERSION}" echo -e "${GREEN}โœ… Release commit created${NC}\n" diff --git a/scripts/wall-entry.mjs b/scripts/wall-entry.mjs index 998431da..043341eb 100644 --- a/scripts/wall-entry.mjs +++ b/scripts/wall-entry.mjs @@ -2,40 +2,76 @@ /** * @module scripts/wall-entry * @description The releases-wall entry, made mechanical. The fleet's HQ page - * reads one public JSON per product (releases/.json โ€” shape - * {product, entries:[{version, date, headline, items, url, thumb}], history}). - * Those entries were hand-written after every release; this script is the - * one door that composes one, so it never has to be typed by hand again. + * reads one public JSON per product from the ONE releases repo on The Source + * (soulcraftlabs/releases, files .json at its root โ€” shape + * {product, entries:[{version, date, headline, items, url, thumb?}]}), at + * https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/.json. + * Those entries were hand-written after every release, then briefly written + * into this repo's own releases/.json; this script is the one door + * that composes an entry and lands it in the shared repo, so it is never + * hand-written and never forked across repos again. * * Two modes: * - * 1. Generate + write in place (default): + * 1. Generate + publish (default): * node wall-entry.mjs --product

--version --date \ - * --from-changelog [--file releases/

.json] + * --from-changelog * Derives an entry from the CHANGELOG.md entry for (headline = the * entry's first bullet, items = every bullet, trimmed of its trailing - * commit hash), prepends it to --file (default releases/.json, - * newest first), refusing by name if is already present, and - * validates the whole file's shape + ordering before and after writing. - * Both engines run this identically, each against its own repo's - * releases/.json โ€” the wall file always lives beside the - * CHANGELOG it is derived from, never in another repo. + * commit hash), then: + * - clones (or, if a cached clone already exists, fetches and resets) + * the releases repo into a local cache directory, + * - prepends the entry to /

.json, newest first โ€” replacing + * any existing entry for the same version so a re-run is idempotent, + * - validates the file's shape before and after, + * - commits the change as "chore(wall):

" and pushes main. + * A failure at any step (clone, validation, commit, push, a + * non-fast-forward remote) exits non-zero naming the cure. Nothing is + * ever skipped โ€” the wall either lands correctly or the release fails. * - * 2. Validate only (--check): - * node wall-entry.mjs --check --file - * Validates the file's exact key set (top-level and per-entry), field - * types, and strict-descending semver ordering with no duplicates. - * Read-only; never writes. Exit 0 = clean, exit 1 = named violations - * printed to stderr. + * 2. Dry run: + * node wall-entry.mjs --dry-run --product

--version \ + * --date --from-changelog + * Derives the entry exactly as above and prints it, along with the file + * it would be written to, but touches no clone and no remote โ€” usable + * from a fresh checkout with no cache and no network. * - * No dependencies โ€” CHANGELOG parsing, semver comparison, and JSON shape - * checking are all hand-rolled below. + * 3. Validate only (--check): + * node wall-entry.mjs --check --file + * Validates an arbitrary wall file's exact key set (top-level and + * per-entry), field types, and strict-descending semver ordering with + * no duplicates. Read-only; never writes. Exit 0 = clean, exit 1 = + * named violations printed to stderr. + * + * The remote and the local cache directory are each overridable + * (--remote / --cache-dir, or WALL_ENTRY_RELEASES_REMOTE / + * WALL_ENTRY_RELEASES_CACHE_DIR) so tests can point at a throwaway local + * bare repo and a throwaway cache directory โ€” never the real remote or the + * real developer cache. + * + * No dependencies beyond the system `git` binary โ€” CHANGELOG parsing, + * semver comparison, and JSON shape checking are all hand-rolled below. */ -import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' -const ENTRY_KEYS = ['version', 'date', 'headline', 'items', 'url', 'thumb'] -const FILE_KEYS = ['product', 'entries', 'history'] +const DEFAULT_REMOTE = 'git@source.soulcraft.com:soulcraftlabs/releases.git' + +/** @returns {string} */ +function defaultCacheDir() { + const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache') + return join(base, 'soulcraft-releases') +} + +// Required on every entry; "thumb" is optional (may be absent, or present as +// string | null) โ€” matching the HQ contract's {..., thumb?}. +const ENTRY_REQUIRED_KEYS = ['version', 'date', 'headline', 'items', 'url'] +const ENTRY_OPTIONAL_KEYS = ['thumb'] +const ENTRY_ALLOWED_KEYS = [...ENTRY_REQUIRED_KEYS, ...ENTRY_OPTIONAL_KEYS] +const FILE_KEYS = ['product', 'entries'] // The public release-page URL pattern, by product โ€” only products with a // PUBLIC forge repo get a derived link. A product without an entry here @@ -110,10 +146,11 @@ function compareSemver(a, b) { } /** - * Validate a wall file's full shape: top-level keys, per-entry keys and - * field types, and strict-descending semver ordering with no duplicates. - * Collects every violation instead of failing on the first, so --check - * reports the whole picture in one pass. + * Validate a wall file's full shape: top-level keys ("product", "entries" โ€” + * no more, no less), per-entry keys and field types ("thumb" optional), and + * strict-descending semver ordering with no duplicates. Collects every + * violation instead of failing on the first, so a caller reports the whole + * picture in one pass. * @param {unknown} data * @returns {string[]} Violation messages; empty means the file is clean. */ @@ -135,9 +172,6 @@ function validateShape(data) { if (typeof obj.product !== 'string' || obj.product.trim() === '') { errors.push('top level: "product" must be a non-empty string') } - if (typeof obj.history !== 'string' || obj.history.trim() === '') { - errors.push('top level: "history" must be a non-empty string') - } if (!Array.isArray(obj.entries)) { errors.push('top level: "entries" must be an array') return errors // nothing further to check without an array @@ -152,8 +186,8 @@ function validateShape(data) { } const entry = /** @type {Record} */ (rawEntry) const keys = Object.keys(entry) - const missing = ENTRY_KEYS.filter((k) => !(k in entry)) - const extra = keys.filter((k) => !ENTRY_KEYS.includes(k)) + const missing = ENTRY_REQUIRED_KEYS.filter((k) => !(k in entry)) + const extra = keys.filter((k) => !ENTRY_ALLOWED_KEYS.includes(k)) if (missing.length) errors.push(`${label}: missing key(s) ${missing.join(', ')}`) if (extra.length) errors.push(`${label}: unexpected key(s) ${extra.join(', ')}`) @@ -172,8 +206,8 @@ function validateShape(data) { if (!(entry.url === null || typeof entry.url === 'string')) { errors.push(`${label}: "url" must be a string or null`) } - if (!(entry.thumb === null || typeof entry.thumb === 'string')) { - errors.push(`${label}: "thumb" must be a string or null`) + if ('thumb' in entry && !(entry.thumb === null || typeof entry.thumb === 'string')) { + errors.push(`${label}: "thumb" must be a string or null when present`) } }) @@ -266,43 +300,110 @@ function deriveEntry({ product, version, date, changelogPath, url, thumb }) { * @returns {Record} */ function loadWallFile(filePath) { - if (!existsSync(filePath)) fail(`--file "${filePath}" does not exist`) + if (!existsSync(filePath)) fail(`"${filePath}" does not exist`) /** @type {unknown} */ let data try { data = JSON.parse(readFileSync(filePath, 'utf8')) } catch (err) { - fail(`--file "${filePath}" is not valid JSON: ${/** @type {Error} */ (err).message}`) + fail(`"${filePath}" is not valid JSON: ${/** @type {Error} */ (err).message}`) } const errors = validateShape(data) if (errors.length) { - fail(`--file "${filePath}" fails shape validation before any write โ€”\n ${errors.join('\n ')}`) + fail(`"${filePath}" fails shape validation โ€”\n ${errors.join('\n ')}`) } return /** @type {Record} */ (data) } /** - * Prepend `entry` to the wall file at `filePath`, refusing by name if the - * version is already present, validating before and after, and writing the - * file back with the repo's exact formatting (2-space JSON, trailing newline). - * @param {{version: string, date: string, headline: string, items: string[], url: string | null, thumb: string | null}} entry - * @param {string} filePath - * @param {string | undefined} expectedProduct + * Run a git command, throwing an Error whose message is git's own stderr + * (trimmed) on failure โ€” every caller wraps this to name the cure. + * @param {string[]} args + * @param {string} cwd + * @returns {string} stdout, trimmed. */ -function applyEntry(entry, filePath, expectedProduct) { - const wall = loadWallFile(filePath) +function git(args, cwd) { + try { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() + } catch (err) { + const stderr = /** @type {any} */ (err).stderr + const message = (typeof stderr === 'string' && stderr.trim()) || /** @type {Error} */ (err).message + throw new Error(message) + } +} - if (expectedProduct && wall.product !== expectedProduct) { +/** + * Ensure a clean, up-to-date local clone of the releases repo at + * `cacheDir`, checked out on `main` โ€” cloning fresh if `cacheDir` has no + * `.git`, otherwise fetching and hard-resetting onto `origin/main` (so a + * stray local commit or edit left by a previous failed run can never leak + * into the next one). + * @param {string} remote + * @param {string} cacheDir + */ +function ensureReleasesClone(remote, cacheDir) { + if (existsSync(join(cacheDir, '.git'))) { + try { + git(['remote', 'set-url', 'origin', remote], cacheDir) + git(['fetch', '--prune', 'origin'], cacheDir) + git(['checkout', 'main'], cacheDir) + git(['reset', '--hard', 'origin/main'], cacheDir) + git(['clean', '-fd'], cacheDir) + } catch (err) { + fail( + `cannot refresh the cached releases checkout at "${cacheDir}" from "${remote}" โ€” ${/** @type {Error} */ (err).message}\n` + + ` cure: delete "${cacheDir}" and re-run so it re-clones from scratch, or confirm SSH access with "ssh -T git@source.soulcraft.com"`, + ) + } + return + } + + mkdirSync(dirname(cacheDir), { recursive: true }) + try { + git(['clone', remote, cacheDir], dirname(cacheDir)) + } catch (err) { fail( - `--file "${filePath}" has product "${wall.product}", but --product "${expectedProduct}" was given โ€” refusing a cross-product write`, + `cannot clone "${remote}" โ€” ${/** @type {Error} */ (err).message}\n` + + ` cure: confirm SSH access with "ssh -T git@source.soulcraft.com" and that the soulcraftlabs/releases repo exists yet`, ) } + try { + git(['checkout', 'main'], cacheDir) + } catch (err) { + fail( + `cloned "${remote}" into "${cacheDir}" but could not check out "main" โ€” ${/** @type {Error} */ (err).message}\n` + + ` cure: confirm the releases repo's default branch is named "main"`, + ) + } +} - if (wall.entries.some((e) => e.version === entry.version)) { - fail(`refusing โ€” version ${entry.version} is already present in "${filePath}"`) +/** + * Prepend `entry` to the wall at `/.json`, replacing any + * existing entry for the same version (idempotent re-runs), validating + * before and after, committing, and pushing โ€” or refusing loudly, naming + * the cure, at whichever step fails. + * @param {{version: string, date: string, headline: string, items: string[], url: string | null, thumb: string | null}} entry + * @param {string} product + * @param {string} remote + * @param {string} cacheDir + */ +function publishEntry(entry, product, remote, cacheDir) { + ensureReleasesClone(remote, cacheDir) + + const filePath = join(cacheDir, `${product}.json`) + if (!existsSync(filePath)) { + fail( + `"${filePath}" does not exist in the releases repo โ€” cure: seed "${product}.json" at the repo root first (it must exist before any release rail can prepend to it)`, + ) + } + const wall = loadWallFile(filePath) + + if (wall.product !== product) { + fail(`"${filePath}" has product "${wall.product}", but --product "${product}" was given โ€” refusing a cross-product write`) } - wall.entries = [entry, ...wall.entries] + const replacing = wall.entries.some((e) => e.version === entry.version) + wall.entries = [entry, ...wall.entries.filter((e) => e.version !== entry.version)] const postErrors = validateShape(wall) if (postErrors.length) { @@ -310,22 +411,48 @@ function applyEntry(entry, filePath, expectedProduct) { } writeFileSync(filePath, JSON.stringify(wall, null, 2) + '\n', 'utf8') - console.log(`wall-entry: wrote v${entry.version} to "${filePath}" (${wall.entries.length} entries, newest first)`) + + const status = git(['status', '--porcelain', '--', `${product}.json`], cacheDir) + if (status === '') { + console.log(`wall-entry: "${product}.json" already carries an identical entry for ${entry.version} โ€” nothing to commit or push`) + return + } + + try { + git(['add', `${product}.json`], cacheDir) + git(['commit', '-m', `chore(wall): ${product} ${entry.version}`], cacheDir) + } catch (err) { + fail(`cannot commit the wall entry in "${cacheDir}" โ€” ${/** @type {Error} */ (err).message}\n cure: inspect "${cacheDir}" by hand and re-run once its git state is clean`) + } + + try { + git(['push', 'origin', 'main'], cacheDir) + } catch (err) { + fail( + `push to "${remote}" failed (likely a non-fast-forward โ€” another release landed on main first) โ€” ${/** @type {Error} */ (err).message}\n` + + ` cure: re-run this release step; it re-fetches and resets onto the latest origin/main before retrying`, + ) + } + + const sha = git(['rev-parse', 'HEAD'], cacheDir) + console.log( + `wall-entry: ${replacing ? 'replaced' : 'wrote'} v${entry.version} in "${product}.json" (${wall.entries.length} entries, newest first) โ€” pushed ${sha} to ${remote} main`, + ) } function main() { const args = parseArgs(process.argv.slice(2)) if (args.check) { - const filePath = /** @type {string | undefined} */ (args.file) ?? - (typeof args.product === 'string' ? `releases/${args.product}.json` : undefined) - if (!filePath) fail('--check needs --file (or --product to default to releases/.json)') + const filePath = /** @type {string | undefined} */ (args.file) + if (!filePath) fail('--check needs --file ') const wall = loadWallFile(/** @type {string} */ (filePath)) console.log(`wall-entry --check: "${filePath}" OK โ€” product "${wall.product}", ${wall.entries.length} entries, newest-first, no duplicates`) process.exit(0) } - // Generate mode (default): --product, --version, --date, --from-changelog required. + // Generate mode (default, also covers --dry-run): --product, --version, + // --date, --from-changelog required. const product = /** @type {string | undefined} */ (args.product) const version = /** @type {string | undefined} */ (args.version) const date = /** @type {string | undefined} */ (args.date) @@ -340,8 +467,8 @@ function main() { fail( `missing required flag(s): ${missing.join(', ')}\n` + 'Usage:\n' + - ' wall-entry.mjs --product

--version --date --from-changelog [--file releases/

.json]\n' + - ' wall-entry.mjs --check --file ', + ' wall-entry.mjs --product

--version --date --from-changelog [--dry-run]\n' + + ' wall-entry.mjs --check --file ', ) } @@ -357,8 +484,16 @@ function main() { thumb: thumbArg, }) - const filePath = /** @type {string} */ (args.file ?? `releases/${product}.json`) - applyEntry(entry, filePath, /** @type {string} */ (product)) + const remote = /** @type {string} */ (args.remote ?? process.env.WALL_ENTRY_RELEASES_REMOTE ?? DEFAULT_REMOTE) + const cacheDir = /** @type {string} */ (args['cache-dir'] ?? process.env.WALL_ENTRY_RELEASES_CACHE_DIR ?? defaultCacheDir()) + + if (args['dry-run']) { + console.log(`wall-entry --dry-run: would write to "${join(cacheDir, `${product}.json`)}" in ${remote} (main), pushed as "chore(wall): ${product} ${version}"`) + console.log(JSON.stringify(entry, null, 2)) + process.exit(0) + } + + publishEntry(entry, /** @type {string} */ (product), remote, cacheDir) } main() diff --git a/tests/unit/release/wall-entry.test.ts b/tests/unit/release/wall-entry.test.ts index fc41731c..7f96da25 100644 --- a/tests/unit/release/wall-entry.test.ts +++ b/tests/unit/release/wall-entry.test.ts @@ -4,12 +4,15 @@ * The script's only real interface is its CLI (it has no importable * exports by design โ€” one door, no parallel API to drift from it), so * these tests spawn it exactly as scripts/release.sh does: as a child - * process, against a temp copy of a wall file and a fixture CHANGELOG, - * never against the repo's real releases/*.json. + * process, against a fixture CHANGELOG and a throwaway local bare repo + * standing in for git@source.soulcraft.com:soulcraftlabs/releases.git + * (--remote) plus a throwaway cache directory (--cache-dir) standing in + * for ~/.cache/soulcraft-releases โ€” never the real remote, never the + * real developer cache. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { execFileSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync, readFileSync, chmodSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -25,6 +28,10 @@ function run(args: string[], cwd: string): { status: number; stdout: string; std } } +function git(args: string[], cwd: string): string { + return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim() +} + const CHANGELOG_HEADER = '# Changelog\n\nAll notable changes, in this fixture.\n' /** Build a CHANGELOG.md with one entry per [version, bullets[]] pair, newest first. */ @@ -41,11 +48,7 @@ function buildChangelog(entries: Array<{ version: string; date: string; bullets: } function wallFile(product: string, entries: unknown[]): string { - return JSON.stringify( - { product, entries, history: 'Earlier releases are recorded in CHANGELOG.md in this repository.' }, - null, - 2, - ) + '\n' + return JSON.stringify({ product, entries }, null, 2) + '\n' } const BASE_ENTRY = { @@ -57,31 +60,76 @@ const BASE_ENTRY = { thumb: null, } +/** A throwaway bare repo standing in for the real soulcraftlabs/releases remote. */ +function initBareRemote(): string { + const remoteDir = mkdtempSync(join(tmpdir(), 'wall-remote-')) + execFileSync('git', ['init', '--bare', '-b', 'main', remoteDir]) + return remoteDir +} + +/** Seed the bare remote with an initial .json, via a throwaway clone. */ +function seedRemote(remoteDir: string, product: string, entries: unknown[]): void { + const seedDir = mkdtempSync(join(tmpdir(), 'wall-seed-')) + execFileSync('git', ['clone', remoteDir, seedDir], { stdio: 'ignore' }) + git(['config', 'user.email', 'seed@example.com'], seedDir) + git(['config', 'user.name', 'Seed'], seedDir) + writeFileSync(join(seedDir, `${product}.json`), wallFile(product, entries)) + git(['add', `${product}.json`], seedDir) + git(['commit', '-m', 'seed'], seedDir) + git(['push', 'origin', 'main'], seedDir) + rmSync(seedDir, { recursive: true, force: true }) +} + +/** Read .json back out of the bare remote's main tip, via a throwaway clone. */ +function readRemote(remoteDir: string, product: string): any { + const readDir = mkdtempSync(join(tmpdir(), 'wall-read-')) + execFileSync('git', ['clone', remoteDir, readDir], { stdio: 'ignore' }) + const data = JSON.parse(readFileSync(join(readDir, `${product}.json`), 'utf8')) + rmSync(readDir, { recursive: true, force: true }) + return data +} + +/** Reject every push โ€” stands in for any push failure (including a genuine + * non-fast-forward raced by a concurrent release rail), which this script + * treats identically: refuse loudly, name the cure, touch nothing further. */ +function makeRemoteRejectPushes(remoteDir: string): void { + const hookPath = join(remoteDir, 'hooks', 'pre-receive') + writeFileSync(hookPath, '#!/bin/sh\necho "remote: simulated push rejection" >&2\nexit 1\n') + chmodSync(hookPath, 0o755) +} + let dir: string +let remoteDir: string +let cacheDir: string beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'wall-entry-test-')) + remoteDir = initBareRemote() + cacheDir = join(mkdtempSync(join(tmpdir(), 'wall-cache-')), 'soulcraft-releases') }) afterEach(() => { rmSync(dir, { recursive: true, force: true }) + rmSync(remoteDir, { recursive: true, force: true }) + rmSync(cacheDir, { recursive: true, force: true }) }) -describe('wall-entry.mjs โ€” generate + prepend', () => { - it('derives headline from the first bullet and items from every bullet, hashes stripped', () => { +describe('wall-entry.mjs โ€” generate + publish', () => { + it('derives headline from the first bullet and items from every bullet, hashes stripped, and pushes it to the remote', () => { + seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) writeFileSync( join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix(wall): mechanize the entry', 'test(wall): pin the shape'] }]), ) - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY])) const result = run( - ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], + ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir, ) expect(result.status).toBe(0) + expect(result.stdout).toMatch(/wrote v10\.4\.12.*pushed/i) - const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8')) + const wall = readRemote(remoteDir, 'open-brainy') expect(wall.entries).toHaveLength(2) expect(wall.entries[0]).toEqual({ version: '10.4.12', @@ -96,68 +144,182 @@ describe('wall-entry.mjs โ€” generate + prepend', () => { }) it('prepends newest-first โ€” the new entry lands at index 0 ahead of every existing one', () => { - writeFileSync( - join(dir, 'CHANGELOG.md'), - buildChangelog([{ version: '10.5.0', date: '2026-09-03', bullets: ['feat: ten five'] }]), - ) - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY, { ...BASE_ENTRY, version: '10.4.10' }])) + seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY, { ...BASE_ENTRY, version: '10.4.10' }]) + writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.5.0', date: '2026-09-03', bullets: ['feat: ten five'] }])) - run(['--product', 'open-brainy', '--version', '10.5.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], dir) + run(['--product', 'open-brainy', '--version', '10.5.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir) - const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8')) + const wall = readRemote(remoteDir, 'open-brainy') expect(wall.entries.map((e: any) => e.version)).toEqual(['10.5.0', '10.4.11', '10.4.10']) }) + it('replaces an entry with the same version instead of duplicating it โ€” idempotent re-runs', () => { + seedRemote(remoteDir, 'open-brainy', [ + { ...BASE_ENTRY, headline: 'stale headline, pre-fix' }, + { ...BASE_ENTRY, version: '10.4.10' }, + ]) + writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: the corrected headline'] }])) + + const result = run( + ['--product', 'open-brainy', '--version', '10.4.11', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], + dir, + ) + expect(result.status).toBe(0) + expect(result.stdout).toMatch(/replaced v10\.4\.11/i) + + const wall = readRemote(remoteDir, 'open-brainy') + expect(wall.entries).toHaveLength(2) // not 3 โ€” replaced, not duplicated + expect(wall.entries[0].version).toBe('10.4.11') + expect(wall.entries[0].headline).toBe('fix: the corrected headline') + expect(wall.entries[1].version).toBe('10.4.10') + }) + + it('a re-run with byte-identical content commits nothing and still succeeds', () => { + // headline always equals items[0] for a derived entry, so this fixture + // (unlike BASE_ENTRY, whose headline/items intentionally diverge for the + // shape-only tests below) has to keep the two in lockstep to ever roundtrip. + const stableEntry = { ...BASE_ENTRY, headline: 'A faster open.', items: ['A faster open.'] } + seedRemote(remoteDir, 'open-brainy', [stableEntry]) + writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['A faster open.'] }])) + const before = readRemote(remoteDir, 'open-brainy') + + const result = run( + ['--product', 'open-brainy', '--version', '10.4.11', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], + dir, + ) + expect(result.status).toBe(0) + expect(result.stdout).toMatch(/nothing to commit/i) + expect(readRemote(remoteDir, 'open-brainy')).toEqual(before) + }) + it('derives no URL (null) for a product with no known public release-page pattern', () => { + seedRemote(remoteDir, 'brainy', [{ ...BASE_ENTRY, version: '11.0.5', url: null }]) writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '11.0.6', date: '2026-09-03', bullets: ['fix: a native-only fix'] }])) - writeFileSync(join(dir, 'wall.json'), wallFile('brainy', [{ ...BASE_ENTRY, version: '11.0.5', url: null }])) - run(['--product', 'brainy', '--version', '11.0.6', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], dir) + const result = run( + ['--product', 'brainy', '--version', '11.0.6', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], + dir, + ) + expect(result.status).toBe(0) - const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8')) + const wall = readRemote(remoteDir, 'brainy') expect(wall.entries[0].url).toBeNull() expect(wall.entries[0].thumb).toBeNull() }) - it('refuses by name when the version is already present, and leaves the file untouched', () => { + it('refuses when the CHANGELOG has no entry yet for the target version, and touches no remote', () => { + seedRemote(remoteDir, 'open-brainy', []) writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: whatever'] }])) - const before = wallFile('open-brainy', [BASE_ENTRY]) - writeFileSync(join(dir, 'wall.json'), before) + const beforeSha = git(['rev-parse', 'main'], remoteDir) const result = run( - ['--product', 'open-brainy', '--version', '10.4.11', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], - dir, - ) - - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/refusing.*10\.4\.11.*already present/i) - expect(readFileSync(join(dir, 'wall.json'), 'utf8')).toBe(before) // untouched - }) - - it('refuses when the CHANGELOG has no entry yet for the target version', () => { - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: whatever'] }])) - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [])) - - const result = run( - ['--product', 'open-brainy', '--version', '99.0.0', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], + ['--product', 'open-brainy', '--version', '99.0.0', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir, ) expect(result.status).toBe(1) expect(result.stderr).toMatch(/no CHANGELOG entry yet/i) + expect(git(['rev-parse', 'main'], remoteDir)).toBe(beforeSha) }) - it('refuses a cross-product write when --product does not match the target file', () => { - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '1.0.0', date: '2026-09-03', bullets: ['fix: wrong repo'] }])) - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY])) + it('refuses by naming the cure when the remote cannot be cloned', () => { + writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: whatever'] }])) + const noSuchRemote = join(tmpdir(), 'wall-remote-does-not-exist-' + Date.now()) const result = run( - ['--product', 'brainy', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], + ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', noSuchRemote, '--cache-dir', cacheDir], dir, ) expect(result.status).toBe(1) - expect(result.stderr).toMatch(/product "open-brainy".*--product "brainy"/i) + expect(result.stderr).toMatch(/cannot clone/i) + expect(result.stderr).toMatch(/cure:/i) + }) + + it('refuses by naming the cure, and touches no remote, when the fetched wall fails shape validation', () => { + const seedDir = mkdtempSync(join(tmpdir(), 'wall-seed-broken-')) + execFileSync('git', ['clone', remoteDir, seedDir], { stdio: 'ignore' }) + git(['config', 'user.email', 'seed@example.com'], seedDir) + git(['config', 'user.name', 'Seed'], seedDir) + writeFileSync( + join(seedDir, 'open-brainy.json'), + JSON.stringify({ product: 'open-brainy', entries: [{ version: '10.4.11', date: '2026-09-02', items: ['x'], url: null }] }, null, 2), + ) + git(['add', 'open-brainy.json'], seedDir) + git(['commit', '-m', 'seed broken'], seedDir) + git(['push', 'origin', 'main'], seedDir) + rmSync(seedDir, { recursive: true, force: true }) + const beforeSha = git(['rev-parse', 'main'], remoteDir) + + writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: whatever'] }])) + + const result = run( + ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], + dir, + ) + + expect(result.status).toBe(1) + expect(result.stderr).toMatch(/fails shape validation/i) + expect(result.stderr).toMatch(/missing key\(s\) headline/i) + expect(git(['rev-parse', 'main'], remoteDir)).toBe(beforeSha) + }) + + it('refuses by naming the cure when the remote rejects the push (stands in for a raced non-fast-forward)', () => { + seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) + makeRemoteRejectPushes(remoteDir) + writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: whatever'] }])) + + const result = run( + ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], + dir, + ) + + expect(result.status).toBe(1) + expect(result.stderr).toMatch(/push to .* failed/i) + expect(result.stderr).toMatch(/cure:/i) + }) + + it('refuses a cross-product write when the file\'s "product" field does not match --product', () => { + seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) + const seedDir = mkdtempSync(join(tmpdir(), 'wall-seed-mismatch-')) + execFileSync('git', ['clone', remoteDir, seedDir], { stdio: 'ignore' }) + git(['config', 'user.email', 'seed@example.com'], seedDir) + git(['config', 'user.name', 'Seed'], seedDir) + const corrupted = JSON.parse(readFileSync(join(seedDir, 'open-brainy.json'), 'utf8')) + corrupted.product = 'brainy' + writeFileSync(join(seedDir, 'open-brainy.json'), JSON.stringify(corrupted, null, 2) + '\n') + git(['add', 'open-brainy.json'], seedDir) + git(['commit', '-m', 'corrupt product field'], seedDir) + git(['push', 'origin', 'main'], seedDir) + rmSync(seedDir, { recursive: true, force: true }) + + writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '1.0.0', date: '2026-09-03', bullets: ['fix: wrong repo'] }])) + + const result = run( + ['--product', 'open-brainy', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], + dir, + ) + + expect(result.status).toBe(1) + expect(result.stderr).toMatch(/product "brainy".*--product "open-brainy"/i) + }) +}) + +describe('wall-entry.mjs โ€” --dry-run', () => { + it('prints the entry and the target path, and touches neither the cache dir nor the remote', () => { + seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) + writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: a dry run'] }])) + const beforeSha = git(['rev-parse', 'main'], remoteDir) + + const result = run( + ['--dry-run', '--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], + dir, + ) + + expect(result.status).toBe(0) + expect(result.stdout).toMatch(/would write to/i) + expect(result.stdout).toMatch(/"version": "10\.4\.12"/) + expect(git(['rev-parse', 'main'], remoteDir)).toBe(beforeSha) }) }) @@ -169,21 +331,28 @@ describe('wall-entry.mjs โ€” --check', () => { expect(result.stdout).toMatch(/OK/) }) + it('passes a file where "thumb" is entirely absent (optional per the HQ contract)', () => { + const { thumb, ...noThumb } = BASE_ENTRY as any + writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [noThumb])) + const result = run(['--check', '--file', 'wall.json'], dir) + expect(result.status).toBe(0) + }) + it('catches a missing entry key', () => { - const broken = { version: '1.0.0', date: '2026-09-03', headline: 'h', items: ['i'], url: null } // no "thumb" + const broken = { version: '1.0.0', date: '2026-09-03', headline: 'h', items: ['i'] } // no "url" writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [broken])) const result = run(['--check', '--file', 'wall.json'], dir) expect(result.status).toBe(1) - expect(result.stderr).toMatch(/missing key\(s\) thumb/) + expect(result.stderr).toMatch(/missing key\(s\) url/) }) - it('catches an unexpected top-level key', () => { + it('catches an unexpected top-level key (e.g. the retired "history" field)', () => { const raw = JSON.parse(wallFile('open-brainy', [BASE_ENTRY])) - raw.extra = 'not allowed' + raw.history = 'retired field' writeFileSync(join(dir, 'wall.json'), JSON.stringify(raw)) const result = run(['--check', '--file', 'wall.json'], dir) expect(result.status).toBe(1) - expect(result.stderr).toMatch(/unexpected key\(s\) extra/) + expect(result.stderr).toMatch(/unexpected key\(s\) history/) }) it('catches entries that are not newest-first', () => { From aa457d715937142607245f93437f987ba00248f0 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 14:52:05 -0700 Subject: [PATCH 41/42] chore(releases): both walls leave the reference repo, RELEASES.md points home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit releases/open-brainy.json follows brainy.json out โ€” the shared repo (soulcraftlabs/releases on The Source) is now the one home for both products' release notes; this repo hosts neither. The releases/ directory is gone. RELEASES.md gains a pointer, under the heading, to the two raw URLs HQ's /hq/releases door reads (this file stays as the human-readable quick reference; those files are the source of truth). --- RELEASES.md | 7 ++ releases/open-brainy.json | 136 -------------------------------------- 2 files changed, 7 insertions(+), 136 deletions(-) delete mode 100644 releases/open-brainy.json diff --git a/RELEASES.md b/RELEASES.md index e8833b80..c875cb26 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,5 +1,12 @@ # @soulcraft/brainy โ€” Release Notes for Consumers +Machine-readable release notes are published at +https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/open-brainy.json +(this engine) and +https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/brainy.json +(the product engine) โ€” read by HQ's `/hq/releases` door, and the source of +truth ahead of this file. + This file is the **quick reference for downstream sessions** tracking Brainy changes. Full auto-generated changelog: `CHANGELOG.md` ยท Releases: https://source.soulcraft.com/soulcraftlabs/open-brainy/releases diff --git a/releases/open-brainy.json b/releases/open-brainy.json deleted file mode 100644 index 9f1cd239..00000000 --- a/releases/open-brainy.json +++ /dev/null @@ -1,136 +0,0 @@ -{ - "product": "open-brainy", - "entries": [ - { - "version": "10.4.11", - "date": "2026-09-02", - "headline": "Hybrid finds filter before they hydrate, one owner per shutdown, and a faster open", - "items": [ - "Hybrid finds (query/vector combined with a filter, including connected and fusion finds) now filter first and hydrate only the page โ€” one batchGet of exactly the requested rows, instead of hydrating everything the search side found. Fixes a bug where any page after the first came back empty.", - "A brain now has exactly one shutdown owner โ€” a host and its engine no longer race to close the same store, and a follow-up flush requested during a running flush is handed off cleanly instead of ever risking a stall.", - "find({ path }) and other path-scoped VFS searches now serve a real range over the indexed path (O(log n)) instead of refusing the query outright โ€” both scoped and recursive:false searches were silently broken before this.", - "Open no longer rescans a brain's whole fact log on every open โ€” sealed segments the manifest already accounts for are skipped, collapsing a multi-second open term to near-zero on large brains.", - "commitTransaction() now refuses by name if single-ops are still pending, and a read-only open no longer writes clean-shutdown evidence it didn't earn โ€” two correctness invariants that were previously assumed, not enforced." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.11", - "thumb": null - }, - { - "version": "10.4.10", - "date": "2026-09-02", - "headline": "A planner door for indexes, batched containment repair, and a fixed near()", - "items": [ - "An optional planFindPage door lets an index plan a find() and answer it in one call, instead of the engine assembling the plan itself.", - "repairContainment's reconcile pass now walks paged edges once instead of issuing one graph call per file.", - "find({ near }) now searches around the anchor's own vector and refuses by name when none is available, instead of silently querying with no vector at all." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.10", - "thumb": null - }, - { - "version": "10.4.9", - "date": "2026-09-02", - "headline": "Graph-first finds, honest verb arrays, and opens that stop rescanning history", - "items": [ - "find({ connected, where }) now walks the neighbours first and filters only those rows โ€” correct at every page, and O(neighbours) instead of O(store).", - "related() with a list of verb types (or sources, or targets) returns every requested kind โ€” four fast paths silently kept only the first.", - "Deferred-embedding recovery resumes from a low-water mark instead of rescanning the whole generation log at every open โ€” measured at two minutes on a large brain, now milliseconds." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.9", - "thumb": null - }, - { - "version": "10.4.7", - "date": "2026-09-01", - "headline": "Count ledgers can no longer race themselves", - "items": [ - "Concurrent count flushes coalesce into one writer with a trailing pass โ€” parallel flushes can no longer corrupt a store's count ledger.", - "Atomic writes carry a per-process sequence, so two processes' temp files can never collide." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.7", - "thumb": null - }, - { - "version": "10.4.6", - "date": "2026-08-31", - "headline": "Transactions cross the index seam safely", - "items": [ - "Deleting relations inside a transact() no longer fails against the metadata index โ€” operations take a JSON-safe view at the moment they execute.", - "Fixes a class of transaction failures on stores with integer-mapped relation endpoints." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.6", - "thumb": null - }, - { - "version": "10.4.5", - "date": "2026-08-31", - "headline": "Recovery tells the truth, docs live at home", - "items": [ - "A torn generation-log tail is a terminal verdict with a named cure โ€” never an endless wait at open.", - "A sealed segment declares only the generations it actually holds.", - "The engine's documentation now publishes from its own repository." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.5", - "thumb": null - }, - { - "version": "10.4.4", - "date": "2026-08-28", - "headline": "Faster opens, quieter idle", - "items": [ - "Opening a store discovers generations from directory names instead of walking the log, and answers \"any entities?\" with one directory read.", - "The flush-request watch is event-driven; idle stores stop paying a polling heartbeat.", - "A slow open now names the exact step it is in, so operators see what is being paid and why." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.4", - "thumb": null - }, - { - "version": "10.4.3", - "date": "2026-08-27", - "headline": "Open Brainy, under its own name", - "items": [ - "The same engine as 10.4.2, now published as @soulcraftlabs/brainy โ€” the MIT reference engine, on The Source.", - "No code changes; your imports change once and everything else stays put." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.3", - "thumb": null - }, - { - "version": "10.4.2", - "date": "2026-08-27", - "headline": "Vectors that lie are refused, counts that drift are caught", - "items": [ - "A zero-norm vector is not a vector: the index refuses them, rebuilds skip them, and a sanctioned unvector door removes them cleanly.", - "The canonical count ledger derives from identity records and marks legacy-derived ledgers suspect at load.", - "Plugin activation failures keep their original error as cause, so the real frame reaches your logs." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.2", - "thumb": null - }, - { - "version": "10.4.1", - "date": "2026-08-26", - "headline": "Writes that change nothing cost nothing", - "items": [ - "The read gate is per index family, and a write carrying unchanged data never re-embeds.", - "The vectored-row count joins the ledger, so vector coverage is a number you can read, not a guess." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.1", - "thumb": null - }, - { - "version": "10.4.0", - "date": "2026-08-26", - "headline": "Repair routing, the vector ledger, and honest empties", - "items": [ - "Repairs route to the index that owns the damage, and the open gate closes the vector leg until coverage is proven.", - "An empty string is real data, not a missing field.", - "The metadata crossing never carries raw integer relation endpoints โ€” a whole class of serialization faults closed." - ], - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.0", - "thumb": null - } - ], - "history": "Earlier releases are recorded in CHANGELOG.md in this repository." -} From 97b5ea2d5ddb739f1d1d0ff4e31664b5ef551df4 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 14:59:49 -0700 Subject: [PATCH 42/42] =?UTF-8?q?fix(wall):=20every=20entry=20carries=20an?= =?UTF-8?q?=20https=20permalink=20=E2=80=94=20the=20product=20engine=20lin?= =?UTF-8?q?ks=20its=20public=20package=20page;=20null=20refused,=20an=20un?= =?UTF-8?q?known=20product=20refuses=20by=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/wall-entry.mjs | 27 ++++++++++++++++----------- tests/unit/release/wall-entry.test.ts | 16 +++++++++++++--- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/scripts/wall-entry.mjs b/scripts/wall-entry.mjs index 043341eb..d4ec7ba5 100644 --- a/scripts/wall-entry.mjs +++ b/scripts/wall-entry.mjs @@ -73,13 +73,14 @@ const ENTRY_OPTIONAL_KEYS = ['thumb'] const ENTRY_ALLOWED_KEYS = [...ENTRY_REQUIRED_KEYS, ...ENTRY_OPTIONAL_KEYS] const FILE_KEYS = ['product', 'entries'] -// The public release-page URL pattern, by product โ€” only products with a -// PUBLIC forge repo get a derived link. A product without an entry here -// (e.g. "brainy", whose repo is private) gets url: null, matching every -// entry the fleet has shipped for it so far โ€” a private link would 404 for -// anyone reading the public HQ page. +// The public permalink pattern, by product. Every entry MUST carry an https +// permalink: HQ's parser rejects a wall whose entries carry url: null (the +// whole feed became unreadable on 2026-09-02). A product whose forge repo is +// private links its PUBLIC package page on The Source instead of a release +// page that would 404 for HQ's readers. const RELEASE_URL_PATTERNS = { 'open-brainy': (version) => `https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v${version}`, + 'brainy': (version) => `https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/${version}`, } /** @@ -203,8 +204,8 @@ function validateShape(data) { if (!Array.isArray(entry.items) || entry.items.length === 0 || entry.items.some((it) => typeof it !== 'string' || it.trim() === '')) { errors.push(`${label}: "items" must be a non-empty array of non-empty strings`) } - if (!(entry.url === null || typeof entry.url === 'string')) { - errors.push(`${label}: "url" must be a string or null`) + if (typeof entry.url !== 'string' || !/^https:\/\/\S+$/.test(entry.url)) { + errors.push(`${label}: "url" must be an https permalink โ€” never null; HQ's parser rejects the whole feed`) } if ('thumb' in entry && !(entry.thumb === null || typeof entry.thumb === 'string')) { errors.push(`${label}: "thumb" must be a string or null when present`) @@ -274,8 +275,8 @@ function extractChangelogBullets(changelog, version) { /** * Derive a wall entry from a CHANGELOG.md. - * @param {{product: string, version: string, date: string, changelogPath: string, url?: string | null, thumb?: string | null}} opts - * @returns {{version: string, date: string, headline: string, items: string[], url: string | null, thumb: string | null}} + * @param {{product: string, version: string, date: string, changelogPath: string, url?: string, thumb?: string | null}} opts + * @returns {{version: string, date: string, headline: string, items: string[], url: string, thumb: string | null}} */ function deriveEntry({ product, version, date, changelogPath, url, thumb }) { if (!parseSemver(version)) fail(`--version "${version}" is not a semver string`) @@ -288,7 +289,11 @@ function deriveEntry({ product, version, date, changelogPath, url, thumb }) { const items = extractChangelogBullets(changelog, version) const headline = items[0] - const resolvedUrl = url !== undefined ? url : (RELEASE_URL_PATTERNS[product]?.(version) ?? null) + const pattern = RELEASE_URL_PATTERNS[product] + if (url === undefined && pattern === undefined) { + throw new Error(`wall-entry: no permalink pattern for product "${product}" โ€” add one to RELEASE_URL_PATTERNS or pass --url; entries never carry url: null`) + } + const resolvedUrl = url !== undefined ? url : pattern(version) const resolvedThumb = thumb !== undefined ? thumb : null return { version, date, headline, items, url: resolvedUrl, thumb: resolvedThumb } @@ -382,7 +387,7 @@ function ensureReleasesClone(remote, cacheDir) { * existing entry for the same version (idempotent re-runs), validating * before and after, committing, and pushing โ€” or refusing loudly, naming * the cure, at whichever step fails. - * @param {{version: string, date: string, headline: string, items: string[], url: string | null, thumb: string | null}} entry + * @param {{version: string, date: string, headline: string, items: string[], url: string, thumb: string | null}} entry * @param {string} product * @param {string} remote * @param {string} cacheDir diff --git a/tests/unit/release/wall-entry.test.ts b/tests/unit/release/wall-entry.test.ts index 7f96da25..8bf9d357 100644 --- a/tests/unit/release/wall-entry.test.ts +++ b/tests/unit/release/wall-entry.test.ts @@ -192,8 +192,8 @@ describe('wall-entry.mjs โ€” generate + publish', () => { expect(readRemote(remoteDir, 'open-brainy')).toEqual(before) }) - it('derives no URL (null) for a product with no known public release-page pattern', () => { - seedRemote(remoteDir, 'brainy', [{ ...BASE_ENTRY, version: '11.0.5', url: null }]) + it('derives the public package-page permalink for the product engine (private repo, never null)', () => { + seedRemote(remoteDir, 'brainy', [{ ...BASE_ENTRY, version: '11.0.5', url: 'https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/11.0.5' }]) writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '11.0.6', date: '2026-09-03', bullets: ['fix: a native-only fix'] }])) const result = run( @@ -203,10 +203,20 @@ describe('wall-entry.mjs โ€” generate + publish', () => { expect(result.status).toBe(0) const wall = readRemote(remoteDir, 'brainy') - expect(wall.entries[0].url).toBeNull() + expect(wall.entries[0].url).toBe('https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/11.0.6') expect(wall.entries[0].thumb).toBeNull() }) + it('refuses a product with no permalink pattern, naming the cure', () => { + seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) + writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '1.0.0', date: '2026-09-03', bullets: ['feat: first'] }])) + + const result = run(['--product', 'mystery', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir) + expect(result.status).not.toBe(0) + expect(result.stderr).toMatch(/no permalink pattern for product "mystery"/) + expect(result.stderr).toMatch(/never carry url: null/) + }) + it('refuses when the CHANGELOG has no entry yet for the target version, and touches no remote', () => { seedRemote(remoteDir, 'open-brainy', []) writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: whatever'] }]))