feat: verb subtype + updateRelation + requireSubtype enforcement
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).
This commit is contained in:
parent
8c68396e71
commit
c0d326b36d
14 changed files with 2180 additions and 68 deletions
700
src/brainy.ts
700
src/brainy.ts
|
|
@ -51,6 +51,7 @@ import {
|
|||
validateAddParams,
|
||||
validateUpdateParams,
|
||||
validateRelateParams,
|
||||
validateUpdateRelationParams,
|
||||
validateFindParams,
|
||||
recordQueryPerformance
|
||||
} from './utils/paramValidation.js'
|
||||
|
|
@ -66,6 +67,7 @@ import {
|
|||
RemoveFromMetadataIndexOperation,
|
||||
RemoveFromGraphIndexOperation,
|
||||
UpdateNounMetadataOperation,
|
||||
UpdateVerbMetadataOperation,
|
||||
DeleteNounMetadataOperation,
|
||||
DeleteVerbMetadataOperation
|
||||
} from './transaction/operations/index.js'
|
||||
|
|
@ -85,6 +87,7 @@ import {
|
|||
AddParams,
|
||||
UpdateParams,
|
||||
RelateParams,
|
||||
UpdateRelationParams,
|
||||
FindParams,
|
||||
SimilarParams,
|
||||
GetRelationsParams,
|
||||
|
|
@ -198,6 +201,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
*/
|
||||
private _trackedFields: Map<string, { perType: boolean; values?: Set<string> }> = new Map()
|
||||
|
||||
/**
|
||||
* Per-NounType / per-VerbType subtype enforcement rules registered via
|
||||
* `brain.requireSubtype(type, options)`. When `required` is true the matching
|
||||
* write path throws if subtype is missing; when `values` is set the matching
|
||||
* write path throws if the value is off-vocabulary. Composes with the
|
||||
* brain-wide `requireSubtype` constructor flag.
|
||||
*/
|
||||
private _requiredSubtypes: Map<string, { required: boolean; values?: Set<string> }> = new Map()
|
||||
|
||||
// State
|
||||
private initialized = false
|
||||
private dimensions?: number
|
||||
|
|
@ -1033,6 +1045,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
'top-level'
|
||||
)
|
||||
|
||||
// Subtype pairing enforcement (Layer 3 — 7.30.0). Per-type rules registered
|
||||
// via brain.requireSubtype() compose with the brain-wide strict-mode flag.
|
||||
// Metadata is passed so infrastructure writes (VFS) can bypass the
|
||||
// missing-subtype check via the `isVFSEntity` marker.
|
||||
this.enforceSubtypeOnAdd('add', params.type, params.subtype, params.metadata)
|
||||
|
||||
// Generate ID if not provided
|
||||
const id = params.id || uuidv4()
|
||||
|
||||
|
|
@ -1511,6 +1529,16 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
)
|
||||
}
|
||||
|
||||
// Subtype pairing enforcement on update (7.30.0). The effective NounType after
|
||||
// the update is `params.type ?? existing.type`; we look it up if needed.
|
||||
if (params.subtype !== undefined || params.type !== undefined) {
|
||||
const existing = await this.get(params.id)
|
||||
const effectiveType = params.type ?? existing?.type
|
||||
const effectiveSubtype = params.subtype !== undefined ? params.subtype : existing?.subtype
|
||||
const effectiveMetadata = params.metadata ?? existing?.metadata
|
||||
this.enforceSubtypeOnAdd('update', effectiveType, effectiveSubtype, effectiveMetadata)
|
||||
}
|
||||
|
||||
// Get existing entity with vectors (fix for regression)
|
||||
// We need includeVectors: true because:
|
||||
// 1. SaveNounOperation requires the vector
|
||||
|
|
@ -1853,6 +1881,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
// Zero-config validation (static import for performance)
|
||||
validateRelateParams(params)
|
||||
|
||||
// Subtype pairing enforcement (Layer 3 — 7.30.0). Per-type rules registered
|
||||
// via brain.requireSubtype() compose with the brain-wide strict-mode flag.
|
||||
// Metadata is passed so infrastructure edges (VFS containment) can bypass
|
||||
// the missing-subtype check via the `isVFSEntity` / `isVFS` marker.
|
||||
this.enforceSubtypeOnRelate('relate', params.type, params.subtype, params.metadata)
|
||||
|
||||
// Verify entities exist
|
||||
const fromEntity = await this.get(params.from)
|
||||
const toEntity = await this.get(params.to)
|
||||
|
|
@ -1898,6 +1932,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
const verbMetadata = {
|
||||
...(params.metadata || {}),
|
||||
verb: params.type,
|
||||
...(params.subtype !== undefined && { subtype: params.subtype }),
|
||||
weight: params.weight ?? 1.0,
|
||||
createdAt: Date.now(),
|
||||
...((params as any).data !== undefined && { data: (params as any).data })
|
||||
|
|
@ -1913,6 +1948,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
target: params.to,
|
||||
verb: params.type,
|
||||
type: params.type,
|
||||
...(params.subtype !== undefined && { subtype: params.subtype }),
|
||||
weight: params.weight ?? 1.0,
|
||||
metadata: params.metadata,
|
||||
data: (params as any).data,
|
||||
|
|
@ -2021,6 +2057,115 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing relationship.
|
||||
*
|
||||
* Mirror of `update()` for relationships. Supports changing the verb type, the
|
||||
* sub-classification (`subtype`), weight/confidence, the opaque `data` payload, and
|
||||
* structured metadata (merge by default; set `merge: false` to replace).
|
||||
*
|
||||
* If `type` changes, the relationship is re-indexed in the graph adjacency
|
||||
* (`RemoveFromGraphIndex` + `AddToGraphIndex`) so traversal by verb type stays
|
||||
* consistent. The relationship ID is preserved across the type change.
|
||||
*
|
||||
* @param params - Update parameters (`id` required; at least one field to change)
|
||||
* @throws If the relationship doesn't exist
|
||||
*
|
||||
* @example Change subtype
|
||||
* await brain.updateRelation({ id: relId, subtype: 'dotted-line' })
|
||||
*
|
||||
* @example Merge metadata
|
||||
* await brain.updateRelation({ id: relId, metadata: { startDate: '2026-Q3' } })
|
||||
*
|
||||
* @example Replace metadata entirely
|
||||
* await brain.updateRelation({ id: relId, metadata: { only: 'this' }, merge: false })
|
||||
*/
|
||||
async updateRelation(params: UpdateRelationParams<T>): Promise<void> {
|
||||
this.assertWritable('updateRelation')
|
||||
await this.ensureInitialized()
|
||||
|
||||
validateUpdateRelationParams(params)
|
||||
|
||||
const existing = await this.storage.getVerb(params.id)
|
||||
if (!existing) {
|
||||
throw new Error(`Relation ${params.id} not found`)
|
||||
}
|
||||
|
||||
const existingAny = existing as any
|
||||
const newVerbType = params.type ?? existingAny.verb ?? existingAny.type
|
||||
|
||||
// Subtype pairing enforcement on update (7.30.0). The effective verb type after
|
||||
// the update may have changed; we check against the new type and the resulting
|
||||
// subtype value (explicit param or preserved existing).
|
||||
if (params.subtype !== undefined || params.type !== undefined) {
|
||||
const effectiveSubtype = params.subtype !== undefined
|
||||
? params.subtype
|
||||
: (existingAny.subtype as string | undefined)
|
||||
const effectiveMetadata = params.metadata ?? (existingAny.metadata as unknown)
|
||||
this.enforceSubtypeOnRelate('updateRelation', newVerbType as VerbType, effectiveSubtype, effectiveMetadata)
|
||||
}
|
||||
const typeChanged = params.type !== undefined && params.type !== (existingAny.verb ?? existingAny.type)
|
||||
|
||||
// Merge metadata (mirror of update() for nouns)
|
||||
const newMetadata = params.merge !== false
|
||||
? { ...(existingAny.metadata || {}), ...(params.metadata || {}) }
|
||||
: (params.metadata as any) || existingAny.metadata
|
||||
|
||||
// Build updated stored metadata. System fields ALWAYS win — same shape as relate().
|
||||
const updatedMetadata = {
|
||||
...newMetadata,
|
||||
verb: newVerbType,
|
||||
...(params.subtype !== undefined
|
||||
? { subtype: params.subtype }
|
||||
: existingAny.subtype !== undefined && { subtype: existingAny.subtype }),
|
||||
weight: params.weight ?? existingAny.weight ?? 1.0,
|
||||
...(params.confidence !== undefined
|
||||
? { confidence: params.confidence }
|
||||
: existingAny.confidence !== undefined && { confidence: existingAny.confidence }),
|
||||
createdAt: existingAny.createdAt,
|
||||
updatedAt: Date.now(),
|
||||
...(params.data !== undefined
|
||||
? { data: params.data }
|
||||
: existingAny.data !== undefined && { data: existingAny.data })
|
||||
}
|
||||
|
||||
// Build the verb view used by the graph index — top-level fields mirror relate()'s.
|
||||
const verbForIndex: GraphVerb = {
|
||||
id: params.id,
|
||||
vector: existingAny.vector,
|
||||
sourceId: existingAny.sourceId,
|
||||
targetId: existingAny.targetId,
|
||||
source: existingAny.sourceId,
|
||||
target: existingAny.targetId,
|
||||
verb: newVerbType,
|
||||
type: newVerbType,
|
||||
...(params.subtype !== undefined
|
||||
? { subtype: params.subtype }
|
||||
: existingAny.subtype !== undefined && { subtype: existingAny.subtype }),
|
||||
weight: updatedMetadata.weight,
|
||||
metadata: newMetadata,
|
||||
data: updatedMetadata.data,
|
||||
createdAt: existingAny.createdAt
|
||||
}
|
||||
|
||||
await this.transactionManager.executeTransaction(async (tx) => {
|
||||
tx.addOperation(
|
||||
new UpdateVerbMetadataOperation(this.storage, params.id, updatedMetadata)
|
||||
)
|
||||
|
||||
// If the verb type changed, re-index in graph adjacency so traversal-by-type
|
||||
// stays consistent. The id is preserved across the swap.
|
||||
if (typeChanged) {
|
||||
tx.addOperation(
|
||||
new RemoveFromGraphIndexOperation(this.graphIndex, existing as any)
|
||||
)
|
||||
tx.addOperation(
|
||||
new AddToGraphIndexOperation(this.graphIndex, verbForIndex)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relationships between entities
|
||||
*
|
||||
|
|
@ -2087,6 +2232,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
filter.verbType = Array.isArray(params.type) ? params.type : [params.type]
|
||||
}
|
||||
|
||||
if (params.subtype !== undefined) {
|
||||
filter.subtype = Array.isArray(params.subtype) ? params.subtype : [params.subtype]
|
||||
}
|
||||
|
||||
if (params.service) {
|
||||
filter.service = params.service
|
||||
}
|
||||
|
|
@ -2399,6 +2548,180 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
return `__fieldCounts__${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Register subtype enforcement for a specific NounType or VerbType.
|
||||
*
|
||||
* Two complementary mechanisms shipped together in 7.30.0:
|
||||
*
|
||||
* 1. **Per-type enforcement** (this method): mark a specific type as requiring
|
||||
* a subtype, optionally with a fixed vocabulary. Writes targeting that
|
||||
* type without a matching subtype throw at the boundary.
|
||||
*
|
||||
* 2. **Brain-wide strict mode**: enable via `new Brainy({ requireSubtype: true })`.
|
||||
* Every public write path checks the strict-mode rule. See
|
||||
* `BrainyConfig.requireSubtype`.
|
||||
*
|
||||
* Both compose: a per-type registration always applies regardless of the
|
||||
* brain-wide flag.
|
||||
*
|
||||
* @param type - The `NounType` or `VerbType` to register
|
||||
* @param options.values - Optional vocabulary whitelist (rejects off-vocab values)
|
||||
* @param options.required - When `true`, subtype is required on `add()`/`relate()`/`update()`/`updateRelation()` for this type (default: `true`)
|
||||
*
|
||||
* @example Lock down Person sub-classification
|
||||
* brain.requireSubtype(NounType.Person, {
|
||||
* values: ['employee', 'customer', 'vendor'],
|
||||
* required: true
|
||||
* })
|
||||
*
|
||||
* @example Lock down management relationships
|
||||
* brain.requireSubtype(VerbType.Manages, {
|
||||
* values: ['direct', 'dotted-line'],
|
||||
* required: true
|
||||
* })
|
||||
*/
|
||||
requireSubtype(
|
||||
type: NounType | VerbType,
|
||||
options: { values?: string[]; required?: boolean } = {}
|
||||
): void {
|
||||
if (type === undefined || type === null) {
|
||||
throw new Error('requireSubtype: type must be a valid NounType or VerbType')
|
||||
}
|
||||
const required = options.required !== false
|
||||
const valuesSet = options.values && options.values.length > 0
|
||||
? new Set(options.values)
|
||||
: undefined
|
||||
this._requiredSubtypes.set(String(type), { required, values: valuesSet })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the per-type subtype rule for a given NounType or VerbType.
|
||||
* Returns `null` when no rule is registered for the type. Used by the
|
||||
* write-path enforcement hooks (`enforceSubtypeOnAdd`, `enforceSubtypeOnRelate`).
|
||||
*/
|
||||
private getSubtypeRule(type: NounType | VerbType | undefined): { required: boolean; values?: Set<string> } | null {
|
||||
if (type === undefined || type === null) return null
|
||||
return this._requiredSubtypes.get(String(type)) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether brain-wide strict mode is on for a given type. Brain-wide
|
||||
* `requireSubtype: true` enforces on every type; the `{ except: [...] }`
|
||||
* form allows the listed types through. Used by the write-path enforcement
|
||||
* hooks for both nouns and verbs.
|
||||
*/
|
||||
private brainWideStrictRequiresSubtype(type: NounType | VerbType | undefined): boolean {
|
||||
const flag = this.config.requireSubtype
|
||||
if (!flag) return false
|
||||
if (flag === true) return true
|
||||
// { except: [...] } form — strict except for listed types
|
||||
if (typeof flag === 'object' && Array.isArray((flag as any).except)) {
|
||||
if (type === undefined || type === null) return true
|
||||
return !(flag as any).except.includes(type)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a write should bypass subtype enforcement because it represents
|
||||
* internal Brainy infrastructure rather than user data. Currently triggers on:
|
||||
*
|
||||
* - `metadata.isVFSEntity === true` — Virtual File System root + directories
|
||||
* + file entities. These are platform plumbing and follow their own
|
||||
* subtype conventions (`'vfs-root'`, `'vfs-directory'`, `'vfs-file'`).
|
||||
* - `metadata.isVFS === true` — same intent; older marker.
|
||||
*
|
||||
* If user code sets these flags, they opt out of enforcement and accept the
|
||||
* responsibility. Documented as such on `AddParams.metadata` JSDoc.
|
||||
*/
|
||||
private isInfrastructureWrite(metadata: unknown): boolean {
|
||||
if (!metadata || typeof metadata !== 'object') return false
|
||||
const m = metadata as Record<string, unknown>
|
||||
return m.isVFSEntity === true || m.isVFS === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce subtype rules for a noun write (`add` / `update`).
|
||||
*
|
||||
* Walks the per-type registration AND the brain-wide strict-mode flag, and
|
||||
* throws with a descriptive message on missing or off-vocabulary values.
|
||||
* Called from `add()` / `addMany()` / `update()` before the storage write.
|
||||
*
|
||||
* Skips infrastructure writes (see `isInfrastructureWrite`) so Brainy's own
|
||||
* VFS root + directories + file entities don't get rejected when strict mode
|
||||
* is on. Vocabulary rules still apply to user-supplied subtypes — the bypass
|
||||
* only covers the missing-subtype case for internal plumbing.
|
||||
*
|
||||
* @param op - 'add' or 'update' (for error messages)
|
||||
* @param type - The NounType being written
|
||||
* @param subtype - The subtype value (or undefined)
|
||||
* @param metadata - The metadata bag (checked for infrastructure markers)
|
||||
*/
|
||||
private enforceSubtypeOnAdd(
|
||||
op: 'add' | 'update',
|
||||
type: NounType | undefined,
|
||||
subtype: string | undefined,
|
||||
metadata?: unknown
|
||||
): void {
|
||||
const rule = this.getSubtypeRule(type)
|
||||
const strict = this.brainWideStrictRequiresSubtype(type)
|
||||
const isInfra = this.isInfrastructureWrite(metadata)
|
||||
|
||||
if (!rule && !strict) return
|
||||
|
||||
if (subtype === undefined || subtype === null || subtype === '') {
|
||||
if ((rule?.required || strict) && !isInfra) {
|
||||
throw new Error(
|
||||
`${op}(): NounType.${type} requires subtype but got ${subtype === undefined ? 'undefined' : 'empty'}. ` +
|
||||
(rule?.values ? `Allowed values: [${Array.from(rule.values).join(', ')}].` : 'Register vocabulary via brain.requireSubtype().')
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (rule?.values && !rule.values.has(subtype)) {
|
||||
throw new Error(
|
||||
`${op}(): NounType.${type} subtype '${subtype}' is not in registered vocabulary ` +
|
||||
`[${Array.from(rule.values).join(', ')}].`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce subtype rules for a verb write (`relate` / `updateRelation`).
|
||||
* Mirror of `enforceSubtypeOnAdd` for relationships. Skips infrastructure
|
||||
* edges (VFS containment, etc.) — same `isVFSEntity` / `isVFS` markers.
|
||||
*/
|
||||
private enforceSubtypeOnRelate(
|
||||
op: 'relate' | 'updateRelation',
|
||||
verb: VerbType | undefined,
|
||||
subtype: string | undefined,
|
||||
metadata?: unknown
|
||||
): void {
|
||||
const rule = this.getSubtypeRule(verb)
|
||||
const strict = this.brainWideStrictRequiresSubtype(verb)
|
||||
const isInfra = this.isInfrastructureWrite(metadata)
|
||||
|
||||
if (!rule && !strict) return
|
||||
|
||||
if (subtype === undefined || subtype === null || subtype === '') {
|
||||
if ((rule?.required || strict) && !isInfra) {
|
||||
throw new Error(
|
||||
`${op}(): VerbType.${verb} requires subtype but got ${subtype === undefined ? 'undefined' : 'empty'}. ` +
|
||||
(rule?.values ? `Allowed values: [${Array.from(rule.values).join(', ')}].` : 'Register vocabulary via brain.requireSubtype().')
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (rule?.values && !rule.values.has(subtype)) {
|
||||
throw new Error(
|
||||
`${op}(): VerbType.${verb} subtype '${subtype}' is not in registered vocabulary ` +
|
||||
`[${Array.from(rule.values).join(', ')}].`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a metadata bag (or top-level field assignment) against any registered
|
||||
* value whitelists. Called from `add()`/`update()` after the standard zero-config
|
||||
|
|
@ -3115,6 +3438,19 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
this.assertWritable('addMany')
|
||||
await this.ensureInitialized()
|
||||
|
||||
// Pre-validate every item against per-type + brain-wide subtype rules BEFORE
|
||||
// any storage write — atomic-fail semantics: a missing-subtype anywhere in
|
||||
// the batch fails the whole call, no partial writes. (7.30.0)
|
||||
for (let i = 0; i < params.items.length; i++) {
|
||||
const item = params.items[i]
|
||||
try {
|
||||
this.enforceSubtypeOnAdd('add', item.type, item.subtype, item.metadata)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(`addMany(): item[${i}] failed subtype enforcement: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Get optimal batch configuration from storage adapter
|
||||
// This automatically adapts to storage characteristics:
|
||||
// - GCS: 50 batch size, 100ms delay, sequential
|
||||
|
|
@ -3465,6 +3801,19 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
this.assertWritable('relateMany')
|
||||
await this.ensureInitialized()
|
||||
|
||||
// Pre-validate every item against per-type + brain-wide subtype rules BEFORE
|
||||
// any storage write — atomic-fail semantics: a missing-subtype anywhere in
|
||||
// the batch fails the whole call, no partial writes. (7.30.0)
|
||||
for (let i = 0; i < params.items.length; i++) {
|
||||
const item = params.items[i]
|
||||
try {
|
||||
this.enforceSubtypeOnRelate('relate', item.type, item.subtype, item.metadata)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(`relateMany(): item[${i}] failed subtype enforcement: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Get optimal batch configuration from storage adapter
|
||||
// Automatically adapts to storage characteristics
|
||||
const storageConfig = this.storage.getBatchConfig()
|
||||
|
|
@ -6291,6 +6640,63 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
return Object.fromEntries(this.graphIndex.getAllRelationshipCounts())
|
||||
},
|
||||
|
||||
/**
|
||||
* O(1) subtype counts for a given VerbType. Verb-side mirror of
|
||||
* `bySubtype`. Returns the count for a single (verb, subtype) pair when
|
||||
* `subtype` is passed; returns the full subtype → count map when omitted.
|
||||
* Backed by the persisted `_system/verb-subtype-statistics.json` rollup.
|
||||
*
|
||||
* @param verb - The VerbType to count subtypes within
|
||||
* @param subtype - Optional specific subtype string for O(1) point count
|
||||
*
|
||||
* @example
|
||||
* brain.counts.byRelationshipSubtype(VerbType.Manages)
|
||||
* // → { direct: 12, 'dotted-line': 3 }
|
||||
*
|
||||
* brain.counts.byRelationshipSubtype(VerbType.Manages, 'direct')
|
||||
* // → 12
|
||||
*/
|
||||
byRelationshipSubtype: (verb: VerbType, subtype?: string): number | Record<string, number> => {
|
||||
const verbSubtypeMap = typeof (this.storage as any).getVerbSubtypeCountsByType === 'function'
|
||||
? (this.storage as any).getVerbSubtypeCountsByType() as Map<number, Map<string, number>>
|
||||
: null
|
||||
if (!verbSubtypeMap) {
|
||||
return subtype !== undefined ? 0 : {}
|
||||
}
|
||||
const verbIdx = TypeUtils.getVerbIndex(verb)
|
||||
const inner = verbSubtypeMap.get(verbIdx)
|
||||
if (!inner) {
|
||||
return subtype !== undefined ? 0 : {}
|
||||
}
|
||||
if (subtype !== undefined) {
|
||||
return inner.get(subtype) || 0
|
||||
}
|
||||
const result: Record<string, number> = {}
|
||||
for (const [k, v] of inner.entries()) result[k] = v
|
||||
return result
|
||||
},
|
||||
|
||||
/**
|
||||
* Top N subtypes for a VerbType, sorted by count (descending). Mirror of
|
||||
* `topSubtypes` for verbs.
|
||||
*
|
||||
* @example
|
||||
* brain.counts.topRelationshipSubtypes(VerbType.Manages, 5)
|
||||
* // → [['direct', 12], ['dotted-line', 3]]
|
||||
*/
|
||||
topRelationshipSubtypes: (verb: VerbType, n: number = 10): Array<[string, number]> => {
|
||||
const verbSubtypeMap = typeof (this.storage as any).getVerbSubtypeCountsByType === 'function'
|
||||
? (this.storage as any).getVerbSubtypeCountsByType() as Map<number, Map<string, number>>
|
||||
: null
|
||||
if (!verbSubtypeMap) return []
|
||||
const verbIdx = TypeUtils.getVerbIndex(verb)
|
||||
const inner = verbSubtypeMap.get(verbIdx)
|
||||
if (!inner) return []
|
||||
return Array.from(inner.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, n)
|
||||
},
|
||||
|
||||
// O(1) count by field-value criteria
|
||||
byCriteria: async (field: string, value: any) => {
|
||||
return this.metadataIndex.getCountForCriteria(field, value)
|
||||
|
|
@ -6437,6 +6843,30 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
return Array.from(inner.keys()).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct subtypes seen for a given VerbType. Mirror of `subtypesOf` for
|
||||
* relationships. Reads from the verb subtype-statistics rollup — no scan, no
|
||||
* storage round-trip. Vocabulary is observed (what's actually in the data),
|
||||
* not registered.
|
||||
*
|
||||
* @param verb - The VerbType to enumerate subtypes for
|
||||
* @returns Sorted list of distinct subtype strings (empty if none)
|
||||
*
|
||||
* @example
|
||||
* const variants = brain.relationshipSubtypesOf(VerbType.Manages)
|
||||
* // → ['direct', 'dotted-line']
|
||||
*/
|
||||
relationshipSubtypesOf(verb: VerbType): string[] {
|
||||
const verbSubtypeMap = typeof (this.storage as any).getVerbSubtypeCountsByType === 'function'
|
||||
? (this.storage as any).getVerbSubtypeCountsByType() as Map<number, Map<string, number>>
|
||||
: null
|
||||
if (!verbSubtypeMap) return []
|
||||
const verbIdx = TypeUtils.getVerbIndex(verb)
|
||||
const inner = verbSubtypeMap.get(verbIdx)
|
||||
if (!inner) return []
|
||||
return Array.from(inner.keys()).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream-and-rewrite a field across every entity in the brain.
|
||||
*
|
||||
|
|
@ -6484,6 +6914,13 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
readBoth?: boolean
|
||||
batchSize?: number
|
||||
onProgress?: (progress: { scanned: number; migrated: number }) => void
|
||||
/**
|
||||
* Which entity kind to walk. Defaults to `'noun'` for backward compat with
|
||||
* 7.29.0. Use `'verb'` to migrate relationship fields, or `'both'` to walk
|
||||
* nouns then verbs in one pass. Path forms (`'subtype'`, `'metadata.X'`,
|
||||
* `'data.X'`) are identical on both sides.
|
||||
*/
|
||||
entityKind?: 'noun' | 'verb' | 'both'
|
||||
}): Promise<{
|
||||
scanned: number
|
||||
migrated: number
|
||||
|
|
@ -6507,58 +6944,177 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
const toPath = this.parseMigrationPath(options.to)
|
||||
const batchSize = Math.max(1, options.batchSize ?? 100)
|
||||
const readBoth = options.readBoth === true
|
||||
const entityKind = options.entityKind ?? 'noun'
|
||||
|
||||
let scanned = 0
|
||||
let migrated = 0
|
||||
let skipped = 0
|
||||
const errors: Array<{ id: string; error: string }> = []
|
||||
let offset = 0
|
||||
|
||||
// Stream via storage pagination — same shape used by streaming.entities()
|
||||
// when no filter is set. Avoids a full in-memory load on large brains.
|
||||
while (true) {
|
||||
const page = await this.storage.getNouns({ pagination: { offset, limit: batchSize } })
|
||||
if (page.items.length === 0) break
|
||||
|
||||
for (const noun of page.items) {
|
||||
scanned++
|
||||
try {
|
||||
const entity = await this.convertNounToEntity(noun as any)
|
||||
const sourceValue = this.readPath(entity, fromPath)
|
||||
if (sourceValue === undefined || sourceValue === null) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const targetValue = this.readPath(entity, toPath)
|
||||
const sourceCleared = readBoth || fromPath.kind === toPath.kind && fromPath.field === toPath.field
|
||||
if (targetValue === sourceValue && (readBoth || !this.pathExists(entity, fromPath))) {
|
||||
// Already migrated and (readBoth || source already gone) → no-op
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const update = this.buildMigrationUpdate(entity, fromPath, toPath, sourceValue, readBoth)
|
||||
await this.update(update)
|
||||
migrated++
|
||||
void sourceCleared
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
id: (noun as any).id ?? '<unknown>',
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const reportProgress = (): void => {
|
||||
if (options.onProgress) {
|
||||
options.onProgress({ scanned, migrated })
|
||||
}
|
||||
if (!page.hasMore) break
|
||||
offset += page.items.length
|
||||
}
|
||||
|
||||
// Walk nouns when entityKind is 'noun' or 'both'.
|
||||
if (entityKind === 'noun' || entityKind === 'both') {
|
||||
let offset = 0
|
||||
while (true) {
|
||||
const page = await this.storage.getNouns({ pagination: { offset, limit: batchSize } })
|
||||
if (page.items.length === 0) break
|
||||
for (const noun of page.items) {
|
||||
scanned++
|
||||
try {
|
||||
const entity = await this.convertNounToEntity(noun as any)
|
||||
const sourceValue = this.readPath(entity, fromPath)
|
||||
if (sourceValue === undefined || sourceValue === null) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const targetValue = this.readPath(entity, toPath)
|
||||
if (targetValue === sourceValue && (readBoth || !this.pathExists(entity, fromPath))) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const update = this.buildMigrationUpdate(entity, fromPath, toPath, sourceValue, readBoth)
|
||||
await this.update(update)
|
||||
migrated++
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
id: (noun as any).id ?? '<unknown>',
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
reportProgress()
|
||||
if (!page.hasMore) break
|
||||
offset += page.items.length
|
||||
}
|
||||
}
|
||||
|
||||
// Walk verbs when entityKind is 'verb' or 'both'. Mirror of the noun loop —
|
||||
// the path forms (`'subtype'`, `'metadata.X'`, `'data.X'`) and the
|
||||
// readPath / buildMigrationUpdate helpers all work for verbs because
|
||||
// `Relation<T>` carries the same shape (top-level standard fields +
|
||||
// metadata bag + optional data object). updateRelation() is the verb-side
|
||||
// mutator.
|
||||
if (entityKind === 'verb' || entityKind === 'both') {
|
||||
let offset = 0
|
||||
while (true) {
|
||||
const page = await this.storage.getVerbs({ pagination: { offset, limit: batchSize } })
|
||||
if (page.items.length === 0) break
|
||||
for (const verb of page.items) {
|
||||
scanned++
|
||||
try {
|
||||
const edge = this.verbToRelationLike(verb as any)
|
||||
const sourceValue = this.readPath(edge, fromPath)
|
||||
if (sourceValue === undefined || sourceValue === null) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const targetValue = this.readPath(edge, toPath)
|
||||
if (targetValue === sourceValue && (readBoth || !this.pathExists(edge, fromPath))) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const update = this.buildRelationMigrationUpdate(edge, fromPath, toPath, sourceValue, readBoth)
|
||||
await this.updateRelation(update)
|
||||
migrated++
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
id: (verb as any).id ?? '<unknown>',
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
reportProgress()
|
||||
if (!page.hasMore) break
|
||||
offset += page.items.length
|
||||
}
|
||||
}
|
||||
|
||||
return { scanned, migrated, skipped, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a storage verb shape onto the Entity<T>-shaped surface that
|
||||
* `readPath` / `pathExists` already understand. The migration helpers were
|
||||
* written for nouns; making them work for verbs is just a shape projection —
|
||||
* `subtype` / `metadata` / `data` live at the same paths on both sides.
|
||||
*/
|
||||
private verbToRelationLike(verb: any): Entity<T> {
|
||||
return {
|
||||
id: verb.id,
|
||||
vector: verb.vector,
|
||||
type: (verb.verb ?? verb.type) as any,
|
||||
subtype: verb.subtype,
|
||||
data: verb.data,
|
||||
metadata: verb.metadata as T,
|
||||
service: verb.service,
|
||||
createdAt: verb.createdAt,
|
||||
updatedAt: verb.updatedAt,
|
||||
createdBy: verb.createdBy,
|
||||
confidence: verb.confidence,
|
||||
weight: verb.weight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an `UpdateRelationParams` payload that mirrors `buildMigrationUpdate`
|
||||
* for verbs. Top-level path → top-level on update; metadata path → metadata
|
||||
* bag with merge:false; data path → data bag.
|
||||
*/
|
||||
private buildRelationMigrationUpdate(
|
||||
edge: Entity<T>,
|
||||
from: { kind: 'top' | 'metadata' | 'data'; field: string },
|
||||
to: { kind: 'top' | 'metadata' | 'data'; field: string },
|
||||
value: unknown,
|
||||
readBoth: boolean
|
||||
): UpdateRelationParams<T> {
|
||||
const id = edge.id
|
||||
const update: UpdateRelationParams<T> = { id }
|
||||
|
||||
if (to.kind === 'top') {
|
||||
;(update as any)[to.field] = value
|
||||
} else if (to.kind === 'metadata') {
|
||||
const base = (edge.metadata as unknown as Record<string, unknown>) ?? {}
|
||||
const nextMeta: Record<string, unknown> = { ...base, [to.field]: value }
|
||||
if (!readBoth && from.kind === 'metadata') {
|
||||
delete nextMeta[from.field]
|
||||
}
|
||||
update.metadata = nextMeta as any
|
||||
update.merge = false
|
||||
} else {
|
||||
const base = (edge.data && typeof edge.data === 'object') ? { ...(edge.data as Record<string, unknown>) } : {}
|
||||
base[to.field] = value
|
||||
if (!readBoth && from.kind === 'data') {
|
||||
delete base[from.field]
|
||||
}
|
||||
update.data = base
|
||||
}
|
||||
|
||||
if (!readBoth) {
|
||||
if (from.kind === 'metadata' && to.kind !== 'metadata') {
|
||||
const base = (edge.metadata as unknown as Record<string, unknown>) ?? {}
|
||||
const nextMeta: Record<string, unknown> = { ...base }
|
||||
delete nextMeta[from.field]
|
||||
update.metadata = nextMeta as any
|
||||
update.merge = false
|
||||
} else if (from.kind === 'data' && to.kind !== 'data') {
|
||||
const base = (edge.data && typeof edge.data === 'object') ? { ...(edge.data as Record<string, unknown>) } : null
|
||||
if (base) {
|
||||
delete base[from.field]
|
||||
update.data = base
|
||||
}
|
||||
} else if (from.kind === 'top' && from.field === 'subtype') {
|
||||
;(update as any).subtype = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return update
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a dotted path used by `migrateField` into its routing kind + field name.
|
||||
* `'subtype'` → top-level standard; `'metadata.X'` → metadata; `'data.X'` → data;
|
||||
|
|
@ -7609,6 +8165,18 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
|
||||
const { from, to, depth, direction = 'both' } = params.connected
|
||||
const via = params.connected.via ?? params.connected.type
|
||||
const subtypeFilter = params.connected.subtype
|
||||
|
||||
// Multi-hop subtype filtering would require per-hop edge enumeration in JS, which doesn't
|
||||
// scale. The native fast path lands in the next Cortex release (see CTX-SUBTYPE-PARITY-V2).
|
||||
// For 7.30.0 JS, restrict subtype filtering to depth-1 — explicit error beats silent
|
||||
// incorrect results.
|
||||
if (subtypeFilter !== undefined && depth !== undefined && depth > 1) {
|
||||
throw new Error(
|
||||
'find({ connected: { subtype, depth > 1 } }) is not yet supported on the JS path. ' +
|
||||
'Use depth: 1, or wait for Cortex native traversal (CTX-SUBTYPE-PARITY-V2).'
|
||||
)
|
||||
}
|
||||
|
||||
// GraphConstraints speaks 'in' | 'out' | 'both'; neighbors() speaks 'incoming' | 'outgoing' | 'both'.
|
||||
const toNeighborDir = (d: 'in' | 'out' | 'both'): 'incoming' | 'outgoing' | 'both' =>
|
||||
|
|
@ -7631,6 +8199,30 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
for (const id of await collect(to, toNeighborDir(reverse))) connectedIds.add(id)
|
||||
}
|
||||
|
||||
// Subtype filter (depth-1 only — see guard above). Intersect connectedIds with the set of
|
||||
// {to} ids whose edge from the anchor carries the matching subtype.
|
||||
if (subtypeFilter !== undefined) {
|
||||
const subtypeArr = Array.isArray(subtypeFilter) ? subtypeFilter : [subtypeFilter]
|
||||
const validIds = new Set<string>()
|
||||
const matchAnchor = async (anchor: string, reverseLookup: boolean): Promise<void> => {
|
||||
// Pull all edges from anchor that match via + subtype, then intersect.
|
||||
const edges = await this.getRelations({
|
||||
...(reverseLookup ? { to: anchor } : { from: anchor }),
|
||||
...(via && { type: via as VerbType | VerbType[] }),
|
||||
subtype: subtypeArr,
|
||||
limit: 10000
|
||||
})
|
||||
for (const e of edges) {
|
||||
validIds.add(reverseLookup ? e.from : e.to)
|
||||
}
|
||||
}
|
||||
if (from) await matchAnchor(from, false)
|
||||
if (to) await matchAnchor(to, true)
|
||||
for (const id of [...connectedIds]) {
|
||||
if (!validIds.has(id)) connectedIds.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter existing results to only connected entities
|
||||
if (existingResults.length > 0) {
|
||||
return existingResults.filter(r => connectedIds.has(r.id))
|
||||
|
|
@ -7921,17 +8513,21 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
* Convert verbs to relations (read from top-level)
|
||||
*/
|
||||
private verbsToRelations(verbs: GraphVerb[]): Relation<T>[] {
|
||||
return verbs.map((v) => ({
|
||||
id: v.id,
|
||||
from: v.sourceId,
|
||||
to: v.targetId,
|
||||
type: (v.verb || v.type) as VerbType,
|
||||
weight: v.weight ?? 1.0,
|
||||
data: v.data,
|
||||
metadata: v.metadata,
|
||||
service: v.service as string,
|
||||
createdAt: typeof v.createdAt === 'number' ? v.createdAt : Date.now()
|
||||
}))
|
||||
return verbs.map((v) => {
|
||||
const va = v as any
|
||||
return {
|
||||
id: v.id,
|
||||
from: v.sourceId,
|
||||
to: v.targetId,
|
||||
type: (v.verb || v.type) as VerbType,
|
||||
...(va.subtype !== undefined && { subtype: va.subtype as string }),
|
||||
weight: v.weight ?? 1.0,
|
||||
data: v.data,
|
||||
metadata: v.metadata,
|
||||
service: v.service as string,
|
||||
createdAt: typeof v.createdAt === 'number' ? v.createdAt : Date.now()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -8463,6 +9059,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
integrations: config?.integrations ?? undefined as any,
|
||||
// Migration — disabled by default, opt-in for automatic migration
|
||||
autoMigrate: config?.autoMigrate ?? false,
|
||||
// Subtype pairing enforcement (7.30.0) — opt-in.
|
||||
// false: only per-type rules registered via brain.requireSubtype() apply.
|
||||
// true: every public write path requires subtype on every type.
|
||||
// { except: [...] }: strict, but listed types may omit subtype.
|
||||
// Becomes the default in 8.0.0.
|
||||
requireSubtype: config?.requireSubtype ?? false,
|
||||
// Multi-process safety
|
||||
mode: config?.mode ?? 'writer',
|
||||
force: config?.force ?? false
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue