Brings verbs to first-class parity with nouns. The 7.29.0 subtype primitive
shipped for entities only; this release ships the symmetric verb mirror plus
the enforcement layer for ensuring every entity AND every relationship has
both type AND subtype.
Layer V1 — verb subtype mirror
- HNSWVerbWithMetadata.subtype + STANDARD_VERB_FIELDS set + resolveVerbField()
- Relation<T>.subtype, RelateParams<T>.subtype, UpdateRelationParams<T> extended,
GetRelationsParams.subtype, GraphConstraints.subtype (for find connected)
- relate() persists subtype on verbMetadata + GraphVerb + transaction ops
- getRelations({ type, subtype }) fast-path filter with set membership
- find({ connected: { via, subtype, depth } }) traversal filter (depth-1 on
the JS path; explicit error on depth > 1 pointing at Cortex native)
- verbsToRelations + storage destructure sites surface subtype to top-level
- All three graph-index fast-path queries (getVerbsBySource/ByTarget) enrich
with subtype from metadata
Layer V2 — updateRelation() closes a pre-7.30 gap
- New first-class verb update method (parallel to update() for nouns)
- Changes subtype/type/weight/confidence/data/metadata in place
- Re-indexes in graph adjacency when verb type changes; id preserved
- validateUpdateRelationParams enforces id + at-least-one-field-to-update
Layer V3 — verb subtype storage rollup
- verbSubtypeCountsByType: Map<number, Map<string, number>> on BaseStorage
- verbSubtypeByIdCache for self-heal during update/delete
- incrementVerbSubtypeCount + decrementVerbSubtypeCount maintain state
- loadVerbSubtypeStatistics + saveVerbSubtypeStatistics persist to
_system/verb-subtype-statistics.json (mirrors noun-side shape)
- rebuildVerbSubtypeCounts for poison recovery / explicit repair
- getVerbSubtypeCountsByType accessor for the public counts API
- Wired into init() / flushCounts() / saveVerbMetadata / deleteVerbMetadata
Layer V4 — verb counts API + relationshipSubtypesOf
- brain.counts.byRelationshipSubtype(verb, subtype?) — O(1) breakdown or point
- brain.counts.topRelationshipSubtypes(verb, n) — top N by count
- brain.relationshipSubtypesOf(verb) — sorted distinct subtypes
Layer V5 — migrateField extended to verbs
- New entityKind?: 'noun' | 'verb' | 'both' option (default 'noun')
- Mirror verb iteration via storage.getVerbs() with same path semantics
- verbToRelationLike + buildRelationMigrationUpdate helpers project the
storage verb shape onto the Entity<T>-shaped surface readPath understands
- Routes through new updateRelation() for the verb-side rewrite
Enforcement (opt-in in 7.30, default in 8.0)
- brain.requireSubtype(type, options) — unified API for NounType OR VerbType.
Registers per-type rules with optional values whitelist; composes with the
brain-wide flag.
- new Brainy({ requireSubtype: true }) — brain-wide strict mode. Every public
write path validates the pairing guarantee.
- { except: [NounType.Thing, ...] } form for catch-all type exemptions
- Atomic-fail semantics on addMany / relateMany — pre-validate every item
before any storage write, throw on first failure with item index
- Per-type rules + brain-wide flag both throw with descriptive messages
- VFS infrastructure bypass via metadata.isVFSEntity / isVFS markers so
brain's own VFS writes don't get rejected when strict mode is on
VFS labeling — concrete subtypes for infrastructure entities
- VFS root: NounType.Collection + subtype: 'vfs-root' (was bare Collection)
- VFS directories: subtype: 'vfs-directory'
- VFS files: subtype: 'vfs-file' (NounType still mime-based)
- VFS containment edges: VerbType.Contains + subtype: 'vfs-contains'
- Lets consumers cleanly enumerate VFS state via find({ subtype: 'vfs-file' })
and distinguish Brainy's VFS Collections from user-created Collections
Docs
- docs/guides/subtypes-and-facets.md extended with Layer V (Verbs) section +
Enforcement section. New full reference at the bottom split into Layer 1
(nouns), Layer V (verbs), Layer 2 (facets), Layer 3 (migration), Enforcement.
- docs/api/README.md adds updateRelation(), getRelations({ subtype }), the
three verb-side counts methods, requireSubtype(), and the brain-wide
constructor option. relate() params include subtype.
- docs/DATA_MODEL.md adds a Subtype-for-VerbType section + STANDARD_VERB_FIELDS
- docs/architecture/finite-type-system.md extends Principle 1a to verbs
- docs/QUERY_OPERATORS.md adds a verb-subtype filter section covering
getRelations and find({connected, subtype}) traversal
- README.md "Subtypes" section now shows both noun + verb in one example +
the enforcement APIs
- RELEASES.md v7.30.0 entry with the full noun/verb capability parity matrix
Tests
- tests/integration/verb-subtype-and-enforcement.test.ts — 30 new tests
covering V1 round-trips, V1 set membership, updateRelation in place,
updateRelation preservation, V2 counts breakdown + point + topN + distinct,
V2 decrements on unrelate, V2 re-routes on updateRelation, V3 depth-1
traversal filter, V3 depth>1 explicit error, V4 verb migration, V4 both
entity kinds, V4 readBoth preservation, V5 per-type required rejection,
V5 vocabulary rejection, V5 on-vocab acceptance, V5 verb-side enforcement,
V5 addMany atomic-fail, V5 relateMany atomic-fail, V5 update enforcement,
V5 updateRelation enforcement, V5 brain-wide strict mode, V5 except clause.
Verification
- Unit suite: 1468/1468 passing
- Noun subtype integration (7.29 carryover): 26/26 passing
- Verb subtype + enforcement integration: 30/30 passing
- Type-check: clean
- Build: clean
- Public closed-source reference audit: clean
Internal 8.0 spec
- .strategy/BRAINY-8.0-SUBTYPE-CONTRACT.md (gitignored, not in npm artifact)
documents the contract upgrade Cortex 3.0 implements against: required-by-
default subtype, SubtypeRegistry typing hook, native simplification,
multi-hop traversal native fast path, brain.fillSubtypes() migration helper.
Coordinated via PLATFORM-HANDOFF rows CTX-SUBTYPE-PARITY-V2 (7.30 parallel
work) and CTX-SUBTYPE-8.0-CONTRACT (8.0 spec).
16 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. |
|
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
subtypewhen there's one primary sub-classification per entity. Limit yourself to one per NounType. Examples: Person→employee/customer/vendor; Document→invoice/contract/policy.trackFieldfor 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()andrelateMany()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: trueinfrastructure 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: '...' })
Reference
Layer 1 — subtype (nouns)
brain.add({ ..., subtype: 'value' })— write the fieldbrain.update({ id, subtype: 'value' })— change the fieldbrain.find({ type, subtype })— filter (fast path)brain.find({ subtype: ['a', 'b'] })— set membershipbrain.counts.bySubtype(type, subtype?)— O(1) countsbrain.counts.topSubtypes(type, n?)— top N by countbrain.subtypesOf(type)— distinct subtype list
Layer V — subtype (verbs / relationships)
brain.relate({ ..., subtype: 'value' })— write the fieldbrain.updateRelation({ id, subtype, type?, weight?, ... })— change a relationshipbrain.getRelations({ subtype })— filter (fast path)brain.getRelations({ subtype: ['a', 'b'] })— set membershipbrain.find({ connected: { via, subtype, depth } })— traversal filterbrain.counts.byRelationshipSubtype(verb, subtype?)— O(1) countsbrain.counts.topRelationshipSubtypes(verb, n?)— top N by countbrain.relationshipSubtypesOf(verb)— distinct subtype list
Layer 2 — generic facets
brain.trackField(name, { perType?, values? })— register a facetbrain.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/VerbTyperulenew Brainy({ requireSubtype: true })— brain-wide strict modenew Brainy({ requireSubtype: { except: [type, ...] } })— strict with exemptions