brainy/docs/guides/subtypes-and-facets.md
David Snelling 9e307e457f fix: recalibrate find({ limit }) cap + two-tier enforcement + caller location
Brainy 7.30.0 introduced a memory-derived synchronous cap on `find({ limit })`
to prevent OOM. The cap was sound in intent but ~4x too conservative in
calibration: assumed 100 KB per result while typical entity footprint is 7-10 KB
(384-dim float32 vector ≈ 1.5 KB + standard fields + metadata). On a 900 MB
free-memory box the cap derived to 9000 — breaking common safety-cap patterns
like `find({ type, where, limit: 10_000 })` that typically return 10-500
entities. Surfaced as a runtime regression with cascading 500s degrading
production dashboards.

Three concurrent fixes:

A. RECALIBRATE THE FORMULA
- src/utils/paramValidation.ts:175,196,212 — the three memory-derived priorities
  (reservedQueryMemory / containerMemory / freeMemory) all divided by
  100 * 1024 * 1024 (100 KB per result, ~10-15x over conservative). Replaced
  with a new MAX_LIMIT_KB_PER_RESULT = 25 constant that matches observed
  entity size.
- Result: 4 GB container cap goes 10_000 → 40_000; 2 GB cap goes 5_000 →
  20_000; 900 MB free-memory cap goes 9_000 → ~36_000. 100k hard ceiling
  unchanged. `maxQueryLimit` / `reservedQueryMemory` constructor overrides
  unchanged in behavior.

B. TWO-TIER ENFORCEMENT (warn-then-throw)
- Below cap (limit <= maxLimit): silent pass, unchanged.
- Soft tier (maxLimit < limit <= 2 * maxLimit): NEW — one-time warning per
  call site (dedup keyed on caller stack frame + limit value), query
  proceeds. Pre-7.30.2 code that relied on the cap silently allowing typical
  safety-cap limits keeps working; the warning teaches the recipe so consumers
  can fix it intentionally.
- Hard tier (limit > 2 * maxLimit): throw with the same teaching message
  format. Real OOM territory; the cap stops being a recommendation and becomes
  a guardrail.
- The 2x soft margin absorbs typical safety-cap patterns (limit: 10_000
  against a 9 K-cap box) without disabling OOM protection. Real OOM territory
  on a JS in-memory brain is hundreds of thousands of results, not 10x the
  safety cap.

C. IMPROVED ERROR / WARNING MESSAGE
- Same shape as the 7.30.1 enforcement-error messages: state the problem,
  name the three escape valves (maxQueryLimit / reservedQueryMemory /
  pagination), include caller location, link to docs.
- Extracted findCallerLocation() helper from brainy.ts to a new
  src/utils/callerLocation.ts so both the subtype enforcement (7.30.1) and
  the limit enforcement (7.30.2) share one implementation without circular
  imports.

DOCS
- New docs/guides/find-limits.md (public: true) — full reference: why the cap
  exists, the four memory sources the auto-config considers, the three escape
  valves with when-to-use-which guidance, and an explicit "pagination is the
  future-proof pattern" callout (8.0 may tighten the cap further; pagination
  keeps working unchanged).
- docs/api/README.md find() entry gets a one-paragraph `limit` tip + pointer
  to the new guide.
- RELEASES.md v7.30.2 entry.

TESTS
- New tests/integration/find-limits.test.ts (9 tests): below-cap silent pass;
  soft-tier warns once per call site (dedup verified by exercising same vs.
  different source lines via wrapper closures); soft-tier message format
  (names all three escape valves + docs link); soft-tier message includes
  caller location; hard-tier throws; hard-tier message format same as
  soft-tier; consumer maxQueryLimit override raises the cap and shifts both
  tiers accordingly; pre-7.30.2 regression scenario explicitly covered.
- tests/unit/utils/memoryLimits.test.ts — 4 tests updated for the recalibrated
  cap values (hardcoded expected numbers bumped 4x to match new 25 KB/result
  assumption).
- tests/unit/utils/paramValidation.test.ts — auto-limit test extended to cover
  the three-tier semantics (below-cap pass / soft-tier silent / hard-tier
  throw).
- Existing suites unchanged: subtype-and-facets 26/26, verb-subtype-and-
  enforcement 30/30, strict-mode-self-test 13/13. Unit 1468/1468.

CORTEX COMPATIBILITY
- Zero Cortex changes required. Every change is JS-side: formula recalibration
  runs in ValidationConfig.constructor(), two-tier enforcement runs in
  validateFindParams(), both fire before any storage / index / Cortex call.
- The new guide notes that Brainy 8.0's Datomic-style Db.find() may tighten
  per-call limits to keep snapshot semantics cheap; pagination remains the
  pattern that's guaranteed to keep working.

REPO-WIDE CLEANUP
Brainy is the only Soulcraft project that is open source. This commit also
scrubs closed-source product names and product-specific class/field references
from every tracked file in the repo (src/, docs/, tests/, RELEASES.md,
CHANGELOG.md). Consumer-reported bugs, regression scenarios, and release
notes now refer to "a consumer", "a downstream application", "a production
deployment", or "an internal report" — never to the named product. Two
product-named test files renamed to neutral diagnostic names. CLAUDE.md gains
a project-level guard rule documenting the policy and an example list of the
identifiers that may not appear in tracked code.

Verification
- npx tsc --noEmit: clean
- npm test: 1468 / 1468 unit
- All four integration subtype + verb + strict + find-limits suites: 78/78
- npm run build: clean
- Closed-source product reference audit: clean
2026-06-08 12:49:43 -07:00

21 KiB

title slug public category template order description next
Subtypes & Facets guides/subtypes-and-facets true guides guide 7 Use the top-level `subtype` field to sub-classify entities within a NounType, track other metadata facets like status or role, and migrate field names without downtime.
guides/aggregation
api/reference

Subtypes & Facets

Sub-classify entities within a NounType, track arbitrary metadata facets, and migrate field names without downtime.

Why this exists

Brainy's NounType (Person, Document, Event, Concept, Task, …) is a stable, flat 42-type taxonomy. It's deliberately coarse — every product needs a way to further classify entities within a type. Is this Person an employee or a customer? Is this Document an invoice or a contract? Is this Event a meeting or a milestone?

Three layers solve this:

Layer Use it for Shape
subtype The primary sub-classification of an entity, one value per entity Top-level standard field
trackField() Other facets you want to count or filter on (status, source, role, paradigm) Registered metadata field
migrateField() Renaming or restructuring fields across an existing dataset One-shot stream-and-rewrite

Layer 1 — subtype

subtype is a top-level standard field on every entity, alongside type / confidence / weight. Flat string, no hierarchy. The vocabulary is your choice — Brainy stores and counts, never validates.

Write

import { Brainy, NounType } from '@soulcraft/brainy'

const brain = new Brainy()
await brain.init()

await brain.add({
  data: 'Avery Brooks — runs the AI lab',
  type: NounType.Person,
  subtype: 'employee',                  // top-level write param
  metadata: { department: 'ai-lab' }
})

Read

// Top-level filter — standard-field fast path, no `where` wrapper:
const employees = await brain.find({ type: NounType.Person, subtype: 'employee' })

// Set membership:
const internal = await brain.find({
  type: NounType.Person,
  subtype: ['employee', 'contractor']
})

// Operator-form predicates use `where`:
const typed = await brain.find({
  type: NounType.Person,
  where: { subtype: { exists: true } }
})

What it looks like

{
  "id": "01HZK3M7TW...",
  "type": "person",
  "subtype": "employee",
  "data": "Avery Brooks — runs the AI lab",
  "metadata": { "department": "ai-lab" }
}

subtype lives at the top level — NOT inside metadata, NOT inside data. That's what makes the fast path possible: queries on subtype hit the column-store index directly, never the metadata fallback.

Counts (O(1))

// All subtypes for a NounType
brain.counts.bySubtype(NounType.Person)
// → { employee: 12, customer: 847, vendor: 34 }

// Point count
brain.counts.bySubtype(NounType.Person, 'employee')
// → 12

// Top N
brain.counts.topSubtypes(NounType.Person, 3)
// → [['customer', 847], ['employee', 12], ['vendor', 34]]

// Distinct subtypes for a NounType
brain.subtypesOf(NounType.Person)
// → ['customer', 'employee', 'vendor']

These are O(1) lookups backed by _system/subtype-statistics.json — no scan, no storage round-trip. The rollup is incrementally maintained as entities are added, updated, and deleted.

Aggregation

subtype is a first-class group-by dimension:

const rows = await brain.find({
  aggregate: {
    groupBy: ['type', 'subtype'],
    metrics: { count: { op: 'COUNT' } }
  }
})
// [
//   { groupKey: { type: 'person',   subtype: 'customer' }, count: 847 },
//   { groupKey: { type: 'person',   subtype: 'employee' }, count: 12 },
//   { groupKey: { type: 'document', subtype: 'invoice'  }, count: 2103 },
//   ...
// ]

Layer 2 — trackField()

Some metadata fields aren't the sub-classification of an entity but are still worth counting: status, source, role, paradigm. Promoting each one to top-level would clutter the contract. trackField() registers a metadata field for cardinality + per-NounType breakdown stats without the contract growth.

Register and query

// Track a single facet
brain.trackField('status')

await brain.add({ data: 'Ship subtype', type: NounType.Task, metadata: { status: 'todo' } })
await brain.add({ data: 'Write docs',   type: NounType.Task, metadata: { status: 'done' } })

await brain.counts.byField('status')
// → { todo: 1, done: 1 }

Per-NounType breakdown

brain.trackField('status', { perType: true })

await brain.counts.byField('status', { type: NounType.Task })
// → { todo: 1, done: 1 }

Vocabulary whitelist (opt-in validation)

brain.trackField('priority', { values: ['low', 'medium', 'high'] })

// Throws — 'urgent' isn't in the vocabulary:
await brain.add({
  data: 'Fix bug',
  type: NounType.Task,
  metadata: { priority: 'urgent' }
})

trackField piggybacks on the existing aggregation engine (see the Aggregation guide for the underlying mechanism). Backfill-on-define means the first call to counts.byField() scans existing entities; subsequent calls are O(groups).

subtype vs trackField — when to use which

  • subtype when there's one primary sub-classification per entity. Limit yourself to one per NounType. Examples: Person→employee/customer/vendor; Document→invoice/contract/policy.
  • trackField for anything else you want to count or filter on. No limit on how many you register. Examples: status, source, role, paradigm.

Layer 3 — migrateField()

Use this when you need to rename or restructure a field across an entire dataset — for example, moving a metadata.kind convention up to the top-level subtype standard field.

One-shot rewrite

// Starting state: every entity has metadata.kind
const result = await brain.migrateField({
  from: 'metadata.kind',
  to: 'subtype'
})

console.log(result)
// {
//   scanned: 1500,
//   migrated: 1500,
//   skipped: 0,
//   errors: []
// }

After this returns, every entity has subtype populated from the old metadata.kind value, and metadata.kind is cleared.

Deprecation window — keep both fields readable

When you can't coordinate all readers and the migration in a single deploy, use readBoth: true to preserve the source field alongside the new one:

// Phase 1: dual-populate (existing readers still work against metadata.kind):
await brain.migrateField({
  from: 'metadata.kind',
  to: 'subtype',
  readBoth: true
})

// ... readers migrate to query subtype at their own pace ...

// Phase 2: clear the source field when ready:
await brain.migrateField({ from: 'metadata.kind', to: 'subtype' })

Supported paths

Path form Refers to
'subtype', 'type', 'confidence' Top-level standard fields
'metadata.X' A key under entity.metadata
'data.X' A key under entity.data (when data is an object)
'X' (bare, non-standard) Shorthand for metadata.X

Idempotent

migrateField is safe to re-run. Entities where the source is absent, or where the destination already holds the same value, are skipped. This makes it safe to use in a deploy-once-then-cleanup workflow.

Progress reporting

await brain.migrateField({
  from: 'metadata.kind',
  to: 'subtype',
  batchSize: 500,
  onProgress: ({ scanned, migrated }) => {
    console.log(`${scanned} scanned, ${migrated} migrated`)
  }
})

Putting it together

A realistic adoption sequence for a brain that started without these primitives:

import { Brainy, NounType } from '@soulcraft/brainy'

const brain = new Brainy({ storage: { type: 'filesystem', options: { path: './brain-data' } } })
await brain.init()

// 1. Migrate any existing metadata.kind convention to the new top-level subtype
await brain.migrateField({ from: 'metadata.kind', to: 'subtype', readBoth: true })

// 2. Register the other facets you want counted
brain.trackField('status', { perType: true })
brain.trackField('source')

// 3. Use subtype on every new write
await brain.add({
  data: 'Quarterly review',
  type: NounType.Event,
  subtype: 'milestone',
  metadata: { status: 'todo', source: 'planning-session' }
})

// 4. Query the breakdowns
brain.counts.bySubtype(NounType.Event)
// → { milestone: 14, meeting: 203, deadline: 7 }

await brain.counts.byField('status', { type: NounType.Event })
// → { todo: 12, done: 212 }

// 5. Once all readers are on the new field, drop the source:
await brain.migrateField({ from: 'metadata.kind', to: 'subtype' })

Layer V — subtype on relationships (verbs)

Relationships are first-class citizens too. Every verb (VerbType) gets the same subtype primitive: a ReportsTo relationship might carry subtype: 'direct' vs 'dotted-line'; a RelatedTo edge might carry 'spouse' / 'sibling' / 'colleague'. Same shape as the noun side: flat string, no hierarchy, top-level standard field, indexed on the fast path.

Write

const ceoId = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'Avery' })
const vpId = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'Jordan' })
const matrixId = await brain.add({ type: NounType.Person, subtype: 'contractor', data: 'Sam' })

await brain.relate({
  from: ceoId,
  to: vpId,
  type: VerbType.ReportsTo,
  subtype: 'direct'
})

await brain.relate({
  from: ceoId,
  to: matrixId,
  type: VerbType.ReportsTo,
  subtype: 'dotted-line'
})

Read & filter

// Direct reports — fast path filter (column-store hit, not metadata fallback)
const direct = await brain.getRelations({
  from: ceoId,
  type: VerbType.ReportsTo,
  subtype: 'direct'
})

// Set membership
const allReports = await brain.getRelations({
  from: ceoId,
  type: VerbType.ReportsTo,
  subtype: ['direct', 'dotted-line']
})

Update (new — updateRelation())

Verbs previously had no update method — the only way to change a relationship was delete-then-recreate. 7.30 closes that gap:

// Promote a dotted-line report to direct without losing the edge id
await brain.updateRelation({ id: relationId, subtype: 'direct' })

// Or change weight/confidence
await brain.updateRelation({ id: relationId, weight: 0.5, confidence: 0.9 })

Traversal filter

find({ connected, subtype }) filters traversal edges by their subtype. Composes with via (verb-type filter):

// All direct reports two hops deep (will be supported in Cortex; for now depth-1)
const directChain = await brain.find({
  connected: {
    from: ceoId,
    via: VerbType.ReportsTo,
    subtype: 'direct',
    depth: 1
  }
})

Multi-hop subtype filtering (depth > 1) lights up on the Cortex native path; the JS path throws today rather than return incorrect partial results.

Counts

Same shape as the noun-side counts API:

brain.counts.byRelationshipSubtype(VerbType.ReportsTo)
// → { direct: 12, 'dotted-line': 3 }

brain.counts.byRelationshipSubtype(VerbType.ReportsTo, 'direct')   // O(1) point
// → 12

brain.counts.topRelationshipSubtypes(VerbType.ReportsTo, 3)
// → [['direct', 12], ['dotted-line', 3]]

brain.relationshipSubtypesOf(VerbType.ReportsTo)
// → ['direct', 'dotted-line']

These are O(1) lookups backed by the persisted _system/verb-subtype-statistics.json rollup — same self-heal machinery as the noun-side rollup.

Migrate verb fields

migrateField() now walks verbs too:

// Migrate verb-side metadata.kind → top-level subtype
await brain.migrateField({
  from: 'metadata.kind',
  to: 'subtype',
  entityKind: 'verb'
})

// Or migrate both nouns and verbs in one pass
await brain.migrateField({
  from: 'metadata.kind',
  to: 'subtype',
  entityKind: 'both'
})

Default is entityKind: 'noun' (backward-compatible with 7.29).

Enforcement — requireSubtype() + brain-wide strict mode

By default (7.30) subtype is optional. Two complementary opt-in mechanisms let you enforce the pairing of type + subtype on every write:

Per-type registration

Mark a specific NounType or VerbType as requiring a subtype, optionally with a fixed vocabulary:

brain.requireSubtype(NounType.Person, {
  values: ['employee', 'customer', 'vendor'],
  required: true
})

brain.requireSubtype(VerbType.ReportsTo, {
  values: ['direct', 'dotted-line'],
  required: true
})

// Now this throws — Person requires a subtype:
await brain.add({ type: NounType.Person, data: 'no subtype' })

// And this throws — 'matrix' isn't in the registered vocabulary:
await brain.relate({
  from: a,
  to: b,
  type: VerbType.ReportsTo,
  subtype: 'matrix'
})

Brain-wide strict mode

Enforce on every write across the whole brain:

const brain = new Brainy({ requireSubtype: true })

// Allow specific types to omit subtype (e.g. catch-all `Thing`)
const brain2 = new Brainy({
  requireSubtype: { except: [NounType.Thing, NounType.Custom] }
})

When strict mode is on:

  • Every add() / addMany() / update() / relate() / relateMany() / updateRelation() rejects writes missing a subtype on a non-exempt type.
  • addMany() and relateMany() validate every item BEFORE any storage write — atomic-fail semantics, no partial writes.
  • Per-type rules registered via requireSubtype() compose with the brain-wide flag; specific rules win when both apply.
  • Brainy's own internal writes (VFS root, VFS directories, VFS file entities) bypass enforcement via the metadata.isVFSEntity: true infrastructure marker.

Migrating to strict mode on an existing brain

brain.migrateField() is your friend — populate subtype from an existing convention before flipping strict mode on:

// Step 1: backfill subtype from your existing metadata.kind convention
await brain.migrateField({
  from: 'metadata.kind',
  to: 'subtype',
  entityKind: 'both'
})

// Step 2: register the vocabulary
brain.requireSubtype(NounType.Person, {
  values: ['employee', 'customer', 'vendor'],
  required: true
})

// Step 3: future writes must have a subtype matching the vocabulary
await brain.add({ type: NounType.Person, subtype: 'employee', data: '...' })

Strict mode in practice (for SDK-style vocabulary consumers)

When a platform layer like the Soulcraft SDK registers requireSubtype() rules on behalf of every consumer's brain, every downstream product that calls brain.add() / brain.relate() against those types must pass a matching subtype. Skipping the field — or passing one outside the registered vocabulary — throws at the boundary.

This pattern is powerful but surfaces a class of latent bug: any brain.add() call site that was written before strict-mode adoption starts rejecting writes. A representative production incident: a booking flow started returning 500 on every request because a service method called brain.add({ type: NounType.Event, ... }) without subtype, and an SDK layer had just registered requireSubtype() for NounType.Event on every brain instance.

The fix is a four-step migration recipe — and Brainy 7.30.1+ ships diagnostic tools to make it deterministic.

Migration recipe

  1. Inventory the gap with brain.audit() — returns the deterministic list of which NounTypes and VerbTypes have entities/relationships missing subtype, grouped by type:

    const report = await brain.audit()
    //   {
    //     entitiesWithoutSubtype: { event: 24, document: 3, ... },
    //     relationshipsWithoutSubtype: { relatedTo: 1402 },
    //     total: 1429,
    //     scanned: 8400,
    //     recommendation: 'Found 1429 entries without subtype. ...'
    //   }
    

    By default, VFS infrastructure entities are excluded (they bypass enforcement anyway via the metadata.isVFSEntity marker). Pass { includeVFS: true } to surface them too.

  2. Bulk-migrate any existing convention with brain.migrateField() if a legacy field can be lifted:

    // Common pattern: subtype mirrors a discriminator field already in metadata
    await brain.migrateField({
      from: 'metadata.entityType',
      to: 'subtype',
      readBoth: true  // safety: keep the source field readable during cutover
    })
    
  3. Hand-fix the remaining call sites. The exact list is in report.entitiesWithoutSubtype. For each call site, add subtype: '<value>' to the brain.add() / brain.relate() params. Choose a stable convention (e.g. mirror metadata.entityType if you have one; any rule that's deterministic from the data works).

  4. Verify with brain.audit() again. Re-run; total should be 0. If you turn on brain-wide strict mode at this point, all future writes are protected.

Brainy's own infrastructure subtype labels (reference)

Brainy's internal write paths set subtype on every entity and edge they create. Consumers don't need to do anything for these — they're documented here so you understand the data shape:

Code path NounType / VerbType Subtype label
VFS root directory / NounType.Collection 'vfs-root'
VFS subdirectories NounType.Collection 'vfs-directory'
VFS files (mime-driven, e.g. Document/Code/Image) 'vfs-file'
VFS symlinks NounType.File 'vfs-symlink'
VFS Contains edges VerbType.Contains 'vfs-contains'
Aggregation materialized output NounType.Measurement 'materialized-aggregate'
Import-document provenance entity NounType.Document 'import-source'
Importer-extracted entities (no caller default) extractor-driven 'imported'
Importer placeholder targets NounType.Thing 'import-placeholder'
Neural extraction (no caller default) extractor-driven 'extracted'
GoogleSheets API entity writes request-driven 'imported-from-sheets'
OData API entity writes request-driven 'imported-from-odata'
MCP client message storage NounType.Message 'mcp-message'
brainy add CLI (no --subtype flag) user-supplied type 'cli-add'
brainy relate CLI (no --subtype flag) user-supplied verb 'cli-relate'

You can query these directly: await brain.find({ subtype: 'vfs-file' }) returns every VFS-managed file regardless of NounType. await brain.counts.bySubtype(NounType.Document) shows you the import-source / imported / extracted / vfs-file breakdown.

Importer and extraction paths accept a caller-supplied defaultSubtype option so you can tag a whole batch with your own provenance label (e.g. 'customer-upload-2026q2') instead of the Brainy default 'imported' / 'extracted'.

Looking ahead — Brainy 8.0

Brainy 8.0 ships:

  • brain.fillSubtypes(rules) — the bulk migration helper that pairs with audit(). Given caller-supplied rules per NounType / VerbType, it walks the brain and fills in missing subtypes via update(). Pre-8.0 brains run this once before upgrading to clear migration debt.
  • subtype: string (non-optional) on AddParams<T> and RelateParams<T>. TypeScript catches missing subtype at compile time, not just runtime.
  • new Brainy({ requireSubtype: true }) becomes the default. Consumers explicitly opt out with { requireSubtype: false } during migration.

7.30.1's audit() is the diagnostic; 8.0's fillSubtypes() is the bulk fixer. Together they close the migration gap deterministically.

Reference

Layer 1 — subtype (nouns)

  • brain.add({ ..., subtype: 'value' }) — write the field
  • brain.update({ id, subtype: 'value' }) — change the field
  • brain.find({ type, subtype }) — filter (fast path)
  • brain.find({ subtype: ['a', 'b'] }) — set membership
  • brain.counts.bySubtype(type, subtype?) — O(1) counts
  • brain.counts.topSubtypes(type, n?) — top N by count
  • brain.subtypesOf(type) — distinct subtype list

Layer V — subtype (verbs / relationships)

  • brain.relate({ ..., subtype: 'value' }) — write the field
  • brain.updateRelation({ id, subtype, type?, weight?, ... }) — change a relationship
  • brain.getRelations({ subtype }) — filter (fast path)
  • brain.getRelations({ subtype: ['a', 'b'] }) — set membership
  • brain.find({ connected: { via, subtype, depth } }) — traversal filter
  • brain.counts.byRelationshipSubtype(verb, subtype?) — O(1) counts
  • brain.counts.topRelationshipSubtypes(verb, n?) — top N by count
  • brain.relationshipSubtypesOf(verb) — distinct subtype list

Layer 2 — generic facets

  • brain.trackField(name, { perType?, values? }) — register a facet
  • brain.counts.byField(name, { type? }) — facet counts

Layer 3 — migration

  • brain.migrateField({ from, to, readBoth?, batchSize?, onProgress?, entityKind? }) — rewrite a field (nouns, verbs, or both)

Enforcement

  • brain.requireSubtype(type, { values?, required }) — per-NounType / VerbType rule
  • new Brainy({ requireSubtype: true }) — brain-wide strict mode
  • new Brainy({ requireSubtype: { except: [type, ...] } }) — strict with exemptions