feat(8.0): brain.fillSubtypes migration helper + pre-RC1 gap closure
- brain.fillSubtypes(rules): idempotent subtype back-fill for pre-8.0 data.
One rule per NounType/VerbType (literal default or per-entry function);
fills only entries still missing a subtype through the public update()/
updateRelation() paths; returns { scanned, filled, skipped, errors, byType }.
Full unit suite in tests/unit/brainy/fill-subtypes.test.ts.
- Fix getNouns/getVerbs pagination hasMore (peek one past the window) —
was permanently false, silently truncating every multi-page walk.
- find({ near }) without near.id now throws a teaching error instead of an
opaque storage sharding failure; CLI --threshold without --near applies a
plain score floor.
- CLI init/close audit: every one-shot command init()s, close()s, and exits
explicitly; delete the unmaintained interactive REPL; replace the cloud-era
storage subcommands with status/batch-delete; new types/validate commands.
- requireSubtype JSDoc now documents the 8.0 default-on contract; audit()
recommendation points at fillSubtypes.
- Docs: data-storage-architecture rewritten to the real 8.0 on-disk layout;
README storage section reflects filesystem+memory and snapshots; eli5 and
SEMANTIC_VFS /as-of/ semantics corrected; internal tracker IDs and
.strategy references scrubbed from published files.
This commit is contained in:
parent
9b0f4acd5b
commit
c44678390e
30 changed files with 1517 additions and 3226 deletions
338
tests/unit/brainy/fill-subtypes.test.ts
Normal file
338
tests/unit/brainy/fill-subtypes.test.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
/**
|
||||
* @module tests/unit/brainy/fill-subtypes
|
||||
* @description Unit tests for `brain.fillSubtypes(rules)` — the 8.0 subtype
|
||||
* migration helper. Proves the full contract: literal and function rules,
|
||||
* entity + relationship fills through one rule map, never overwriting an
|
||||
* existing subtype, function rules declining entries (counted as skipped),
|
||||
* VFS-marker exclusion, the `{ scanned, filled, skipped, errors, byType }`
|
||||
* report, `_rev` bumping through the real update path, idempotent re-runs,
|
||||
* per-entry error collection, and fail-fast rule-map validation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../../src/brainy'
|
||||
import { NounType, VerbType } from '../../../src/types/graphTypes'
|
||||
import { createTestConfig } from '../../helpers/test-factory'
|
||||
|
||||
describe('Brainy.fillSubtypes()', () => {
|
||||
let brain: Brainy
|
||||
|
||||
beforeEach(async () => {
|
||||
// requireSubtype: false so the tests can create the pre-8.0 migration
|
||||
// debt (entities/relationships without subtype) that fillSubtypes exists
|
||||
// to clear.
|
||||
brain = new Brainy(createTestConfig())
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
describe('literal rules', () => {
|
||||
it('fills missing subtypes with a literal default', async () => {
|
||||
const a = await brain.add({ type: NounType.Person, data: 'Alice' })
|
||||
const b = await brain.add({ type: NounType.Person, data: 'Bob' })
|
||||
|
||||
const report = await brain.fillSubtypes({
|
||||
[NounType.Person]: 'unspecified'
|
||||
})
|
||||
|
||||
expect(report.filled).toBe(2)
|
||||
expect(report.byType).toEqual({ person: 2 })
|
||||
expect((await brain.get(a))!.subtype).toBe('unspecified')
|
||||
expect((await brain.get(b))!.subtype).toBe('unspecified')
|
||||
})
|
||||
|
||||
it('never overwrites an existing subtype', async () => {
|
||||
const employee = await brain.add({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'Has a subtype already'
|
||||
})
|
||||
const blank = await brain.add({ type: NounType.Person, data: 'No subtype' })
|
||||
|
||||
const report = await brain.fillSubtypes({
|
||||
[NounType.Person]: 'unspecified'
|
||||
})
|
||||
|
||||
expect(report.filled).toBe(1)
|
||||
expect((await brain.get(employee))!.subtype).toBe('employee') // untouched
|
||||
expect((await brain.get(blank))!.subtype).toBe('unspecified')
|
||||
})
|
||||
|
||||
it('leaves types without a rule untouched and counts them as skipped', async () => {
|
||||
await brain.add({ type: NounType.Person, data: 'Person without rule coverage' })
|
||||
const doc = await brain.add({ type: NounType.Document, data: 'Doc gets filled' })
|
||||
|
||||
const report = await brain.fillSubtypes({
|
||||
[NounType.Document]: 'general'
|
||||
})
|
||||
|
||||
expect(report.filled).toBe(1)
|
||||
expect(report.skipped).toBe(1) // the Person — still migration debt
|
||||
expect((await brain.get(doc))!.subtype).toBe('general')
|
||||
const audit = await brain.audit()
|
||||
expect(audit.total).toBe(report.skipped) // skipped IS the remaining debt
|
||||
})
|
||||
})
|
||||
|
||||
describe('function rules', () => {
|
||||
it('derives the subtype from entity fields', async () => {
|
||||
const vendor = await brain.add({
|
||||
type: NounType.Person,
|
||||
data: 'Vendor person',
|
||||
metadata: { kind: 'vendor' }
|
||||
})
|
||||
const fallback = await brain.add({ type: NounType.Person, data: 'No kind field' })
|
||||
|
||||
const report = await brain.fillSubtypes({
|
||||
[NounType.Person]: (e) => (e.metadata as { kind?: string })?.kind ?? 'unspecified'
|
||||
})
|
||||
|
||||
expect(report.filled).toBe(2)
|
||||
expect((await brain.get(vendor))!.subtype).toBe('vendor')
|
||||
expect((await brain.get(fallback))!.subtype).toBe('unspecified')
|
||||
})
|
||||
|
||||
it('treats undefined returns as declines (selective fill, counted as skipped)', async () => {
|
||||
const classified = await brain.add({
|
||||
type: NounType.Person,
|
||||
data: 'Classifiable',
|
||||
metadata: { department: 'engineering' }
|
||||
})
|
||||
const unclassified = await brain.add({ type: NounType.Person, data: 'Not classifiable' })
|
||||
|
||||
const report = await brain.fillSubtypes({
|
||||
[NounType.Person]: (e) =>
|
||||
(e.metadata as { department?: string })?.department ? 'employee' : undefined
|
||||
})
|
||||
|
||||
expect(report.filled).toBe(1)
|
||||
expect(report.skipped).toBe(1)
|
||||
expect((await brain.get(classified))!.subtype).toBe('employee')
|
||||
expect((await brain.get(unclassified))!.subtype).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats empty-string returns as declines (an empty subtype would fail enforcement)', async () => {
|
||||
await brain.add({ type: NounType.Person, data: 'Rule returns empty string' })
|
||||
|
||||
const report = await brain.fillSubtypes({
|
||||
[NounType.Person]: () => ''
|
||||
})
|
||||
|
||||
expect(report.filled).toBe(0)
|
||||
expect(report.skipped).toBe(1)
|
||||
})
|
||||
|
||||
it('collects per-entry rule errors and continues the pass', async () => {
|
||||
const poisoned = await brain.add({
|
||||
type: NounType.Person,
|
||||
data: 'Rule throws on this one',
|
||||
metadata: { poison: true }
|
||||
})
|
||||
const healthy = await brain.add({ type: NounType.Person, data: 'Rule works on this one' })
|
||||
|
||||
const report = await brain.fillSubtypes({
|
||||
[NounType.Person]: (e) => {
|
||||
if ((e.metadata as { poison?: boolean })?.poison) {
|
||||
throw new Error('cannot classify poisoned entity')
|
||||
}
|
||||
return 'unspecified'
|
||||
}
|
||||
})
|
||||
|
||||
expect(report.filled).toBe(1)
|
||||
expect(report.errors).toHaveLength(1)
|
||||
expect(report.errors[0].id).toBe(poisoned)
|
||||
expect(report.errors[0].error).toMatch(/cannot classify/)
|
||||
expect((await brain.get(healthy))!.subtype).toBe('unspecified')
|
||||
})
|
||||
})
|
||||
|
||||
describe('relationship rules (VerbType keys)', () => {
|
||||
it('fills missing relationship subtypes through the same rule map', async () => {
|
||||
const a = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'A' })
|
||||
const b = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'B' })
|
||||
const relId = await brain.relate({ from: a, to: b, type: VerbType.RelatedTo })
|
||||
const labeled = await brain.relate({
|
||||
from: b,
|
||||
to: a,
|
||||
type: VerbType.RelatedTo,
|
||||
subtype: 'colleague'
|
||||
})
|
||||
|
||||
const report = await brain.fillSubtypes({
|
||||
[VerbType.RelatedTo]: 'unspecified'
|
||||
})
|
||||
|
||||
expect(report.filled).toBe(1)
|
||||
expect(report.byType).toEqual({ relatedTo: 1 })
|
||||
|
||||
const relations = await brain.getRelations({ from: a })
|
||||
expect(relations.find((r) => r.id === relId)!.subtype).toBe('unspecified')
|
||||
const reverse = await brain.getRelations({ from: b })
|
||||
expect(reverse.find((r) => r.id === labeled)!.subtype).toBe('colleague') // untouched
|
||||
})
|
||||
|
||||
it('passes the full Relation shape to relationship rule functions', async () => {
|
||||
const a = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'A' })
|
||||
const b = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'B' })
|
||||
await brain.relate({
|
||||
from: a,
|
||||
to: b,
|
||||
type: VerbType.ReportsTo,
|
||||
metadata: { dotted: true }
|
||||
})
|
||||
|
||||
const report = await brain.fillSubtypes({
|
||||
[VerbType.ReportsTo]: (r) => {
|
||||
// The rule sees the public Relation shape — endpoints included.
|
||||
expect(r.from).toBe(a)
|
||||
expect(r.to).toBe(b)
|
||||
return (r.metadata as { dotted?: boolean })?.dotted ? 'dotted-line' : 'direct'
|
||||
}
|
||||
})
|
||||
|
||||
expect(report.filled).toBe(1)
|
||||
const relations = await brain.getRelations({ from: a })
|
||||
expect(relations[0].subtype).toBe('dotted-line')
|
||||
})
|
||||
|
||||
it('handles entity and relationship rules in a single pass', async () => {
|
||||
const a = await brain.add({ type: NounType.Person, data: 'A' })
|
||||
const b = await brain.add({ type: NounType.Person, data: 'B' })
|
||||
await brain.relate({ from: a, to: b, type: VerbType.RelatedTo })
|
||||
|
||||
const report = await brain.fillSubtypes({
|
||||
[NounType.Person]: 'unspecified',
|
||||
[VerbType.RelatedTo]: 'unspecified'
|
||||
})
|
||||
|
||||
expect(report.filled).toBe(3)
|
||||
expect(report.byType).toEqual({ person: 2, relatedTo: 1 })
|
||||
const audit = await brain.audit()
|
||||
expect(audit.total).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('report + write semantics', () => {
|
||||
it('bumps _rev through the real update path', async () => {
|
||||
const id = await brain.add({ type: NounType.Person, data: 'Rev check' })
|
||||
const before = await brain.get(id)
|
||||
|
||||
await brain.fillSubtypes({ [NounType.Person]: 'unspecified' })
|
||||
|
||||
const after = await brain.get(id)
|
||||
expect(after!._rev).toBe((before!._rev ?? 1) + 1)
|
||||
})
|
||||
|
||||
it('is idempotent — a re-run scans but fills nothing', async () => {
|
||||
await brain.add({ type: NounType.Person, data: 'Fill once' })
|
||||
|
||||
const first = await brain.fillSubtypes({ [NounType.Person]: 'unspecified' })
|
||||
expect(first.filled).toBe(1)
|
||||
|
||||
const second = await brain.fillSubtypes({ [NounType.Person]: 'unspecified' })
|
||||
expect(second.filled).toBe(0)
|
||||
expect(second.skipped).toBe(0)
|
||||
expect(second.scanned).toBeGreaterThan(0)
|
||||
expect(second.errors).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes VFS-marked entries by default, fills them with includeVFS: true', async () => {
|
||||
// VFS markers bypass subtype enforcement, so marked entries are not
|
||||
// migration debt. Fabricate one explicitly (public consumers shouldn't
|
||||
// set this marker; the test exercises the exclusion contract).
|
||||
const vfsLike = await brain.add({
|
||||
type: NounType.Document,
|
||||
data: 'Infrastructure-marked entry',
|
||||
metadata: { isVFS: true }
|
||||
})
|
||||
|
||||
const defaultRun = await brain.fillSubtypes({ [NounType.Document]: 'general' })
|
||||
expect(defaultRun.filled).toBe(0)
|
||||
expect((await brain.get(vfsLike))!.subtype).toBeUndefined()
|
||||
|
||||
const inclusiveRun = await brain.fillSubtypes(
|
||||
{ [NounType.Document]: 'general' },
|
||||
{ includeVFS: true }
|
||||
)
|
||||
expect(inclusiveRun.filled).toBeGreaterThanOrEqual(1)
|
||||
expect((await brain.get(vfsLike))!.subtype).toBe('general')
|
||||
})
|
||||
|
||||
it('reports progress after each batch', async () => {
|
||||
await brain.add({ type: NounType.Person, data: 'P1' })
|
||||
await brain.add({ type: NounType.Person, data: 'P2' })
|
||||
await brain.add({ type: NounType.Person, data: 'P3' })
|
||||
|
||||
const snapshots: Array<{ scanned: number; filled: number; skipped: number }> = []
|
||||
await brain.fillSubtypes(
|
||||
{ [NounType.Person]: 'unspecified' },
|
||||
{
|
||||
batchSize: 1,
|
||||
onProgress: (p) => snapshots.push({ ...p })
|
||||
}
|
||||
)
|
||||
|
||||
expect(snapshots.length).toBeGreaterThanOrEqual(3)
|
||||
const last = snapshots[snapshots.length - 1]
|
||||
expect(last.filled).toBe(3)
|
||||
})
|
||||
|
||||
it('clears the debt brain.audit() reports', async () => {
|
||||
await brain.add({ type: NounType.Person, data: 'Debt 1' })
|
||||
await brain.add({ type: NounType.Document, data: 'Debt 2' })
|
||||
|
||||
const before = await brain.audit()
|
||||
expect(before.total).toBe(2)
|
||||
|
||||
await brain.fillSubtypes({
|
||||
[NounType.Person]: 'unspecified',
|
||||
[NounType.Document]: 'general'
|
||||
})
|
||||
|
||||
const after = await brain.audit()
|
||||
expect(after.total).toBe(0)
|
||||
expect(after.recommendation).toMatch(/strict-mode-ready/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rule-map validation (fail fast, before touching data)', () => {
|
||||
it('rejects an empty rules map', async () => {
|
||||
await expect(brain.fillSubtypes({})).rejects.toThrow(/rules map is empty/)
|
||||
})
|
||||
|
||||
it('rejects keys that are not a NounType or VerbType', async () => {
|
||||
await expect(
|
||||
brain.fillSubtypes({ 'not-a-type': 'whatever' } as never)
|
||||
).rejects.toThrow(/'not-a-type' is not a valid NounType or VerbType/)
|
||||
})
|
||||
|
||||
it('rejects empty-string literal rules', async () => {
|
||||
await expect(
|
||||
brain.fillSubtypes({ [NounType.Person]: '' })
|
||||
).rejects.toThrow(/empty string/)
|
||||
})
|
||||
|
||||
it('rejects rule values that are neither string nor function', async () => {
|
||||
await expect(
|
||||
brain.fillSubtypes({ [NounType.Person]: 42 } as never)
|
||||
).rejects.toThrow(/must be a subtype string or a function/)
|
||||
})
|
||||
|
||||
it('does not write anything when validation fails', async () => {
|
||||
const id = await brain.add({ type: NounType.Person, data: 'Untouched on failure' })
|
||||
|
||||
await expect(
|
||||
brain.fillSubtypes({
|
||||
[NounType.Person]: 'unspecified',
|
||||
'bogus-type': 'x'
|
||||
} as never)
|
||||
).rejects.toThrow(/bogus-type/)
|
||||
|
||||
expect((await brain.get(id))!.subtype).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue