Also: idle PathResolver stats tick no longer logs NaN% every minute (logs only on new traffic, via prodLog); graph-lsm-* key family recognized as system resources (kills the per-boot unknown-key warning on provider-backed brains). Four regression pins in tests/integration/update-write-granularity.
126 lines
4.3 KiB
TypeScript
126 lines
4.3 KiB
TypeScript
/**
|
|
* @module tests/integration/update-write-granularity
|
|
* @description Write-granularity law for update() (SELF-ENGINE-RESTART-GRIND,
|
|
* 2026-07-29): a metadata-only update must NEVER rewrite the noun record —
|
|
* the record carries the full vector, so an unconditional save turns every
|
|
* metadata touch into a whole-vector rewrite + fsync. Under a read-heavy
|
|
* consumer sweep bumping per-entity stats this amplified into disk saturation
|
|
* on a production deployment. Laws:
|
|
* (1) metadata-only update() → zero saveNoun calls (metadata leg only);
|
|
* (2) data/vector/type-changing update() → saveNoun runs (the vector leg and
|
|
* HNSW reindex still happen when the vector side actually changed);
|
|
* (3) the metadata-only path still lands: merged metadata readable, _rev
|
|
* bumped, find() by the new field sees the entity.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import { Brainy } from '../../src/brainy.js'
|
|
import { NounType } from '../../src/types/graphTypes.js'
|
|
|
|
const stubEmbedding = async (text: string): Promise<number[]> => {
|
|
const hash = text.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
|
return new Array(384).fill(0).map((_, i) => Math.sin(hash + i))
|
|
}
|
|
|
|
describe('update() write granularity', () => {
|
|
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('metadata-only update never rewrites the noun record (no vector rewrite)', async () => {
|
|
const id = await brain.add({
|
|
data: 'granularity law subject',
|
|
type: NounType.Concept,
|
|
metadata: { touched: 0 }
|
|
})
|
|
|
|
const storage = (brain as any).storage
|
|
const saveNounSpy = vi.spyOn(storage, 'saveNoun')
|
|
|
|
await brain.update({ id, metadata: { touched: 1 } })
|
|
|
|
expect(saveNounSpy).not.toHaveBeenCalled()
|
|
saveNounSpy.mockRestore()
|
|
|
|
// The metadata leg still landed with full semantics.
|
|
const after = await brain.get(id, { includeVectors: true })
|
|
expect(after?.metadata?.touched).toBe(1)
|
|
expect(after?._rev).toBe(2)
|
|
expect(Array.isArray(after?.vector) && after!.vector!.length).toBe(384)
|
|
|
|
const found = await brain.find({ where: { touched: 1 } })
|
|
expect(found.some((r: any) => r.id === id)).toBe(true)
|
|
})
|
|
|
|
it('confidence/weight/subtype-only updates also skip the noun record', async () => {
|
|
const id = await brain.add({
|
|
data: 'reserved-field touch subject',
|
|
type: NounType.Concept,
|
|
metadata: {}
|
|
})
|
|
|
|
const storage = (brain as any).storage
|
|
const saveNounSpy = vi.spyOn(storage, 'saveNoun')
|
|
|
|
await brain.update({ id, confidence: 0.5, weight: 2, subtype: 'note' })
|
|
|
|
expect(saveNounSpy).not.toHaveBeenCalled()
|
|
saveNounSpy.mockRestore()
|
|
|
|
const after = await brain.get(id)
|
|
expect(after?.confidence).toBe(0.5)
|
|
expect(after?.subtype).toBe('note')
|
|
})
|
|
|
|
it('data-changing update still writes the noun record and reindexes', async () => {
|
|
const id = await brain.add({
|
|
data: 'original embedded text',
|
|
type: NounType.Concept,
|
|
metadata: {}
|
|
})
|
|
|
|
const before = await brain.get(id, { includeVectors: true })
|
|
|
|
const storage = (brain as any).storage
|
|
const saveNounSpy = vi.spyOn(storage, 'saveNoun')
|
|
|
|
await brain.update({ id, data: 'completely different embedded text' })
|
|
|
|
expect(saveNounSpy).toHaveBeenCalled()
|
|
saveNounSpy.mockRestore()
|
|
|
|
const after = await brain.get(id, { includeVectors: true })
|
|
expect(after?.data).toBe('completely different embedded text')
|
|
expect(after?.vector).not.toEqual(before?.vector)
|
|
})
|
|
|
|
it('explicit-vector update still writes the noun record', async () => {
|
|
const id = await brain.add({
|
|
data: 'vector swap subject',
|
|
type: NounType.Concept,
|
|
metadata: {}
|
|
})
|
|
|
|
const storage = (brain as any).storage
|
|
const saveNounSpy = vi.spyOn(storage, 'saveNoun')
|
|
|
|
const newVector = new Array(384).fill(0).map((_, i) => Math.cos(i))
|
|
await brain.update({ id, vector: newVector })
|
|
|
|
expect(saveNounSpy).toHaveBeenCalled()
|
|
saveNounSpy.mockRestore()
|
|
|
|
const after = await brain.get(id, { includeVectors: true })
|
|
expect(after?.vector?.[0]).toBeCloseTo(1) // cos(0)
|
|
})
|
|
})
|