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:
David Snelling 2026-06-11 10:42:34 -07:00
parent 9b0f4acd5b
commit c44678390e
30 changed files with 1517 additions and 3226 deletions

View 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()
})
})
})