fix(adoption): the baseline backfill cures hydration-law drift — existing brains reach the crash-safe default with zero operator steps
The last rung of the default-flip ruling: with the sentinel exemption in, real production-shaped brains still refused adoption over state-differs mismatches the backfill could not cure — rows written before the hydration law carry denormalized wrapper fields that disagree with their own metadata leg, and the previous as-is identity re-commit PRESERVED that drift, so the oracle re-flagged it every pass and the flip never happened. In practice the crash-safe default reached zero existing brains: the exact outcome the hold ruling forbade. The cure: the backfill now rewrites canonical in the LAW SHAPE — exactly the wrapper the log's reconstruction produces (denormalized enumeration fields derived from the metadata leg, which is their authority under the field-addressing law; the embedding floats ride through byte-identical; adjacency residue keeps its own rebuild path). The oracle then verifies the rewrite before the flip — the same safety, no operator chore. Log-ahead divergence classes (a log the witness denies) still refuse loudly, exactly as before. Classification note for the record: the flagged uuid-v7 rows postdate the fact log's introduction, so they classify as state-differs (in-log, drift-shaped) rather than pre-log — both classes ride the same backfill. Pins: a manufactured depot-shape drifted wrapper adopts green with floats preserved and metadata intact; log-ahead still refuses typed. Gates: unit 2065/2065 · integration 832 · conformance 31/31.
This commit is contained in:
parent
2abe8b3806
commit
25f0dd964e
3 changed files with 134 additions and 19 deletions
|
|
@ -196,6 +196,7 @@ import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.js
|
|||
import { GenerationConflictError, StoreInconsistentError } from './db/errors.js'
|
||||
import { BrainyError, GraphIndexNotReadyError, MetadataIndexNotReadyError, MigrationInProgressError, VectorIndexNotReadyError } from './errors/brainyError.js'
|
||||
import { assessIndexReadiness } from './utils/indexReadiness.js'
|
||||
import { reconstructNounWrapper } from './db/factLog.js'
|
||||
import {
|
||||
readLogAuthority,
|
||||
runLogCompletenessOracle,
|
||||
|
|
@ -8274,15 +8275,17 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
for (const m of curable) {
|
||||
const raw = await this.storage.readNounRaw(m.id)
|
||||
if (raw.metadata === null && raw.vector === null) continue // vanished since the scan
|
||||
// IDENTITY re-commit: preserve the stored vector-file wrapper AS-IS —
|
||||
// the denormalized enumeration fields and the embedding floats ride
|
||||
// through, because a backfill must never DEGRADE the row it cures
|
||||
// (a skeleton rewrite would drop the row's floats and its enumerable
|
||||
// fields, and a later log replay could only reproduce the metadata
|
||||
// leg's hydration). The wrapper's floats sit nested under `vector`
|
||||
// (canonical noun vector files hold the denormalized noun, not a
|
||||
// bare array); adjacency legs stay in SaveNounOperation's
|
||||
// placeholder shape (the vector index owns them).
|
||||
// LAW-SHAPE RE-COMMIT: rewrite canonical as EXACTLY the wrapper the
|
||||
// log's reconstruction produces (the hydration law: denormalized
|
||||
// enumeration fields derived from the metadata leg + the embedding
|
||||
// floats). This is what makes the backfill actually CURE
|
||||
// state-differs drift: rows written before the hydration law carry
|
||||
// denormalized copies that disagree with their own metadata leg, and
|
||||
// an as-is identity re-commit preserves that drift forever — the
|
||||
// oracle re-flags it every pass and existing brains never flip. The
|
||||
// metadata leg is the authority (denormalized fields are its
|
||||
// projections, per the field-addressing law); nothing degrades: the
|
||||
// floats ride through, adjacency residue has its own rebuild path.
|
||||
const wrapper =
|
||||
raw.vector !== null && typeof raw.vector === 'object' && !Array.isArray(raw.vector)
|
||||
? (raw.vector as Record<string, unknown>)
|
||||
|
|
@ -8292,16 +8295,21 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
: Array.isArray(wrapper?.vector)
|
||||
? (wrapper!.vector as number[])
|
||||
: []
|
||||
const lawWrapper = reconstructNounWrapper(m.id, raw.metadata, vector)
|
||||
const priorRaw = { metadata: raw.metadata, vector: raw.vector }
|
||||
await this.persistSingleOp({ nouns: [m.id] }, async (tx) => {
|
||||
tx.addOperation(
|
||||
new SaveNounOperation(this.storage, {
|
||||
...(wrapper ?? {}),
|
||||
id: m.id,
|
||||
vector,
|
||||
connections: new Map(),
|
||||
level: typeof wrapper?.level === 'number' ? (wrapper.level as number) : 0
|
||||
} as HNSWNoun)
|
||||
)
|
||||
tx.addOperation({
|
||||
name: 'BaselineLawShapeRewrite',
|
||||
execute: async () => {
|
||||
await this.storage.writeNounRaw(m.id, {
|
||||
metadata: raw.metadata,
|
||||
vector: lawWrapper
|
||||
})
|
||||
return async () => {
|
||||
await this.storage.writeNounRaw(m.id, priorRaw)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
const next = await this.verifyLogAuthority()
|
||||
|
|
|
|||
|
|
@ -423,7 +423,7 @@ function reconstructTimestamp(value: unknown): number | undefined {
|
|||
* wrapper digests byte-equal to canonical. A drifted denormalized copy
|
||||
* surfaces as an oracle `state-differs` — named, never silently absorbed.
|
||||
*/
|
||||
function reconstructNounWrapper(
|
||||
export function reconstructNounWrapper(
|
||||
id: string,
|
||||
metadataLeg: unknown,
|
||||
floats: number[]
|
||||
|
|
|
|||
107
tests/integration/adopt-drift-cure.test.ts
Normal file
107
tests/integration/adopt-drift-cure.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* @module tests/integration/adopt-drift-cure
|
||||
* @description THE DRIFT-CURING BACKFILL — the actual completion of the
|
||||
* default-flip ruling: existing brains whose canonical wrappers carry
|
||||
* pre-hydration-law drift (denormalized fields disagreeing with their own
|
||||
* metadata leg — the real depot-brain shape, uuid-v7 rows from the 9.0 era)
|
||||
* must ADOPT AUTOMATICALLY: the backfill rewrites canonical in the law
|
||||
* shape (metadata leg = the authority; floats preserved), the oracle then
|
||||
* verifies the rewrite before flipping. Same safety, zero operator chores.
|
||||
* Log-ahead divergences still refuse as before.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Brainy } from '../../src/index.js'
|
||||
import { NounType } from '../../src/types/graphTypes.js'
|
||||
|
||||
const dirs: string[] = []
|
||||
const brains: Brainy[] = []
|
||||
afterEach(async () => {
|
||||
for (const b of brains.splice(0)) await b.close().catch(() => {})
|
||||
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
type RawBox = {
|
||||
storage: {
|
||||
readNounRaw(id: string): Promise<{ metadata: unknown; vector: unknown }>
|
||||
writeNounRaw(id: string, r: { metadata: unknown; vector: unknown }): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
describe('adoption cures hydration-law drift automatically', () => {
|
||||
it('a drifted wrapper (stale denormalized fields) adopts green with floats preserved', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'brainy-drift-cure-'))
|
||||
dirs.push(dir)
|
||||
const brain = new Brainy({
|
||||
storage: { type: 'filesystem', path: dir },
|
||||
requireSubtype: false,
|
||||
logAuthority: 'defer'
|
||||
})
|
||||
await brain.init()
|
||||
brains.push(brain)
|
||||
const id = await brain.add({
|
||||
data: 'early-era row with drift',
|
||||
type: NounType.Document,
|
||||
metadata: { k: 1 }
|
||||
})
|
||||
await brain.flush()
|
||||
const before = await brain.get(id, { includeVectors: true })
|
||||
const floats = [...(before!.vector as number[])]
|
||||
expect(floats.length).toBeGreaterThan(0)
|
||||
|
||||
// Manufacture the depot shape: the stored wrapper's denormalized fields
|
||||
// disagree with the metadata leg (pre-hydration-law drift) — an as-is
|
||||
// identity re-commit preserves this forever; the law-shape rewrite cures it.
|
||||
const storage = (brain as unknown as RawBox).storage
|
||||
const raw = await storage.readNounRaw(id)
|
||||
const wrapper = raw.vector as Record<string, unknown>
|
||||
await storage.writeNounRaw(id, {
|
||||
metadata: raw.metadata,
|
||||
vector: {
|
||||
...wrapper,
|
||||
noun: 'thing', // stale denormalized type (metadata leg says document)
|
||||
legacyField: 'pre-law residue',
|
||||
createdAt: '1999-01-01T00:00:00.000Z'
|
||||
}
|
||||
})
|
||||
// Confirm the drift is oracle-visible before the cure.
|
||||
expect((await brain.verifyLogAuthority()).verdict, 'drift detected').toBe('red')
|
||||
|
||||
// THE PIN: adoption cures it without any operator step.
|
||||
const report = await brain.adoptLogAuthority()
|
||||
expect(report.verdict).toBe('green')
|
||||
expect(brain.logAuthority().authority).toBe('log')
|
||||
|
||||
// Nothing degraded: floats byte-identical, metadata intact, row serves.
|
||||
const after = await brain.get(id, { includeVectors: true })
|
||||
expect(after!.vector as number[], 'floats preserved through the cure').toEqual(floats)
|
||||
expect((after!.metadata as { k: number }).k).toBe(1)
|
||||
expect((await brain.find({ where: { k: 1 }, limit: 5 })).map((r) => r.id)).toContain(id)
|
||||
}, 120000)
|
||||
|
||||
it('log-ahead divergences still refuse — the backfill never papers over a log the witness denies', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'brainy-logahead-'))
|
||||
dirs.push(dir)
|
||||
const brain = new Brainy({
|
||||
storage: { type: 'filesystem', path: dir },
|
||||
requireSubtype: false,
|
||||
logAuthority: 'defer'
|
||||
})
|
||||
await brain.init()
|
||||
brains.push(brain)
|
||||
const id = await brain.add({ data: 'row', type: NounType.Document, metadata: { k: 1 } })
|
||||
await brain.flush()
|
||||
|
||||
// Log-ahead shape: canonical loses the record while the log still
|
||||
// claims it live (log-live-canonical-absent — NOT curable by baseline).
|
||||
const storage = (brain as unknown as RawBox).storage
|
||||
await storage.writeNounRaw(id, { metadata: null, vector: null })
|
||||
|
||||
await expect(brain.adoptLogAuthority()).rejects.toThrow(
|
||||
/log-ahead|witness denies|log claims/i
|
||||
)
|
||||
expect(brain.logAuthority().authority).toBe('tree')
|
||||
}, 120000)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue