fix(namespace): noun-record updates preserve legacy inline HNSW adjacency — the placeholder-adjacency write stamped out pre-codec records' stored connections (crash-window unreachability); codec-era records were never at risk (empty field is the blob marker); pin covers the legacy shape
This commit is contained in:
parent
7a28a94639
commit
4679c89458
2 changed files with 68 additions and 2 deletions
|
|
@ -77,8 +77,27 @@ export class SaveNounOperation implements Operation {
|
||||||
? null
|
? null
|
||||||
: await this.storage.getNoun(this.noun.id)
|
: await this.storage.getNoun(this.noun.id)
|
||||||
|
|
||||||
// Save new noun
|
// PRESERVE stored graph state on updates. Callers stage this op with
|
||||||
await this.storage.saveNoun(this.noun)
|
// placeholder adjacency ({connections: empty, level: 0}) because the
|
||||||
|
// vector index owns those values and persists them at flush. Codec-era
|
||||||
|
// records (2.4.0+) carry an empty connections field by design (adjacency
|
||||||
|
// lives in a separate compressed blob — the placeholder is harmless), but
|
||||||
|
// LEGACY pre-codec records store adjacency INLINE: writing the
|
||||||
|
// placeholder over one stamped out its stored connections, leaving a
|
||||||
|
// crash window (until the next flush) where a reload found the node
|
||||||
|
// unreachable. Stale adjacency in that window is tolerable — HNSW
|
||||||
|
// self-corrects at the reindex flush; EMPTY adjacency is silent recall
|
||||||
|
// loss. The read above is already paid for rollback; preservation is free.
|
||||||
|
const toSave: HNSWNoun =
|
||||||
|
previousNoun && this.noun.connections.size === 0
|
||||||
|
? {
|
||||||
|
...this.noun,
|
||||||
|
connections: previousNoun.connections || this.noun.connections,
|
||||||
|
level: previousNoun.level ?? this.noun.level
|
||||||
|
}
|
||||||
|
: this.noun
|
||||||
|
|
||||||
|
await this.storage.saveNoun(toSave)
|
||||||
|
|
||||||
// Return rollback action
|
// Return rollback action
|
||||||
return async () => {
|
return async () => {
|
||||||
|
|
|
||||||
|
|
@ -145,3 +145,50 @@ describe('level field shadow — user metadata named level is a real field', ()
|
||||||
expect(EXPECTED_INDEX_EPOCH).toBe(2)
|
expect(EXPECTED_INDEX_EPOCH).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('noun-record writes never stamp over stored graph state', () => {
|
||||||
|
let brain: Brainy
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
brain = new Brainy({
|
||||||
|
requireSubtype: false,
|
||||||
|
storage: { type: 'memory' as const },
|
||||||
|
embeddingFunction: stubEmbedding
|
||||||
|
})
|
||||||
|
await brain.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await brain.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a data-changing update preserves LEGACY inline connections in the record', async () => {
|
||||||
|
// Codec-era records carry an EMPTY connections field by design (the
|
||||||
|
// adjacency lives in a separate compressed blob) — the clobber window
|
||||||
|
// exists only for legacy pre-codec records whose adjacency is inline.
|
||||||
|
// Simulate one: write the record with inline connections directly.
|
||||||
|
const id = await brain.add({
|
||||||
|
data: 'legacy-shaped node',
|
||||||
|
type: NounType.Concept,
|
||||||
|
metadata: { n: 1 }
|
||||||
|
})
|
||||||
|
const storage = (brain as any).storage
|
||||||
|
const rec = await storage.getNoun(id)
|
||||||
|
const legacy = {
|
||||||
|
...rec,
|
||||||
|
connections: new Map([[0, new Set(['00000000-0000-4000-8000-00000000aaaa'])]]),
|
||||||
|
level: 1
|
||||||
|
}
|
||||||
|
await storage.saveNoun(legacy)
|
||||||
|
const before = await storage.getNoun(id)
|
||||||
|
expect(before.connections.size).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// A data-changing update stages SaveNounOperation with placeholder
|
||||||
|
// adjacency — the legacy inline connections must survive the write.
|
||||||
|
await brain.update({ id, data: 'completely re-embedded text' })
|
||||||
|
|
||||||
|
const after = await storage.getNoun(id)
|
||||||
|
expect(after.connections.size).toBeGreaterThan(0)
|
||||||
|
expect(after.level).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue