Commit graph

160 commits

Author SHA1 Message Date
a52dba2168 refactor(8.0): API-surface + quality polish from the readiness audit
- find() search-mode: collapsed the two overlapping options to one canonical
  `searchMode: SearchMode` (the redundant `mode` alias was silently ignored on
  the primary find() path while honored on the historical path — a footgun) and
  removed the unwired `explain?` FindParams field (never read by find()).
- Documentation accuracy on the public type surface: entity ids documented as
  UUID v7 (auto) / v5 (natural key), not v4; dropped the stale "Brainy 3.0"
  banners and "backward compatibility" hedging on the fresh-8.0 Result type;
  documented the via/type alias; removed the dead GraphConstraints.bidirectional;
  fixed the no-op `EmbeddedGraphVerb = Omit<GraphVerb,'source'>` to omit the real
  sourceId; corrected the GraphIndexProvider.isInitialized JSDoc; documented
  removeMany's per-chunk generation granularity; JSDoc'd the public VFS surface.
- Honest perf comments in source: removed unmeasured billion-scale figures from
  the LSMTree / graphAdjacencyIndex headers (the JS fallback is in-memory after
  open; native is the scale path).
- Robustness: getVerbMetadata propagates read errors symmetrically with
  getNounMetadata; the find() egress integrity-guard keeps a row when the JS
  matcher doesn't implement an operator the provider already matched on; hardened
  the boundary-no-native CI guard to catch side-effect imports + re-exports.
- Tests: de-theatricalized a relateMany test to assert the real contract; fixed
  a stale Model-B header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 10:04:19 -07:00
47e8031124 fix(8.0): close GA-blocking correctness gaps from the readiness audit
- Version-coupling cold-init: getBrainyVersion() returned a stale build-time
  default ('3.14.0') on the FIRST synchronous call — the one loadPlugins() makes
  to build context.version — because the package.json read was async. A native
  provider declaring a realistic `>=8.0.0` range was therefore rejected on cold
  init. version.ts now reads package.json synchronously (8.0 targets Node-like
  runtimes only); added a cold-init coupling regression with a `^8.0.0` plugin.
- No-silent-failures on the highest-fan-in read path: getNouns()/getVerbs()
  converted a storage read failure into a success-shaped empty page, which the
  cold-start rebuild then read as "store empty" and skipped the rebuild — booting
  a permanently-empty index with no signal (the same silent-failure class as the
  phantom bug). Both now re-throw a named BrainyError; the rebuild path re-throws
  read failures (fail loud) and records a queryable degraded state, surfaced via
  checkHealth(), for non-fatal rebuild hiccups.
- LSM SSTable leak: graph compaction wrote a merged SSTable but only dropped the
  old ones from the manifest, orphaning their payloads forever ("In production
  we'd add a cleanup mechanism"). Added StorageAdapter.deleteMetadata() and now
  reclaim each compacted-away SSTable.
- Documented the two headline methods add() and find() (the only undocumented
  public methods on the class).

Full gate green: build, unit 1512, integration 607.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 10:03:38 -07:00
3d116190f3 fix(8.0): re-validate find() results against the predicate (index-integrity guard)
find() trusted the metadata index's returned ids: it loaded each id's entity
and returned it, checking only that the entity existed — never that it actually
matched the query. The indexes are acceleration structures; the loaded entity
is ground truth. A stale or cross-bucket index posting (e.g. an id left in a
field-value bucket by a delete or an `update({ field: undefined })`) therefore
surfaced an entity matching NEITHER the requested type NOR the where filter — a
production report saw a timeslot (NounType.Event) returned for
`find({ type: Person, where: { entityType: 'staff' } })`.

Add a single egress chokepoint after the result IIFE that re-validates every
result with `entityMatchesFind` (type/subtype/where/service/excludeVFS) — the
live-path mirror of the historical path's per-candidate check in db.ts. It
covers every find() branch (metadata, vector, text, proximity, graph) and
`similar()` (which delegates to find) in one place. A no-op on a healthy index;
on a corrupted one it drops the bad row instead of returning a phantom. Full
unit + integration gate green confirms the where re-validation is consistent
with the index (no valid rows dropped).

This is the brainy-side safety net. The durable corruption itself lives in the
metadata index that owns the query (the native provider when present) and is
addressed separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:25:12 -07:00
d321cf5f33 fix(8.0): gate native graph analytics on the provider readiness flag
The optional native graph-acceleration provider exposes `isInitialized`,
which is false during its cold-start / rebuild window (engine + cursor not
yet loaded). The accessor cached the resolved provider INSTANCE but never
re-checked readiness, so `brain.graph.*` could dispatch to a not-ready
provider and throw instead of answering.

Resolve and cache the instance once, but check `isInitialized` LIVE on every
call: route to the pure-TS analytics path while the provider is not ready,
and re-engage the native path automatically the moment it flips true. This
gates every native route at once — subgraph, export, rank, communities,
path, and the query→expand fusion. Adds a test asserting the provider is
never called (and the TS fallback returns real answers) while not ready.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:18:50 -07:00
bf0afe8563 refactor(8.0): remove dead, unreachable, and unwired modules
Pre-GA dead-code sweep. A deterministic import-reachability walk from the
package's real entry points (the exports map, the CLI bin, the conversation
surface) found 34 source modules that nothing reachable imports — they
compile and ship as dist output that no consumer or internal path can ever
reach. All were superseded duplicates, abandoned parallel implementations,
or built-but-never-wired features:

  - superseded duplicates of live modules: an older "unified" entry, a
    standalone neural-import variant, a static NLP processor + its matcher,
    a parallel API-types module, a duplicate progress-types module
  - an abandoned import path (orchestrator + entity deduplicator + barrels)
    left behind when ingestion moved to the coordinator
  - unwired feature modules (instance pool, import presets, cached
    embeddings, relationship-confidence scorer) reachable only from tests
  - dead leaf utilities (write buffer, deleted-items index, bounded
    registry, two crypto shims, a cache manager, a structured logger, a
    stale v5 type-migration helper, a browser-only FS type shim) and
    several dead re-export barrels
  - a CLI catalog command wired into no command

Also removed two tests that only exercised deleted modules, repointed two
VFS tests off a deleted barrel onto the implementation module, and deleted
one example that demoed removed features.

Verified: clean build (both tsc passes), unit 1505 + integration 607 green,
and a re-run of the reachability walk reports zero remaining orphans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:18:40 -07:00
1c363e8c4b feat(8.0): asOf at-gen vector defer — provider-served historical semantic search (#35)
A filtered semantic read at a historical generation (db.asOf(g).find({ query, where }))
no longer unconditionally rebuilds an ephemeral JS-HNSW over every at-g vector
(O(n@G), the OOM ceiling the 2026-06-24 scaling audit flagged). When the vector
index is a VersionedIndexProvider that advertises isGenerationVisible(g), Brainy:
  1. resolves the at-g metadata∩graph universe from the record-overlay path (no
     materialization — it's the metadata-only historical find),
  2. routes the vector leg to the provider with { allowedIds, generation }, and
  3. composes the at-g entities ranked by the provider's at-g vector distance.
Without a versioned provider (the JS index, or a native one that refuses the
generation) it falls through to the existing materialization — unchanged.

- Seam (A): VectorIndexProvider.search gains an optional `generation?: bigint`
  ("omitted = now"), the vector mirror of the graph provider's trailing-gen + the
  #46 allowedIds field. JsHnswVectorIndex accepts and ignores it (no per-gen
  segments → refuse/fall-back posture; Brainy never routes a historical read there).
- Gate: Db.find tries the native at-gen vector path before materialize(); host
  exposes canServeVectorAtGeneration + vectorSearchAtGeneration.
- generation is Brainy's u64 commit counter — the same value handed to the graph
  index on writes (graphWriteGeneration), so it maps 1:1 to the native side.

Decided lockstep with the cor team (handoff #35 thread: (A) + vector-only). The
gen-g candidate-vector supply for cor's exact-rerank (part-3) is a separate,
non-blocking seam still being confirmed; the honesty guard keeps this path inactive
(falls through to materialize) until cor's native at-gen rerank is live.

Tested: provider routing + at-gen universe correctness (excludes future-born,
applies the filter) + page window via a mock versioned provider; seam-ignore on the
JS index; 38 db-mvcc/db-temporal historical-read tests green (materialize fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:52:09 -07:00
450084b6ce perf(8.0): bound find({ where, orderBy }) sort to the page (CTX-BR-FIND-ORDERBY)
A filtered + ordered find() materialized the FULL sorted match set before slicing
the page — passing k = filteredIds.length to the column store's filteredSortTopK.
A broad filter + orderBy returning 20 rows at billion scale produced hundreds of
millions of sorted ids to discard all but the page (O(matches) heap).

Thread the page bound (offset + limit + the hidden-tier over-fetch) into
getSortedIdsForFilter → filteredSortTopK / sortTopK / the sparse-path slice, so
the sort produces only the page. Ordering and pagination are unchanged.

From cor's 2026-06-24 co-release scaling audit (item 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:01:49 -07:00
82dde92077 feat(8.0): brain.graph.subgraph(query) query→expand fusion (#61)
The subgraph seed selector now accepts not just entity id(s) but a find() result
or a FindParams query — brain.graph.subgraph({ where: { team: 'platform' } },
{ depth: 1 }) runs the query and expands the neighborhood of every match in one
call. Existing id-seeded calls are unchanged.

The win is the native query→expand path: for a metadata-only query, the matched
universe is forwarded to traverse() as an OpaqueIdSet (a roaring Buffer) with NO
id materialization in TypeScript — the find() result never leaves the engine's
representation (the O(1)-crossing the cor 3.0 contract is built around). The
pure-JS path (or any query carrying vector/text/proximity criteria) materializes
the matched ids via find() and seeds the traversal from them, capped at a bounded
default when the caller pins no limit.

- New public `SubgraphSelector<T>` union (id | id[] | Result[] | FindParams).
- find()'s metadata filter-builder extracted to a shared `buildMetadataFilter`
  (reused by the opaque universe producer); behavior unchanged.
- graphSubgraphNative generalized to accept `bigint[] | OpaqueIdSet` seeds.

Tested: JS query→expand + Result[] selector + empty-match in graph-subgraph, and
the native opaque pass-through (Buffer reaches traverse unmaterialized) vs the
find()-materialized bigint[] seeds via the mock provider in graph-native-routing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:30:30 -07:00
dd325f2f94 feat(8.0): vector allowedIds predicate-pushdown into find() (#46)
Filtered semantic search now keeps its recall. A find({ query, where }) that
pairs vector search with a metadata filter restricts the HNSW beam walk to the
matching candidates INSIDE the traversal (walk-all, collect-allowed) rather than
filtering the top-k afterward — so a query whose nearest vectors are all filtered
out still returns the best matches that DO pass the filter, instead of empty.

- JS HNSW search() honors the 8.0 `allowedIds: OpaqueIdSet | ReadonlySet<string>`
  contract param (ANDs with candidateIds; ignores the opaque Buffer form it can't
  decode — only a native provider consumes that).
- MetadataIndexProvider gains an OPTIONAL `getIdSetForFilter(filter): OpaqueIdSet`
  producer — the native (cor) metadata index returns its roaring filter result as
  a serialized buffer; the JS index does not implement it.
- find() forwards the matched universe to the vector walk: the opaque buffer
  (zero id materialization) when the native producer is present, alongside the
  materialized visibility-precise candidateIds for the JS index. Visibility stays
  correct via the existing post-search hard filter.

Tested: JS recall/restriction/AND/opaque-ignore on the index directly, plus
find() forwarding the opaque universe through to the beam walk via a stubbed
native producer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:18:34 -07:00
632d90aac5 feat(8.0): graph analytics — brain.graph.rank / communities / path
Adds three intent-level graph reads to the `brain.graph` namespace, each
native-dispatched to the optional `@soulcraft/cor` 3.0 graph engine when present
and served from pure-TS kernels otherwise (identical public shapes, default
visibility filter respected on both paths):

- `rank(opts?)` → `{ id, score }[]` descending — importance / centrality.
  TS fallback: PageRank power-iteration with dangling-mass redistribution.
- `communities(opts?)` → `{ groups, count }` — connected grouping. TS fallback:
  union-find weakly-connected components, or iterative Tarjan SCC when
  `{ directed: true }`.
- `path(from, to, opts?)` → `{ nodes, relationships, cost } | null` — best route.
  TS fallback: BFS for fewest hops, Dijkstra (min-heap) for least summed edge
  weight (`by: 'weight'`); on-demand frontier expansion so short paths terminate
  early. `direction` / `type` / `maxDepth` filters apply.

These are intent contracts, not algorithm contracts — the question is the
promise, the algorithm is the engine's choice.

Pure kernels live in src/graph/analyticsFallback.ts (PageRank, connected
components, Tarjan SCC, MinHeap) — unit-tested in isolation. The full surface is
tested end-to-end through the TS fallback, and the native dispatch + int↔uuid
hydration paths are covered by a mock provider in graph-native-routing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:32:03 -07:00
3783e61b30 test(8.0): cover pending-tier range queries + setRetentionBudget adaptive reclaim
Closes the two coverage gaps a temporal-completeness audit surfaced (the code
was already correct, just untested):
- changedBetween / generationsTouching include un-flushed PENDING generations
  and stay identical across the flush — the building blocks of since/diff/history
  see single-op writes with no forced flush.
- setRetentionBudget() drives adaptive auto-compaction on flush() down to the
  byte budget: history is reclaimed, the live record is untouched (the cor #65
  retention-consume wiring, proven end-to-end).

65 generation/temporal tests green; 1528 unit green.
2026-06-23 11:11:43 -07:00
5c3bb2c864 feat(8.0): Model-B per-write generation-stamping + adaptive retention knob
Every write — transact() AND single-op add/update/remove/relate — is now its
own immutable generation (Model-B), so a now() pin always freezes and
asOf/since/diff/history/transactionLog reflect single-ops exactly like
transacts. Closes the Model-A hole where pins did not freeze against single-op
writes.

Generation-stamping:
- GenerationStore.commitSingleOp: a one-operation commitTransaction with
  deferred durability. Wired into add/update/remove/relate/updateRelation/
  unrelate + removeMany (the *Many and VFS paths delegate to these).
- Async group-commit (flushPendingSingleOps): the live write is acknowledged
  immediately; its before-image is buffered in an in-memory pending tier that
  resolveAt/chains/changedBetween/tx-log read like on-disk generations, so the
  synchronous now() freezes with no forced flush. One fsync per window
  (triggers: size / 50ms timer / flush / close / transact / compactHistory).
- Crash recovery is drop-without-restore for group-commit generations (marked
  groupCommit:true): a crash mid-flush discards the partial generation and
  never restores its before-images, which would otherwise revert the
  already-acknowledged live write.
- Init-time infrastructure (the VFS root) is the un-versioned generation-0
  baseline: a fresh brain reports generation()===0 and an empty
  transactionLog(); the first user write is generation 1.
- Historical find()/related() overlay bound is the full reserved watermark
  (generation()), so un-flushed single-op writes are overlaid too.

Retention knob:
- config `history` -> `retention`: 'all' | 'adaptive' |
  { maxGenerations?, maxAge?, maxBytes?, budgetBytes?, autoCompact? }; unset ->
  adaptive (disk/RAM pressure, zero-config). CompactHistoryOptions floors ->
  caps (retainGenerations->maxGenerations, retainMs->maxAge, +maxBytes):
  reclaim oldest-unpinned while ANY cap is exceeded; pins always exempt.
- brain.setRetentionBudget(bytes) drives the adaptive byte budget at runtime
  (a coordinator's fair-share input). Per-generation bytes recorded in each
  delta enable historyBytes() introspection without a storage size API.

Tests: per-write generation resolution, pin freeze vs add/update/remove,
drop-without-restore corruption-trap (fault injector), clean-reopen replay,
maxBytes/maxAge/no-cap reclamation, retention-then-reopen. 107 db/generation/
temporal tests green, tsc clean. Docs (ADR-001, consistency-model, snapshots
guide, api reference, RELEASES) updated to per-write granularity + retention.
2026-06-22 15:19:58 -07:00
ceed70d7be perf(8.0): per-id history chains for O(log) historical reads + bounded delta cache
Historical reads (asOf/get) scanned the global committedGens list linearly,
making them O(database-age): a read of an unchanged entity at an old pin scaled
~12x for 10x history depth. Add per-id inverted history chains (nounChains/
verbChains) so resolveAt binary-searches the id's own generation chain instead —
O(log) and flat with depth. Chains build lazily under the commit mutex,
maintain incrementally on commit, and invalidate on compaction.

Also bound deltaCache (LRU cap 4096; getDelta re-reads evicted deltas) so a
long-lived high-write process's heap is O(cap) not O(generations) on the
disk-backed path, and binary-search commitTimestampAtOrBefore (O(log)).

Verified: 373 unit tests green; new tests/unit/db/generation-chain.test.ts
covers chain resolution, eviction re-read, and chain rebuild after compaction.
2026-06-22 13:47:33 -07:00
f3e69110f0 refactor(8.0): graph analytics contract — intent names, not algorithm names
The public brain.graph.* surface and the GraphAccelerationProvider seam must name
operations by INTENT, not by the algorithm that happens to implement them — so a
plugin can swap algorithms without the contract lying. The JS fallback computes
communities via connected-components and rank via PageRank, but a native provider
(cor) is free to use Louvain/Leiden for communities and personalized-PageRank /
eigenvector-centrality for rank: same intent, different algorithm, same name.

Renamed the (not-yet-implemented) Phase-C analytics methods + their option/result
types on GraphAccelerationProvider:
- pageRank -> rank            (PageRankOptions -> RankOptions; dropped the
                               pagerank-internal damping/maxIterations/tolerance
                               knobs from the contract — each impl tunes itself)
- connectedComponents -> communities   (ComponentsOptions -> CommunitiesOptions,
                               mode:'weak'|'strong' -> directed?:boolean;
                               GraphComponents -> GraphCommunities, componentIds/
                               Count -> communityIds/Count)
- shortestPath -> path        (ShortestPathOptions -> PathOptions; weight -> by:'hops'|'weight')
- neighborhoodSample -> sample (NeighborhoodSampleOptions -> SampleOptions)
- topByDegree -> mostConnected (TopByDegreeOptions -> MostConnectedOptions)

traverse / edgesForNode / graphCursorOpen/Next/Close + GraphScores/GraphPath/
OpaqueIdSet/Subgraph are UNCHANGED — everything cor has already built is untouched;
this is a pure pre-implementation rename (cor coordinated). JSDoc reworded to state
the algorithm is the provider's choice. index.ts exports + the native-seam test
mock updated. tsc/unit 1517/integration 599 green.
2026-06-22 09:54:01 -07:00
29410bc568 test(8.0): cover the native graph seam + make provider resolution factory-tolerant
The brain.graph.subgraph/export NATIVE routing (graphSubgraphNative /
graphExportNative / hydrateNativeSubgraph + provider resolution) had ZERO brainy
CI coverage — in production it's exercised only cross-layer against cor's engine,
so a columnar return-shape or hydration-alignment drift would pass brainy CI
silently. This registers a faithful MOCK GraphAccelerationProvider returning a
columnar Subgraph built from the brain's REAL ints, locking the seam:
- subgraph() routes native (traverse called) and hydrates node int->id, the
  nodeDepth column aligned to the node column, node type via batchGet, and edge
  verb-int->Relation via verbIntsToIds + getVerbsBatchCached.
- node<->depth alignment is preserved when a node int does NOT resolve
  (deleted/unknown) — the unresolvable int is dropped, not collapsed (which would
  shift every later depth). This is the exact hydration risk the release audit flagged.
- export() routes to the graph cursor, hydrates chunks, and ALWAYS closes the handle.

Also fixes a latent contract bug found writing the test: graphAccelerationProvider()
only accepted a ready instance, so a provider registered as a (storage)=>provider
FACTORY (the convention graphIndex/metadataIndex/vector use) would fail the duck-test
and the native path would silently never engage. Now resolves instance OR factory,
cached. Covered by the factory-registration test.
2026-06-22 09:34:09 -07:00
c2a84c9d1f feat(8.0): brain.graph.export() + noun-walk cursor + noun visibility hydration
brain.graph.export() streams the WHOLE graph in one O(N+E) pass — node chunks
then edge chunks, async-iterable — the right primitive for visualizing all data
(vs. paging per node). Native snapshot-consistent graphCursor when present, else
a TS cursor walk over nouns + verbs. Building it surfaced two latent noun bugs,
both fixed here (each a correctness win beyond export):

- getNounsWithPagination IGNORED its cursor ('offset-based, cursor planned') —
  the noun mirror of the verb bug fixed in the cursor-pagination commit. Latent
  because the only multi-page consumer (aggregate backfill) uses one big page; a
  small chunkSize needed page 2 and, trusting the returned-but-ignored nextCursor,
  re-fetched page 0 forever (infinite loop). Ported the proven verb cursor:
  opaque cn1:<shard>:<id> token, stable within-shard id order, resume-after,
  O(N) at any chunk size. (Generalized verbIdFromVectorPath → idFromVectorPath.)
- hydrateNounWithMetadata DROPPED 'visibility' (noun mirror of the Fix #1 verb
  hydration bug) — so getNouns-fed visibility filters saw nothing and leaked
  system (VFS root) / internal nodes. Now hydrated.

- New: GraphApi.export + GraphExportOptions (chunkSize, includeInternal/System,
  includeNodes/Edges). hydrateNativeSubgraph extracted + shared by subgraph+export.

Test: graph-export.test.ts — full-graph completeness incl. isolated nodes,
default-hides-internal, node/edge include toggles, chunkSize chunking (the
case that exposed the cursor hang). Full gate green.
2026-06-21 10:22:12 -07:00
8c2b57a488 feat(8.0): brain.graph.subgraph() + native-provider routing
Introduces the brain.graph namespace and its first op, subgraph() — bounded
multi-hop neighborhood extraction returning a hydrated GraphView (nodes with hop
depth + type/subtype, edges as Relations, a truncated flag). The one-call answer
to 'show me everything around this node, N hops out' the graph-viz path needs.

- brain.graph.subgraph(seeds, { depth, direction, type, subtype, includeInternal,
  includeSystem, maxNodes, maxEdges, hydrateNodes }). seeds: id or id[].
- Feature-detect routing: when a GraphAccelerationProvider is registered
  (isGraphAccelerationProvider on the 'graphAcceleration' provider) it routes to
  native traverse() and hydrates the columnar Subgraph (node ints↔ids paired with
  depth position-preserving, edge verb ints → Relations); otherwise a pure-TS BFS
  fallback expands each frontier through the O(degree) related() adjacency.
- Both paths produce the identical GraphView, so CI exercises the shape via the
  fallback; the native path is cross-layer-tested against cor's provider.
- New public types: GraphView, GraphNode, SubgraphOptions, GraphApi (the surface
  grows: export/rank/path/communities follow). isGraphAccelerationProvider guard
  added to plugin.ts.

Test: graph-subgraph.test.ts — depth tracking, direction, type filter, default-
hides-internal, node hydration on/off, maxNodes truncation, multi-seed.
2026-06-21 09:23:33 -07:00
d4de48d004 feat(8.0): related({ node }) — one-call both-direction incident edges
related({ from }) / related({ to }) each return one direction; related({ node })
unions both (every edge where the node is source OR target), deduped, in a single
O(degree) call — the indexed equivalent of merging two related() calls by hand.
The 'all edges on a noun' query the graph-viz path needs.

- RelatedParams.node: mutually exclusive with from/to (throws if combined);
  composes with type/subtype/visibility filters; natural-key ids resolved.
- Implemented as a parallel out-edge (sourceId) + in-edge (targetId) fetch through
  the O(degree) graph-index fast paths, merged + deduped by id (a self-loop appears
  once), sorted for a stable page. Full incidence is the intended O(degree) cost;
  deep offset paging of a very-high-degree node is best-effort (use the native
  edgesForNode / a graph cursor for exhaustive paging).
- db.related() parity: node resolved + honored in relationMatchesParams (matches an
  edge incident in either direction) for the historical/overlay paths.

Test: related-node-bidirectional.test.ts — both directions, equals from∪to, self-loop
dedupe, type-filter composition, default-hides-internal, node+from/to throws.
2026-06-21 08:43:55 -07:00
a3d6fdb8b3 feat(8.0): GraphAccelerationProvider contract — the native graph-engine seam
Defines the optional native graph-acceleration provider (cor 3.0) that
brainy feature-detects and routes brain.graph.* / related({node}) /
find({connected}) to, falling back to pure-TS adjacency when absent. This is
the published seam cor wires its native build against (mirrors the existing
VersionedIndexProvider pattern: defined in plugin.ts, exported from index.ts,
implemented externally, feature-detected at runtime).

Contract (locked cross-team in the design thread):
- GraphAccelerationProvider: traverse, edgesForNode, graphCursorOpen/Next/Close,
  pageRank, connectedComponents, shortestPath, neighborhoodSample, topByDegree.
  Every read op is generation-aware (optional trailing generation?: bigint) so
  db.asOf(g).graph.* resolves historically; omitted = now.
- Subgraph: columnar wire format (BigInt64Array node/edge columns, Uint16Array
  edgeTypes, optional Uint8Array nodeDepth, truncated flag) — parallel typed
  arrays, never array-of-objects; brainy maps u64<->UUID lazily for rendered rows.
- OpaqueIdSet: a find() universe forwarded opaquely into traverse/search for the
  zero-crossing query->expand fusion (brainy never inspects it; the provider
  version-tags + validates the envelope).
- VectorIndexProvider.search gains allowedIds?: OpaqueIdSet | ReadonlySet<string>
  (predicate pushdown — recovers filtered recall lost to post-filtering).
- EntityIdMapperProvider gains entityIntsToUuids(BigInt64Array) — the bigint batch
  reverse resolver for Subgraph.nodes; implemented on the JS mapper (TS fallback).

All new public types exported from index.ts. Test: entityIntsToUuids round-trip
+ order-preservation + empty-int sentinel.
2026-06-21 08:12:22 -07:00
682e786cc3 perf(8.0): cursor pagination for the verb walk — full edge pagination O(N²) → O(N)
getVerbsWithPagination was offset-only: it re-scanned from shard 0 on every page
and early-terminated at offset+limit, so a full edge walk was O(N²) (a consumer
measured ~19k edges → 27s paging at 900/page). It now accepts an opaque resume
token (cv1:<shard>:<verbId>) that resumes the shard walk immediately AFTER the
last returned verb — O(N) total at any page size, no scale cliff.

- Stable within-shard order (sort by verb id); ids come from the path so verbs
  skipped by the cursor are never read.
- Cursor supersedes offset when present; offset path preserved for back-compat,
  and offset callers now get a usable nextCursor so they can switch to cursor
  paging. Foreign/malformed tokens decode to null → offset fallback (no throw).
- The relationships stream generator now uses cursor (was offset → O(N²)).
- Parity with the noun side (getNounsWithPagination already cursored).

Test: tests/unit/storage/verb-cursor-pagination.test.ts — a full cursored walk
visits every edge exactly once (no dup, no skip), matches the offset walk's set,
and a foreign cursor falls back gracefully.
2026-06-20 16:55:02 -07:00
a914313e3a perf(8.0): visibility-aware fast adjacency — related() stays O(degree) under default visibility
related({from/to}) routes through storage.getVerbs, which has O(degree)
GraphAdjacencyIndex fast paths (getVerbsBySource/Target/Type_internal). But
those fast paths were disqualified whenever excludeVisibility was set —
and every default related() sets it, because visibility defaults to excluding
internal+system. So the common per-node edge lookup fell through to a full O(E)
shard scan (multi-second per node on large graphs; a consumer measured
1.4-4.6s/node and an O(N^2) whole-graph walk).

Root cause two-parter, both fixed here:
- hydrateVerbWithMetadata dropped 'visibility' (mapped subtype but not the
  reserved visibility field), so fast-path results couldn't be visibility-filtered
  AND related({includeInternal}) couldn't even report which edges were internal
  (latent correctness bug — verbsToRelations already mapped it). Now hydrated.
- getVerbs disqualified the fast paths on subtype/excludeVisibility. Now the
  fast paths apply both filters on their already-hydrated O(degree) candidate set
  via applyVerbMetadataFilters() — matching the full-scan fallback's semantics —
  so default related() stays on the index instead of scanning the whole graph.

Filtering semantics are unchanged (same result set as the scan); only the path
that computes it changes. Test: related-visibility-fast-path.test.ts (default
excludes internal both directions, includeInternal surfaces + reports visibility,
subtype filter on the fast path).
2026-06-20 16:34:32 -07:00
4cc2088aed feat(8.0): upsert + FindParams.includeVectors + removeMany adaptive chunking
Three additive ergonomics from the API-simplification audit (no behavior change
to existing call sites):

- AddParams.upsert: create-or-update in one call. With a custom id, an existing
  entity is MERGED via the update path (merges metadata, re-embeds changed data,
  bumps _rev, PRESERVES createdAt) instead of the destructive full overwrite a
  plain add() does. Mutually exclusive with ifAbsent (throws if both set);
  ignored when no id is supplied. Wired into add(), addMany (per-item flag
  propagation), and the transact add op (routes to planTxUpdate). Kills the
  get()-then-add() round-trip for idempotent writes.
- FindParams.includeVectors: mirror of GetOptions.includeVectors — find() returns
  stored vectors when set; default stays empty (the perf contract is preserved).
  Honored on both the query and metadata-only where paths, and in db.find().
- removeMany adaptive chunking: replaced the hardcoded chunkSize=10 with
  params.chunkSize ?? storageConfig.maxBatchSize, matching addMany/relateMany —
  one storage-adaptive batch policy across all *Many methods (no thrash on
  high-latency backends).

Tests: tests/unit/brainy/upsert.test.ts (insert/merge/createdAt-preserved/
re-embed/ifAbsent-conflict/no-id/addMany/transact), find-include-vectors.test.ts
(true/default/where-path), batch-operations.test.ts (removeMany >10 items).
2026-06-20 16:34:20 -07:00
54c7c39669 feat(8.0): reserved-field enforcement — reservedFieldPolicy defaults to throw
An untyped (JS) caller that smuggles a Brainy-reserved field (confidence,
weight, subtype, visibility, service, createdBy, noun/verb, data, createdAt,
updatedAt, _rev) inside a write-path metadata bag previously got a silent
remap-or-drop — a class of bug where confidence-evolution writes no-oped for
weeks before being caught on read-back. 8.0 closes this with no silent failures.

- New BrainyConfig.reservedFieldPolicy: 'throw' | 'warn' | 'remap' (default 'throw').
  'throw' rejects the write naming every offending key + its correct write path;
  'warn' remaps with a one-shot per-key warning; 'remap' is the legacy silent path.
- Central enforceReservedPolicy gate wired into all four remap methods (add,
  update, relate, updateRelation) so live calls AND their transact()/with()
  mirrors honor it. Single-source reservedWritePath guidance shared by throw+warn.
- 'warn' now warns for EVERY reserved key (closes the gap where only
  system-managed fields warned). Dead warnDropped* helpers removed.
- Import pipeline migrated to route reserved values (confidence/weight/subtype)
  through dedicated params and strip reserved keys from extractor/customMetadata
  bags via the canonical split*MetadataRecord helpers — imports no longer trip
  the default throw.
- Tests: new reservedFieldPolicy matrix (throw/warn/remap across every write
  path + transact); remap-correctness suite reframed as opt-in 'remap'; shared
  test-factory no longer emits reserved keys in custom metadata.
2026-06-20 15:37:21 -07:00
d02e522a3e feat(8.0): id-normalization (#18) + aggregation min/max delete-safety + RC-safe release
- id-normalization (#18): `brain.newId()` (UUID v7) + v7 default ids; non-UUID string ids are
  transparently normalized to a stable UUID v5 on creation AND every lookup — get/update/remove,
  relate(from,to), related, find({connected}), getMany, removeMany, the transact op-handler, and
  db.with() overlays — with the caller's original key preserved under `_originalId`. The engine only
  ever sees a UUID; real UUIDs pass through untouched. universal/uuid.ts gains v5/v7/isUUID +
  BRAINY_ID_NAMESPACE; new utils/idNormalization.ts (coerceNewEntityId / resolveEntityId).
- aggregation min/max delete-safety: `queryAggregate` no longer leaves a stale min/max after
  deleting the current extreme — min/max now track a value multiset and recompute in-memory (no
  entity scan; that scan was the prior delete-then-hang a consumer reported). Other metrics were
  already exact across deletes. Regression test proves resolve-after-delete + correct new min/max.
- release.sh RC-safe: explicit version arg, prerelease detection (npm `--tag rc` + GitHub
  `--prerelease`), full `test:ci` gate (unit + integration), current-branch push, npm-access
  verification after publish.
- native-engine references point at `@soulcraft/cor` (8.0's partner) in user-facing strings/docs.

Unit 1461/0 · integration 599/0 · tsc clean.
2026-06-20 14:46:40 -07:00
606445cd61 feat(8.0): API simplification — remove neural()/Db.search, one storage path key, integration→0
8.0 RC cleanup toward "one place per thing, zero-config, no deprecation":

- Remove the `brain.neural()` clustering namespace (ImprovedNeuralAPI + the dead
  legacy NeuralAPI + the neural CLI + neural-only types). Similarity is `find({vector})`
  / `similar({to})`; attribute grouping is the aggregation `GROUP BY` engine. The separate
  entity-extraction / smart-import feature (NeuralImport, NeuralEntityExtractor, SmartExtractor,
  NaturalLanguageProcessor, `brain.extract()`/`brain.nlp()`) is kept.
- Remove `Db.search()`; `find()` is the one query verb (accepts a bare string or FindParams).
  Fix the bundled MCP client, which called a non-existent `brain.search(query, limit)` →
  now `find({ query, limit })`.
- Storage config: collapse to one canonical top-level `path` key. The pre-8.0 aliases
  (`rootDirectory`, `options.*`, `fileSystemStorage.*`) are removed and now THROW with the
  exact rename instead of silently defaulting to `./brainy-data` on upgrade. A single resolver
  feeds createStorage, the 7.x→8.0 migration probe, and the plugin-factory handoff, so a native
  storage provider resolves the identical root (no split-brain).
- Fix `similar({ threshold })`: the min-similarity filter was silently dropped; it is now
  applied as a post-filter on `result.score` (the documented way to bound semantic results).
- Fix `vfs.rename()` on a directory: child path updates spread the entity vector into `update()`
  and failed dimension validation; they are metadata-only updates now.
- Fix `vfs.move()`: copy+delete orphaned the content-addressed content blob (the destination
  shared the source hash, then unlink removed it). `move()` now delegates to `rename()` — an
  in-place path change that preserves the blob and the entity id, for files and directories.
- Fix streaming import: the bulk fast path never flushed mid-import nor signalled queryability.
  Entity writes are now chunked by a progressive flush interval (100 → 1000 → 5000); each chunk
  flushes and emits `progress.queryable`, so imported data is queryable during the import.
- Sweep all docs, comments, and JSDoc for the removed/changed APIs.

Integration suite: 49 files / 588 passed / 0 failed. Unit: 80 files / 1456 passed, no type errors.
2026-06-20 13:31:11 -07:00
2c84f86815 feat(8.0): temporal range verbs — diff, history, since(gen|Date), asOf{exclusive}, transactionLog window
asOf() answers "state AT a point"; these answer "what happened BETWEEN two
points" and "one entity's whole history", all on the existing generation records.

- Extract resolveAsOfGeneration() as the shared gen|Date→{generation,timestamp}
  chokepoint (reachability + Date semantics identical everywhere). asOf()'s
  inclusive path is byte-identical — db-mvcc still 25/25.
- asOf(target, { exclusive }) — strict-before (resolved−1, clamped at gen 0,
  never a RangeError).
- db.since(Db | generation | Date) — overload; number/Date resolve via the shared
  resolver (new DbHost.resolveGeneration); EXCLUSIVE lower bound;
  since(db) === since(db.generation). Same-store guard via Db.belongsToStore.
- brain.diff(a, b) → { added, removed, modified } split by nouns/verbs. EARNS its
  name: candidate set is ONLY changedBetween(gLow,gHigh), each classified by
  existence at both endpoints + a key-order-insensitive value compare
  (new src/db/stableEqual.ts) — a touched-but-reverted / born-and-died id is in
  no bucket.
- brain.history(id, { from, to }) → every distinct version oldest→newest, each
  value === asOf(version.generation).get(id); null = removal; kind auto-detected
  (throws on UUID-space collision). New store helper generationsTouching().
- brain.transactionLog({ from, to, limit }) — INCLUSIVE generation/Date window
  (contrast since's exclusive lower bound); limit applied last.
- Compaction policy locked: diff/since THROW GenerationCompactedError below the
  horizon; history TRUNCATES to it.

Types DiffResult/HistoryVersion/EntityHistory exported from the package root.
Tests: tests/integration/db-temporal.test.ts (8 proofs incl. diff-earns-its-name,
history↔asOf cross-check, the composition proof, granularity, compaction contrast)
+ tests/unit/db/stableEqual.test.ts (6). docs/guides/snapshots-and-time-travel.md
+ RELEASES updated. 1477 unit + db-temporal 8 + db-mvcc 25 green.
2026-06-19 13:21:02 -07:00
373a48122d refactor(8.0): rename BackupData → PortableGraph (the type is interchange, not a backup)
The export()/import() document type was named BackupData, but it is not a backup:
it is the portable, versioned, partial-or-whole interchange representation of a
graph (entities + relations + optional vectors/blobs) — the unit Brainy exports
for transport between instances, versions, and products, and the payload other
artifacts embed. The actual backup is persist()/load() (the native whole-brain,
generation-preserving snapshot), so "Backup*" actively collided with that concept.

Rename every developer-visible symbol, file, doc, JSDoc and comment:
- BackupData→PortableGraph, BackupEntity→PortableGraphEntity,
  BackupRelation→PortableGraphRelation, BackupReader/Writer→PortableGraphReader/Writer,
  BackupValidation→PortableGraphValidation, isBackupData→isPortableGraph,
  validateBackup→validatePortableGraph, BACKUP_FORMAT[_VERSION]→PORTABLE_GRAPH_FORMAT[_VERSION].
- src/db/backup.ts → src/db/portableGraph.ts; test → db-portable-graph.test.ts.
- Guide, api/README, RELEASES updated.

The on-the-wire `format` tag is renamed 'brainy-backup' → 'brainy-portable-graph':
the format was introduced in 7.32.0 and has not been adopted by any consumer, so
there are no stored documents to stay compatible with — a clean rename beats
carrying a legacy tag. No deprecated aliases (nothing to alias). 7.x shipped the
same rename as 7.32.2.

1471 unit green; build clean; db-portable-graph.test.ts 17/17.
2026-06-19 12:37:23 -07:00
3a3aa43b3a fix(8.0): VFS path-cache instance-scoping + verb totalCount page-cap
Two independent restart/multi-instance correctness bugs.

VFS path cache (A.3): PathResolver keyed the PROCESS-GLOBAL path cache on
`vfs:path:<path>` with no instance scope. Multiple Brainys per process (a
supported pattern) over DIFFERENT storage collided — instance A's `/x → id_A`
satisfied instance B's `stat('/x')` against unrelated storage → stale id →
"Entity not found" (surfaced by metadata-only-comprehensive's VFS stat() once
another VFS test had seeded the cache). Scope every `vfs:path:` key by a
monotonic per-process instance token (the VFS root id is a fixed sentinel, so it
can't disambiguate; the global cache is itself process-scoped so the token
suffices). Scoped the invalidate/prefix-delete paths too, so a branch-switch /
clear / fork on one brain no longer wipes another brain's cache. Cross-instance
sharing was only an optimization and was the collision itself; each instance
keeps its own local pathCache.

Verb totalCount (verb mirror of b2005ff): getVerbsWithPagination returned
`collectedVerbs.length` (the peeked page size, bounded by offset+limit+1) as
totalCount, so getVerbs({limit:1}).totalCount read 1/2 for any non-empty brain
on the unfiltered path. Now returns the authoritative O(1) totalVerbCount
(isNew-gated, visibility-filtered, rehydrated on init); filtered scans keep the
collected length. Regression: tests/unit/storage/getVerbs-totalCount.test.ts
(warm + cold reopen, mirrors the noun test).

metadata-only-comprehensive + vfs-api-wiring integration green; 1471 unit green.
2026-06-19 11:45:13 -07:00
009e50681e fix(8.0): column-store range queries honor exclusive bounds (lessThan/greaterThan)
getIdsForRange computed includeMin/includeMax correctly for every operator
(gt→false/true, lt→true/false, between→true/true) but DROPPED them when the
column store served the query — it called columnStore.rangeQuery(field,min,max)
with no flags, and the column-store path was inclusive-only. So whenever a field
lived in the column store, strict lessThan/greaterThan silently behaved as
lte/gte. The sparse-fallback path already forwarded the flags, so this was
column-store-only.

Thread includeMin/includeMax end to end:
- ColumnSegmentCursor: add textbook lowerBound/upperBound helpers and express
  binarySearchRange in terms of them. Inclusive/inclusive is byte-identical to
  the old impl (lowerBound(lo) == old binarySearchValue(lo).index; upperBound(hi)
  == old "first position after hi"). Exclusive lower advances past ALL duplicates
  of the boundary value; exclusive upper stops before them.
- ColumnStore.rangeQuery: accept the flags, apply them in the segment cursor AND
  the tail-buffer linear scan (strict > / < when exclusive). A bound taken from a
  segment's own min/max stays inclusive — it is a real stored value.
- VectorIndex/types interface + getIdsForRange call site updated.

Surfaced by brainy-complete dual-bound test (year/popularity exclusive both ends
→ ['Express','Vue.js']; React@95 and Angular@75 correctly excluded). Added 5
column-store unit tests: exclusive lower, exclusive upper, both, duplicate
boundary values, and the unflushed tail-buffer path. 1469 unit green.
2026-06-19 11:01:41 -07:00
1264fec534 feat(8.0): version-coupling guard — a mismatched/failed native plugin fails loud, never silent JS fallback
A wrong or missing accelerator (@soulcraft/cor) used to silently degrade to the
default JS engine — the #1 source of invisible cross-repo drift. Brainy does no
plugin auto-detection (a registered plugin is always explicitly requested via
config.plugins or brain.use()), so any mismatch/failure is now fatal:

- BrainyPlugin gains optional `brainyRange` (semver range of brainy it supports);
  init() throws if the running brainy is outside it. New dep-free, prerelease-safe
  matcher pluginRangeSatisfies() (8.0.0-rc1 satisfies >=8.0.0).
- activateAll(): a plugin that THROWS during activate now re-throws (was swallowed
  to a JS fallback); a graceful decline (activate()→false) stays non-fatal but logs
  a loud warning.
- loadPlugins(): an explicitly-listed package that can't load — or isn't a valid
  plugin — throws (was a silent skip).
- Provider-key cliff: a pre-8.0 plugin that registers a legacy vector key
  ('hnsw'/'diskann') but not the 8.0 'vector' key throws (brainy 8.x reads only
  'vector', so it would otherwise run JS vectors invisibly).

Tests: tests/unit/plugin-version-coupling.test.ts (range matcher incl. rc1; range
mismatch / activate-throw / cliff / missing-package all throw; decline non-fatal).
Updated two plugin.test.ts cases that asserted the old silent-swallow behavior.
Build green; 1464 unit green. cor declares its brainyRange per handoff LV.1 #2.
2026-06-19 10:13:04 -07:00
b198281ce1 test(8.0): boundary guard forbids @soulcraft/cor too (cortex→cor rename)
The open/closed separation test now forbids the proprietary native package under
BOTH names (@soulcraft/cor + legacy @soulcraft/cortex) — in any dependency field
and any static import under src/ or tests/. Renamed boundary-no-cortex →
boundary-no-native. This is load-bearing for the lockstep model: because brainy
cannot depend on cor, the combined brainy-8.0 × cor-3.0 integration matrix lives
in the cor repo (which devDeps brainy), not here.
2026-06-19 09:03:54 -07:00
21d02d3bae fix(8.0): stats() per-type counts no longer inflate with HNSW re-saves
nounCountsByType (read by stats().entitiesByType / counts.byTypeEnum) was
incremented in saveNoun_internal(), which runs on every noun write — including
the HNSW index re-saving a node whenever its neighbor links change. So per-type
counts grew with graph connectivity instead of entity count (an internal report
saw 8 documents read as 44). Moved the increment into saveNounMetadata_internal(),
gated on isNew + visibility, exactly parallel to the authoritative total; the
visibility-flip path adjusts it in lockstep too.

Tests: tests/unit/storage/stats-count-accuracy.test.ts (30 dense-type entities ->
exactly 30 across stats/counts/getNounCount) + de-theatricalized the >=2 assertion
in brainy-core.unit.test.ts to exact equality. Build green; 1455 unit green.

Part of the 8.0 readiness audit Phase 1; cold-reopen count rehydration is still WIP.
2026-06-17 17:07:58 -07:00
b2005ff22a fix(8.0): getNouns().totalCount reports true total, not page size (port of 7.32.1)
getNounsWithPagination returned collectedNouns.length as totalCount, but the
type-first shard scan early-terminates at offset+limit+1 (peeks one for hasMore)
— so getNouns({ pagination: { limit: 1 } }).totalCount was the page size for any
non-empty brain. The index-rebuild gate calls exactly that, so cold starts
mis-logged "Small dataset (N items)". Now reports the authoritative O(1) noun
counter (rehydrated on init) as the unfiltered total; 8.0's peek-based hasMore is
already correct and unchanged. Filtered scans unchanged.

Regression: tests/unit/storage/getNouns-totalCount.test.ts. Full 8.0 unit suite green (1453).
2026-06-17 14:10:06 -07:00
574a8b147c fix(8.0): distinctCount aggregates distinct values of any type + edge-case regression tests
distinctCount previously routed through getNumericField, so it silently returned 0 for
string/categorical fields — its primary use case (distinct categories / users / tags).
It now tracks the raw value (any type, keyed by string form) on both the add and remove
contribution paths; numeric ops (sum/avg/min/max/stddev/variance/percentile) are unchanged.

Also adds regression coverage confirming two long-standing query behaviors hold on the 8.0
engine: find({ where: { field: { missing: true } } }) matches a never-registered field, and
find({ type: [...], orderBy, limit }) returns the full set on the first call after
mutate+query+get. (percentile/median were already correct.)

Tests: tests/unit/brainy/find-agg-edge-cases.test.ts + existing aggregation suite green.
2026-06-17 12:03:06 -07:00
7aad80395e feat(8.0): validateBackup() dry-run + includeContent blob round-trip test + clone test
- validateBackup(data) → { valid, errors, warnings }: structural check, supported
  formatVersion, entity-id uniqueness + required fields, relation-endpoint coverage.
  Lets consumers (e.g. a serializer checking a .wbench graph) validate before import()
  without mutating the brain. Exported from the package root.
- Tests: validateBackup cases, remapIds clone (copy-not-move), and the includeContent
  VFS-blob filesystem round-trip (export captures bytes → import writes them).

Completes the #196 export/import surface for the showcase bar (deferred to 8.0.x:
since(prior).export() delta convenience, exportStream/importStream NDJSON).
2026-06-17 11:44:29 -07:00
010ccf816d feat(8.0): portable graph export()/import() (BackupData v1) — Db.export + polymorphic import
Re-adds the portable graph round-trip on 8.0 (deleted with DataAPI), now on the
immutable Db so it composes with the generational model.

- src/db/backup.ts: BackupData v1 engine (identical wire format to the 7.32.0 line).
  exportGraph() reads through a Db at its pinned generation; importGraph() applies the
  whole backup as ONE atomic transact() (a single generation).
- Db.export(selector?, options?) — read at this view's generation, so
  brain.asOf(g).export() is a time-travel export and brain.now().with(ops).export() a
  what-if export.
- brain.export(selector?, options?) — sugar for now().export().
- brain.import() is polymorphic: a BackupData document -> graph round-trip; a
  file/buffer -> existing foreign-file ingestion (dispatched on the 'brainy-backup'
  tag; zero migration for ingestion callers).
- Selectors (ids/collection/connected/vfsPath/predicate/whole) + edge policy
  (induced/incident/none) + includeVectors/includeContent/includeSystem; system
  entities excluded by visibility. onConflict merge/replace/skip, reembed auto/never,
  remapIds. DbHost gains storage (VFS blob bytes).
- Types exported from the package root: BackupData/BackupEntity/BackupRelation/
  ExportSelector/ExportOptions/ImportOptions/ImportResult + isBackupData.

Tests: tests/unit/db/db-backup.test.ts (10, green) — round-trip, selectors, edges,
vectors+re-embed, merge, asOf/with composition, validation. Full unit suite green.

Follow-ups: docs (8.0 guide + RELEASES + api/README), completeness adds
(since(prior).export() delta, exportStream/importStream, validate()), includeContent
filesystem test, ids-at-asOf get() refinement, @soulcraft/formats Zod schema mirror.
2026-06-16 16:38:18 -07:00
f4dea80176 feat(8.0): visibility field (public/internal/system) on nouns + verbs
Adds a reserved, top-level `visibility` field (mirrors the subtype rollout):
'public' (default, surfaced) | 'internal' (developer app-internal — hidden from
default find/count/stats, opt-in via includeInternal) | 'system' (Brainy
plumbing, library-set only).

Fixes a real leak: the VFS root entity counted in getNounCount() and appeared in
find() (a fresh brain reported 1 entity). It is now visibility:'system' →
excluded from every user-facing surface. Developers also get a first-class
hidden-unless-asked tier (e.g. learned internals vs user-exposed data).

- Reserved (RESERVED_ENTITY_FIELDS / RESERVED_RELATION_FIELDS) — spoof-proof from metadata.
- Threaded through add/relate/update/transact; surfaced top-level on reads.
- Default exclusion in counts (baseStorage), find()/related() (hard candidate
  filter via excludeVisibility — keeps topK/limit correct), and stats;
  includeInternal/includeSystem opt-ins.
- VFS root marked 'system'.

Tests: visibility.test.ts 17/17 (fresh-brain getNounCount()===0, internal hidden
+ opt-in, verb symmetry, top-level surfacing, metadata-spoof rejection). Unit
1431 green; count-synchronization integration now passes (off-by-one fixed).
2026-06-16 15:20:26 -07:00
dc94af3a6a test(8.0): get() resolves null for absent custom ids instead of throwing
Follow-up to accepting application-supplied ids (36b7216): get('not-a-uuid')
and long ids are now valid lookups that miss → null, not "Invalid UUID format"
throws. Empty / null / undefined still throw (structurally invalid input).
Aligns the unit suite with the accept-any-id behaviour; the v5.1.0 "stricter
UUID validation" rule these cases encoded is superseded.
2026-06-16 10:47:10 -07:00
36b7216929 fix(8.0): accept application-supplied entity ids, not just UUIDs
Sharding required a 32-hex UUID and threw "Invalid UUID format" on every
non-UUID id, even though add()'s own docs show `id: "user-12345"` and the u64
id-mapper happily assigns ints to any string. So custom ids mapped fine in
memory then threw on save — breaking documented usage and any 7.x consumer
using friendly ids (`'user-123'`, slugs, emails) on upgrade.

getShardId() (renamed from getShardIdFromUuid) now buckets UUID-format ids by
their first byte (on-disk layout unchanged, so existing data never moves) and
hashes any other id via FNV-1a into the same 256-bucket space. The hash is part
of the on-disk contract and must not change. Verbs stay Brainy-generated UUIDs
by contract; only custom noun ids take the hash path.

Adds getShardId unit coverage: dual scheme, determinism, even distribution
across buckets, and the empty-id guard.
2026-06-16 10:40:40 -07:00
5096f90fbc fix(8.0): honor top-level storage.path as a rootDirectory alias
`storage: { type: 'filesystem', path: '…' }` is a widely-used, doc-promoted
config shape, but the 8.0 storage refactor dropped top-level `path` from the
root-directory resolution chain — so it was silently ignored and every such
brain wrote to the default `./brainy-data` instead. A consumer upgrading with
`storage: { path: './my-data' }` would have had their data quietly relocated.

Restores `path` as a first-class alias for `rootDirectory` (it already worked
nested under `options.path`; now it works top-level too), adds it to the
StorageOptions / BrainyConfig.storage types, and pins every accepted spelling
(rootDirectory, path, options.rootDirectory, options.path, default) in a unit
test of the resolution chain.
2026-06-16 09:46:39 -07:00
0951fa1da0 feat(8.0): thread commit generation through the graph-write provider contract
Graph time-travel needs an edge's existence recorded per generation so
db.asOf(g) hops resolve historically correct endpoints. The metadata layer
already threads brainy's commit generation per write; the graph write path did
not, leaving a versioned verb-endpoint store unable to answer "which edges
existed at generation g".

- GraphIndexProvider.addVerb/removeVerb gain a `generation: bigint` parameter
  (the same watermark the storage layer stamps onto the record). A provider with
  a per-generation edge chain stamps the edge at that generation; the JS baseline
  has no such chain and accepts-and-ignores it — graph time-travel is a
  native-provider capability, and the open-core path serves edges as-of-now (the
  one documented graph time-travel limitation).
- The two graph transaction operations resolve the generation via a thunk at
  EXECUTE time: the generation store assigns the batch generation only once the
  commit begins executing, after the operations are planned. The same generation
  is reused for an operation's rollback half.
- All graph-write call sites pass the in-flight generation.

Adds a spy-provider test proving the threading, execute-time resolution, and
forward/rollback generation reuse. The JS index ignores the value, so behaviour
is unchanged: unit 1402/1402, db-mvcc 25/25, bigint-contract relate/unrelate 10/10.
2026-06-16 09:42:35 -07:00
b26d3d42b3 fix(8.0): drive query-cap off MemAvailable + floor auto-detected caps
The auto-detected query-limit cap was computed from os.freemem() (MemFree),
which excludes reclaimable page cache. On hosts that memory-map large index
files the cache holding those pages dominates RAM, so MemFree collapses to a
sliver and the cap cratered to its floor on perfectly healthy machines,
rejecting legitimate find() calls.

- getAvailableMemory() now prefers /proc/meminfo MemAvailable (counts
  reclaimable cache), falling back to os.freemem() off-Linux, then a 2 GB
  constant where no OS module is available.
- Auto-detected caps (container + free-memory tiers) are floored at
  MIN_AUTO_QUERY_LIMIT (10_000); a transient low reading can never throttle
  queries to a near-useless ceiling. Consumer-supplied maxQueryLimit /
  reservedQueryMemory bypass the floor — an explicit caller knows their box.

Also scrubs two stale comments referencing the removed cloud storage
adapters and the retired mmap-vector backend: 8.0's native vector provider
persists its own .dkann file via getBinaryBlobPath, so the old rootDirectory
hook does not apply on this line.

Tests: memoryLimits 26/26 (4 new floor regressions), unit 1398/1398,
find-limits + db-mvcc + api-parameter-validation 37/37.
2026-06-16 09:01:45 -07:00
c605b34f98 test(8.0): A/B benchmark harness (open leg) — generic corpus+metrics lib, brainy-alone scaling bench, boundary guard, real-embedding recall guard
The open-core, Cortex-free half of the library A/B (handoff AJ/AK), authored once in
brainy so the proprietary A/B comparison can import it for both legs:

- tests/benchmarks/lib/corpus.js — deterministic clustered-mixture corpus generator
  (recompute-on-demand, O(clusters·dim) memory) for latency/ingest/memory at scale.
- tests/benchmarks/lib/metrics.js — percentiles, brute-force recall@k, RSS snapshot.
- tests/benchmarks/brainy-scale.js — brainy-alone scaling leg (ingest, find p50/p99 for
  vector/metadata/graph/triple, RSS). Recall is intentionally NOT measured on synthetic
  data — see below.
- tests/unit/boundary-no-cortex.test.ts — CI guard: fails if @soulcraft/cortex ever
  appears in a src/ or tests/ import or in any package.json dependency field.
- tests/integration/vector-recall.test.ts — semantic-search correctness on REAL
  embeddings (19-20/20 exact-text top-1).

Methodology note: synthetic vectors (random/one-hot/clustered/latent) are near-orthogonal
under cosine, so HNSW (any graph ANN, incl. DiskANN) cannot navigate them and recall
collapses regardless of engine — a property of the data, not the index. Brainy vector
search is verified correct on real embeddings. The A/B recall@10 column is therefore
measured on SIFT/BIGANN, identically for both legs.
2026-06-15 15:51:17 -07:00
f8e0079d3f feat(8.0): zero-config finalize + cut JS quantization (config.vector = recall + persistMode) 2026-06-15 10:08:51 -07:00
f4c5d9749f fix(8.0): vfs.rename() issues a metadata-only update (port of the 7.31.7 fix)
rename() spread the entire fetched entity into brain.update(), forwarding a
vector field that failed dimension validation when the entity was fetched
without vectors. A rename is a path/metadata change: the update is now
metadata-only — no vector forwarded, no re-embedding, no vector-index touch.
Regression suite ported from the 7.x line (verbatim consumer repro,
cross-directory move, EEXIST). The companion rollback fix from 7.31.7 was
already present on this branch via the pre-RC1 sweep.
2026-06-11 15:05:12 -07:00
1f7e365a4e chore(8.0): final pre-RC1 sweep — API consistency, named errors, orphans, zero-cast codebase 2026-06-11 14:51:00 -07:00
970e08c466 feat(8.0): reserved-field contract — one canonical location, typed prevention, unified read/write
Brainy-owned field names (noun/verb, subtype, createdAt, updatedAt,
confidence, weight, service, data, createdBy, _rev) now have exactly one
home — top level — enforced by three layers driven from a single source of
truth, src/types/reservedFields.ts (RESERVED_ENTITY_FIELDS /
RESERVED_RELATION_FIELDS, exported):

1. Compile time — AddParams/UpdateParams/RelateParams/UpdateRelationParams
   metadata (and the transact() ops that extend them) reject a literal
   reserved key as a TypeScript error while keeping generic T ergonomics
   (typed bags, untyped brains, index-signature shapes, and a documented
   exemption for T-declared reserved keys). Pinned by @ts-expect-error
   type tests run under vitest typecheck mode on every unit run.

2. Write time — the 7.x update() remap is ported to 8.0 and extended to
   every write path: add/update/relate/updateRelation, their transact()
   mirrors, and db.with() overlays. User-settable fields lift to their
   dedicated param (top-level wins when both are supplied — closes the 7.x
   trap where update({metadata:{confidence}}) silently no-oped), and
   system-managed fields drop with a one-shot warning naming the right
   path. A remapped subtype satisfies subtype-pairing enforcement exactly
   like a top-level one.

3. Read time — every storage combine goes through one canonical hydration
   helper (hydrateNounWithMetadata / hydrateVerbWithMetadata over
   splitNoun/VerbMetadataRecord), so reserved fields surface ONLY top-level
   and entity/relation.metadata carry ONLY custom fields on live reads,
   batch reads, paginated listings, getRelations by source/target, streamed
   verbs, and historical asOf() materialization alike.

Read-path echoes found and fixed (previously the full stored record —
including the verb type key — leaked inside metadata): noun pagination,
verb pagination, getVerbsBySource/ByTarget (adjacency + shard fallback),
getVerbsBySourceBatch (which also dropped subtype/data), and the
filesystem verb stream. getRelations() results now surface
confidence/updatedAt top-level via verbsToRelations, updateRelation() no
longer erases service/createdBy, relate() persists its top-level
confidence/service params, and the dead convertHNSWVerbToGraphVerb echo
path is deleted. Import paths (CLI extract, deduplicator, coordinators,
neural import) write confidence through the dedicated param instead of the
bag. UpdateRelationParams is now exported from the package root.

Documented for consumers in docs/concepts/consistency-model.md ("Reserved
fields") and RELEASES.md. Regression tests ported from the 7.x fix and
extended to the full 8.0 contract (17 runtime tests + 41 type-level
assertions); full unit suite 1427/1427, db-mvcc integration 24/24.
2026-06-11 13:13:09 -07:00
c44678390e 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.
2026-06-11 10:53:55 -07:00
478fa176f2 refactor(8.0): delete DataAPI — superseded by Db persist/restore + import API + stats
The legacy backup/import/export/stats facade (src/api/DataAPI.ts) drifted
from the modern entity shape and every job it did now has a first-class
surface. Delete it and brain.data(), and rewire the CLI:

- data-stats → brain.stats() (full BrainyStats report: per-type breakdowns,
  indexed fields, index health, storage backend, writer lock, version)
- clean → brain.clear()
- export → alias of snapshot; a db.persist() snapshot is the full-fidelity
  export format (open with Brainy.load, load wholesale with brainy restore);
  external data ingestion remains brainy import (UniversalImportAPI)

Rewiring clean onto brain.clear() exposed two real bugs, both fixed:

- clear() left this.graphIndex undefined forever — any graph-touching call
  afterwards (relate, getNeighbors, stats) crashed. clear() now re-resolves
  the graph index exactly as init() does and re-wires the shared UUID↔int
  resolver, and re-resolves the metadata index with the same provider
  fallback as init().
- storage.clear() reset the legacy totals but not the per-type/subtype
  count rollups or id→type caches, so stats() reported phantom counts for
  deleted entities. Both adapters now delegate derived-state reset to
  reloadDerivedState(), the same path restore-from-snapshot uses.

One-shot CLI commands (data-stats, clean, snapshot/export, restore,
history, generation) now close the brain and exit explicitly — global
cache timers otherwise keep the process alive holding the writer lock.

Verified: build clean, 1383/1383 unit tests, 24/24 db-mvcc integration,
plus an end-to-end CLI smoke (add → data-stats → export → clean →
data-stats).
2026-06-11 09:05:12 -07:00
e5feae4104 feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.

Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
  immutable before-images otherwise) into a fresh MemoryStorage; a final
  reconciliation pass under the commit mutex makes the copy exact even
  when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
  graph-adjacency indexes from the records; the vector index is built by
  inserting every at-G vector (the at-G HNSW graph never existed on disk,
  so there is nothing to restore). Host embedder and aggregate definitions
  are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
  (handle cached; freed by release(), with a FinalizationRegistry backstop
  that also closes leaked readers). A native VersionedIndexProvider serves
  the same reads from retained segments with no rebuild.

Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.

UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.

GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.

Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00