diff --git a/.claude/skills/architecture.md b/.claude/skills/architecture.md
index de046b17..4de3c287 100644
--- a/.claude/skills/architecture.md
+++ b/.claude/skills/architecture.md
@@ -2,7 +2,7 @@
## What Is Brainy
-@soulcraft/brainy (v7.17.0) is a Universal Knowledge Protocol -- a Triple Intelligence database combining vector search, graph traversal, and metadata filtering in a single library. Published to npm as a public MIT-licensed package.
+@soulcraftlabs/brainy (v7.17.0) is a Universal Knowledge Protocol -- a Triple Intelligence database combining vector search, graph traversal, and metadata filtering in a single library. Published to npm as a public MIT-licensed package.
## Core Architecture
diff --git a/.forgejo/workflows/publish-source.yml b/.forgejo/workflows/publish-source.yml
index a08875ae..58cb1d30 100644
--- a/.forgejo/workflows/publish-source.yml
+++ b/.forgejo/workflows/publish-source.yml
@@ -32,7 +32,7 @@ jobs:
run: |
set -eo pipefail
- SOURCE_NPM_REG="https://source.soulcraft.com/api/packages/soulcraft/npm/"
+ SOURCE_NPM_REG="https://source.soulcraft.com/api/packages/soulcraftlabs/npm/"
VERSION="$(node -p "require('./package.json').version")"
# The dist-tag follows the version: a prerelease (any hyphen —
# 10.4.0-rc.1) publishes under 'rc' and must NEVER move 'latest' —
@@ -43,13 +43,13 @@ jobs:
case "$VERSION" in
*-*) NPM_TAG="rc" ;;
esac
- echo "Publishing @soulcraft/brainy@${VERSION} to The Source registry (dist-tag: ${NPM_TAG})..."
+ echo "Publishing @soulcraftlabs/brainy@${VERSION} to The Source registry (dist-tag: ${NPM_TAG})..."
TMPRC="$(mktemp)"
chmod 600 "$TMPRC"
{
- echo "@soulcraft:registry=${SOURCE_NPM_REG}"
- echo "//source.soulcraft.com/api/packages/soulcraft/npm/:_authToken=${FORGE_NPM_TOKEN}"
+ echo "@soulcraftlabs:registry=${SOURCE_NPM_REG}"
+ echo "//source.soulcraft.com/api/packages/soulcraftlabs/npm/:_authToken=${FORGE_NPM_TOKEN}"
} > "$TMPRC"
# The release script bumps package.json's version before it tags, so
@@ -64,7 +64,7 @@ jobs:
# exit code: a benign duplicate publish (a prior run, or a mirror, already
# landed this exact version) reports failure even though the registry
# already holds the right content.
- LANDED_VERSION="$(npm view "@soulcraft/brainy@${VERSION}" version --userconfig "$TMPRC" 2>/dev/null || echo "")"
+ LANDED_VERSION="$(npm view "@soulcraftlabs/brainy@${VERSION}" version --userconfig "$TMPRC" 2>/dev/null || echo "")"
rm -f "$TMPRC"
if [ "$LANDED_VERSION" != "$VERSION" ]; then
@@ -73,7 +73,7 @@ jobs:
fi
if [ "$PUBLISH_OK" = true ]; then
- echo "Published and verified @soulcraft/brainy@${VERSION} on The Source registry."
+ echo "Published and verified @soulcraftlabs/brainy@${VERSION} on The Source registry."
else
- echo "::warning::npm publish reported failure, but readback confirms @soulcraft/brainy@${VERSION} is already live on The Source (a prior run or mirror landed it) — treating this run as successful, since the registry content is correct. Any OTHER failure mode would have failed the readback check above instead."
+ echo "::warning::npm publish reported failure, but readback confirms @soulcraftlabs/brainy@${VERSION} is already live on The Source (a prior run or mirror landed it) — treating this run as successful, since the registry content is correct. Any OTHER failure mode would have failed the readback check above instead."
fi
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec925642..a54d609e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,23 +2,81 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
-### [10.4.1](https://source.soulcraft.com/soulcraft/brainy/compare/v10.4.0...v10.4.1) (2026-08-26)
+### [10.4.4](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.3...v10.4.4) (2026-08-28)
+
+- fix(vfs): the old-root sweep narrates only when it has something to say (d49148e1)
+- fix(tests): the health-gate pin follows the verdict, and the VFS suite uses its own store (42e2da25)
+- Merge branch 'next/open-lazy-open-and-counts' (5ebd3b40)
+- docs: the contract manifest stands alone; public docs describe this engine only (a8c724a2)
+- docs(releases): 10.4.4 consumer notes — correctness and observability, with the performance line stated exactly (61a46927)
+- docs: measurements in public history carry numbers, not provenance (02c61636)
+- feat(open): name the two steps that hold the vfs-bootstrap phase (2cf38010)
+- fix(storage): a dead flush watch falls back to the 500ms poll, not the 30s sweep (5c22f950)
+- fix(storage): the flush watcher cannot arm twice in its async window (16d2e1a9)
+- perf(idle): the flush-request watch is event-driven; the heartbeat is observability (fb1da1c5)
+- perf(open): answer "are there any entities?" with one directory read (417ddb51)
+- perf(generations): discover generations by directory name, not by walking the log (9dd39921)
+- fix(flush): clear() and repairIndex() set the dirty witness themselves (e4c27fbc)
+- feat(open): the open names the STEP that cost the time, not just the phase (5a091cca)
+- perf(vfs): the old-root sweep runs once per store, not once per open (4a67aa0f)
+- chore: keep the generated neural stamps at main's values (c1f09723)
+- feat(contract): declare contract 1, serve three operators, refuse four by name (48802ba3)
+- fix(open): a provider rebuilding itself is a third state, not a CRITICAL (50676c02)
+- feat(open): open never waits for a provider that is rebuilding itself (131daa08)
+- perf(flush): an idle brain does no work — no periodic flush without a write (f5a6cb3f)
+- feat(repair): repairIndex narrates every phase and its receipt carries the walls (3fffd9c6)
+- fix(storage): a suspect count ledger heals itself, and counts.json is written atomically (f4e2d34b)
+- feat(open): the open narrates itself, on a channel production cannot clamp (afe08a1f)
+- fix(storage): a clean close is recorded, and the writer lock is always given up (e652162c)
+- docs: repository links point at soulcraftlabs/open-brainy — the soulcraft/brainy path becomes the native engine's repo tonight (38c3397b)
+
+
+### [10.4.3](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.2...v10.4.3) (2026-08-27)
+
+- Merge branch 'next/open-brainy-rename' (a58372f0)
+- chore: rename to @soulcraftlabs/brainy for Open Brainy on The Source (a99b1e83)
+- docs(releases): 10.4.3 — Open Brainy's first release under the new name, same engine as 10.4.2; The Source is the one registry (9f248b24)
+
+
+### [10.4.2](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.2-rc.1...v10.4.2) (2026-08-27)
+
+- docs(releases): 10.4.1 and 10.4.2 consumer notes; 10.4.2 is the last MIT release under this name, Open Brainy continues at @soulcraftlabs/brainy (a082e0ef)
+
+
+### [10.4.2-rc.1](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.1...v10.4.2-rc.1) (2026-08-27)
+
+- Merge branch 'next/zero-norm-unvector-door' (9b84ef5b)
+- fix(vectors): a zero-norm vector is not a vector, canonical side included, plus the sanctioned unvector door (0de76659)
+- fix(hnsw): skip unvectored rows on rebuild; refuse empty vectors in the index (8fc553b1)
+- fix(storage): derive the canonical count ledger from identity records, stamp the derivation rule, and mark legacy-derived ledgers suspect at load (fd6b4ce4)
+- Merge branch 'next/enumeration-identity-rekey' (204d74c1)
+- fix(storage): enumeration re-keys on the identity record, not the vector leg (f8d8ce16)
+- fix(init): rethrow plugin activation failures with the original error as cause so the originating frame survives to the caller (2496e09a)
+- Merge branch 'next/vfs-root-zero-norm' (4c7b0fab)
+- fix(vfs): the VFS root never persists a zero-norm vector (c6cc0de9)
+- build: derive generated-file stamps from git commit time, not wall clock (8a5c1245)
+- Merge remote-tracking branch 'origin/release/10.4.1' (aad9e2ee)
+- docs(concepts): the serving law — a failure is graded by whether an answer could be wrong, never by the cost of the fix; reads refuse per family (2914e0eb)
+- chore(release): 10.4.1-rc.1 (7870dc40)
+
+
+### [10.4.1](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.0...v10.4.1) (2026-08-26)
- fix(reads): the read gate is per-family; a write carrying unchanged data never re-embeds (c039411e)
- docs(guide): the docs pipeline publishes through the ingest API — the separate deploy step is retired (21e506e8)
-### [10.4.0](https://source.soulcraft.com/soulcraft/brainy/compare/v10.4.0-rc.4...v10.4.0) (2026-08-26)
+### [10.4.0](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.0-rc.4...v10.4.0) (2026-08-26)
- docs(releases): the 10.4.0 entry catches up to the late trains — repair routing, the vector ledger and open-gate leg, the loud config guard, the JSON-safe crossing (834149ed)
-### [10.4.0-rc.4](https://source.soulcraft.com/soulcraft/brainy/compare/v10.4.0-rc.3...v10.4.0-rc.4) (2026-08-25)
+### [10.4.0-rc.4](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.0-rc.3...v10.4.0-rc.4) (2026-08-25)
- feat(vector): the vectored-noun scalar joins the count ledger; the open gate closes the vector leg (9730835b)
-### [10.4.0-rc.3](https://source.soulcraft.com/soulcraft/brainy/compare/v10.4.0-rc.2...v10.4.0-rc.3) (2026-08-25)
+### [10.4.0-rc.3](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.0-rc.2...v10.4.0-rc.3) (2026-08-25)
- fix(update-seam): the metadata crossing never carries BigInt endpoint ints (f4780c8e)
- Merge branch 'worktree-agent-ad3aff0dffd17a6eb' (f14da34b)
@@ -27,7 +85,7 @@ All notable changes to this project will be documented in this file. See [standa
- feat(open-path): init never gates on the embedding model; open goes concurrent; slow opens narrate (96624f40)
-### [10.4.0-rc.2](https://source.soulcraft.com/soulcraft/brainy/compare/v10.4.0-rc.1...v10.4.0-rc.2) (2026-08-25)
+### [10.4.0-rc.2](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.0-rc.1...v10.4.0-rc.2) (2026-08-25)
- test(readiness): the report helper's clock freezes — two independently-built reports compared across a millisecond tick made the plant lane red (39b916a3)
- feat(repair): a heal:'repair' verdict routes to the provider's own incremental repair() (553e0d97)
@@ -38,7 +96,7 @@ All notable changes to this project will be documented in this file. See [standa
- feat(health): the gate reads the named report — reads refuse loudly, never rebuild; open serves before it returns; the ceremony door (f8f64780)
-### [10.4.0-rc.1](https://source.soulcraft.com/soulcraft/brainy/compare/v10.3.1...v10.4.0-rc.1) (2026-08-24)
+### [10.4.0-rc.1](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.3.1...v10.4.0-rc.1) (2026-08-24)
- ci(publish): the home dist-tag follows the version — a prerelease publishes under 'rc' and never moves 'latest' (a1376e4a)
- chore(release): --source-only — a home-only prerelease mode (The Source, never the storefront) (dcbad176)
@@ -51,13 +109,13 @@ All notable changes to this project will be documented in this file. See [standa
- ci(gate): the machine-health preflight and the truncation verdict guard (1e046aa1)
-### [10.3.1](https://source.soulcraft.com/soulcraft/brainy/compare/v10.3.0...v10.3.1) (2026-08-18)
+### [10.3.1](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.3.0...v10.3.1) (2026-08-18)
- docs(releases): the 10.3.1 consumer entry — the fold that behaves (900cc895)
- fix(recovery): the fold streams and narrates; the checkpoint chain arms at the flip (ed7d1db9)
-### [10.3.0](https://source.soulcraft.com/soulcraft/brainy/compare/v10.2.0...v10.3.0) (2026-08-18)
+### [10.3.0](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.2.0...v10.3.0) (2026-08-18)
- docs(releases): the 10.3.0 consumer entry — the trust-and-provenance release (97d75649)
- fix(locks): the fence keys ownership on pid+hostname — a same-process re-open never fences its predecessor (0991cf28)
@@ -66,14 +124,14 @@ All notable changes to this project will be documented in this file. See [standa
- feat(log): system commits carry their origin; the attested per-id reconcile door (9ac9e706)
-### [10.2.0](https://source.soulcraft.com/soulcraft/brainy/compare/v10.1.0...v10.2.0) (2026-08-17)
+### [10.2.0](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.1.0...v10.2.0) (2026-08-17)
- docs(releases): the 10.2.0 consumer entry — adoption completes in one call (97538e1f)
- ci: the correctness plant runs integration + conformance on every push — a release never waits on a second machine (b17fdc8e)
- fix(adoption): the baseline backfill runs to completion — one call adopts a pre-log baseline of any size (a5a18838)
-### [10.1.0](https://source.soulcraft.com/soulcraft/brainy/compare/v10.0.0...v10.1.0) (2026-08-13)
+### [10.1.0](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.0.0...v10.1.0) (2026-08-13)
- docs(releases): the 10.1.0 consumer entry — bounded recovery, restore founding, the two write-path cures (7d3c8696)
- fix(restore): a restore is an unclean event — the swap runs quiesced and the snapshot's durability stamps never survive it (9ca80667)
@@ -82,7 +140,7 @@ All notable changes to this project will be documented in this file. See [standa
- feat(query): the sparse-store cut — where on a never-carried field serves operator truth, never a refusal (7b67db4d)
-### [10.0.0](https://source.soulcraft.com/soulcraft/brainy/compare/v9.0.0...v10.0.0) (2026-08-12)
+### [10.0.0](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v9.0.0...v10.0.0) (2026-08-12)
- fix(adoption): the baseline backfill cures hydration-law drift — existing brains reach the crash-safe default with zero operator steps (25f0dd96)
- fix(adoption): the reserved-root mint exemption — int 0 is legitimate for exactly one id (2abe8b38)
@@ -114,7 +172,7 @@ All notable changes to this project will be documented in this file. See [standa
- test: version-coupling pins go major-agnostic — the 8.x literals broke at the 9.0.0 bump while the coupling law itself behaved correctly (8a6807e8)
-### [9.0.0](https://source.soulcraft.com/soulcraft/brainy/compare/v8.11.0...v9.0.0) (2026-08-04)
+### [9.0.0](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v8.11.0...v9.0.0) (2026-08-04)
- docs: 9.0 namespace-migration guide — the simple story + the mechanical sweep checklist, published for humans and tooling alike (61ab9db2)
- fix(release): storefront leg republishes CI's exact forge artifact — byte-identity by construction, verified by cross-registry shasum before the ceremony reports success (d89df2ed)
@@ -149,7 +207,7 @@ All notable changes to this project will be documented in this file. See [standa
- feat: scanFacts liveness contract — first batch or loud failure within a documented bound (f8e6da2b)
-### [8.11.0](https://source.soulcraft.com/soulcraft/brainy/compare/v8.10.1...v8.11.0) (2026-07-27)
+### [8.11.0](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v8.10.1...v8.11.0) (2026-07-27)
- docs: the last two archived-host links point home (91ef1c8b)
- feat: includeHidden — export carries every visibility tier for migration-grade canon completeness (63c1eeb9)
@@ -158,19 +216,19 @@ All notable changes to this project will be documented in this file. See [standa
- ci: run the pipeline on the forge (999d0ebb)
-### [8.10.3](https://source.soulcraft.com/soulcraft/brainy/compare/v8.10.2...v8.10.3) (2026-08-03)
+### [8.10.3](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v8.10.2...v8.10.3) (2026-08-03)
- docs: dedupe the 8.10.2 release-notes entry the cherry doubled onto the branch (8c956608)
- fix: user metadata named 'level' is a real field everywhere — the engine-internal node layer no longer shadows it in sort/filter/aggregation, and the indexing views stop stamping a phantom 0 into its column; index epoch 2 rebuilds existing brains at first open (958a0859)
-### [8.10.2](https://source.soulcraft.com/soulcraft/brainy/compare/v8.10.1...v8.10.2) (2026-07-29)
+### [8.10.2](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v8.10.1...v8.10.2) (2026-07-29)
- docs: 8.10.2 consumer release notes — update() write granularity, PathResolver idle-log fix, graph-lsm key recognition (a0123b5b)
- fix: metadata-only update() never rewrites the noun record — the unconditional whole-vector save turned per-entity stat touches into full rewrites+fsync, amplifying read-heavy sweeps into disk saturation on a production deployment (5b65eb82)
-### [8.10.1](https://source.soulcraft.com/soulcraft/brainy/compare/v8.10.0...v8.10.1) (2026-07-24)
+### [8.10.1](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v8.10.0...v8.10.1) (2026-07-24)
- refactor: remove the orphaned transaction-result type left behind by the dead-path removal (edf123a5)
- fix: warm() metadata surface routes through the active provider (warm hook added to the metadata contract); add maintenanceDebt() observability surface (5b2cbf74)
diff --git a/CLAUDE.md b/CLAUDE.md
index 6acd0e0d..56df0b72 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,13 +12,13 @@ Handoff file: `/home/dpsifr/.strategy/PLATFORM-HANDOFF.md`
**Brainy's current open actions:** None. MIT open-source — no platform-specific actions.
-**Current version:** run `npm view @soulcraft/brainy version` (never trust a hardcoded number here — this line went stale for months); consumer-facing changes tracked in `RELEASES.md`
+**Current version:** run `npm view @soulcraftlabs/brainy version --registry https://source.soulcraft.com/api/packages/soulcraftlabs/npm/` (never trust a hardcoded number here — this line went stale for months); consumer-facing changes tracked in `RELEASES.md`
---
## Project Overview
-Brainy is a Universal Knowledge Protocol -- a Triple Intelligence database that combines vector similarity search, graph traversal, and metadata filtering into a single TypeScript library. Published as `@soulcraft/brainy` on npm under the MIT license.
+Brainy is a Universal Knowledge Protocol -- a Triple Intelligence database that combines vector similarity search, graph traversal, and metadata filtering into a single TypeScript library. Published as `@soulcraftlabs/brainy` on The Source (source.soulcraft.com registry) under the MIT license.
## Getting Started
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d277091d..54d4f784 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -6,7 +6,7 @@ may find elsewhere in the repo's history.
## Where the project lives
-The source of truth is a self-hosted forge: **source.soulcraft.com/soulcraft/brainy**.
+The source of truth is a self-hosted forge: **source.soulcraft.com/soulcraftlabs/open-brainy**.
It's anonymously readable and cloneable — no account needed to browse, clone,
or build.
@@ -31,7 +31,7 @@ fine) to talk through the approach saves everyone rework.
## Development setup
```bash
-git clone https://source.soulcraft.com/soulcraft/brainy.git
+git clone https://source.soulcraft.com/soulcraftlabs/open-brainy.git
cd brainy
npm install
npm run build
@@ -57,6 +57,17 @@ see `package.json` for `test:integration`, `test:coverage`, and friends.
description states a number, cite the benchmark that produced it (see
[docs/performance-envelopes.md](docs/performance-envelopes.md) for the
pattern). Don't state an estimate as if it were measured.
+- **Measurements carry numbers, not provenance.** Public commit messages and
+ docs give the SHAPE a number was taken at and never where it was taken: no
+ hostnames, no store or deployment identities, no operational anecdotes about
+ someone's running system. "A 14,056-noun / 72,679-verb production-shaped
+ store, measured solo under an exclusive lock" tells a reader everything the
+ number depends on; the machine it ran on and whose data it was tell them
+ nothing except where somebody's infrastructure lives.
+- **Documents that answer or reference a confidential specification never enter
+ this repository, even summarized.** The public docs describe THIS engine and
+ the published contract, and nothing else — a summary of a private document is
+ still that document's contents.
## License
diff --git a/README.md b/README.md
index ca558340..762c9ec3 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
Brainy
@@ -11,9 +11,9 @@
-
-
-
+
+
+
@@ -30,6 +30,8 @@
---
+**Open Brainy** is the MIT engine — the open API, client library, types, and protocol; an openly specified canonical on-disk format; and this TypeScript reference engine, scoped as a single-node engine for stores up to roughly one million rows. `@soulcraft/brainy` 10.4.2 was the last release under the old package name — the name passes to the native engine, **Brainy**, at 11.0.0: the same API over the same open format at production scale, and it requires a license.
+
Built because we were tired of stitching a vector store to a graph database to a document store — and spending weeks on plumbing before writing a line of business logic. Brainy indexes every fact **three ways at once** and lets one call query them together:
| You write | Brainy indexes it as | You query it with |
@@ -45,12 +47,14 @@ It runs **inside your process** — no server, no Docker, nothing to operate —
## Quick start
```bash
-bun add @soulcraft/brainy # Bun ≥ 1.1 — recommended
-npm install @soulcraft/brainy # Node.js ≥ 22
+bun add @soulcraftlabs/brainy # Bun ≥ 1.1 — recommended
+npm install @soulcraftlabs/brainy # Node.js ≥ 22
```
+> **Registry**: add `@soulcraftlabs:registry=https://source.soulcraft.com/api/packages/soulcraftlabs/npm/` to your `.npmrc` (anonymous read).
+
```javascript
-import { Brainy, NounType, VerbType } from '@soulcraft/brainy'
+import { Brainy, NounType, VerbType } from '@soulcraftlabs/brainy'
const brain = new Brainy() // in-memory; one line swaps to disk
await brain.init()
diff --git a/RELEASES.md b/RELEASES.md
index bc400412..e8833b80 100644
--- a/RELEASES.md
+++ b/RELEASES.md
@@ -1,7 +1,7 @@
# @soulcraft/brainy — Release Notes for Consumers
This file is the **quick reference for downstream sessions** tracking Brainy changes.
-Full auto-generated changelog: `CHANGELOG.md` · Releases: https://source.soulcraft.com/soulcraft/brainy/releases
+Full auto-generated changelog: `CHANGELOG.md` · Releases: https://source.soulcraft.com/soulcraftlabs/open-brainy/releases
**How to use:** Brainy is the underlying data engine for downstream applications. Read this when:
- Upgrading `@soulcraft/brainy` in your application
@@ -31,6 +31,227 @@ is sometimes cited as a 7.x removal — those methods never existed on 7.x; the
---
+## v10.4.4 — 2026-08-28
+
+**A correctness and observability release.** The headline is not speed: it is that a
+restart now tells you the truth about itself, a store stops lying about how much it
+holds, and the engine stops doing work nobody asked for. There is a performance
+improvement and it is modest; it is stated exactly below rather than rounded up.
+
+### The dark restart — fixed at the root
+
+A service could stop cleanly, exit 0, having awaited `close()` on every store it held,
+and its next boot would announce `Overwriting stale writer lock … appears dead` for
+every one of them. Nothing had crashed. Two deployments hit this; the same defect also
+made those boots pay a crash-recovery fold they did not owe.
+
+The cause was not the lock. `close()` released it correctly — when it got there. A
+failure part-way through close skipped both the release AND the clean-shutdown marker,
+and "the recorded pid is gone" reads identically for an orderly restart and a crash.
+
+- `close()` is now two parts and the second is unconditional: the flush-request watcher,
+ the **writer lock**, the VFS timers and the terminal `closed` flag are released whether
+ the durable steps succeeded or not. The original failure is narrated with what it costs
+ the next open, then rethrown.
+- Releasing the lock writes a **clean-close record** naming the lock generation it gave
+ up. The next open reads that record instead of guessing: recorded → nothing to recover;
+ absent → it says so, and names the recovery it is about to run. This also ends two
+ long-standing false alarms — a recycled pid locking a store out of its own reopen, and
+ `Re-acquiring writer lock … this is a bug` after a perfectly clean close.
+- The signal path stopped failing in a batch. One store's failing flush used to strand
+ every remaining store's lock and markers — at exit code 0. Now: per-store isolation, the
+ generation store's close (the marker) is part of shutdown, the lock goes in a `finally`,
+ and the handler no longer calls `process.exit()` when the host application has its own
+ signal handler, a race that truncated the host's own shutdown mid-flight.
+
+### The count ledger stops lying, and `counts.json` is written atomically
+
+The all-tier scalars are the denominator a coverage check subtracts against. A ledger
+derived under the old rule — one entity per id DIRECTORY — counted ghost and scar
+containers as rows, and was only FLAGGED suspect: it went on serving wrong numbers for
+the life of the store. Two copies of one archive could disagree, and a downstream index
+heal reported remaining work that did not exist.
+
+- Such a ledger now derives itself honestly **in the background** after the open, counting
+ identity records, and persists the correction stamped. Nothing waits for it, because no
+ read is served from a denominator.
+- A derivation that raced a write refuses to stamp its number: one retry on a quiet store,
+ then the ledger stays SUSPECT and names `repairIndex()` as the door that recounts under
+ a barrier.
+- `counts.json` is written temp+rename. A truncating write left a window in which a
+ concurrent reader saw the file EMPTY — and an unparseable ledger sends the next open
+ down the full-rescan path, so the cheapest file in the store was buying the most
+ expensive recovery.
+
+### An open and a repair narrate themselves — on a channel a log level cannot silence
+
+A store could open for three minutes and print nothing at all. The phase timings existed;
+they were written to a channel that every production-looking environment clamps away.
+
+- Narration moved to an always-visible channel. An open now heartbeats the phase it is in,
+ names each phase as it ends with what it was paying for, and names the expensive STEP
+ inside a phase. `repairIndex()` does the same and its receipt carries a per-family
+ `durationMs` — a repair that ran for half an hour with no output could only be watched
+ through `top`.
+- A brain nobody has written to now does nothing: a flush over a clean store is a no-op
+ and says nothing, the graph index's auto-flush asks before it acts, and the
+ cross-process flush-request watch is **event-driven** (`fs.watch`) instead of polling a
+ directory every 500 ms per store forever, with a slow safety sweep behind it and a
+ narrated fall back to polling where a filesystem cannot be watched.
+- A provider that is REBUILDING ITSELF is no longer confused with a broken one. `init()`
+ does not wait for it, every other family serves, and that family's doors refuse **by
+ name, carrying the provider's own progress**, saying plainly that they open by
+ themselves and no action is needed. Health narration dedupes by content, so an unchanged
+ verdict is silent however a provider's generation counter moves.
+
+### For operators — one behaviour change
+
+**Four `where` operators that previously returned an empty page now raise
+`INVALID_QUERY`:** `startsWith`, `endsWith`, `matches` and `length`. An equality/range
+posting index cannot evaluate a substring, a pattern or an array length without reading
+every row, and it now refuses by name instead of answering with an empty result that
+looks like an answer.
+
+**Three that previously returned an empty page are now SERVED:** `hasAll`, `noneOf` and
+`excludes`. All 25 accepted operator tokens now agree between this engine and its
+accelerated counterpart.
+
+### Performance — stated exactly
+
+Measured on a 14,056-noun / 72,679-verb production-shaped store, both builds solo under
+an exclusive lock:
+
+- **Warm reopen after a clean close: 85.7 s → 77.0 s (−10.2%).** The whole of that gain is
+ one fix — generation discovery reads directory NAMES instead of recursively walking the
+ entire generation log (−9.2 s, and it scales with history rather than row count). The
+ VFS phase is **unchanged**.
+- **Cold open: −31.4 s** (518.1 s → 486.7 s), of which the count-ledger derivation moving
+ off the critical path accounts for storage-init dropping 5,941 ms → 25 ms.
+- **A dominant ~38 s remains, diagnosed and NOT fixed.** It is not the VFS — the VFS's own
+ init is under 2 s of that phase. It is the log-authority adoption and/or the
+ pending-embed log recovery, both now instrumented so the next measurement names the
+ culprit outright.
+
+Continuing work, named so nobody has to rediscover it: that ~38 s term; making the
+generation store's committed-range set lazy; the hydration path that substitutes
+`Date.now()` for an unreadable stored timestamp (inventing data); and a VFS path-prefix
+filter built with a `$startsWith` spelling no operator set accepts, so
+`searchFiles({ path })` throws today.
+
+---
+
+## v10.4.3 — 2026-08-27 (Open Brainy's first release)
+
+**`@soulcraftlabs/brainy` 10.4.3 is the same engine as `@soulcraft/brainy` 10.4.2, byte for
+byte — only the name, the registry, and the pointers changed.** Install:
+
+```bash
+npm install @soulcraftlabs/brainy
+```
+
+with the registry line in your `.npmrc` (anonymous read):
+
+```
+@soulcraftlabs:registry=https://source.soulcraft.com/api/packages/soulcraftlabs/npm/
+```
+
+- **The Source is the one registry.** Open Brainy publishes to source.soulcraft.com only; the
+ npmjs republish step is retired from the release rail. Existing npmjs versions of
+ `@soulcraft/brainy` stay as they are and receive no new versions.
+- **The repository moved** to `soulcraftlabs/open-brainy` on The Source; the old path redirects.
+- **No engine change.** Everything in the 10.4.2 notes applies unchanged; adoption is one
+ install-line change (`@soulcraft/brainy` → `@soulcraftlabs/brainy`), which downstream
+ applications make together with their native-engine bump.
+
+## v10.4.2 — 2026-08-27 (a zero-norm vector is not a vector)
+
+**This is the last release of the MIT engine under the `@soulcraft/brainy` name.**
+The MIT package continues as **Open Brainy** — `@soulcraftlabs/brainy`: the open API,
+client library, types and protocol, an openly specified canonical format, and the TypeScript
+reference engine, scoped honestly as a single-node engine for stores up to roughly one
+million rows. The `@soulcraft/brainy` name passes to the native engine, **Brainy**, at a
+major version bump; that engine implements the same API over the same open format at
+production scale, requires a license, and refuses loudly without one. Nothing changes
+for existing installs until that major ships; the move is announced with it.
+
+Six fixes, one law: a vector with no magnitude carries no information, so it must
+never reach a vector index — in any engine — and the canonical store must say so.
+
+- **The permanently-unvectored row.** `add({ ..., vector: [] })` (and the same item
+ shape in `addMany` / `transact`) is now the sanctioned "no vector" row: persisted
+ with an empty vector leg, never embedded, never indexed, counted as unvectored in
+ the canonical ledger. Metadata-only rows — telemetry tallies, counters, plumbing —
+ no longer need a placeholder vector and never enter the vector leg. `vector: []`
+ together with `deferEmbedding: true` is refused with a typed error (a supplied
+ vector has nothing to defer). Previously `vector: []` threw a dimension error.
+- **The unvector door.** `update({ id, vector: [] })` (and its `transact()` twin) is
+ the sanctioned way to strip a vector from an existing row: canonical vector → `[]`,
+ removal from the vector index, the vectored ledger decremented exactly once — and
+ idempotent, so a resumed cleanup pass may simply re-issue. It never re-embeds, and
+ it clears a pending deferred-embed marker durably so the background worker cannot
+ re-vector the row later. Note that a rebuild never sheds vectors (it re-derives the
+ index from canonical rows); shedding historical vectors needs this door.
+- **Zero-norm vectors are normalized at the write.** An explicit all-zero vector on
+ any write path is persisted as unvectored (`[]`) with one warning naming the row;
+ the vector-index operations keep their own refusal as a second line. The engine's
+ own VFS root, which used to persist a deliberate all-zero placeholder (harmless
+ under cosine distance, a false attractor under a downstream engine's
+ squared-euclidean serving — a production incident this week), is now created
+ unvectored, and an existing store's legacy root is migrated on open by a single
+ fixed-path read before the health gate runs — never a walk.
+- **Enumeration keys on the identity record.** `getNouns()` / `getVerbs()` and the
+ cursor walks behind them enumerate by the metadata record, the same key the
+ canonical ledger counts by — previously the walk keyed on the vector file, so a
+ row holding metadata but no vector was counted yet never yielded (a permanent
+ "missing" phantom in coverage math), while an orphaned vector-only directory
+ could be yielded as a phantom id. The recovery fold also never deletes an existing
+ vector when it replays a metadata-only after-image (preserve-if-absent). One
+ documented gap remains: a verb's endpoints live only in its vector leg, so a
+ metadata-only verb is counted and loudly skipped, never fabricated — the fix is a
+ canonical-format change and lands with the open format.
+- **The ledger's one-time derivation counts identity records.** Stores upgraded from
+ pre-ledger versions derived their ALL-visibility scalars once by counting id
+ directories, which included ghost and scar containers left by an old partial-delete
+ defect — an inflated denominator whose coverage row could never reach exact. The
+ derivation now counts only directories holding a metadata record, `counts.json`
+ carries a derivation-rule stamp, and a ledger derived under the old rule is marked
+ `suspect` at open (one O(1) field read, one warning) so the online `repairIndex()`
+ path clears it with a real recount.
+- **The vector index refuses what it cannot hold.** `rebuild()` skips unvectored and
+ zero-norm rows (one summary line), re-pins the vector dimension from the first real
+ vector after a restart (previously a restart left the pin unset, so a wrong-length
+ insert became the new pin instead of being rejected), and `addItem` / `updateItem`
+ throw a typed `EmptyVectorIndexError` on a length-0 vector instead of ever storing
+ a vector-less node.
+- **Smaller:** a failing plugin activation now rethrows with the original error as
+ `cause` (the originating file and line survive to the caller's log); build
+ generators stamp from the repository history of their inputs instead of wall clock,
+ so two builds of the same tree are byte-identical.
+
+Adoption: one restart, paired with its native-engine release. The first open of an
+existing store runs the legacy-root migration (one narrated line) and, on stores that
+upgraded from pre-ledger versions, marks the ledger suspect until the next sanctioned
+recount — no rebuild in either case.
+
+## v10.4.1 — 2026-08-26 (reads refuse per family; an unchanged write never re-embeds)
+
+Two production defects from the same week, fixed together as a patch to 10.4.0.
+
+- **The read gate is per family.** A read now refuses only when the index family it
+ actually consults is unhealthy: a metadata filter is served while the vector leg is
+ rebuilding; a semantic query is refused only by the vector family; a graph
+ traversal only by the graph family. Previously any unhealthy family refused every
+ read on the brain — under a long vector rebuild, a production deployment's
+ metadata-only reads were refused for the duration, and the retries became a write
+ pump of their own.
+- **Unchanged data never re-embeds.** `update()` compares the incoming `data`
+ structurally with the stored record; an update carrying identical data (a common
+ shape for periodic upserts) no longer embeds again and no longer churns the vector
+ leg. Previously every such update re-embedded and re-inserted, which under load
+ saturated the vector index with near-identical vectors.
+
+Adoption: one restart, paired with its native-engine release.
+
## v10.4.0 — 2026-08-25 (the health report has a name)
Three related cures, one root cause: an index deciding whether it could be trusted
diff --git a/SECURITY.md b/SECURITY.md
index 1f3c4732..91d40d49 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -30,7 +30,7 @@ commit to backporting fixes to unsupported lines.
## Scope
-This policy covers the `@soulcraft/brainy` package itself — the code in
+This policy covers the `@soulcraftlabs/brainy` package itself — the code in
this repository. If you're evaluating a deployment that also uses
`@soulcraft/cor`, report issues in that package the same way, to the same
address; we'll route internally.
diff --git a/bin/brainy-ts.js b/bin/brainy-ts.js
index 4e9aedb8..90a35e98 100644
--- a/bin/brainy-ts.js
+++ b/bin/brainy-ts.js
@@ -3,7 +3,7 @@
/**
* Modern TypeScript CLI Runner
*
- * This is the entry point after npm install @soulcraft/brainy
+ * This is the entry point after npm install @soulcraftlabs/brainy
* It runs the compiled TypeScript CLI code
*/
diff --git a/bun.lock b/bun.lock
index c31b3865..1e3e66e2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -3,7 +3,7 @@
"configVersion": 0,
"workspaces": {
"": {
- "name": "@soulcraft/brainy",
+ "name": "@soulcraftlabs/brainy",
"dependencies": {
"@aws-sdk/client-s3": "^3.540.0",
"@azure/identity": "^4.0.0",
diff --git a/docs/DEVELOPER_LEARNING_PATH.md b/docs/DEVELOPER_LEARNING_PATH.md
index 4134ae63..b2d22fb1 100644
--- a/docs/DEVELOPER_LEARNING_PATH.md
+++ b/docs/DEVELOPER_LEARNING_PATH.md
@@ -25,13 +25,13 @@
### Prerequisites
```bash
-npm install @soulcraft/brainy
+npm install @soulcraftlabs/brainy
```
### Your First Neural Database
```typescript
-import { Brainy, NounType } from '@soulcraft/brainy'
+import { Brainy, NounType } from '@soulcraftlabs/brainy'
// Step 1: Create and initialize Brainy
const brain = new Brainy({
@@ -143,7 +143,7 @@ Once you're comfortable with basic operations, move to **Level 2** to learn abou
### Building a Knowledge Graph
```typescript
-import { Brainy, NounType, VerbType } from '@soulcraft/brainy'
+import { Brainy, NounType, VerbType } from '@soulcraftlabs/brainy'
const brain = new Brainy({ storage: { type: 'memory' } })
await brain.init()
@@ -314,7 +314,7 @@ Ready for AI-powered search and clustering? Move to **Level 3**.
### Triple Intelligence in Action
```typescript
-import { Brainy, NounType, VerbType } from '@soulcraft/brainy'
+import { Brainy, NounType, VerbType } from '@soulcraftlabs/brainy'
const brain = new Brainy({ storage: { type: 'memory' } })
await brain.init()
@@ -529,7 +529,7 @@ Want to treat files as intelligent entities? Learn the **Virtual Filesystem** in
### Files as Intelligent Entities
```typescript
-import { Brainy, NounType, VerbType } from '@soulcraft/brainy'
+import { Brainy, NounType, VerbType } from '@soulcraftlabs/brainy'
const brain = new Brainy({ storage: { type: 'memory' } })
await brain.init()
@@ -832,7 +832,7 @@ Ready for production deployment? Level 5 covers **planet-scale architecture**.
### Production-Ready Deployment
```typescript
-import { Brainy, NounType } from '@soulcraft/brainy'
+import { Brainy, NounType } from '@soulcraftlabs/brainy'
// 1. PRODUCTION STORAGE - Filesystem with off-site snapshots
console.log('Initializing production storage...\n')
diff --git a/docs/FIND_SYSTEM.md b/docs/FIND_SYSTEM.md
index 1cc38ce9..77fbbd79 100644
--- a/docs/FIND_SYSTEM.md
+++ b/docs/FIND_SYSTEM.md
@@ -1217,7 +1217,7 @@ where: {
await brain.find({ type: 'Document' })
// ✅ Correct: Use NounType enum
-import { NounType } from '@soulcraft/brainy'
+import { NounType } from '@soulcraftlabs/brainy'
await brain.find({ type: NounType.Document })
// ❌ Error: Operator not recognized
diff --git a/docs/MIGRATION-V3-TO-V4.md b/docs/MIGRATION-V3-TO-V4.md
index 29c409ac..680b6928 100644
--- a/docs/MIGRATION-V3-TO-V4.md
+++ b/docs/MIGRATION-V3-TO-V4.md
@@ -153,13 +153,13 @@ brainy-data/
### Step 1: Update Brainy Package
```bash
-npm install @soulcraft/brainy@latest
+npm install @soulcraftlabs/brainy@latest
```
**Check your version:**
```bash
-npm list @soulcraft/brainy
-# Should show: @soulcraft/brainy@4.0.0
+npm list @soulcraftlabs/brainy
+# Should show: @soulcraftlabs/brainy@4.0.0
```
### Step 2: No Code Changes Required! ✅
@@ -374,7 +374,7 @@ If you encounter issues, you can rollback:
```bash
# Reinstall v3
-npm install @soulcraft/brainy@^3.50.0
+npm install @soulcraftlabs/brainy@^3.50.0
# Restart application
```
@@ -389,7 +389,7 @@ rm -rf ./data
cp -r ./data-backup ./data
# Reinstall v3
-npm install @soulcraft/brainy@^3.50.0
+npm install @soulcraftlabs/brainy@^3.50.0
```
## Common Migration Scenarios
@@ -539,7 +539,7 @@ console.log('Storage type:', status.type)
**Migration Checklist:**
- ✅ Backup data
-- ✅ Update npm package (`npm install @soulcraft/brainy@latest`)
+- ✅ Update npm package (`npm install @soulcraftlabs/brainy@latest`)
- ✅ Restart application (automatic migration)
- ✅ Verify data integrity
- ✅ Enable lifecycle policies
diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md
index 3078c239..238d2252 100644
--- a/docs/PLUGINS.md
+++ b/docs/PLUGINS.md
@@ -46,7 +46,7 @@ If no plugin provides a given key, brainy uses its built-in JavaScript implement
### 1. Implement the `BrainyPlugin` interface
```typescript
-import type { BrainyPlugin, BrainyPluginContext } from '@soulcraft/brainy/plugin'
+import type { BrainyPlugin, BrainyPluginContext } from '@soulcraftlabs/brainy/plugin'
const myPlugin: BrainyPlugin = {
name: 'my-brainy-plugin', // Must be unique (typically your npm package name)
@@ -90,7 +90,7 @@ await brain.init()
**Programmatic registration:** For plugins not installed as npm packages, use `brain.use()`:
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
import myPlugin from './my-plugin.js'
const brain = new Brainy()
@@ -272,10 +272,10 @@ When provided by an optional native acceleration plugin (such as `@soulcraft/cor
#### `cache`
**Type:** `UnifiedCache`
-Replaces the global `UnifiedCache` singleton used for VFS path resolution, semantic caching, and vector index caching. Must implement the `UnifiedCache` interface (available from `@soulcraft/brainy/internals`).
+Replaces the global `UnifiedCache` singleton used for VFS path resolution, semantic caching, and vector index caching. Must implement the `UnifiedCache` interface (available from `@soulcraftlabs/brainy/internals`).
```typescript
-import type { UnifiedCache } from '@soulcraft/brainy/internals'
+import type { UnifiedCache } from '@soulcraftlabs/brainy/internals'
context.registerProvider('cache', myNativeCache)
```
@@ -325,8 +325,8 @@ Plugins can register custom storage backends that users reference by name.
### Implementing a Storage Adapter
```typescript
-import type { StorageAdapterFactory } from '@soulcraft/brainy/plugin'
-import type { StorageAdapter } from '@soulcraft/brainy'
+import type { StorageAdapterFactory } from '@soulcraftlabs/brainy/plugin'
+import type { StorageAdapter } from '@soulcraftlabs/brainy'
class MyStorageAdapter implements StorageAdapter {
async init(): Promise { /* ... */ }
@@ -360,9 +360,9 @@ Brainy provides three entry points for plugin developers:
| Import Path | Contents | Stability |
|-------------|----------|-----------|
-| `@soulcraft/brainy` | Public API, types, StorageAdapter | Stable (semver) |
-| `@soulcraft/brainy/plugin` | BrainyPlugin, BrainyPluginContext, StorageAdapterFactory | Stable (semver) |
-| `@soulcraft/brainy/internals` | UnifiedCache, EntityIdMapper, logger utilities | Internal (may change between minor versions) |
+| `@soulcraftlabs/brainy` | Public API, types, StorageAdapter | Stable (semver) |
+| `@soulcraftlabs/brainy/plugin` | BrainyPlugin, BrainyPluginContext, StorageAdapterFactory | Stable (semver) |
+| `@soulcraftlabs/brainy/internals` | UnifiedCache, EntityIdMapper, logger utilities | Internal (may change between minor versions) |
## Diagnostics
@@ -440,7 +440,7 @@ A minimal but useful plugin that provides SIMD-accelerated distance calculations
```typescript
// simd-distance-plugin/src/plugin.ts
-import type { BrainyPlugin, BrainyPluginContext } from '@soulcraft/brainy/plugin'
+import type { BrainyPlugin, BrainyPluginContext } from '@soulcraftlabs/brainy/plugin'
// Hypothetical native module
import { simdCosineDistance } from './native.js'
@@ -470,7 +470,7 @@ export default simdDistancePlugin
"main": "./dist/plugin.js",
"types": "./dist/plugin.d.ts",
"peerDependencies": {
- "@soulcraft/brainy": ">=7.0.0"
+ "@soulcraftlabs/brainy": ">=7.0.0"
}
}
```
@@ -478,7 +478,7 @@ export default simdDistancePlugin
Usage:
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const brain = new Brainy({ plugins: ['brainy-simd-distance'] })
await brain.init()
diff --git a/docs/PRODUCTION_SERVICE_ARCHITECTURE.md b/docs/PRODUCTION_SERVICE_ARCHITECTURE.md
index 4568cd31..ad4a4a40 100644
--- a/docs/PRODUCTION_SERVICE_ARCHITECTURE.md
+++ b/docs/PRODUCTION_SERVICE_ARCHITECTURE.md
@@ -54,7 +54,7 @@ After 40 API calls:
```typescript
// server.ts
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
// SINGLETON INSTANCE
let brainInstance: Brainy | null = null
@@ -174,7 +174,7 @@ process.on('SIGTERM', async () => {
```typescript
// server.ts - Clean Bun implementation
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
let brain: Brainy | null = null
diff --git a/docs/README.md b/docs/README.md
index 3290001f..ddb37d20 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -5,7 +5,7 @@
## Quick Start
```typescript
-import { Brainy, NounType, VerbType } from '@soulcraft/brainy'
+import { Brainy, NounType, VerbType } from '@soulcraftlabs/brainy'
const brain = new Brainy()
await brain.init()
diff --git a/docs/RELEASE-GUIDE.md b/docs/RELEASE-GUIDE.md
index 94c6a6cb..4b120bd8 100644
--- a/docs/RELEASE-GUIDE.md
+++ b/docs/RELEASE-GUIDE.md
@@ -99,7 +99,7 @@ Examples:
```bash
# 1. Deprecate wrong version on npm
-npm deprecate @soulcraft/brainy@X.X.X "Incorrect version - use Y.Y.Y"
+npm deprecate @soulcraftlabs/brainy@X.X.X "Incorrect version - use Y.Y.Y"
# 2. Fix version in package.json
# 3. Republish correct version
diff --git a/docs/SCALING.md b/docs/SCALING.md
index e9ae1136..054d2096 100644
--- a/docs/SCALING.md
+++ b/docs/SCALING.md
@@ -13,7 +13,7 @@
### In-Memory
```typescript
-import Brainy from '@soulcraft/brainy'
+import Brainy from '@soulcraftlabs/brainy'
const brain = new Brainy({ storage: { type: 'memory' } })
```
@@ -43,7 +43,7 @@ The native vector provider (via the optional `@soulcraft/cor` package) extends t
Numbers below are **measured** by `tests/benchmarks/find-composition-scale.js` (a single
Node 22 process, in-memory storage, 384-dim vectors, `balanced` recall). They are the
-open-core (pure-TypeScript) path — what you get from `@soulcraft/brainy` with no native
+open-core (pure-TypeScript) path — what you get from `@soulcraftlabs/brainy` with no native
provider installed. Run it yourself: `node --max-old-space-size=8192 tests/benchmarks/find-composition-scale.js 100000`.
`find()` query latency, p50 / p95 (200 queries each):
diff --git a/docs/api-contract.json b/docs/api-contract.json
new file mode 100644
index 00000000..aafd838a
--- /dev/null
+++ b/docs/api-contract.json
@@ -0,0 +1,1544 @@
+{
+ "contractVersion": 1,
+ "engine": "@soulcraftlabs/brainy",
+ "compatibility": {
+ "minor": "additive — a new optional door, a new served operator, a new error class; every existing implementation still conforms",
+ "major": "breaking — a door removed, an answer narrowed, an ordering law changed, an optional door promoted to required, or an operator moved from served to refused"
+ },
+ "doors": [
+ {
+ "name": "adaptiveHistoryBudgetBytes",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "add",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "addMany",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "adoptLogAuthority",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "adoptLogAuthorityInner",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "aggViewFromEntity",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "anyProviderMigrating",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "applyFusionScoring",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "applyGraphConstraints",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "armIdleFlushTimer",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "asOf",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "assertGenerationStoreReady",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "assertWritable",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "audit",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "auditGraph",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "autoAdoptLegacyVfsBlobsIfNeeded",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "autoAlpha",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "autoCompactHistory",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "awaitMigrationLock",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "awaitPendingEmbeds",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "backfillAggregateIfNeeded",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "batchGet",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "brainWideStrictRequiresSubtype",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "bridgeLegacyPendingEmbedSidecars",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "buildAtGenerationVectors",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "buildGraphView",
+ "kind": "method",
+ "arity": 4
+ },
+ {
+ "name": "buildMetadataFilter",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "buildMigrationUpdate",
+ "kind": "method",
+ "arity": 5
+ },
+ {
+ "name": "buildRelationMigrationUpdate",
+ "kind": "method",
+ "arity": 5
+ },
+ {
+ "name": "cacheVerbInt",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "canServeVectorAtGeneration",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "checkHealth",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "checkMigrations",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "clear",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "clearPendingEmbed",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "close",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "closeDurableSteps",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "cluster",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "collectProviderInvariants",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "compactHistory",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "consumeMetadataWatermarkVerdict",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "convertMetadataToEntity",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "convertNounToEntity",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "counts",
+ "kind": "accessor"
+ },
+ {
+ "name": "createIndex",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "createMigrationBackupIfNeeded",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "createPinnedDb",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "createResult",
+ "kind": "method",
+ "arity": 4
+ },
+ {
+ "name": "dbFinalizationRegistry",
+ "kind": "accessor"
+ },
+ {
+ "name": "dbHost",
+ "kind": "accessor"
+ },
+ {
+ "name": "defineAggregate",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "detectIdKind",
+ "kind": "method",
+ "arity": 3
+ },
+ {
+ "name": "diagnostics",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "diff",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "embed",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "embedBatch",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "emitCommitted",
+ "kind": "method",
+ "arity": 4
+ },
+ {
+ "name": "enforceSubtypeOnAdd",
+ "kind": "method",
+ "arity": 4
+ },
+ {
+ "name": "enforceSubtypeOnRelate",
+ "kind": "method",
+ "arity": 4
+ },
+ {
+ "name": "enforceTrackedFieldValues",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "enhanceNLPResult",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "enqueuePendingEmbed",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "ensureAggregationIndex",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "ensureIndexesLoaded",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "ensureInitialized",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "entityForAggFromRawRecord",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "entityFromGenerationRecord",
+ "kind": "method",
+ "arity": 3
+ },
+ {
+ "name": "entityIntsToUuids",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "entityViewFromRawRecord",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "excludedVisibilityTiers",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "executeGraphSearch",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "executeProximitySearch",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "executeTextSearch",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "executeVectorSearch",
+ "kind": "method",
+ "arity": 3
+ },
+ {
+ "name": "explain",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "export",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "extract",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "extractConcepts",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "extractEntities",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "factSegmentPaths",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "fieldCountsAggregateName",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "fillSubtypes",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "filterIdsBelted",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "find",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "findAggregate",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "findDuplicates",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "findMatchingWords",
+ "kind": "method",
+ "arity": 3
+ },
+ {
+ "name": "flush",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "formatInfo",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "formatSubtypeError",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "generation",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "generationDigest",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "get",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "getActivePlugins",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "getAvailableFields",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "getBackgroundDeduplicator",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "getFieldsForType",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "getFieldStatistics",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "getFieldsWithCardinality",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "getFieldValues",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "getIndexStats",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "getIndexStatus",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "getMemoryStats",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "getNeighborUuids",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "getNounCount",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "getOptimalQueryPlan",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "getStats",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "getStorageType",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "getSubtypeRule",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "getTripleIntelligence",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "getTypedNeighbors",
+ "kind": "method",
+ "arity": 4
+ },
+ {
+ "name": "getVerbCount",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "graph",
+ "kind": "accessor"
+ },
+ {
+ "name": "graphAccelerationProvider",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "graphCommunities",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "graphCommunitiesFallback",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "graphCommunitiesNative",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "graphEntityInt",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "graphExport",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "graphExportFallback",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "graphExportNative",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "graphPath",
+ "kind": "method",
+ "arity": 3
+ },
+ {
+ "name": "graphPathFallback",
+ "kind": "method",
+ "arity": 3
+ },
+ {
+ "name": "graphPathNative",
+ "kind": "method",
+ "arity": 4
+ },
+ {
+ "name": "graphRank",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "graphRankFallback",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "graphRankNative",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "graphSubgraph",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "graphSubgraphFallback",
+ "kind": "method",
+ "arity": 4
+ },
+ {
+ "name": "graphSubgraphFromQuery",
+ "kind": "method",
+ "arity": 5
+ },
+ {
+ "name": "graphSubgraphNative",
+ "kind": "method",
+ "arity": 5
+ },
+ {
+ "name": "groupByLabel",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "hasStorageMethod",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "hasVectorOrTextCriteria",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "health",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "highlight",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "highlightSemanticPhase",
+ "kind": "method",
+ "arity": 5
+ },
+ {
+ "name": "history",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "historyStats",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "hub",
+ "kind": "accessor"
+ },
+ {
+ "name": "hydrateIdMapperForGraphRebuild",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "hydrateNativeSubgraph",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "import",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "importPluginPackage",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "incidentEdges",
+ "kind": "method",
+ "arity": 3
+ },
+ {
+ "name": "indexStats",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "init",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "insights",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "isEmbeddingReady",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "isInfrastructureWrite",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "isInitialized",
+ "kind": "accessor"
+ },
+ {
+ "name": "isReadOnly",
+ "kind": "accessor"
+ },
+ {
+ "name": "kickBackgroundFlush",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "kickEmbedWorker",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "legacyLayoutMigrationPhase",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "loadAnalyticsGraph",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "loadPlugins",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "logAuthority",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "maintenanceDebt",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "materializeAtGeneration",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "metadataIndexRetractionOp",
+ "kind": "method",
+ "arity": 3
+ },
+ {
+ "name": "migrate",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "migrateField",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "migrateInternal",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "migrateLegacyZeroNormVfsRootIfNeeded",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "migrationSnapshot",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "neededFamiliesMigrating",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "neighbors",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "newId",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "nlp",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "normalizeConfig",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "noteWriteForPersistence",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "now",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "onChange",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "pagination",
+ "kind": "accessor"
+ },
+ {
+ "name": "parseMigrationPath",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "parseNaturalQuery",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "pathExists",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "pendingEmbedCount",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "performInit",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "persistPinnedGeneration",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "persistSingleOp",
+ "kind": "method",
+ "arity": 6
+ },
+ {
+ "name": "pickMetadataProbe",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "pickVectorProbe",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "pinGeneration",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "planGetEntity",
+ "kind": "method",
+ "arity": 3
+ },
+ {
+ "name": "planTransact",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "planTxAdd",
+ "kind": "method",
+ "arity": 3
+ },
+ {
+ "name": "planTxRelate",
+ "kind": "method",
+ "arity": 3
+ },
+ {
+ "name": "planTxRemove",
+ "kind": "method",
+ "arity": 3
+ },
+ {
+ "name": "planTxUnrelate",
+ "kind": "method",
+ "arity": 3
+ },
+ {
+ "name": "planTxUpdate",
+ "kind": "method",
+ "arity": 3
+ },
+ {
+ "name": "projectionGauges",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "providerForFamily",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "providerIsMigrating",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "providerMigrationStatus",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "queryAggregate",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "queryIndexFamilies",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "readPath",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "ready",
+ "kind": "accessor"
+ },
+ {
+ "name": "rebuildIndexesIfNeeded",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "rebuildMetadataIndexOnline",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "reconcileLogDivergence",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "reconstructPath",
+ "kind": "method",
+ "arity": 4
+ },
+ {
+ "name": "recordStateAt",
+ "kind": "method",
+ "arity": 3
+ },
+ {
+ "name": "recoverPendingEmbedsFromLog",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "registerShutdownHooks",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "relate",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "related",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "relateMany",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "relationFromGenerationRecord",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "relationshipSubtypesOf",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "releaseGeneration",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "remove",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "removeAggregate",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "removeMany",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "removeMigrationBackupSafe",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "repackHistory",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "repairIndex",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "requestFlush",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "requireProviders",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "requireSubtype",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "resolveAsOfGeneration",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "resolveDiffEndpoint",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "resolveHiddenIds",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "resolveHNSWPersistMode",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "resolveRawGeneration",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "resolveRetentionPolicy",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "resolveVerbEndpointInts",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "resolveVerbIntsToIds",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "restore",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "rrfFusion",
+ "kind": "method",
+ "arity": 4
+ },
+ {
+ "name": "runAggregationBackfillWalk",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "runAggregationCatchUp",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "runEmbedWorker",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "runOracle",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "runRepairIndexPhases",
+ "kind": "method",
+ "arity": 5
+ },
+ {
+ "name": "scanFacts",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "seedIdsToInts",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "selectorToSeedIds",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "setRetentionBudget",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "setupEmbedder",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "setupIndex",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "setupStorage",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "similar",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "similarity",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "splitForHighlighting",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "stampBrainFormat",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "stampBrainFormatIfNeeded",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "stampEntityTree",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "stampProjectionWatermarks",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "stats",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "storageAdapter",
+ "kind": "accessor"
+ },
+ {
+ "name": "stream",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "streaming",
+ "kind": "accessor"
+ },
+ {
+ "name": "subtypesOf",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "trackField",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "transact",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "transactionLog",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "unrelate",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "unvectorNounForRootMigration",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "update",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "updateMany",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "updateRelation",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "upsertMergeParams",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "use",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "usesDefaultWasmEmbedder",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "validateIndexConsistency",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "vectorSearchAtGeneration",
+ "kind": "method",
+ "arity": 4
+ },
+ {
+ "name": "verbsToRelations",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "verbToRelationLike",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "verifyEntityTreeStamp",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "verifyGraphAdjacencyLive",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "verifyLogAuthority",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "verifyMetadataLive",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "verifyVectorLive",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "versionedIndexProviders",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "vfs",
+ "kind": "accessor"
+ },
+ {
+ "name": "waitForIndexed",
+ "kind": "method",
+ "arity": 2
+ },
+ {
+ "name": "warm",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "warmupEmbeddings",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "warnIfReadsDegraded",
+ "kind": "method",
+ "arity": 1
+ },
+ {
+ "name": "wireConnectionsCodec",
+ "kind": "method",
+ "arity": 0
+ },
+ {
+ "name": "wireGraphIdResolver",
+ "kind": "method",
+ "arity": 0
+ }
+ ],
+ "errors": [
+ "BrainyError",
+ "DerivedArtifactMissingError",
+ "GraphIndexNotReadyError",
+ "MetadataIndexNotReadyError",
+ "MigrationInProgressError",
+ "ProtectedArtifactError",
+ "VectorIndexNotReadyError"
+ ],
+ "operators": {
+ "accepted": [
+ "between",
+ "contains",
+ "endsWith",
+ "eq",
+ "equals",
+ "excludes",
+ "exists",
+ "greaterThan",
+ "greaterThanOrEqual",
+ "gt",
+ "gte",
+ "hasAll",
+ "in",
+ "length",
+ "lessThan",
+ "lessThanOrEqual",
+ "lt",
+ "lte",
+ "matches",
+ "missing",
+ "ne",
+ "noneOf",
+ "notEquals",
+ "oneOf",
+ "startsWith"
+ ],
+ "servedOnIndexPath": [
+ "between",
+ "contains",
+ "eq",
+ "equals",
+ "excludes",
+ "exists",
+ "greaterThan",
+ "greaterThanOrEqual",
+ "gt",
+ "gte",
+ "hasAll",
+ "in",
+ "lessThan",
+ "lessThanOrEqual",
+ "lt",
+ "lte",
+ "missing",
+ "ne",
+ "noneOf",
+ "notEquals",
+ "oneOf"
+ ],
+ "refusedByIndexPath": [
+ "endsWith",
+ "length",
+ "matches",
+ "startsWith"
+ ],
+ "combinators": [
+ "allOf",
+ "anyOf",
+ "not"
+ ]
+ },
+ "fieldAddressing": {
+ "systemKeyPrefix": "system.",
+ "systemEntityScalars": [
+ "confidence",
+ "createdAt",
+ "createdBy",
+ "id",
+ "service",
+ "subtype",
+ "type",
+ "updatedAt",
+ "visibility",
+ "weight"
+ ],
+ "systemRelationScalars": [
+ "confidence",
+ "createdAt",
+ "createdBy",
+ "service",
+ "sourceId",
+ "subtype",
+ "targetId",
+ "updatedAt",
+ "verb",
+ "visibility",
+ "weight"
+ ],
+ "plumbingFields": [
+ "_rev",
+ "connections",
+ "data",
+ "level",
+ "vector"
+ ]
+ },
+ "health": {
+ "verdicts": [
+ "pass",
+ "warn",
+ "fail"
+ ],
+ "healKinds": [
+ "none",
+ "repair",
+ "rebuild"
+ ],
+ "servingWithholdingInvariants": [
+ "index-initialized",
+ "durable-state-present",
+ "manifest-residency",
+ "replay-clean",
+ "strand-latch"
+ ]
+ }
+}
diff --git a/docs/api/README.md b/docs/api/README.md
index 82f48c91..ba49ff48 100644
--- a/docs/api/README.md
+++ b/docs/api/README.md
@@ -24,7 +24,7 @@ next:
## Quick Start
```typescript
-import { Brainy, NounType, VerbType } from '@soulcraft/brainy'
+import { Brainy, NounType, VerbType } from '@soulcraftlabs/brainy'
const brain = new Brainy() // Zero config!
await brain.init() // VFS auto-initialized!
@@ -1010,7 +1010,7 @@ await db.release() // unpin + free cached materialization
### Db API errors
-All exported from `@soulcraft/brainy`:
+All exported from `@soulcraftlabs/brainy`:
| Error | Thrown by | Meaning |
|---|---|---|
@@ -1918,11 +1918,11 @@ isn't serving throws instead of rebuilding mid-query:
| `MetadataIndexNotReadyError` | `find({ where })` | Metadata/field index isn't serving |
| `VectorIndexNotReadyError` | `find({ query })`, `similar()` | Vector index isn't serving |
-All three are exported from `@soulcraft/brainy`. Catch them to distinguish
+All three are exported from `@soulcraftlabs/brainy`. Catch them to distinguish
"index not ready" from a genuine empty result:
```typescript
-import { MetadataIndexNotReadyError } from '@soulcraft/brainy'
+import { MetadataIndexNotReadyError } from '@soulcraftlabs/brainy'
try {
const rows = await brain.find({ where: { status: 'active' } })
@@ -2208,7 +2208,7 @@ For the full taxonomy with all 169 types and their descriptions, see:
- **📖 Documentation:** [Full Documentation](../)
- **🐛 Issues:** [GitHub Issues](https://github.com/soulcraftlabs/brainy/issues)
- **💬 Discussions:** [GitHub Discussions](https://github.com/soulcraftlabs/brainy/discussions)
-- **📦 NPM:** [@soulcraft/brainy](https://www.npmjs.com/package/@soulcraft/brainy)
+- **📦 NPM:** [@soulcraftlabs/brainy](https://www.npmjs.com/package/@soulcraftlabs/brainy)
- **⭐ GitHub:** [Star us](https://github.com/soulcraftlabs/brainy)
---
diff --git a/docs/architecture/data-storage-architecture.md b/docs/architecture/data-storage-architecture.md
index 48064757..83b9e23a 100644
--- a/docs/architecture/data-storage-architecture.md
+++ b/docs/architecture/data-storage-architecture.md
@@ -268,7 +268,7 @@ locks/_flush_responses/ # writer answers with .ack
| **Counts/statistics** | Per-type and per-subtype maps | `_system/{type,subtype,verb-subtype}-statistics.json.gz`, `counts.json` | Recomputable by scanning entities (`brainy inspect repair`) |
A pluggable index provider (the 8.0 plugin contract in
-`@soulcraft/brainy/plugin`) may replace any of the JS implementations; the
+`@soulcraftlabs/brainy/plugin`) may replace any of the JS implementations; the
persisted formats above are contract-bound so JS and native implementations
can interleave on the same directory.
diff --git a/docs/architecture/finite-type-system.md b/docs/architecture/finite-type-system.md
index 76492ee5..48a8b1fe 100644
--- a/docs/architecture/finite-type-system.md
+++ b/docs/architecture/finite-type-system.md
@@ -126,7 +126,7 @@ class TypeAwareMetadataIndex {
**The Design**: Specify types clearly in your API calls:
```typescript
-import { Brainy, NounType, VerbType } from '@soulcraft/brainy'
+import { Brainy, NounType, VerbType } from '@soulcraftlabs/brainy'
// Add entity with explicit type
await brain.add({
@@ -231,7 +231,7 @@ class OrgEnrichmentAugmentation {
**Brainy's Approach**: Extract **typed** concepts:
```typescript
-import { NaturalLanguageProcessor } from '@soulcraft/brainy'
+import { NaturalLanguageProcessor } from '@soulcraftlabs/brainy'
const nlp = new NaturalLanguageProcessor()
const concepts = await nlp.extractConcepts("Alice works at Google in San Francisco")
@@ -382,7 +382,7 @@ import {
getVerbTypes,
BrainyTypes,
suggestType
-} from '@soulcraft/brainy'
+} from '@soulcraftlabs/brainy'
// Get all available noun types
const nounTypes = getNounTypes()
diff --git a/docs/architecture/multiprocess-storage-mixin.md b/docs/architecture/multiprocess-storage-mixin.md
index 46f98398..1593bf8f 100644
--- a/docs/architecture/multiprocess-storage-mixin.md
+++ b/docs/architecture/multiprocess-storage-mixin.md
@@ -127,7 +127,7 @@ For reference, a clean migration path:
`isMultiProcessSafe` type-guard. Keep `hasStorageMethod` for
build/install artifact protection.
5. Document the new contract in `concepts/storage-adapters.md`.
-6. Major-version-bump the `@soulcraft/brainy` peerDep range expected by
+6. Major-version-bump the `@soulcraftlabs/brainy` peerDep range expected by
plugins.
Estimated work: ~half a day of code, ~2 hours of doc/example updates,
diff --git a/docs/architecture/noun-verb-taxonomy.md b/docs/architecture/noun-verb-taxonomy.md
index 286464be..3dac6892 100644
--- a/docs/architecture/noun-verb-taxonomy.md
+++ b/docs/architecture/noun-verb-taxonomy.md
@@ -20,7 +20,7 @@ next:
Every example on this page is written against the real Brainy 8.0 API. The setup is always the same:
```typescript
-import { Brainy, NounType, VerbType } from '@soulcraft/brainy'
+import { Brainy, NounType, VerbType } from '@soulcraftlabs/brainy'
const brain = new Brainy()
await brain.init()
@@ -40,7 +40,7 @@ Brainy's **Noun-Verb Taxonomy** achieves broad coverage of human knowledge throu
- **Multi-hop Graph Traversals = Relationship Complexity**
- **Result: Model data across virtually any industry**
-Every piece of information can be represented as entities (nouns) connected by relationships (verbs) carrying properties (metadata). The standardized type system from `@soulcraft/brainy` (`NounType`, `VerbType`) gives those nouns and verbs a stable, shared name.
+Every piece of information can be represented as entities (nouns) connected by relationships (verbs) carrying properties (metadata). The standardized type system from `@soulcraftlabs/brainy` (`NounType`, `VerbType`) gives those nouns and verbs a stable, shared name.
## The Power of Standardization: Universal Interoperability
diff --git a/docs/architecture/zero-config.md b/docs/architecture/zero-config.md
index d42d6784..a35e6416 100644
--- a/docs/architecture/zero-config.md
+++ b/docs/architecture/zero-config.md
@@ -35,7 +35,7 @@ constructor and `init()`.
## Instant Start
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
// That's it. No config needed.
const brain = new Brainy()
diff --git a/docs/concepts/field-addressing.md b/docs/concepts/field-addressing.md
index c459021b..d24dd66b 100644
--- a/docs/concepts/field-addressing.md
+++ b/docs/concepts/field-addressing.md
@@ -167,7 +167,7 @@ await brain.find({ orderBy: 'createdAt' })
`UnresolvableFieldError` is exported from the package root:
```typescript
-import { UnresolvableFieldError } from '@soulcraft/brainy'
+import { UnresolvableFieldError } from '@soulcraftlabs/brainy'
try {
await brain.find({ orderBy: 'createdAt' })
diff --git a/docs/concepts/index-health.md b/docs/concepts/index-health.md
index 96199f11..923267df 100644
--- a/docs/concepts/index-health.md
+++ b/docs/concepts/index-health.md
@@ -95,7 +95,7 @@ catchable error naming the reason:
| `MetadataIndexNotReadyError` | `find({ where })` | The metadata/field index isn't serving — a filtered read would otherwise return `[]` indistinguishable from "no matches" |
| `VectorIndexNotReadyError` | `find({ query })`, `similar()` | The vector index isn't serving — a semantic search would otherwise return `[]` indistinguishable from "nothing similar" |
-All three are exported from `@soulcraft/brainy`. Catch them where your application
+All three are exported from `@soulcraftlabs/brainy`. Catch them where your application
needs to distinguish "this index isn't ready yet" from "there's genuinely nothing
here" — a health dashboard, a retry policy, an operator alert. The fix is always
the same: reconcile the index, either by reopening the brain (which brings every
diff --git a/docs/concepts/storage-adapters.md b/docs/concepts/storage-adapters.md
index af6d068f..82aa01e8 100644
--- a/docs/concepts/storage-adapters.md
+++ b/docs/concepts/storage-adapters.md
@@ -61,7 +61,7 @@ The only required override is the capability flag. Returning `true` from
to call `acquireWriterLock()` at init.
```typescript
-import { FileSystemStorage } from '@soulcraft/brainy'
+import { FileSystemStorage } from '@soulcraftlabs/brainy'
export class MmapFileSystemStorage extends FileSystemStorage {
public supportsMultiProcessLocking(): boolean {
@@ -79,7 +79,7 @@ If your storage is **not filesystem-backed** (a custom
network backend), extend `BaseStorage` directly:
```typescript
-import { BaseStorage } from '@soulcraft/brainy'
+import { BaseStorage } from '@soulcraftlabs/brainy'
export class MyCloudStorage extends BaseStorage {
// BaseStorage's default no-op implementations of the multi-process
@@ -101,7 +101,7 @@ The defensive check at every new-storage-method call site (`brainy.ts`,
`hasStorageMethod(name)`) does **not** exist to handle "plugin bundles a
stale BaseStorage." Plugins ship a dist that preserves the dynamic ESM
import (verify in your plugin's `dist/`: `import { FileSystemStorage } from
-'@soulcraft/brainy'` is not rewritten to a vendored copy). The prototype
+'@soulcraftlabs/brainy'` is not rewritten to a vendored copy). The prototype
chain at runtime resolves to whatever Brainy version your consumer has
installed.
@@ -109,8 +109,8 @@ installed.
the prototype chain at the consumer-app level:
- **Stale `node_modules`** — a lingering install from before the consumer
- upgraded Brainy. The package.json says `@soulcraft/brainy@7.22.0` but
- `node_modules/@soulcraft/brainy` is still 7.20.x.
+ upgraded Brainy. The package.json says `@soulcraftlabs/brainy@7.22.0` but
+ `node_modules/@soulcraftlabs/brainy` is still 7.20.x.
- **Lockfile drift** — `bun.lockb` / `package-lock.json` pins a brainy
version older than the package.json range, and `bun install` honors the
lockfile.
@@ -131,7 +131,7 @@ and the warning names the adapter class plus a remediation hint:
methods on its prototype chain. Writer locking and the flush-request RPC are
disabled for this directory. Likely fix: clean install (`rm -rf node_modules
bun.lockb && bun install`) or rebuild your container image to refresh
-`@soulcraft/brainy` to ≥7.21. See docs/concepts/storage-adapters.md.
+`@soulcraftlabs/brainy` to ≥7.21. See docs/concepts/storage-adapters.md.
```
## Authoring a new storage adapter — minimum checklist
@@ -168,7 +168,7 @@ bun.lockb && bun install`) or rebuild your container image to refresh
install time — fix install, not your plugin.
6. **Pin your peer dep generously.** `"peerDependencies": {
- "@soulcraft/brainy": "^7.21.0" }` accepts any compatible 7.x. Don't pin
+ "@soulcraftlabs/brainy": "^7.21.0" }` accepts any compatible 7.x. Don't pin
to an exact patch unless you're tracking a known regression.
## Future direction
@@ -185,5 +185,5 @@ follow-up; consumers don't need to anticipate the change.
heartbeat semantics, what the lock protects.
- [`guides/inspection`](../guides/inspection.md) — `brainy inspect` and the
read-only mode.
-- `node_modules/@soulcraft/brainy/dist/storage/baseStorage.d.ts` — the
+- `node_modules/@soulcraftlabs/brainy/dist/storage/baseStorage.d.ts` — the
authoritative type signatures for every method this page references.
diff --git a/docs/guides/aggregation.md b/docs/guides/aggregation.md
index 11d86ec8..616c8fc4 100644
--- a/docs/guides/aggregation.md
+++ b/docs/guides/aggregation.md
@@ -22,7 +22,7 @@ they share a single scan.
## Quick Start
```typescript
-import { Brainy, NounType } from '@soulcraft/brainy'
+import { Brainy, NounType } from '@soulcraftlabs/brainy'
const brain = new Brainy()
await brain.init()
diff --git a/docs/guides/framework-integration.md b/docs/guides/framework-integration.md
index 984466c5..8f85da00 100644
--- a/docs/guides/framework-integration.md
+++ b/docs/guides/framework-integration.md
@@ -8,7 +8,7 @@ Brainy is **framework-friendly** - designed to drop into the server side of any
Brainy embeds an HNSW vector index, a graph engine, and a filesystem-backed persistence layer. These belong on the server:
-- **Zero configuration**: Just `import { Brainy } from '@soulcraft/brainy'`
+- **Zero configuration**: Just `import { Brainy } from '@soulcraftlabs/brainy'`
- **Auto storage detection**: `new Brainy()` auto-selects filesystem persistence on Node
- **Cleaner code**: No browser polyfills, no conditional client/server imports
- **Better DX**: One instance shared across your server routes
@@ -18,13 +18,13 @@ Brainy embeds an HNSW vector index, a graph engine, and a filesystem-backed pers
### Install Brainy
```bash
-npm install @soulcraft/brainy
+npm install @soulcraftlabs/brainy
```
### Basic Integration
```javascript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
// Run on the server (API route, server component, backend service)
// new Brainy() auto-detects filesystem persistence on Node
@@ -105,7 +105,7 @@ On the server, create one Brainy instance and reuse it across requests. This mod
```javascript
// lib/brain.server.js
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
let brainPromise
@@ -163,7 +163,7 @@ On the server, create one Brainy instance and reuse it across requests:
```javascript
// server/brain.js (server-only module)
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
let brainPromise
@@ -248,7 +248,7 @@ The matching backend endpoint uses Brainy directly (Node/Bun):
```typescript
// server: api/search
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const brain = new Brainy() // auto-detects filesystem persistence on Node
await brain.init()
@@ -266,7 +266,7 @@ In Next.js, Brainy lives in server code only: API routes, server components, or
```javascript
// lib/brain.server.js (imported only by server code)
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
let brainPromise
@@ -318,7 +318,7 @@ Brainy runs in a server-only module (`*.server.js`); the component fetches resul
```javascript
// src/lib/server/brain.js (server-only — note the .server suffix)
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
let brainPromise
@@ -432,7 +432,7 @@ import { defineConfig } from 'vite'
export default defineConfig({
ssr: {
- external: ['@soulcraft/brainy']
+ external: ['@soulcraftlabs/brainy']
}
})
```
@@ -440,7 +440,7 @@ export default defineConfig({
```javascript
// rollup.config.js (server bundle)
export default {
- external: ['@soulcraft/brainy', 'node:fs', 'node:path', 'node:crypto']
+ external: ['@soulcraftlabs/brainy', 'node:fs', 'node:path', 'node:crypto']
}
```
@@ -466,7 +466,7 @@ export async function load({ url }) {
```javascript
// For build-time usage (runs in Node during the build)
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
export async function generateStaticProps() {
const brain = new Brainy({
@@ -513,7 +513,7 @@ export async function generateStaticProps() {
### Issue: Large client bundle size
**Cause**: A client module is pulling in Brainy.
-**Solution**: Move the `import { Brainy } from '@soulcraft/brainy'` into a server-only module so it never reaches the browser bundle.
+**Solution**: Move the `import { Brainy } from '@soulcraftlabs/brainy'` into a server-only module so it never reaches the browser bundle.
### Issue: SSR hydration mismatch
**Solution**: Run the search on the server (loader / server action / API route) and pass the results down as props, so server and client render the same markup.
diff --git a/docs/guides/import-anything.md b/docs/guides/import-anything.md
index b1bb15ef..ffabe55c 100644
--- a/docs/guides/import-anything.md
+++ b/docs/guides/import-anything.md
@@ -9,7 +9,7 @@ Brainy's import is **ONE magical method** that understands EVERYTHING:
## The Ultimate Simplicity
```javascript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const brain = new Brainy()
await brain.init()
diff --git a/docs/guides/import-progress-examples.md b/docs/guides/import-progress-examples.md
index 66f50713..18c3cb9a 100644
--- a/docs/guides/import-progress-examples.md
+++ b/docs/guides/import-progress-examples.md
@@ -13,7 +13,7 @@ Brainy provides real-time progress tracking for **all 7 supported file formats**
### Basic Progress Tracking
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
import * as fs from 'fs'
const brain = await Brainy.create()
diff --git a/docs/guides/import-quick-reference.md b/docs/guides/import-quick-reference.md
index 7837d49e..3bc26dae 100644
--- a/docs/guides/import-quick-reference.md
+++ b/docs/guides/import-quick-reference.md
@@ -7,7 +7,7 @@
## Basic Import
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const brain = new Brainy()
await brain.init()
@@ -187,7 +187,7 @@ await brain.import(file, {
## Complete Example
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
import * as fs from 'fs'
async function importCatalog() {
diff --git a/docs/guides/inspection.md b/docs/guides/inspection.md
index 240e81ae..8560b543 100644
--- a/docs/guides/inspection.md
+++ b/docs/guides/inspection.md
@@ -108,7 +108,7 @@ check fails — useful for piping into monitoring or CI.
## Programmatic inspection
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const reader = await Brainy.openReadOnly({
storage: { type: 'filesystem', path: '/data/brain' }
diff --git a/docs/guides/installation.md b/docs/guides/installation.md
index 0a36f632..20d40ea2 100644
--- a/docs/guides/installation.md
+++ b/docs/guides/installation.md
@@ -21,21 +21,21 @@ next:
## Install
```bash
-npm install @soulcraft/brainy
+npm install @soulcraftlabs/brainy
```
Or with your preferred package manager:
```bash
-bun add @soulcraft/brainy
-yarn add @soulcraft/brainy
-pnpm add @soulcraft/brainy
+bun add @soulcraftlabs/brainy
+yarn add @soulcraftlabs/brainy
+pnpm add @soulcraftlabs/brainy
```
## Verify
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const brain = new Brainy()
await brain.init()
@@ -52,7 +52,7 @@ npm install @soulcraft/cor
```
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const brain = new Brainy({ plugins: ['@soulcraft/cor'] })
await brain.init() // native providers registered during init
@@ -71,7 +71,7 @@ remains available on npm if you need it.
Brainy ships with full TypeScript types. No `@types/` package needed:
```typescript
-import { Brainy, NounType, VerbType } from '@soulcraft/brainy'
+import { Brainy, NounType, VerbType } from '@soulcraftlabs/brainy'
const brain = new Brainy()
await brain.init()
diff --git a/docs/guides/migration-3.36.0.md b/docs/guides/migration-3.36.0.md
index 8b1f239e..5f00534a 100644
--- a/docs/guides/migration-3.36.0.md
+++ b/docs/guides/migration-3.36.0.md
@@ -66,7 +66,7 @@ const results = await brain.search("query")
**New diagnostics for capacity planning and performance tuning.**
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const brain = new Brainy()
await brain.init()
@@ -112,7 +112,7 @@ Recommendations: ${stats.recommendations.join(', ')}
### Step 1: Update Package
```bash
-npm install @soulcraft/brainy@latest
+npm install @soulcraftlabs/brainy@latest
```
### Step 2: Restart Your Application
@@ -134,7 +134,7 @@ npm run start
### Check Adaptive Sizing is Working
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const brain = new Brainy()
await brain.init()
@@ -218,7 +218,7 @@ For debugging or compatibility testing:
If you need to rollback to v3.35.0:
```bash
-npm install @soulcraft/brainy@3.35.0
+npm install @soulcraftlabs/brainy@3.35.0
```
**Note:** We don't anticipate any issues, but rollback is straightforward if needed.
@@ -367,7 +367,7 @@ if (stats.fairness.fairnessViolation) {
## Next Steps
-1. ✅ **Upgrade:** `npm install @soulcraft/brainy@latest`
+1. ✅ **Upgrade:** `npm install @soulcraftlabs/brainy@latest`
2. 📊 **Monitor:** Use `getCacheStats()` to verify performance improvements
3. 🎯 **Tune:** Adjust based on recommendations (if needed)
4. 📖 **Read:** [Operations Guide](../operations/capacity-planning.md) for capacity planning
diff --git a/docs/guides/model-loading.md b/docs/guides/model-loading.md
index e5b7b1d6..cc1b2b6a 100644
--- a/docs/guides/model-loading.md
+++ b/docs/guides/model-loading.md
@@ -37,7 +37,7 @@ This single WASM file contains everything needed for sentence embeddings.
```bash
# Bun as a runtime — supported and recommended
-bun add @soulcraft/brainy
+bun add @soulcraftlabs/brainy
bun run server.ts
```
diff --git a/docs/guides/namespace-migration.md b/docs/guides/namespace-migration.md
index fad3c766..f7d2c7f7 100644
--- a/docs/guides/namespace-migration.md
+++ b/docs/guides/namespace-migration.md
@@ -80,7 +80,7 @@ If you read raw stored records (fact-log scanners, export tooling), use
the exported shape-aware splitters — they handle both record eras:
```typescript
-import { splitNounMetadataRecord } from '@soulcraft/brainy'
+import { splitNounMetadataRecord } from '@soulcraftlabs/brainy'
const { reserved, custom } = splitNounMetadataRecord(rawRecord)
// reserved = engine fields · custom = the user's bag, ANY names
```
@@ -88,7 +88,7 @@ const { reserved, custom } = splitNounMetadataRecord(rawRecord)
Feature detection (never version-sniff):
```typescript
-import * as brainy from '@soulcraft/brainy'
+import * as brainy from '@soulcraftlabs/brainy'
const lawActive = 'FIELD_ADDRESSING_CAPABILITY' in brainy // 'field-addressing/v1'
```
diff --git a/docs/guides/nextjs-integration.md b/docs/guides/nextjs-integration.md
index ab55e51f..25d6062d 100644
--- a/docs/guides/nextjs-integration.md
+++ b/docs/guides/nextjs-integration.md
@@ -9,7 +9,7 @@ Complete guide to integrating Brainy with Next.js applications, covering App Rou
```bash
npx create-next-app@latest my-brainy-app
cd my-brainy-app
-npm install @soulcraft/brainy
+npm install @soulcraftlabs/brainy
```
### Basic Setup
@@ -18,7 +18,7 @@ npm install @soulcraft/brainy
// app/components/BrainyProvider.jsx
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const BrainyContext = createContext()
@@ -271,7 +271,7 @@ export default function SearchPage() {
```javascript
// app/api/search/route.js (App Router)
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
let brain = null
@@ -332,7 +332,7 @@ export async function GET() {
```javascript
// pages/api/search.js (Pages Router)
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
let brain = null
@@ -374,7 +374,7 @@ export default async function handler(req, res) {
```javascript
// app/api/data/route.js
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
let brain = null
@@ -418,7 +418,7 @@ export async function POST(request) {
```jsx
// app/actions/brainy.js
'use server'
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
let brain = null
@@ -630,7 +630,7 @@ CMD ["npm", "start"]
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
- serverComponentsExternalPackages: ['@soulcraft/brainy']
+ serverComponentsExternalPackages: ['@soulcraftlabs/brainy']
},
webpack: (config, { isServer }) => {
if (!isServer) {
@@ -797,7 +797,7 @@ export function rateLimit(req, limit = 100, window = 60000) {
// app/contexts/BrainyContext.jsx
'use client'
import { createContext, useContext, useReducer, useEffect } from 'react'
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const BrainyContext = createContext()
@@ -873,7 +873,7 @@ import { BrainyProvider } from '../app/components/BrainyProvider'
import { Search } from '../app/components/Search'
// Mock Brainy
-jest.mock('@soulcraft/brainy', () => ({
+jest.mock('@soulcraftlabs/brainy', () => ({
Brainy: jest.fn().mockImplementation(() => ({
init: jest.fn().mockResolvedValue(undefined),
find: jest.fn().mockResolvedValue([
diff --git a/docs/guides/optimistic-concurrency.md b/docs/guides/optimistic-concurrency.md
index 268bc5fa..2984998b 100644
--- a/docs/guides/optimistic-concurrency.md
+++ b/docs/guides/optimistic-concurrency.md
@@ -32,7 +32,7 @@ Brainy 7.31.0 adds a per-entity revision counter so multiple writers can coordin
Every distributed-job scheduler eventually wants this exact loop:
```ts
-import { Brainy, RevisionConflictError } from '@soulcraft/brainy'
+import { Brainy, RevisionConflictError } from '@soulcraftlabs/brainy'
const LOCK_ID = '...uuid for this job slot...'
@@ -137,7 +137,7 @@ await brain.addIfMissing({ // ← not a real API
It's race-prone as a plain read-then-write: two concurrent imports both see "not found," both insert, you get duplicates. Without a unique-index primitive (which Brainy doesn't have today), close the race with whole-store CAS — read at a pinned generation, then commit only if nothing moved:
```ts
-import { GenerationConflictError } from '@soulcraft/brainy'
+import { GenerationConflictError } from '@soulcraftlabs/brainy'
async function addIfMissingByEmail(email: string, data: string) {
for (let attempt = 0; attempt < 5; attempt++) {
diff --git a/docs/guides/quick-start.md b/docs/guides/quick-start.md
index 097c55fe..d9a4e896 100644
--- a/docs/guides/quick-start.md
+++ b/docs/guides/quick-start.md
@@ -18,13 +18,13 @@ Get Brainy running in under a minute.
## 1. Install
```bash
-npm install @soulcraft/brainy
+npm install @soulcraftlabs/brainy
```
## 2. Initialize
```typescript
-import { Brainy, NounType, VerbType } from '@soulcraft/brainy'
+import { Brainy, NounType, VerbType } from '@soulcraftlabs/brainy'
const brain = new Brainy()
await brain.init()
@@ -67,7 +67,7 @@ await brain.relate({
## 5. Query with Triple Intelligence
```typescript
-import type { Result } from '@soulcraft/brainy'
+import type { Result } from '@soulcraftlabs/brainy'
// All three search paradigms in one call
const results: Result[] = await brain.find({
diff --git a/docs/guides/standard-import-progress.md b/docs/guides/standard-import-progress.md
index 9f2e2e5b..27dabe75 100644
--- a/docs/guides/standard-import-progress.md
+++ b/docs/guides/standard-import-progress.md
@@ -11,7 +11,7 @@
### One Interface for Everything
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const brain = await Brainy.create()
@@ -78,7 +78,7 @@ interface ImportProgress {
```typescript
import { useState } from 'react'
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
function UniversalImportProgress({ file }: { file: File }) {
const [progress, setProgress] = useState({
@@ -177,7 +177,7 @@ function UniversalImportProgress({ file }: { file: File }) {
```typescript
import ora from 'ora'
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
async function importWithProgress(filePath: string) {
const spinner = ora('Starting import...').start()
diff --git a/docs/guides/storage-adapters.md b/docs/guides/storage-adapters.md
index 06ec9f3a..a4224bc8 100644
--- a/docs/guides/storage-adapters.md
+++ b/docs/guides/storage-adapters.md
@@ -28,7 +28,7 @@ on-disk layout (memory's "disk" is a JS Map).
## Quick start
```ts
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
// Filesystem (recommended for any persistent workload):
const brain = new Brainy({
@@ -134,7 +134,7 @@ config; the `type` is optional.
If you want to skip the factory:
```ts
-import { FileSystemStorage, MemoryStorage } from '@soulcraft/brainy'
+import { FileSystemStorage, MemoryStorage } from '@soulcraftlabs/brainy'
const fsStorage = new FileSystemStorage('./brainy-data')
const memStorage = new MemoryStorage()
diff --git a/docs/guides/subtypes-and-facets.md b/docs/guides/subtypes-and-facets.md
index ff5de320..74311528 100644
--- a/docs/guides/subtypes-and-facets.md
+++ b/docs/guides/subtypes-and-facets.md
@@ -34,7 +34,7 @@ Three layers solve this:
### Write
```typescript
-import { Brainy, NounType } from '@soulcraft/brainy'
+import { Brainy, NounType } from '@soulcraftlabs/brainy'
const brain = new Brainy()
await brain.init()
@@ -240,7 +240,7 @@ await brain.migrateField({
A realistic adoption sequence for a brain that started without these primitives:
```typescript
-import { Brainy, NounType } from '@soulcraft/brainy'
+import { Brainy, NounType } from '@soulcraftlabs/brainy'
const brain = new Brainy({ storage: { type: 'filesystem', path: './brain-data' } })
await brain.init()
diff --git a/docs/guides/upgrading-7-to-8.md b/docs/guides/upgrading-7-to-8.md
index a3c64fb9..53aa2a5c 100644
--- a/docs/guides/upgrading-7-to-8.md
+++ b/docs/guides/upgrading-7-to-8.md
@@ -25,7 +25,7 @@ content — and how 8.0 recovers it for you.
## TL;DR
-- **Just upgrade to `@soulcraft/brainy@8.0.12` (or later) and open the store.**
+- **Just upgrade to `@soulcraftlabs/brainy@8.0.12` (or later) and open the store.**
If a previous upgrade left VFS content stranded, 8.0.12 **heals it on open**,
with no operator action.
- Want to force or script it? Call **`await brain.vfs.adoptOrphanedBlobs()`**.
@@ -90,7 +90,7 @@ So the operator action for a stranded store is simply: **upgrade to 8.0.12 and
open it.**
```ts
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
// Opening the store is all that is required — recovery runs during init().
const brain = new Brainy({ storage: { type: 'filesystem', path: '/data/my-store' } })
@@ -182,5 +182,5 @@ and opening each store is sufficient.
The recovery is copy-only, so no rollback of the recovery itself is ever needed.
If you need to roll back the **whole** 7→8 upgrade, restore the directory from
your pre-upgrade backup (retained automatically while recovery is incomplete, or
-your own snapshot) and pin `@soulcraft/brainy@7.x`. 8.0 does not keep the old
+your own snapshot) and pin `@soulcraftlabs/brainy@7.x`. 8.0 does not keep the old
branch layout in place, so a directory-level restore is the rollback path.
diff --git a/docs/guides/vue-integration.md b/docs/guides/vue-integration.md
index 7f7c6a06..34d18ebf 100644
--- a/docs/guides/vue-integration.md
+++ b/docs/guides/vue-integration.md
@@ -12,7 +12,7 @@ Complete guide to integrating Brainy with Vue.js applications, covering Vue 3, N
npm create vue@latest my-brainy-app
cd my-brainy-app
npm install
-npm install @soulcraft/brainy
+npm install @soulcraftlabs/brainy
```
### Basic Setup
@@ -574,7 +574,7 @@ Nuxt's server engine (Nitro) is the natural home for Brainy: it runs on Node/Bun
```javascript
// server/utils/brain.js (server-only — Nitro never bundles this into the client)
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
let brainPromise
@@ -1201,7 +1201,7 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
ssr: {
- external: ['@soulcraft/brainy']
+ external: ['@soulcraftlabs/brainy']
}
})
```
diff --git a/docs/neural-extraction.md b/docs/neural-extraction.md
index 989b1b60..cfb6d764 100644
--- a/docs/neural-extraction.md
+++ b/docs/neural-extraction.md
@@ -24,7 +24,7 @@ Brainy's neural extraction system uses a **4-signal ensemble architecture** to c
### Method 1: Brain Instance (Recommended)
```typescript
-import { Brainy, NounType } from '@soulcraft/brainy'
+import { Brainy, NounType } from '@soulcraftlabs/brainy'
const brain = new Brainy()
await brain.init()
@@ -62,9 +62,9 @@ const people = await brain.extractEntities('...', {
import {
SmartExtractor,
SmartRelationshipExtractor
-} from '@soulcraft/brainy'
+} from '@soulcraftlabs/brainy'
// Or use subpath imports:
-import { SmartExtractor } from '@soulcraft/brainy/neural/SmartExtractor'
+import { SmartExtractor } from '@soulcraftlabs/brainy/neural/SmartExtractor'
const brain = new Brainy()
await brain.init()
@@ -176,7 +176,7 @@ const withVectors = await brain.extractEntities(text, {
**Direct entity type classifier.** Use when you have pre-detected candidates or need custom configuration.
```typescript
-import { SmartExtractor, FormatContext } from '@soulcraft/brainy'
+import { SmartExtractor, FormatContext } from '@soulcraftlabs/brainy'
const extractor = new SmartExtractor(brain, {
minConfidence: 0.7, // Threshold
@@ -229,7 +229,7 @@ interface ExtractionResult {
**Relationship type classifier.** Determines verb/relationship types between entities.
```typescript
-import { SmartRelationshipExtractor } from '@soulcraft/brainy'
+import { SmartRelationshipExtractor } from '@soulcraftlabs/brainy'
const relExtractor = new SmartRelationshipExtractor(brain, {
minConfidence: 0.6,
@@ -286,7 +286,7 @@ const rel = await relExtractor.infer(
**Full extraction orchestrator.** Handles candidate detection, classification, and deduplication.
```typescript
-import { NeuralEntityExtractor } from '@soulcraft/brainy'
+import { NeuralEntityExtractor } from '@soulcraftlabs/brainy'
const extractor = new NeuralEntityExtractor(brain)
@@ -607,7 +607,7 @@ const locations = entities.filter(e => e.type === NounType.Location)
### Example 2: Excel Data Classification
```typescript
-import { SmartExtractor } from '@soulcraft/brainy'
+import { SmartExtractor } from '@soulcraftlabs/brainy'
const extractor = new SmartExtractor(brain)
@@ -629,7 +629,7 @@ for (let i = 0; i < cells.length; i++) {
### Example 3: Relationship Extraction
```typescript
-import { SmartRelationshipExtractor } from '@soulcraft/brainy'
+import { SmartRelationshipExtractor } from '@soulcraftlabs/brainy'
const relExtractor = new SmartRelationshipExtractor(brain)
diff --git a/docs/transactions.md b/docs/transactions.md
index fce7d10e..cbea39c0 100644
--- a/docs/transactions.md
+++ b/docs/transactions.md
@@ -204,8 +204,8 @@ await brain.add({ data: { name: 'Entity' }, type: NounType.Thing })
### Basic Add Operation
```typescript
-import { Brainy } from '@soulcraft/brainy'
-import { NounType } from '@soulcraft/brainy/types'
+import { Brainy } from '@soulcraftlabs/brainy'
+import { NounType } from '@soulcraftlabs/brainy/types'
const brain = new Brainy()
await brain.init()
@@ -428,7 +428,7 @@ await brain.relate({ ... }) // a crash here leaves the entity unlinked
```typescript
import { describe, it, expect } from 'vitest'
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
describe('Transaction Tests', () => {
it('should rollback on failure', async () => {
diff --git a/docs/universal-display-augmentation.md b/docs/universal-display-augmentation.md
index da42874c..464b91fb 100644
--- a/docs/universal-display-augmentation.md
+++ b/docs/universal-display-augmentation.md
@@ -23,7 +23,7 @@ The Universal Display Augmentation is a powerful AI-powered system that automati
### Basic Usage
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const brainy = new Brainy()
await brainy.init()
diff --git a/docs/vfs/PROJECTION_STRATEGY_API.md b/docs/vfs/PROJECTION_STRATEGY_API.md
index 380862e1..f1319d5b 100644
--- a/docs/vfs/PROJECTION_STRATEGY_API.md
+++ b/docs/vfs/PROJECTION_STRATEGY_API.md
@@ -71,9 +71,9 @@ Let's build a projection that organizes files by priority (high, medium, low):
### Step 1: Create the Strategy Class
```typescript
-import { BaseProjectionStrategy } from '@soulcraft/brainy/vfs/semantic'
-import { Brainy } from '@soulcraft/brainy'
-import { VirtualFileSystem, VFSEntity } from '@soulcraft/brainy/vfs'
+import { BaseProjectionStrategy } from '@soulcraftlabs/brainy/vfs/semantic'
+import { Brainy } from '@soulcraftlabs/brainy'
+import { VirtualFileSystem, VFSEntity } from '@soulcraftlabs/brainy/vfs'
export class PriorityProjection extends BaseProjectionStrategy {
readonly name = 'priority'
@@ -141,7 +141,7 @@ export class PriorityProjection extends BaseProjectionStrategy {
### Step 2: Register the Strategy
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
import { PriorityProjection } from './PriorityProjection'
const brain = new Brainy()
@@ -537,7 +537,7 @@ Use the projection's resolve cache:
```typescript
import { describe, it, expect, beforeAll } from 'vitest'
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
import { PriorityProjection } from './PriorityProjection'
describe('PriorityProjection', () => {
@@ -714,7 +714,7 @@ async resolve(brain, vfs, value: string) {
3. Use appropriate limits: Don't fetch more than needed
### Type errors
-1. Import correct types: `import { Brainy, VirtualFileSystem } from '@soulcraft/brainy'`
+1. Import correct types: `import { Brainy, VirtualFileSystem } from '@soulcraftlabs/brainy'`
2. Use `as VFSEntity` when mapping results
3. Check BaseProjectionStrategy import
diff --git a/docs/vfs/QUICK_START.md b/docs/vfs/QUICK_START.md
index 8b0efce6..4a1f83dc 100644
--- a/docs/vfs/QUICK_START.md
+++ b/docs/vfs/QUICK_START.md
@@ -14,11 +14,11 @@ A file explorer that:
## ⚡ Step 1: Basic Setup (1 minute)
```bash
-npm install @soulcraft/brainy
+npm install @soulcraftlabs/brainy
```
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
// ✅ CORRECT: Use filesystem storage for production
const brain = new Brainy({
@@ -115,7 +115,7 @@ Here's a complete React component using the correct patterns:
```tsx
import React, { useState, useEffect } from 'react'
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
export function FileExplorer() {
const [brain, setBrain] = useState(null)
@@ -288,8 +288,8 @@ Your file explorer is now working! Here's what to explore next:
### "Module not found" errors
```bash
# Make sure you're using the right import
-npm ls @soulcraft/brainy # Check version
-npm install @soulcraft/brainy@latest # Update if needed
+npm ls @soulcraftlabs/brainy # Check version
+npm install @soulcraftlabs/brainy@latest # Update if needed
```
### "VFS not initialized" errors
diff --git a/docs/vfs/README.md b/docs/vfs/README.md
index b95f0d7b..a94910c9 100644
--- a/docs/vfs/README.md
+++ b/docs/vfs/README.md
@@ -24,7 +24,7 @@ Brainy VFS is a revolutionary virtual filesystem that runs on top of Brainy's ne
## Quick Start
```javascript
-import { VirtualFileSystem } from '@soulcraft/brainy/vfs'
+import { VirtualFileSystem } from '@soulcraftlabs/brainy/vfs'
// Initialize the VFS
const vfs = new VirtualFileSystem({
@@ -381,7 +381,7 @@ Brainy VFS fully leverages Brainy's revolutionary Triple Intelligence system:
## Installation
```bash
-npm install @soulcraft/brainy
+npm install @soulcraftlabs/brainy
```
## Requirements
diff --git a/docs/vfs/ROADMAP.md b/docs/vfs/ROADMAP.md
index 93c5b901..c8d15cd2 100644
--- a/docs/vfs/ROADMAP.md
+++ b/docs/vfs/ROADMAP.md
@@ -135,7 +135,7 @@ Mount VFS as a native filesystem on Linux/Mac/Windows.
```typescript
// Planned (research phase)
-import { mountVFS } from '@soulcraft/brainy/vfs/fuse'
+import { mountVFS } from '@soulcraftlabs/brainy/vfs/fuse'
await mountVFS(vfs, {
mountPoint: '/mnt/brainy',
@@ -160,7 +160,7 @@ These features would benefit from community contributions. If you're interested
### Express.js Static Middleware
```typescript
// Wanted: Community contribution
-import { createStaticMiddleware } from '@soulcraft/brainy/vfs/express'
+import { createStaticMiddleware } from '@soulcraftlabs/brainy/vfs/express'
app.use('/files', createStaticMiddleware(vfs, {
index: ['index.html', 'index.md'],
@@ -172,7 +172,7 @@ app.use('/files', createStaticMiddleware(vfs, {
### VSCode Extension
```typescript
// Wanted: Community contribution
-import { VFSProvider } from '@soulcraft/brainy/vfs/vscode'
+import { VFSProvider } from '@soulcraftlabs/brainy/vfs/vscode'
const provider = new VFSProvider(vfs)
vscode.workspace.registerFileSystemProvider('brainy', provider)
diff --git a/docs/vfs/SEMANTIC_VFS.md b/docs/vfs/SEMANTIC_VFS.md
index 9298c822..f34ee9ae 100644
--- a/docs/vfs/SEMANTIC_VFS.md
+++ b/docs/vfs/SEMANTIC_VFS.md
@@ -327,7 +327,7 @@ console.log(id1 === id2 && id2 === id3) // true
Create your own semantic dimensions:
```typescript
-import { BaseProjectionStrategy } from '@soulcraft/brainy/vfs/semantic'
+import { BaseProjectionStrategy } from '@soulcraftlabs/brainy/vfs/semantic'
class PriorityProjection extends BaseProjectionStrategy {
readonly name = 'priority'
diff --git a/docs/vfs/VFS_API_GUIDE.md b/docs/vfs/VFS_API_GUIDE.md
index e0c6a94c..5dcaaeb8 100644
--- a/docs/vfs/VFS_API_GUIDE.md
+++ b/docs/vfs/VFS_API_GUIDE.md
@@ -7,7 +7,7 @@ Brainy's Virtual Filesystem (VFS) provides a POSIX-like filesystem interface tha
## Quick Start
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
// Initialize Brainy
const brain = new Brainy({
@@ -598,7 +598,7 @@ const user = await store.findById('users', 'user123')
VFS uses standard POSIX-style errors:
```typescript
-import { VFSError, VFSErrorCode } from '@soulcraft/brainy'
+import { VFSError, VFSErrorCode } from '@soulcraftlabs/brainy'
try {
await vfs.readFile('/nonexistent.txt')
diff --git a/docs/vfs/VFS_CORE.md b/docs/vfs/VFS_CORE.md
index 1eeaf9f8..c1d502c0 100644
--- a/docs/vfs/VFS_CORE.md
+++ b/docs/vfs/VFS_CORE.md
@@ -280,7 +280,7 @@ GitBridge provides Git import/export capabilities:
#### GitBridge Usage
```javascript
// Import and instantiate GitBridge
-import { GitBridge } from '@soulcraft/brainy'
+import { GitBridge } from '@soulcraftlabs/brainy'
const gitBridge = new GitBridge(vfs, brain)
// Export VFS to Git repository structure
@@ -452,7 +452,7 @@ This ordering prevents race conditions where file writes might fail because pare
## Complete Example
```javascript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
async function vfsExample() {
// Initialize
diff --git a/docs/vfs/VFS_GRAPH_TYPES.md b/docs/vfs/VFS_GRAPH_TYPES.md
index 3c1f30f0..478bef7f 100644
--- a/docs/vfs/VFS_GRAPH_TYPES.md
+++ b/docs/vfs/VFS_GRAPH_TYPES.md
@@ -196,5 +196,5 @@ await brain.relate({
Always import and use the type enums:
```javascript
-import { NounType, VerbType } from '@soulcraft/brainy'
+import { NounType, VerbType } from '@soulcraftlabs/brainy'
```
\ No newline at end of file
diff --git a/docs/vfs/VFS_INITIALIZATION.md b/docs/vfs/VFS_INITIALIZATION.md
index 97e6b0bf..fd12fc71 100644
--- a/docs/vfs/VFS_INITIALIZATION.md
+++ b/docs/vfs/VFS_INITIALIZATION.md
@@ -5,7 +5,7 @@
The Brainy VFS is automatically initialized during `brain.init()`. No separate initialization needed!
```javascript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
// Create and initialize Brainy
const brain = new Brainy({
@@ -71,7 +71,7 @@ VFS stores files as entities and relationships in the same graph as everything e
## Complete Example
```javascript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
async function useVFS() {
// Initialize Brainy
@@ -100,7 +100,7 @@ useVFS().catch(console.error)
## TypeScript Usage
```typescript
-import { Brainy, VirtualFileSystem } from '@soulcraft/brainy'
+import { Brainy, VirtualFileSystem } from '@soulcraftlabs/brainy'
class FileManager {
private brain: Brainy
diff --git a/docs/vfs/building-file-explorers.md b/docs/vfs/building-file-explorers.md
index 6bb31871..7514c12e 100644
--- a/docs/vfs/building-file-explorers.md
+++ b/docs/vfs/building-file-explorers.md
@@ -37,7 +37,7 @@ Brainy VFS provides safe, tree-aware methods that prevent these issues:
### Method 1: Use `getDirectChildren()` (Recommended)
```typescript
-import { Brainy, VirtualFileSystem } from '@soulcraft/brainy'
+import { Brainy, VirtualFileSystem } from '@soulcraftlabs/brainy'
const brain = new Brainy()
await brain.init()
@@ -97,7 +97,7 @@ Here's a complete example using React:
```tsx
import React, { useState, useEffect } from 'react'
-import { VirtualFileSystem } from '@soulcraft/brainy'
+import { VirtualFileSystem } from '@soulcraftlabs/brainy'
interface FileNode {
name: string
@@ -177,7 +177,7 @@ function TreeView({ node, onToggle, expanded }) {
If you must build trees manually from flat lists, use the `VFSTreeUtils`:
```typescript
-import { VFSTreeUtils } from '@soulcraft/brainy/vfs'
+import { VFSTreeUtils } from '@soulcraftlabs/brainy/vfs'
// Get all entities somehow
const allEntities = await vfs.getDescendants('/root')
diff --git a/examples/bluesky-distributed-setup.js b/examples/bluesky-distributed-setup.js
index 9e83cf25..e3b33506 100644
--- a/examples/bluesky-distributed-setup.js
+++ b/examples/bluesky-distributed-setup.js
@@ -7,7 +7,7 @@
* the Bluesky firehose with Brainy's distributed architecture
*/
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
import { WebSocket } from 'ws'
// =====================================================
diff --git a/examples/monitor-cache-performance.ts b/examples/monitor-cache-performance.ts
index 9d50d476..87c965a2 100644
--- a/examples/monitor-cache-performance.ts
+++ b/examples/monitor-cache-performance.ts
@@ -14,7 +14,7 @@
* ts-node examples/monitor-cache-performance.ts
*/
-import { Brainy, NounType } from '@soulcraft/brainy'
+import { Brainy, NounType } from '@soulcraftlabs/brainy'
// ANSI color codes for pretty output
const colors = {
diff --git a/integrations/README.md b/integrations/README.md
index aa3d795b..de156623 100644
--- a/integrations/README.md
+++ b/integrations/README.md
@@ -5,7 +5,7 @@ Connect Brainy to spreadsheets, BI tools, and external systems with zero configu
## Quick Start
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const brain = new Brainy({ integrations: true })
await brain.init()
@@ -178,7 +178,7 @@ Webhooks include `X-Brainy-Signature` header with HMAC-SHA256 signature.
### Minimal (in-memory):
```typescript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const brain = new Brainy({ integrations: true })
await brain.init()
@@ -194,7 +194,7 @@ console.log(brain.hub.getInstructions())
```typescript
import express from 'express'
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const app = express()
const brain = new Brainy({
@@ -232,7 +232,7 @@ app.listen(3000, () => {
```typescript
import { Hono } from 'hono'
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const app = new Hono()
diff --git a/integrations/google-sheets/README.md b/integrations/google-sheets/README.md
index b2b0af3a..8309a30a 100644
--- a/integrations/google-sheets/README.md
+++ b/integrations/google-sheets/README.md
@@ -99,7 +99,7 @@ Add the `BRAINY_URL` script property in Apps Script settings.
The simplest way to enable all integrations:
```javascript
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const brain = new Brainy({ integrations: true })
await brain.init()
@@ -112,7 +112,7 @@ With Express:
```javascript
import express from 'express'
-import { Brainy } from '@soulcraft/brainy'
+import { Brainy } from '@soulcraftlabs/brainy'
const app = express()
const brain = new Brainy({ integrations: true })
diff --git a/package-lock.json b/package-lock.json
index 70635835..c4f66561 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
- "name": "@soulcraft/brainy",
- "version": "10.4.1",
+ "name": "@soulcraftlabs/brainy",
+ "version": "10.4.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "@soulcraft/brainy",
- "version": "10.4.1",
+ "name": "@soulcraftlabs/brainy",
+ "version": "10.4.4",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.2",
diff --git a/package.json b/package.json
index 4125b073..06ce0253 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,7 @@
{
- "name": "@soulcraft/brainy",
- "version": "10.4.1",
+ "name": "@soulcraftlabs/brainy",
+ "version": "10.4.4",
+ "brainyContract": 1,
"description": "Universal Knowledge Protocol™ - World's first Triple Intelligence database unifying vector, graph, and document search in one API. Stage 3 CANONICAL: 42 nouns × 127 verbs covering 96-97% of all human knowledge.",
"main": "dist/index.js",
"module": "dist/index.js",
@@ -126,15 +127,16 @@
"license": "MIT",
"private": false,
"publishConfig": {
- "access": "public"
+ "access": "public",
+ "registry": "https://source.soulcraft.com/api/packages/soulcraftlabs/npm/"
},
- "homepage": "https://source.soulcraft.com/soulcraft/brainy",
+ "homepage": "https://source.soulcraft.com/soulcraftlabs/open-brainy",
"bugs": {
- "url": "https://source.soulcraft.com/soulcraft/brainy/issues"
+ "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/issues"
},
"repository": {
"type": "git",
- "url": "git+https://source.soulcraft.com/soulcraft/brainy.git"
+ "url": "git+https://source.soulcraft.com/soulcraftlabs/open-brainy.git"
},
"files": [
"dist/**/*.js",
diff --git a/releases/brainy.json b/releases/brainy.json
new file mode 100644
index 00000000..8f61c7f2
--- /dev/null
+++ b/releases/brainy.json
@@ -0,0 +1,76 @@
+{
+ "product": "brainy",
+ "entries": [
+ {
+ "version": "11.0.5",
+ "date": "2026-09-02",
+ "headline": "Graph-first finds in production, and opens that stop rescanning history",
+ "items": [
+ "find({ connected, where }) now walks the neighbours first and filters only those rows through a native door — correct at every page and O(neighbours), never the whole store.",
+ "related() with a list of verb types returns every requested kind (a fast path had silently kept only the first).",
+ "Deferred-embedding recovery resumes from a low-water mark instead of rescanning the whole generation log at every open — measured at two minutes on a large brain, now milliseconds."
+ ],
+ "url": null,
+ "thumb": null
+ },
+ {
+ "version": "11.0.4",
+ "date": "2026-09-01",
+ "headline": "Closes in milliseconds, index rebuilds without the disk-sync storm",
+ "items": [
+ "close() no longer pays deferred compaction or waits out an in-flight rebuild — measured 8 ms against the 4-minute closes it replaces; deferred work resumes at the next open, in the background.",
+ "The metadata index's rebuild syncs to disk per shard instead of per row, and the durability point moved to the publish step — the same guarantee, a fraction of the disk traffic.",
+ "A new native filter door evaluates queries over exactly the candidate rows a graph walk found, never the whole store."
+ ],
+ "url": null,
+ "thumb": null
+ },
+ {
+ "version": "11.0.3",
+ "date": "2026-09-01",
+ "headline": "The embedding upgrade ceremony runs on every brain",
+ "items": [
+ "A brain opened through the standard plugin now carries its embedding-model identity, so the full-precision upgrade ceremony can run on it.",
+ "A one-fix release; nothing else changed."
+ ],
+ "url": null,
+ "thumb": null
+ },
+ {
+ "version": "11.0.2",
+ "date": "2026-08-31",
+ "headline": "One embedding quality everywhere, 3–4× faster imports",
+ "items": [
+ "Every runtime embeds with the same full-precision model — search quality no longer depends on where you run.",
+ "Bulk embedding measured 3.1–4.2× faster, and an online re-embed ceremony upgrades existing stores without downtime.",
+ "The engine's change feed is documented, with the SSE/WebSocket fan-out pattern for realtime surfaces."
+ ],
+ "url": null,
+ "thumb": null
+ },
+ {
+ "version": "11.0.1",
+ "date": "2026-08-31",
+ "headline": "Deletes inside transactions are safe",
+ "items": [
+ "Deleting relations inside a transact() no longer corrupts index bookkeeping.",
+ "A store that deletes its last relation keeps serving instead of refusing."
+ ],
+ "url": null,
+ "thumb": null
+ },
+ {
+ "version": "11.0.0",
+ "date": "2026-08-28",
+ "headline": "One install, one engine — Brainy",
+ "items": [
+ "The former two-package pair is one package: the native engine under the familiar API. One import is the whole install.",
+ "A missing native build refuses loudly with its cures named; nothing falls back silently.",
+ "Stores open in place — no migration."
+ ],
+ "url": null,
+ "thumb": null
+ }
+ ],
+ "history": "The version line continues from the 4.3.x native-engine releases; their record lives in the product repository's CHANGELOG.md."
+}
diff --git a/releases/open-brainy.json b/releases/open-brainy.json
new file mode 100644
index 00000000..582f4847
--- /dev/null
+++ b/releases/open-brainy.json
@@ -0,0 +1,110 @@
+{
+ "product": "open-brainy",
+ "entries": [
+ {
+ "version": "10.4.9",
+ "date": "2026-09-02",
+ "headline": "Graph-first finds, honest verb arrays, and opens that stop rescanning history",
+ "items": [
+ "find({ connected, where }) now walks the neighbours first and filters only those rows — correct at every page, and O(neighbours) instead of O(store).",
+ "related() with a list of verb types (or sources, or targets) returns every requested kind — four fast paths silently kept only the first.",
+ "Deferred-embedding recovery resumes from a low-water mark instead of rescanning the whole generation log at every open — measured at two minutes on a large brain, now milliseconds."
+ ],
+ "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.9",
+ "thumb": null
+ },
+ {
+ "version": "10.4.7",
+ "date": "2026-09-01",
+ "headline": "Count ledgers can no longer race themselves",
+ "items": [
+ "Concurrent count flushes coalesce into one writer with a trailing pass — parallel flushes can no longer corrupt a store's count ledger.",
+ "Atomic writes carry a per-process sequence, so two processes' temp files can never collide."
+ ],
+ "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.7",
+ "thumb": null
+ },
+ {
+ "version": "10.4.6",
+ "date": "2026-08-31",
+ "headline": "Transactions cross the index seam safely",
+ "items": [
+ "Deleting relations inside a transact() no longer fails against the metadata index — operations take a JSON-safe view at the moment they execute.",
+ "Fixes a class of transaction failures on stores with integer-mapped relation endpoints."
+ ],
+ "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.6",
+ "thumb": null
+ },
+ {
+ "version": "10.4.5",
+ "date": "2026-08-31",
+ "headline": "Recovery tells the truth, docs live at home",
+ "items": [
+ "A torn generation-log tail is a terminal verdict with a named cure — never an endless wait at open.",
+ "A sealed segment declares only the generations it actually holds.",
+ "The engine's documentation now publishes from its own repository."
+ ],
+ "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.5",
+ "thumb": null
+ },
+ {
+ "version": "10.4.4",
+ "date": "2026-08-28",
+ "headline": "Faster opens, quieter idle",
+ "items": [
+ "Opening a store discovers generations from directory names instead of walking the log, and answers \"any entities?\" with one directory read.",
+ "The flush-request watch is event-driven; idle stores stop paying a polling heartbeat.",
+ "A slow open now names the exact step it is in, so operators see what is being paid and why."
+ ],
+ "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.4",
+ "thumb": null
+ },
+ {
+ "version": "10.4.3",
+ "date": "2026-08-27",
+ "headline": "Open Brainy, under its own name",
+ "items": [
+ "The same engine as 10.4.2, now published as @soulcraftlabs/brainy — the MIT reference engine, on The Source.",
+ "No code changes; your imports change once and everything else stays put."
+ ],
+ "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.3",
+ "thumb": null
+ },
+ {
+ "version": "10.4.2",
+ "date": "2026-08-27",
+ "headline": "Vectors that lie are refused, counts that drift are caught",
+ "items": [
+ "A zero-norm vector is not a vector: the index refuses them, rebuilds skip them, and a sanctioned unvector door removes them cleanly.",
+ "The canonical count ledger derives from identity records and marks legacy-derived ledgers suspect at load.",
+ "Plugin activation failures keep their original error as cause, so the real frame reaches your logs."
+ ],
+ "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.2",
+ "thumb": null
+ },
+ {
+ "version": "10.4.1",
+ "date": "2026-08-26",
+ "headline": "Writes that change nothing cost nothing",
+ "items": [
+ "The read gate is per index family, and a write carrying unchanged data never re-embeds.",
+ "The vectored-row count joins the ledger, so vector coverage is a number you can read, not a guess."
+ ],
+ "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.1",
+ "thumb": null
+ },
+ {
+ "version": "10.4.0",
+ "date": "2026-08-26",
+ "headline": "Repair routing, the vector ledger, and honest empties",
+ "items": [
+ "Repairs route to the index that owns the damage, and the open gate closes the vector leg until coverage is proven.",
+ "An empty string is real data, not a missing field.",
+ "The metadata crossing never carries raw integer relation endpoints — a whole class of serialization faults closed."
+ ],
+ "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.0",
+ "thumb": null
+ }
+ ],
+ "history": "Earlier releases are recorded in CHANGELOG.md in this repository."
+}
diff --git a/scripts/emit-contract-manifest.mjs b/scripts/emit-contract-manifest.mjs
new file mode 100644
index 00000000..be73d4ca
--- /dev/null
+++ b/scripts/emit-contract-manifest.mjs
@@ -0,0 +1,128 @@
+#!/usr/bin/env node
+/**
+ * Emit this build's API-contract manifest to docs/api-contract.json.
+ *
+ * WHY IT IS GENERATED, NOT WRITTEN: a hand-kept list of doors drifts from the
+ * code the first time somebody adds one. This reads the surface the build
+ * actually exposes — the prototype's own methods and accessors, the exported
+ * error classes, the `where` operator sets, the field-addressing vocabulary,
+ * the health verdicts — so a diff between two engines' manifests is a diff
+ * between two engines, never between two authors.
+ *
+ * Requirement marking (required / optional per door) is NOT derivable from the
+ * surface — it is a commitment, recorded with the contract's owner rather than
+ * here. This manifest carries the surface; the promise lives with the contract.
+ *
+ * Usage: node scripts/emit-contract-manifest.mjs [--check]
+ * --check exits non-zero when the committed manifest is stale.
+ */
+
+import { writeFileSync, readFileSync, existsSync } from 'node:fs'
+import { join, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
+const OUT = join(ROOT, 'docs', 'api-contract.json')
+
+const { Brainy } = await import(join(ROOT, 'dist', 'brainy.js'))
+const errorsModule = await import(join(ROOT, 'dist', 'errors', 'brainyError.js'))
+const versionModule = await import(join(ROOT, 'dist', 'utils', 'version.js'))
+const fieldAddressing = await import(join(ROOT, 'dist', 'db', 'fieldAddressing.js'))
+
+/** Every own method and accessor on the class's prototype, minus the private ones. */
+function surfaceOf(ctor) {
+ const doors = []
+ for (const name of Object.getOwnPropertyNames(ctor.prototype)) {
+ if (name === 'constructor' || name.startsWith('_')) continue
+ const descriptor = Object.getOwnPropertyDescriptor(ctor.prototype, name)
+ if (!descriptor) continue
+ if (typeof descriptor.value === 'function') {
+ doors.push({ name, kind: 'method', arity: descriptor.value.length })
+ } else if (descriptor.get) {
+ doors.push({ name, kind: 'accessor' })
+ }
+ }
+ return doors.sort((a, b) => a.name.localeCompare(b.name))
+}
+
+const errors = Object.entries(errorsModule)
+ .filter(([name, value]) => typeof value === 'function' && /Error$/.test(name))
+ .map(([name]) => name)
+ .sort()
+
+// The operator sets, read from the engine's own refusal message so the
+// manifest can never disagree with the validator.
+const filterSource = readFileSync(join(ROOT, 'src', 'utils', 'metadataFilter.ts'), 'utf-8')
+const acceptedMatch = filterSource.match(/const VALUE_OPERATORS = new Set\(\[([\s\S]*?)\]\)/)
+if (!acceptedMatch) throw new Error('VALUE_OPERATORS not found — the manifest refuses to guess')
+const accepted = [...acceptedMatch[1].matchAll(/'([^']+)'/g)].map((m) => m[1]).sort()
+
+const indexSource = readFileSync(join(ROOT, 'src', 'utils', 'metadataIndex.ts'), 'utf-8')
+const refusedByIndex = ['endsWith', 'length', 'matches', 'startsWith'].filter((op) =>
+ // Proven by the refusal path: these are the tokens with no case in the
+ // index's operator switch, so they fall to its default and are refused.
+ !new RegExp(`case '${op}':`).test(indexSource)
+)
+const servedOnIndex = accepted.filter((op) => !refusedByIndex.includes(op))
+
+const manifest = {
+ contractVersion: versionModule.contractVersion(),
+ engine: '@soulcraftlabs/brainy',
+ compatibility: {
+ minor:
+ 'additive — a new optional door, a new served operator, a new error class; every existing implementation still conforms',
+ major:
+ 'breaking — a door removed, an answer narrowed, an ordering law changed, an optional door promoted to required, or an operator moved from served to refused'
+ },
+ doors: surfaceOf(Brainy),
+ errors,
+ operators: {
+ accepted,
+ servedOnIndexPath: servedOnIndex,
+ refusedByIndexPath: refusedByIndex,
+ combinators: ['allOf', 'anyOf', 'not']
+ },
+ fieldAddressing: {
+ systemKeyPrefix: 'system.',
+ systemEntityScalars: [...(fieldAddressing.SYSTEM_ENTITY_SCALARS ?? [])].sort(),
+ systemRelationScalars: [...(fieldAddressing.SYSTEM_RELATION_SCALARS ?? [])].sort(),
+ plumbingFields: [...(fieldAddressing.PLUMBING_FIELDS ?? [])].sort()
+ },
+ health: {
+ verdicts: ['pass', 'warn', 'fail'],
+ healKinds: ['none', 'repair', 'rebuild'],
+ servingWithholdingInvariants: [
+ 'index-initialized',
+ 'durable-state-present',
+ 'manifest-residency',
+ 'replay-clean',
+ 'strand-latch'
+ ]
+ }
+}
+
+const rendered = `${JSON.stringify(manifest, null, 2)}\n`
+
+if (process.argv.includes('--check')) {
+ if (!existsSync(OUT)) {
+ console.error(`docs/api-contract.json is missing — run: node scripts/emit-contract-manifest.mjs`)
+ process.exit(1)
+ }
+ if (readFileSync(OUT, 'utf-8') !== rendered) {
+ console.error(
+ `docs/api-contract.json is STALE — the public surface changed. Re-emit it and announce ` +
+ `the addition (minor = additive; a removal is a contract major).`
+ )
+ process.exit(1)
+ }
+ console.log(`docs/api-contract.json is current (${manifest.doors.length} doors, contract ${manifest.contractVersion}).`)
+ process.exit(0)
+}
+
+writeFileSync(OUT, rendered)
+console.log(
+ `Wrote docs/api-contract.json — contract ${manifest.contractVersion}, ` +
+ `${manifest.doors.length} doors, ${manifest.errors.length} error classes, ` +
+ `${manifest.operators.accepted.length} operators ` +
+ `(${manifest.operators.refusedByIndexPath.length} refused by the index path).`
+)
diff --git a/scripts/release.sh b/scripts/release.sh
index 5d03e66b..5d434320 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -15,11 +15,11 @@ NC='\033[0m' # No Color
RELEASE_TYPE="${1:-patch}" # patch, minor, or major
SKIP_TESTS=false
DRY_RUN=false
-# --source-only: the HOME leg only — tag, CI's publish to The Source, and the
-# release page; NO storefront (npmjs) publish, NO pair verification, NO docs
-# push. The pair-gate shape: a prerelease the fleet's other engine devDeps
-# from our own registry while the pair is proven, never a public artifact.
-# Refused for a non-prerelease version — a public floor is always a pair.
+# --source-only is now a no-op: The Source is the one registry, so every
+# release already ships Source-only — tag, CI's publish to The Source, the
+# release page, and the docs push, with no separate storefront leg to skip.
+# The flag is still accepted (for backward-compatible invocations) and just
+# prints a notice; it no longer changes behavior.
SOURCE_ONLY=false
for arg in "$@"; do
@@ -109,7 +109,7 @@ else
;;
*)
echo -e "${RED}❌ Invalid release type: ${RELEASE_TYPE}${NC}"
- echo "Usage: ./scripts/release.sh [patch|minor|major|] [--dry-run] [--source-only (prereleases only)]"
+ echo "Usage: ./scripts/release.sh [patch|minor|major|] [--dry-run] [--source-only (no-op; The Source is the one registry)]"
exit 1
;;
esac
@@ -129,11 +129,7 @@ if [ "$PRERELEASE" = true ]; then
echo -e "${YELLOW}⚠️ Prerelease → npm dist-tag '${NPM_TAG}', GitHub prerelease${NC}"
fi
if [ "$SOURCE_ONLY" = true ]; then
- if [ "$PRERELEASE" != true ]; then
- echo -e "${RED}❌ --source-only is for prereleases only: a non-prerelease version is a public floor and always ships as the byte-identical pair.${NC}"
- exit 1
- fi
- echo -e "${YELLOW}⚠️ --source-only → The Source (home) ONLY: no npmjs publish, no pair verification, no docs push${NC}"
+ echo -e "${YELLOW}⚠️ The Source is the one registry; --source-only is implied${NC}"
fi
echo ""
@@ -158,7 +154,7 @@ else
fi
# Create new changelog entry
-CHANGELOG_ENTRY="### [${NEW_VERSION}](https://source.soulcraft.com/soulcraft/brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) ($(date +%Y-%m-%d))
+CHANGELOG_ENTRY="### [${NEW_VERSION}](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) ($(date +%Y-%m-%d))
${COMMITS}
"
@@ -209,9 +205,9 @@ echo -e "${GREEN}✅ Pushed to origin${NC}\n"
# .forgejo/workflows/publish-source.yml, which builds and publishes on The
# Source's own runner (datacenter-side: seconds, not the laptop's WAN timing
# out on an 87MB tarball PUT). The laptop holds no home-registry publish
-# credential anymore; it only waits for CI's result before trusting the
-# home/npmjs pair enough to publish the storefront leg.
-SOURCE_NPM_REG="https://source.soulcraft.com/api/packages/soulcraft/npm/"
+# credential anymore; it only waits for CI's result before continuing on to
+# the release page and the docs push.
+SOURCE_NPM_REG="https://source.soulcraft.com/api/packages/soulcraftlabs/npm/"
SOURCE_POLL_INTERVAL_S=15
SOURCE_POLL_MAX_ATTEMPTS=200 # 200 × 15s = 50 minutes — the runner is sequential and a busy day's ci.yml
# backlog has twice exceeded the old 20-minute window (8.10.3, 9.0.0);
@@ -219,7 +215,7 @@ SOURCE_POLL_MAX_ATTEMPTS=200 # 200 × 15s = 50 minutes — the runner is sequen
echo -e "${BLUE}9️⃣ Waiting for CI to publish v${NEW_VERSION} to The Source registry (home)...${NC}"
SOURCE_LANDED=false
for ((attempt = 1; attempt <= SOURCE_POLL_MAX_ATTEMPTS; attempt++)); do
- LANDED_VERSION=$(npm view "@soulcraft/brainy@${NEW_VERSION}" version "--@soulcraft:registry=${SOURCE_NPM_REG}" 2>/dev/null || echo "")
+ LANDED_VERSION=$(npm view "@soulcraftlabs/brainy@${NEW_VERSION}" version "--@soulcraftlabs:registry=${SOURCE_NPM_REG}" 2>/dev/null || echo "")
if [ "$LANDED_VERSION" = "$NEW_VERSION" ]; then
SOURCE_LANDED=true
break
@@ -232,62 +228,16 @@ if [ "$SOURCE_LANDED" = true ]; then
echo -e "${GREEN}✅ CI published v${NEW_VERSION} to The Source${NC}\n"
else
echo -e "${RED}❌ CI's home publish did not land — check the workflow run on The Source; the pair must not diverge.${NC}"
- echo -e "${RED} v${NEW_VERSION} was tagged and pushed, but @soulcraft/brainy@${NEW_VERSION} never became visible on the${NC}"
- echo -e "${RED} Source registry after ${SOURCE_POLL_MAX_ATTEMPTS} attempts, ${SOURCE_POLL_INTERVAL_S}s apart. Aborting before npmjs.${NC}"
+ echo -e "${RED} v${NEW_VERSION} was tagged and pushed, but @soulcraftlabs/brainy@${NEW_VERSION} never became visible on the${NC}"
+ echo -e "${RED} Source registry after ${SOURCE_POLL_MAX_ATTEMPTS} attempts, ${SOURCE_POLL_INTERVAL_S}s apart. Aborting.${NC}"
exit 1
fi
-if [ "$SOURCE_ONLY" = true ]; then
- echo -e "${YELLOW}9️⃣½ Storefront (npmjs) leg SKIPPED — --source-only: v${NEW_VERSION} lives on The Source under dist-tag '${NPM_TAG}' only${NC}\n"
-else
- echo -e "${BLUE}9️⃣½ Publishing to npmjs (storefront, dist-tag: ${NPM_TAG})...${NC}"
- # BYTE-IDENTITY LAW: the storefront republishes CI's EXACT artifact — download
- # the tarball The Source serves and publish that file, never a fresh local pack
- # (a local rebuild can differ byte-wise, and the fleet verifies the pair by
- # shasum across registries).
- STOREFRONT_TMP="$(mktemp -d)"
- (cd "$STOREFRONT_TMP" && npm pack "@soulcraft/brainy@${NEW_VERSION}" "--@soulcraft:registry=${SOURCE_NPM_REG}" >/dev/null)
- SOURCE_TARBALL="$(ls "$STOREFRONT_TMP"/soulcraft-brainy-*.tgz)"
- echo -e "${BLUE} home artifact: $(sha256sum "$SOURCE_TARBALL" | cut -d' ' -f1)${NC}"
- npm publish "$SOURCE_TARBALL" --tag "$NPM_TAG" "--@soulcraft:registry=https://registry.npmjs.org/"
- rm -rf "$STOREFRONT_TMP"
- # Brainy is the only PUBLIC @soulcraft package — verify visibility after every publish.
- npm access get status @soulcraft/brainy "--@soulcraft:registry=https://registry.npmjs.org/" || true
- # Verify the pair is byte-identical by registry-reported shasum — divergence
- # here means the storefront leg must be treated as failed, loudly. RETRIED
- # with raw curl: npmjs metadata propagates with a lag measured in minutes,
- # and a one-shot npm-view probe fired a false DIVERGENCE on 10.0.0 while a
- # raw curl of the registry document already confirmed byte-identity. The
- # probe now reads the registry JSON directly (no npm cache in the path) and
- # gives propagation up to 5 minutes before calling the pair divergent.
- NPMJS_VERIFY_ATTEMPTS=20
- NPMJS_VERIFY_INTERVAL_S=15 # 20 × 15s = 5 minutes of propagation grace
- SOURCE_SHA=$(npm view "@soulcraft/brainy@${NEW_VERSION}" dist.shasum "--@soulcraft:registry=${SOURCE_NPM_REG}" 2>/dev/null || echo "source-unavailable")
- PAIR_IDENTICAL=false
- for ((attempt = 1; attempt <= NPMJS_VERIFY_ATTEMPTS; attempt++)); do
- NPMJS_SHA=$(curl -fsSL "https://registry.npmjs.org/@soulcraft%2Fbrainy" 2>/dev/null \
- | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const v=JSON.parse(d).versions[process.argv[1]];console.log(v?v.dist.shasum:'')}catch{console.log('')}})" "${NEW_VERSION}" \
- || echo "")
- if [ -n "$NPMJS_SHA" ] && [ "$SOURCE_SHA" = "$NPMJS_SHA" ]; then
- PAIR_IDENTICAL=true
- break
- fi
- echo -e "${YELLOW} … npmjs metadata not settled (attempt ${attempt}/${NPMJS_VERIFY_ATTEMPTS}: '${NPMJS_SHA:-absent}' vs '${SOURCE_SHA}'); retrying in ${NPMJS_VERIFY_INTERVAL_S}s${NC}"
- sleep "$NPMJS_VERIFY_INTERVAL_S"
- done
- if [ "$PAIR_IDENTICAL" = true ]; then
- echo -e "${GREEN}✅ Published to npmjs — byte-identical pair (shasum ${NPMJS_SHA})${NC}\n"
- else
- echo -e "${RED}❌ REGISTRY DIVERGENCE: The Source shasum ${SOURCE_SHA} != npmjs shasum ${NPMJS_SHA} after ${NPMJS_VERIFY_ATTEMPTS} attempts — investigate before announcing${NC}\n"
- exit 1
- fi
-fi
-
# Step 11: Release object on The Source (presentational — the tag, CHANGELOG,
# and RELEASES.md are the record; this just gives The Source's UI a release page).
echo -e "${BLUE}🔟 Creating release page on The Source...${NC}"
if [ -n "${FORGEJO_RELEASE_TOKEN:-}" ]; then
- if curl -sf -X POST "https://source.soulcraft.com/api/v1/repos/soulcraft/brainy/releases" \
+ if curl -sf -X POST "https://source.soulcraft.com/api/v1/repos/soulcraftlabs/open-brainy/releases" \
-H "Authorization: token ${FORGEJO_RELEASE_TOKEN}" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"v${NEW_VERSION}\",\"name\":\"v${NEW_VERSION}\",\"prerelease\":${PRERELEASE}}" >/dev/null; then
echo -e "${GREEN}✅ Release page created on The Source${NC}\n"
@@ -298,29 +248,15 @@ else
echo -e "${RED}⚠️ FORGEJO_RELEASE_TOKEN unset — no release page created; tag + CHANGELOG remain the record${NC}\n"
fi
-# Step 12: Push public docs to the soulcraft.com docs ingest door
-# (VENUE-DOCS-RELEASE-PUSH). Skips with a loud warning when
-# DOCS_INGEST_SECRET is unset; fails loudly (without undoing the publish —
-# that already happened) when a push errors, so the docs site never
-# silently trails npm.
-if [ "$SOURCE_ONLY" = true ]; then
- echo -e "${YELLOW}1️⃣2️⃣ Docs push SKIPPED — --source-only (a home-only prerelease publishes no public docs)${NC}\n"
-else
- echo -e "${BLUE}1️⃣2️⃣ Pushing public docs to soulcraft.com/docs...${NC}"
- if node scripts/push-docs.js; then
- echo -e "${GREEN}✅ Docs push step done${NC}\n"
- else
- echo -e "${RED}❌ Docs push FAILED — soulcraft.com/docs trails npm until re-run or interim sync${NC}\n"
- fi
-fi
+# Step 12 RETIRED (2026-08-31, CORTEX-SITE-BRAINY-RENAME round 12, David-ruled):
+# soulcraft.com/docs carries the paid product's documentation only. This
+# engine's documentation home is THIS repository — README and docs/ — and the
+# site serves 301s for the slugs this rail used to push. The push script stays
+# in the tree for history; the rail no longer calls it.
+echo -e "${BLUE}Docs step: this engine documents itself in its own repo (site push retired 2026-08-31)${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN}🎉 Release ${NEW_VERSION} complete!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
-if [ "$SOURCE_ONLY" = true ]; then
- echo -e "📦 npmjs: ${YELLOW}not published (--source-only)${NC}"
-else
- echo -e "📦 npm: ${BLUE}https://www.npmjs.com/package/@soulcraft/brainy/v/${NEW_VERSION}${NC}"
-fi
-echo -e "🏠 The Source: ${BLUE}https://source.soulcraft.com/soulcraft/brainy/releases/tag/v${NEW_VERSION}${NC}"
+echo -e "🏠 The Source: ${BLUE}https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v${NEW_VERSION}${NC}"
diff --git a/src/brainy.ts b/src/brainy.ts
index bfac8c08..da04577e 100644
--- a/src/brainy.ts
+++ b/src/brainy.ts
@@ -198,7 +198,12 @@ import {
import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.js'
import { GenerationConflictError, StoreInconsistentError } from './db/errors.js'
import { BrainyError, GraphIndexNotReadyError, MetadataIndexNotReadyError, MigrationInProgressError, VectorIndexNotReadyError } from './errors/brainyError.js'
-import { assessIndexReadiness, assessProviderHealth } from './utils/indexReadiness.js'
+import {
+ assessIndexReadiness,
+ assessProviderHealth,
+ assessProviderRebuild,
+ describeRebuildProgress
+} from './utils/indexReadiness.js'
import { reconstructNounWrapper } from './db/factLog.js'
import { asBrainyFieldRefusal } from './db/fieldAddressing.js'
import {
@@ -396,6 +401,15 @@ interface PlannedTransact {
* marker outlives its write.
*/
markerRecords: FactMarkerRecord[]
+ /**
+ * Ids the batch's `{ op: 'update' }` unvector door (`vector: []`) needs to
+ * decrement on the vectored-noun ledger — consumed by `transact()` with a
+ * proper `await this.storage.noteVectorUnlanded?.(id)` per id, AFTER
+ * `commitTransaction` resolves (never for a rejected batch). Kept separate
+ * from `postCommit` (`Array<() => void>`, called synchronously, fire-and-
+ * forget) because the ledger hook is async and must be awaited.
+ */
+ vectorUnlands: string[]
}
/**
@@ -731,6 +745,24 @@ export class Brainy implements BrainyInterface {
// Write acks NEVER await it; a failed background flush is LOUD and re-armed.
private _persistDirtyWrites = 0
private _persistLastFlushAt = Date.now()
+ /**
+ * Whether a write has been committed since the last flush that ran. THE
+ * ENGINE DOES NO PERIODIC WORK WITHOUT A CAUSE: a brain nobody has written
+ * to has nothing to make durable, and a flush over it must cost nothing and
+ * say nothing. Before this, a flush called every provider, stamped the
+ * watermarks, persisted the generation counter and re-stamped the entity
+ * tree whether or not anything had changed — roughly 28 writes for a store
+ * that had not moved.
+ *
+ * WHAT THIS DOES NOT EXPLAIN, stated so nobody reads it as solved: a
+ * production process holding 21 brains printed "All indexes flushed to disk
+ * in 216-601ms" per brain every ~35s and idled at 1.26 cores with no writes
+ * for ten minutes. This engine's cadence is WRITE-DRIVEN — every trigger
+ * runs through noteWriteForPersistence, which only a committed write calls —
+ * so something was calling flush() on those brains, and this gate makes such
+ * a call free rather than accounting for it. The caller is still unidentified.
+ */
+ private _dirtySinceLastFlush = false
private _persistIdleTimer: ReturnType | null = null
private _persistBackgroundFlight: Promise | null = null
@@ -825,7 +857,18 @@ export class Brainy implements BrainyInterface {
// Read-gate narration dedup: a degraded-but-serving or not-ready health
// report narrates via prodLog.warn ONCE per (provider, report.generation) —
// never once per read. Keyed on the provider instance itself.
- private _lastNarratedHealthGeneration = new Map()
+ /**
+ * The last health narration emitted per provider, keyed by its CONTENT.
+ *
+ * This used to dedupe on the provider's `generation` counter, which bumps on
+ * every ledger mutation and every rebuild boundary — so a provider that
+ * bumps its generation on routine work re-emitted the same unchanged health
+ * line on every read that consulted it, and a provider that never bumped
+ * could suppress a line whose reasons had genuinely changed. The dedupe key
+ * is now what the line SAYS: an unchanged verdict is silent however the
+ * generation moves, and a changed verdict is always heard.
+ */
+ private _lastNarratedHealth = new Map()
constructor(config?: BrainyConfig) {
// The reserved-field write policy died with the field-addressing law:
@@ -943,14 +986,14 @@ export class Brainy implements BrainyInterface {
* extends FileSystemStorage`) inherit new methods Brainy adds to
* `FileSystemStorage` / `BaseStorage` automatically — `typeof` walks the
* prototype chain, so there's no in-package version skew to worry about as
- * long as the plugin's own dist resolves `@soulcraft/brainy` dynamically
+ * long as the plugin's own dist resolves `@soulcraftlabs/brainy` dynamically
* (which Cortex 2.2.x onward does — see
* `node_modules/@soulcraft/cor/dist/storage/mmapFileSystemStorage.js`).
*
* This helper exists for the **build/install** failure modes the import
* resolution can't catch:
* - Stale `node_modules` left over from a prior `bun install` against
- * `@soulcraft/brainy ≤7.20.x`.
+ * `@soulcraftlabs/brainy ≤7.20.x`.
* - Lockfile drift pinning brainy below the version that introduced the
* method.
* - Docker layer caches that reuse a `node_modules` from an earlier image.
@@ -1089,21 +1132,84 @@ export class Brainy implements BrainyInterface {
configureLogger({ level: LogLevel.DEBUG }) // Enable verbose logging
}
- // OPEN-PATH NARRATION: lightweight phase timing across the five
- // named stretches of init — storage init / generation-store open+fold /
- // index init+gate / VFS bootstrap / embedding-warm-started. Each
- // `markPhase()` call records elapsed ms SINCE THE PREVIOUS checkpoint,
- // so the buckets always sum to the pre-integration/warmOnOpen total.
- // Silent under 2s; one `prodLog.warn` line naming every phase's ms
- // above it, so the operator's next restart storm names its own slow
- // phase instead of re-deriving it from a stack of raw timestamps.
+ // OPEN-PATH NARRATION: phase timing across the five named stretches of
+ // init — storage init / generation-store open+fold / index init+gate /
+ // VFS bootstrap / embedding-warm-started. Each `markPhase()` call records
+ // elapsed ms SINCE THE PREVIOUS checkpoint, so the buckets always sum to
+ // the pre-integration/warmOnOpen total.
+ //
+ // THE LAW THIS ENFORCES: an open is never silent for more than
+ // OPEN_HEARTBEAT_MS. A production service opening a 16 GB store logged
+ // NOTHING for three minutes and then began work — the operator could not
+ // tell a slow open from a hung one, and restarted into the same wall.
+ // Two mechanisms, both on the always-visible narration channel (the old
+ // breakdown used `prodLog.warn`, which production clamps away — that is
+ // why the three minutes were silent):
+ // - a heartbeat that names the phase currently running and its elapsed
+ // wall, every OPEN_HEARTBEAT_MS, for as long as the open lasts;
+ // - one line per phase AS IT ENDS, naming its wall and its cause, for
+ // any phase over OPEN_PHASE_NARRATE_MS.
+ // The heartbeat is unref'd and cleared in the `finally` below, so it can
+ // neither hold the process open nor outlive a failed init. It cannot fire
+ // inside a phase that blocks the event loop synchronously; such a phase
+ // must narrate its own progress (the generation-log fold does).
+ const OPEN_HEARTBEAT_MS = 5_000
+ const OPEN_PHASE_NARRATE_MS = 2_000
+ /** Phase order + what each one is paying for, quoted in its narration. */
+ const OPEN_PHASES: ReadonlyArray<{ name: string; cause: string }> = [
+ { name: 'storage-init', cause: 'opening the store and loading its count ledger' },
+ {
+ name: 'generation-store-open-fold',
+ cause: 'opening the generation store: crash-recovery replay/fold, derived-family registration, format handshake'
+ },
+ { name: 'index-init-gate', cause: 'constructing the derived indexes and gating them for serving' },
+ { name: 'vfs-bootstrap', cause: 'bootstrapping the virtual filesystem' },
+ { name: 'embedding-warm-started', cause: 'starting the background embedding warm' }
+ ]
const initStart = Date.now()
let lastPhaseCheckpoint = initStart
+ let currentPhaseIndex = 0
const phaseTimingsMs: Record = {}
+ const openHeartbeat: ReturnType = setInterval(() => {
+ const phase = OPEN_PHASES[currentPhaseIndex]
+ if (!phase) return
+ prodLog.narrate(
+ `[Brainy] open: still in phase ${currentPhaseIndex + 1}/${OPEN_PHASES.length} ` +
+ `"${phase.name}" after ${Math.round((Date.now() - lastPhaseCheckpoint) / 1000)}s ` +
+ `(${Math.round((Date.now() - initStart) / 1000)}s into the open) — ${phase.cause}`
+ )
+ }, OPEN_HEARTBEAT_MS)
+ if (typeof openHeartbeat.unref === 'function') openHeartbeat.unref()
+ /**
+ * Narrate one STEP inside a phase when it turns out to be expensive.
+ * A phase that costs a minute and names only itself tells an operator
+ * where to look but not what to look at; this names the step. Silent
+ * under OPEN_PHASE_NARRATE_MS, so a fast open says nothing extra.
+ */
+ const step = async (name: string, cause: string, run: () => Promise): Promise => {
+ const startedAt = Date.now()
+ try {
+ return await run()
+ } finally {
+ const elapsed = Date.now() - startedAt
+ if (elapsed >= OPEN_PHASE_NARRATE_MS) {
+ prodLog.narrate(`[Brainy] open: step "${name}" took ${elapsed}ms — ${cause}`)
+ }
+ }
+ }
const markPhase = (name: string): void => {
const now = Date.now()
- phaseTimingsMs[name] = now - lastPhaseCheckpoint
+ const elapsed = now - lastPhaseCheckpoint
+ phaseTimingsMs[name] = elapsed
lastPhaseCheckpoint = now
+ const finished = OPEN_PHASES[currentPhaseIndex]
+ if (elapsed >= OPEN_PHASE_NARRATE_MS && finished && finished.name === name) {
+ prodLog.narrate(
+ `[Brainy] open: phase ${currentPhaseIndex + 1}/${OPEN_PHASES.length} ` +
+ `"${name}" finished in ${elapsed}ms — ${finished.cause}`
+ )
+ }
+ currentPhaseIndex++
}
try {
@@ -1166,7 +1272,7 @@ export class Brainy implements BrainyInterface {
`and the flush-request RPC are disabled for this directory. ` +
`Likely fix: clean install (\`rm -rf node_modules bun.lockb && ` +
`bun install\`) or rebuild your container image to refresh ` +
- `\`@soulcraft/brainy\` to ≥7.21. See docs/concepts/storage-adapters.md.`
+ `\`@soulcraftlabs/brainy\` to ≥7.21. See docs/concepts/storage-adapters.md.`
)
} else {
console.warn(
@@ -1191,9 +1297,12 @@ export class Brainy implements BrainyInterface {
// instances skip recovery (readers never write; the next writer
// repairs).
this.generationStore = new GenerationStore(this.storage)
- const generationOpenResult = await this.generationStore.open({
- readOnly: this.config.mode === 'reader'
- })
+ const generationOpenResult = await step(
+ 'generation-store.open',
+ 'reading the generation manifest and committed ranges, opening the fact log and the ' +
+ 'packed segment tier, and folding any crash-recovery replay',
+ () => this.generationStore.open({ readOnly: this.config.mode === 'reader' })
+ )
// The generation fact log is CANONICAL state, not a derived index — no
// sweeper, GC, or blob-lifecycle path may ever delete under it. Declare
@@ -1231,7 +1340,11 @@ export class Brainy implements BrainyInterface {
// rollup invariants against the log head + live counters. Loud on
// genuine incoherence (repairIndex heals), silent on absent/coherent,
// benign-behind refreshes at the next flush. Never blocks open.
- await this.verifyEntityTreeStamp()
+ await step(
+ 'verify-entity-tree-stamp',
+ 'comparing the entity tree\'s stamped generation and rollups against the store',
+ () => this.verifyEntityTreeStamp()
+ )
// 8.0 ⇄ native-provider version handshake: load the on-disk brain-format
// marker (`_system/brain-format.json`) into an in-memory field NOW —
@@ -1243,7 +1356,11 @@ export class Brainy implements BrainyInterface {
// them from the canonical records and then re-stamps the marker AFTER the
// rebuild verifies (non-destructive: a crash mid-rebuild leaves the old /
// absent marker, so the next open idempotently re-rebuilds).
- this._brainFormat = await readBrainFormat(this.storage)
+ this._brainFormat = await step(
+ 'read-brain-format',
+ 'reading the on-disk format marker that decides whether the derived indexes are stale',
+ () => readBrainFormat(this.storage)
+ )
this._indexEpochStale =
this._brainFormat === null || this._brainFormat.indexEpoch !== EXPECTED_INDEX_EPOCH
@@ -1254,7 +1371,11 @@ export class Brainy implements BrainyInterface {
// upgrade verifies + stamps; retained on failure. No-op for a reader, for
// non-filesystem storage, or for a brain with no persisted data.
if (this._indexEpochStale && this.config.migrationBackup && !this.isReadOnly) {
- await this.createMigrationBackupIfNeeded()
+ await step(
+ 'pre-upgrade-backup',
+ 'snapshotting the brain directory before a one-time format rebuild (migrationBackup)',
+ () => this.createMigrationBackupIfNeeded()
+ )
}
// PHASE 2 of 5 — "generation-store open+fold": GenerationStore
@@ -1417,10 +1538,27 @@ export class Brainy implements BrainyInterface {
`[Brainy] Rebuilding indexes after crash recovery rolled back ` +
`${generationOpenResult.rolledBackGenerations} uncommitted transaction(s)`
)
+ // SELF-REBUILD DEFERENCE, same law as the open gate: a provider that
+ // is already rebuilding itself from canonical is doing exactly this
+ // work. Kicking a second rebuild on top of it is redundant at best.
+ // Safe by ordering: the crash-recovery fold ran in the generation
+ // store's open, BEFORE any provider was constructed, so a provider
+ // rebuilding now is reading the repaired canonical records.
+ const kick = async (leg: string, provider: { rebuild: () => Promise }) => {
+ const rebuilding = assessProviderRebuild(provider)
+ if (rebuilding) {
+ prodLog.narrate(
+ `[Brainy] crash-recovery rebuild: the ${leg} provider is already ` +
+ `${describeRebuildProgress(rebuilding)} from canonical — not kicking a second one.`
+ )
+ return
+ }
+ await provider.rebuild()
+ }
await Promise.all([
- this.metadataIndex.rebuild(),
- this.index.rebuild(),
- this.graphIndex.rebuild()
+ kick('metadata', this.metadataIndex),
+ kick('vector', this.index as unknown as { rebuild: () => Promise }),
+ kick('graph', this.graphIndex)
])
}
@@ -1504,11 +1642,24 @@ export class Brainy implements BrainyInterface {
}).backfillBlobHistoryRefCountsIfNeeded()
}
+ // LEG C (zero-norm/unvector-door law): migrate a legacy zero-norm VFS
+ // root BEFORE the vector-leg open gate below ever compares the
+ // canonical vectored-noun count against the vector index's size — see
+ // migrateLegacyZeroNormVfsRootIfNeeded's JSDoc for why this is a safe
+ // O(1) exception to "nothing at open may scale with brain size", and
+ // why it must run here rather than waiting on VirtualFileSystem's own
+ // (VFS-instance-gated) lazy migration.
+ await this.migrateLegacyZeroNormVfsRootIfNeeded()
+
// Rebuild indexes if needed for existing data. Runs to completion before
// init() returns — there is no more first-query lazy path, so the flag
// below (kept for getIndexStatus() API compatibility) simply flips true
// once this open-time step has run.
- await this.rebuildIndexesIfNeeded()
+ await step(
+ 'rebuild-indexes-if-needed',
+ 'the derived-index gate: each family\'s readiness verdict, and any build it asks for',
+ () => this.rebuildIndexesIfNeeded()
+ )
this.lazyRebuildCompleted = true
// Check for pending data migrations
@@ -1518,8 +1669,9 @@ export class Brainy implements BrainyInterface {
// cache, roaring, msgpack, sort:topK, distance), HNSW/metadata/graph
// index construction, the eager cold-load, id-resolver + connections-
// codec wiring, crash-recovery index rebuild, the replay-gap check,
- // legacy VFS blob adoption, blob-history backfill, and the
- // rebuildIndexesIfNeeded() gate + migration check.
+ // legacy VFS blob adoption, blob-history backfill, the legacy
+ // zero-norm VFS root migration, and the rebuildIndexesIfNeeded() gate
+ // + migration check.
markPhase('index-init-gate')
// Register shutdown hooks for graceful count flushing (once globally)
@@ -1580,7 +1732,11 @@ export class Brainy implements BrainyInterface {
// Initialize VFS: Ensure VFS is ready when accessed as property
// This eliminates need for separate vfs.init() calls - zero additional complexity
this._vfs = new VirtualFileSystem(this)
- await this._vfs.init()
+ await step(
+ 'vfs.init',
+ 'creating or adopting the VFS root and wiring the path resolver',
+ () => this._vfs!.init()
+ )
this._vfsInitialized = true // Mark VFS as fully initialized
// 8.0 MVCC: infrastructure bootstrap (VFS root, etc.) is now the
@@ -1604,7 +1760,11 @@ export class Brainy implements BrainyInterface {
const storedArtifact = await this.storage
.readRawObject(LOG_AUTHORITY_PATH)
.catch(() => null)
- const authority = await readLogAuthority(this.storage)
+ const authority = await step(
+ 'read-log-authority',
+ 'reading the stored storage-authority artifact',
+ () => readLogAuthority(this.storage)
+ )
this._logAuthority = authority
if (authority.authority === 'log') {
this.generationStore.setLogDurability('at-ack')
@@ -1615,7 +1775,12 @@ export class Brainy implements BrainyInterface {
this.generationStore.getFactLog() !== null
) {
try {
- await this.adoptLogAuthority()
+ await step(
+ 'adopt-log-authority',
+ 'the adoption oracle: verifying the log against canonical before flipping this ' +
+ 'brain to durable-at-ack, and backfilling any curable divergence',
+ () => this.adoptLogAuthority()
+ )
prodLog.info(
'[Brainy] storage authority adopted at open: generation log ' +
'(fleet default; oracle green; durable-at-ack enabled)'
@@ -1655,8 +1820,16 @@ export class Brainy implements BrainyInterface {
// this is where it lands.
if (!this.isReadOnly) {
try {
- await this.bridgeLegacyPendingEmbedSidecars()
- await this.recoverPendingEmbedsFromLog()
+ await step(
+ 'bridge-pending-embed-sidecars',
+ 'migrating any pre-log deferred-embed marker files into the generation log',
+ () => this.bridgeLegacyPendingEmbedSidecars()
+ )
+ await step(
+ 'recover-pending-embeds',
+ 'folding the generation log\'s deferred-embed markers back into the pending set',
+ () => this.recoverPendingEmbedsFromLog()
+ )
if (this._pendingEmbedIds.size > 0) {
prodLog.info(
`[Brainy] ${this._pendingEmbedIds.size} deferred embed(s) pending from a previous ` +
@@ -1758,7 +1931,7 @@ export class Brainy implements BrainyInterface {
const phaseList = Object.entries(phaseTimingsMs)
.map(([name, ms]) => `${name}=${ms}ms`)
.join(', ')
- prodLog.warn(
+ prodLog.narrate(
`[Brainy] slow open: ${totalOpenMs}ms total (${phaseList}) — see the ` +
`phase breakdown above to find which one to investigate first`
)
@@ -1820,6 +1993,10 @@ export class Brainy implements BrainyInterface {
// log — a plain string interpolation discards both stack and cause.
const message = error instanceof Error ? error.message : String(error)
throw new Error(`Failed to initialize Brainy: ${message}`, { cause: error })
+ } finally {
+ // The open is over — succeeded or failed. Stop the heartbeat here so a
+ // failed init never leaves a timer narrating a phase nobody is running.
+ clearInterval(openHeartbeat)
}
}
@@ -1837,76 +2014,112 @@ export class Brainy implements BrainyInterface {
* NOTE: Registers globally (once for all instances) to avoid MaxListenersExceededWarning
*/
private registerShutdownHooks(): void {
+ /**
+ * The signal-path shutdown. THREE LAWS, each written by a production
+ * shutdown that looked clean and wasn't:
+ *
+ * 1. PER-INSTANCE ISOLATION. This used to be one `try` around a loop over
+ * every open brain: the first instance whose flush rejected aborted the
+ * loop, so every remaining brain kept its writer lock and its unwritten
+ * markers — and the process still exited 0. A pool of brains failed in
+ * a batch, not one at a time.
+ * 2. THE MARKER IS PART OF SHUTDOWN. Flushing the indexes without closing
+ * the generation store leaves the clean-shutdown marker unwritten, so
+ * the NEXT open reads the store as crashed and folds the whole
+ * generation log — measured in tens of seconds on a real store, paid on
+ * every restart, after a shutdown the operator saw exit 0.
+ * 3. THE LOCK IS ALWAYS GIVEN UP. In a `finally`, per instance: a process
+ * on its way out holds nothing.
+ */
const flushOnShutdown = async () => {
console.log('Shutdown signal received - flushing pending data...')
- try {
- let flushedCount = 0
- for (const instance of Brainy.instances) {
- if (instance.initialized) {
- // Flush all buffered data, then close to release resources (timers, handles)
- await Promise.all([
- (async () => {
- if (instance.storage && typeof instance.storage.flushCounts === 'function') {
- await instance.storage.flushCounts()
- }
- })(),
- (async () => {
- if (instance.metadataIndex && typeof instance.metadataIndex.flush === 'function') {
- await instance.metadataIndex.flush()
- }
- })(),
- (async () => {
- if (instance.graphIndex && typeof instance.graphIndex.flush === 'function') {
- await instance.graphIndex.flush()
- }
- })(),
- (async () => {
- if (instance.index && typeof instance.index.flush === 'function') {
- await instance.index.flush()
- }
- })()
- ])
- // Close components to stop timers that would prevent clean process exit
- await Promise.all([
- (async () => {
- if (instance.graphIndex && typeof instance.graphIndex.close === 'function') {
- await instance.graphIndex.close()
- }
- })(),
- (async () => {
- const index = instance.index as JsHnswVectorIndex & VectorIndexOptionalHooks
- if (index && typeof index.close === 'function') {
- await index.close()
- }
- })(),
- (async () => {
- const metadataIndex = instance.metadataIndex as MetadataIndexManager & MetadataIndexOptionalHooks
- if (metadataIndex && typeof metadataIndex.close === 'function') {
- await metadataIndex.close()
- }
- })(),
- // Release the writer lock so a successor process can take over.
- // No-op for readers and for backends without locking.
- (async () => {
- if (instance.storage && typeof instance.storage.releaseWriterLock === 'function') {
- await instance.storage.releaseWriterLock()
- }
- })(),
- // Stop the flush-request watcher to release its interval timer.
- (async () => {
- if (instance.storage && typeof instance.storage.stopFlushRequestWatcher === 'function') {
- instance.storage.stopFlushRequestWatcher()
- }
- })(),
- ])
- flushedCount++
+ let flushedCount = 0
+ let failedCount = 0
+ // Snapshot: close() splices Brainy.instances while we iterate.
+ for (const instance of [...Brainy.instances]) {
+ if (!instance.initialized) continue
+ try {
+ // Flush all buffered data (parallel across components, this brain only).
+ await Promise.all([
+ (async () => {
+ if (instance.storage && typeof instance.storage.flushCounts === 'function') {
+ await instance.storage.flushCounts()
+ }
+ })(),
+ (async () => {
+ if (instance.metadataIndex && typeof instance.metadataIndex.flush === 'function') {
+ await instance.metadataIndex.flush()
+ }
+ })(),
+ (async () => {
+ if (instance.graphIndex && typeof instance.graphIndex.flush === 'function') {
+ await instance.graphIndex.flush()
+ }
+ })(),
+ (async () => {
+ if (instance.index && typeof instance.index.flush === 'function') {
+ await instance.index.flush()
+ }
+ })()
+ ])
+
+ // Close the generation store: persists the counter, advances the
+ // fold checkpoint, and stamps the clean-shutdown marker LAST — the
+ // one step that decides whether the next open adopts or folds. Law 2.
+ if (instance.generationStore && !instance.isReadOnly) {
+ await instance.generationStore.close()
+ }
+
+ // Close components to stop timers that would prevent clean process exit
+ await Promise.all([
+ (async () => {
+ if (instance.graphIndex && typeof instance.graphIndex.close === 'function') {
+ await instance.graphIndex.close()
+ }
+ })(),
+ (async () => {
+ const index = instance.index as JsHnswVectorIndex & VectorIndexOptionalHooks
+ if (index && typeof index.close === 'function') {
+ await index.close()
+ }
+ })(),
+ (async () => {
+ const metadataIndex = instance.metadataIndex as MetadataIndexManager & MetadataIndexOptionalHooks
+ if (metadataIndex && typeof metadataIndex.close === 'function') {
+ await metadataIndex.close()
+ }
+ })()
+ ])
+ flushedCount++
+ } catch (error) {
+ failedCount++
+ console.error('Failed to flush one Brainy instance on shutdown:', error)
+ } finally {
+ // Law 3 — the lock and the watcher go regardless.
+ try {
+ if (instance.storage && typeof instance.storage.stopFlushRequestWatcher === 'function') {
+ instance.storage.stopFlushRequestWatcher()
+ }
+ } catch (error) {
+ console.error('Failed to stop the flush-request watcher on shutdown:', error)
+ }
+ try {
+ if (instance.storage && typeof instance.storage.releaseWriterLock === 'function') {
+ await instance.storage.releaseWriterLock()
+ }
+ } catch (error) {
+ console.error('Failed to release the writer lock on shutdown:', error)
}
}
- if (flushedCount > 0) {
- console.log(`Flushed successfully (${flushedCount} instance${flushedCount > 1 ? 's' : ''})`)
- }
- } catch (error) {
- console.error('Failed to flush on shutdown:', error)
+ }
+ if (flushedCount > 0) {
+ console.log(`Flushed successfully (${flushedCount} instance${flushedCount > 1 ? 's' : ''})`)
+ }
+ if (failedCount > 0) {
+ console.error(
+ `${failedCount} Brainy instance${failedCount > 1 ? 's' : ''} did not complete shutdown — ` +
+ `their writer locks were released, but their next open will run crash recovery.`
+ )
}
}
@@ -1914,13 +2127,32 @@ export class Brainy implements BrainyInterface {
// kept as statics so the last live instance's close() can deregister them
// — the signal handles they hold are ref'd and would otherwise keep the
// process alive forever after every brain is closed.
+ /**
+ * Exit the process ONLY when Brainy is the sole handler for this signal.
+ *
+ * Registering a signal listener suppresses Node's default terminate
+ * behaviour, so a library that attaches one must either exit or be sure
+ * someone else will. Brainy attaching one AND exiting was the wrong half
+ * of that choice for every host application with its own graceful
+ * shutdown: both handlers run concurrently, and whichever finishes first
+ * wins — a library flush finishing before an application's close()
+ * terminated that close mid-flight, at exit code 0, with locks and
+ * markers unwritten. When the host has its own handler (listener count
+ * above our own), the host owns the exit; Brainy only makes its data
+ * durable and steps aside.
+ */
+ const exitIfSoleShutdownOwner = (signal: 'SIGTERM' | 'SIGINT'): void => {
+ if (process.listenerCount(signal) <= 1) {
+ process.exit(0)
+ }
+ }
Brainy.sigtermListener = async () => {
await flushOnShutdown()
- process.exit(0)
+ exitIfSoleShutdownOwner('SIGTERM')
}
Brainy.sigintListener = async () => {
await flushOnShutdown()
- process.exit(0)
+ exitIfSoleShutdownOwner('SIGINT')
}
Brainy.beforeExitListener = async () => {
// Self-deregister FIRST: Node re-emits 'beforeExit' after every event-
@@ -2544,6 +2776,12 @@ export class Brainy implements BrainyInterface {
* engine's own cadence (callers never call flush() in hot paths).
*/
private noteWriteForPersistence(): void {
+ // THE DIRTY WITNESS. Set on every committed write — both commit paths
+ // (single-op and transaction) end here, and the deferred-embed worker
+ // lands its vectors through the single-op path — BEFORE the policy check,
+ // so a `'manual'` consumer's explicit flush() is never skipped either.
+ // Cleared by a flush that actually runs; see flush().
+ this._dirtySinceLastFlush = true
const cfg = this.config.persistence
if (this.isReadOnly || cfg?.policy === 'manual') return
this._persistDirtyWrites++
@@ -2912,10 +3150,34 @@ export class Brainy implements BrainyInterface {
// vector shape, is structurally impossible). The background worker
// embeds + inserts.
const deferringEmbed = params.deferEmbedding === true && !params.vector
- const vector = deferringEmbed
+ let vector = deferringEmbed
? []
: params.vector || (await this.embed(params.data))
+ // THE ZERO-NORM LAW (canonical write side): a zero-norm vector is not a
+ // vector — it never crosses an engine boundary (the engine pair's seam
+ // law). This engine's own cosine distance treats an all-zero vector
+ // safely (a zero-norm operand always scores MAXIMUM distance — see
+ // isZeroNormVector's JSDoc), but a downstream engine serving squared-
+ // euclidean distance cannot tell it apart from a legitimate origin
+ // point — a false attractor that silently darkened 150+ rows in a
+ // production deployment. The index belt (AddToVectorIndexOperation)
+ // already refuses to INDEX a zero-norm vector, but until now the
+ // CANONICAL write still persisted it and the vectored-noun ledger
+ // counted it — so a near-empty store whose only vectored row was
+ // zero-norm read "canonical vectored > 0, index size 0" and threw a
+ // not-ready error at open. Normalize HERE, before the dimension pin,
+ // the vectored-ledger flag (`SaveNounMetadataOperation`'s `hasVector`),
+ // and the index ops below ever see it, so it persists as the sanctioned
+ // "unvectored" `[]` shape instead — the canonical write still succeeds.
+ if (!deferringEmbed && vector.length > 0 && isZeroNormVector(vector)) {
+ prodLog.warn(
+ `[Brainy] add(): entity ${id} was given an explicit all-zero vector — ` +
+ `a zero-norm vector is not a vector; persisted unvectored ([]) instead.`
+ )
+ vector = []
+ }
+
// Ensure dimensions are set (a deferred-embed stub carries no dimension
// information — the worker's real vector goes through the same guard).
// Gated on `vector.length > 0`, not `!deferringEmbed`: ANY insert whose
@@ -3623,25 +3885,53 @@ export class Brainy implements BrainyInterface {
// often the host writes.
const dataUnchanged = rawHasNewData && Brainy.sameEntityData(params.data, existing.data)
const hasNewData = rawHasNewData && !dataUnchanged
+
+ // THE ZERO-NORM LAW (canonical write side) — see add()'s matching
+ // comment: an explicit REAL all-zero vector is not a vector. Normalize
+ // to the sanctioned "unvectored" `[]` shape BEFORE the dimension
+ // check, the unvector-door decision below, and the index ops ever see
+ // it — a local copy; `params.vector` itself is never mutated.
+ let explicitVector = params.vector
+ if (explicitVector && explicitVector.length > 0 && isZeroNormVector(explicitVector)) {
+ prodLog.warn(
+ `[Brainy] update(): entity ${params.id} was given an explicit all-zero vector — ` +
+ `a zero-norm vector is not a vector; persisted unvectored ([]) instead.`
+ )
+ explicitVector = []
+ }
+
+ // THE SANCTIONED UNVECTOR DOOR: `explicitVector` at length 0 (an
+ // explicit `vector: []`, or a real all-zero vector just normalized
+ // above) is an instruction to remove the vector NOW — never "please
+ // embed". `validateUpdateParams` already refuses combining it with
+ // `deferEmbedding: true` (an empty array is truthy, so that guard
+ // fires unconditionally on any explicit `vector`). Idempotent on an
+ // already-unvectored row: the ledger decrement near the end of this
+ // method is gated on the PRIOR vector actually having been real.
+ const isExplicitUnvector = explicitVector !== undefined && explicitVector.length === 0
+
// MT5 deferred re-embedding: the OLD vector keeps serving semantic
// search — stale-but-present, never absent (the flicker law) — until
// the background worker embeds the new data and swaps it atomically.
const deferringEmbed =
- params.deferEmbedding === true && hasNewData && !params.vector
- if (params.vector) {
- if (this.dimensions && params.vector.length !== this.dimensions) {
+ params.deferEmbedding === true && hasNewData && !explicitVector
+ if (explicitVector) {
+ // A length-0 explicit vector (the unvector door) carries no
+ // dimension information — exempt from the check, mirroring add()'s
+ // own `vector.length > 0` gate on the dimension pin.
+ if (explicitVector.length > 0 && this.dimensions && explicitVector.length !== this.dimensions) {
throw new Error(
- `Vector dimension mismatch: expected ${this.dimensions}, got ${params.vector.length}`
+ `Vector dimension mismatch: expected ${this.dimensions}, got ${explicitVector.length}`
)
}
- vector = params.vector
+ vector = explicitVector
} else if (hasNewData && !deferringEmbed) {
vector = await this.embed(params.data)
}
// A deferred data change does NOT reindex now (the vector is unchanged;
// the worker's atomic swap carries the real reindex later).
const needsReindexing = Boolean(
- (hasNewData && !deferringEmbed) || params.type || params.vector
+ (hasNewData && !deferringEmbed) || params.type || explicitVector
)
// Always update the noun with new metadata
@@ -3735,6 +4025,22 @@ export class Brainy implements BrainyInterface {
? [this.enqueuePendingEmbed(params.id)]
: undefined
+ // Leg D — the unvector door clears a PENDING deferred-embed marker:
+ // without this, the worker would later embed this row's current data
+ // and silently re-vector it, defeating the caller's explicit "remove
+ // the vector now" instruction. The clear rides THIS SAME commit fact
+ // (an `embed.landed` record with an empty vector — the recovery fold
+ // disarms a pending marker on ANY `embed.landed` for the id,
+ // regardless of the vector it carries), so a crash between the write
+ // and the in-memory clear below still recovers disarmed. Mutually
+ // exclusive with `embedMarkers` above: `deferringEmbed` requires an
+ // ABSENT `explicitVector`, so the two branches never both apply.
+ const clearsPendingEmbed = isExplicitUnvector && this._pendingEmbedIds.has(params.id)
+ const commitRecords: FactMarkerRecord[] | undefined =
+ embedMarkers ?? (clearsPendingEmbed
+ ? [{ type: 'embed.landed', id: params.id, vector: [] }]
+ : undefined)
+
// Execute atomically with transaction system, generation-stamped as one
// immutable Model-B generation (before-image = the entity's prior state).
await this.persistSingleOp({ nouns: [params.id] }, async (tx) => {
@@ -3823,7 +4129,33 @@ export class Brainy implements BrainyInterface {
}
}
]
- : undefined, embedMarkers)
+ : undefined, commitRecords)
+
+ // Leg D continued — the in-memory pending-embed clear runs only AFTER
+ // the commit above actually succeeded (an aborted update must not
+ // disarm a marker whose durable `embed.landed` twin was never
+ // written).
+ if (clearsPendingEmbed) {
+ this.clearPendingEmbed(params.id)
+ prodLog.warn(
+ `[Brainy] update(): entity ${params.id} had a pending deferred embed — ` +
+ `the unvector door cleared it ('vector: []' is an explicit instruction, ` +
+ `never "please embed").`
+ )
+ }
+
+ // Leg D — vectored-ledger decrement for the sanctioned unvector door.
+ // update()'s own metadata write goes through UpdateNounMetadataOperation
+ // (isNew=false), so the saveNounMetadata(..., hasVector) seam never
+ // fires here — noteVectorUnlanded is the ONLY seam, the same
+ // sanctioned hook unvectorNounForRootMigration() uses. Gated on the
+ // PRIOR vector having actually been real (non-empty, non-zero-norm):
+ // an already-unvectored row's second call is a true no-op — no
+ // decrement, matching the ledger-exactness law (never double-count,
+ // never drift negative).
+ if (isExplicitUnvector && existing.vector.length > 0 && !isZeroNormVector(existing.vector)) {
+ await this.storage.noteVectorUnlanded?.(params.id)
+ }
// Aggregation hook (outside transaction — derived data). `existing` is
// the full get() view — every reserved field top-level — and must be
@@ -4304,6 +4636,19 @@ export class Brainy implements BrainyInterface {
this._graphAdjacencyVerified = true
return 'live'
}
+ // A provider that is REBUILDING ITSELF gets a refusal that says so,
+ // with its own progress: open deliberately did not wait for it (see
+ // rebuildIndexesIfNeeded), so this door is temporarily closed and will
+ // open on its own. Anything else is a broken index needing a repair.
+ const rebuilding = assessProviderRebuild(this.graphIndex)
+ if (rebuilding) {
+ throw new GraphIndexNotReadyError(
+ `Graph adjacency index is ${describeRebuildProgress(rebuilding)} and is not serving ` +
+ `yet. find({ connected }), neighbors() and related() refuse rather than serve an ` +
+ `empty result. The brain is open and every other family is serving; this door opens ` +
+ `by itself when the provider reports serving — no action is needed.`
+ )
+ }
throw new GraphIndexNotReadyError(
`Graph adjacency index is not serving (via ${assessment.via}): ` +
`${assessment.reasons.join('; ') || 'not ready'}. find({ connected }), neighbors() and ` +
@@ -4407,6 +4752,15 @@ export class Brainy implements BrainyInterface {
this._metadataVerified = true
return 'live'
}
+ const rebuilding = assessProviderRebuild(this.metadataIndex)
+ if (rebuilding) {
+ throw new MetadataIndexNotReadyError(
+ `Metadata field index is ${describeRebuildProgress(rebuilding)} and is not serving ` +
+ `yet. find({ where }) and other filtered reads refuse rather than serve an empty ` +
+ `result. The brain is open and every other family is serving; this door opens by ` +
+ `itself when the provider reports serving — no action is needed.`
+ )
+ }
throw new MetadataIndexNotReadyError(
`Metadata field index is not serving (via ${assessment.via}): ` +
`${assessment.reasons.join('; ') || 'not ready'}. find({ where }) and other filtered ` +
@@ -4536,6 +4890,15 @@ export class Brainy implements BrainyInterface {
this._vectorVerified = true
return 'live'
}
+ const rebuilding = assessProviderRebuild(this.index)
+ if (rebuilding) {
+ throw new VectorIndexNotReadyError(
+ `Vector index is ${describeRebuildProgress(rebuilding)} and is not serving yet. ` +
+ `Semantic find({ query }) and proximity search refuse rather than serve an empty ` +
+ `result. The brain is open and every other family is serving; this door opens by ` +
+ `itself when the provider reports serving — no action is needed.`
+ )
+ }
throw new VectorIndexNotReadyError(
`Vector index is not serving (via ${assessment.via}): ` +
`${assessment.reasons.join('; ') || 'not ready'}. Semantic find({ query }) and ` +
@@ -8194,6 +8557,11 @@ export class Brainy implements BrainyInterface {
*/
async clear(): Promise {
await this.ensureInitialized()
+ // A clear mutates durable state without going through a commit path, so
+ // it must set the dirty witness itself — otherwise a `clear()` followed by
+ // `flush()` would find the brain "clean" and skip the entity-tree stamp,
+ // leaving a stamp that describes the population this call just removed.
+ this._dirtySinceLastFlush = true
// Clear storage
await this.storage.clear()
@@ -9179,6 +9547,15 @@ export class Brainy implements BrainyInterface {
hook()
}
+ // Leg D — vectored-ledger decrements for this batch's unvector-door
+ // updates (see planTxUpdate's matching comment), applied after the
+ // commit point and properly awaited (unlike `postCommit`'s synchronous
+ // fire-and-forget hooks) — each is the same sanctioned hook
+ // unvectorNounForRootMigration() uses.
+ for (const id of plan.vectorUnlands) {
+ await this.storage.noteVectorUnlanded?.(id)
+ }
+
// Change feed: the batch's events share its single committed generation.
// A rejected batch throws at commitTransaction and never reaches here.
this.emitCommitted(plan.changeEvents, undefined, generation, timestamp)
@@ -10422,7 +10799,8 @@ export class Brainy implements BrainyInterface {
casUpdates: [],
createdNouns: new Set(),
changeEvents: [],
- markerRecords: []
+ markerRecords: [],
+ vectorUnlands: []
}
for (const op of ops) {
@@ -10544,9 +10922,22 @@ export class Brainy implements BrainyInterface {
// marker-less committed row would be a silently missing vector, which is
// the disallowed direction). The background worker embeds + inserts.
const deferringEmbed = params.deferEmbedding === true && !params.vector
- const vector = deferringEmbed
+ let vector = deferringEmbed
? []
: params.vector || (await this.embed(params.data))
+
+ // THE ZERO-NORM LAW — see the single-add() insert path's matching
+ // comment (a zero-norm vector is not a vector; never crosses an engine
+ // boundary). Normalized here BEFORE the dimension pin and the
+ // vectored-ledger `hasVector` flag below ever see it.
+ if (!deferringEmbed && vector.length > 0 && isZeroNormVector(vector)) {
+ prodLog.warn(
+ `[Brainy] transact add: entity ${id} was given an explicit all-zero vector — ` +
+ `a zero-norm vector is not a vector; persisted unvectored ([]) instead.`
+ )
+ vector = []
+ }
+
// Gated on `vector.length > 0` — see the single-add() insert path's
// matching comment: an explicit `vector: []` carries no dimension
// information either, deferred or not.
@@ -10717,17 +11108,62 @@ export class Brainy implements BrainyInterface {
const dataUnchanged = rawHasNewData && Brainy.sameEntityData(params.data, existing.data)
const hasNewData = rawHasNewData && !dataUnchanged
let vector = existing.vector
- if (params.vector) {
- if (this.dimensions && params.vector.length !== this.dimensions) {
+
+ // THE ZERO-NORM LAW + THE SANCTIONED UNVECTOR DOOR — transact() mirror
+ // of update()'s matching block: an explicit REAL all-zero vector
+ // normalizes to `[]` (never crosses an engine boundary), and an
+ // explicit `vector: []` (post-normalization) is the sanctioned unvector
+ // instruction, exempt from the dimension check. `validateUpdateParams`
+ // already refuses combining it with `deferEmbedding: true`.
+ let explicitVector = params.vector
+ if (explicitVector && explicitVector.length > 0 && isZeroNormVector(explicitVector)) {
+ prodLog.warn(
+ `[Brainy] transact update: entity ${params.id} was given an explicit all-zero ` +
+ `vector — a zero-norm vector is not a vector; persisted unvectored ([]) instead.`
+ )
+ explicitVector = []
+ }
+ const isExplicitUnvector = explicitVector !== undefined && explicitVector.length === 0
+
+ if (explicitVector) {
+ if (explicitVector.length > 0 && this.dimensions && explicitVector.length !== this.dimensions) {
throw new Error(
- `Vector dimension mismatch: expected ${this.dimensions}, got ${params.vector.length}`
+ `Vector dimension mismatch: expected ${this.dimensions}, got ${explicitVector.length}`
)
}
- vector = params.vector
+ vector = explicitVector
} else if (hasNewData) {
vector = await this.embed(params.data)
}
- const needsReindexing = Boolean(hasNewData || params.type || params.vector)
+ const needsReindexing = Boolean(hasNewData || params.type || explicitVector)
+
+ // Leg D — the unvector door clears a PENDING deferred-embed marker (see
+ // update()'s matching comment for the full rationale): the durable
+ // clear (an `embed.landed` record, empty vector) rides the batch's ONE
+ // commit fact via `plan.markerRecords`; the in-memory clear is deferred
+ // to `plan.postCommit` so an aborted batch never disarms a marker whose
+ // durable twin was never written.
+ const clearsPendingEmbed = isExplicitUnvector && this._pendingEmbedIds.has(params.id)
+ if (clearsPendingEmbed) {
+ plan.markerRecords.push({ type: 'embed.landed', id: params.id, vector: [] })
+ plan.postCommit.push(() => {
+ this.clearPendingEmbed(params.id)
+ prodLog.warn(
+ `[Brainy] transact update: entity ${params.id} had a pending deferred embed — ` +
+ `the unvector door cleared it ('vector: []' is an explicit instruction, ` +
+ `never "please embed").`
+ )
+ })
+ }
+
+ // Leg D — vectored-ledger decrement for the sanctioned unvector door,
+ // deferred to `plan.vectorUnlands` (consumed with a proper `await` in
+ // `transact()`, AFTER the commit succeeds — see its matching comment).
+ // Gated on the PRIOR vector having actually been real (non-empty,
+ // non-zero-norm): idempotent on an already-unvectored row.
+ if (isExplicitUnvector && existing.vector.length > 0 && !isZeroNormVector(existing.vector)) {
+ plan.vectorUnlands.push(params.id)
+ }
const newMetadata =
params.merge !== false
@@ -11960,6 +12396,27 @@ export class Brainy implements BrainyInterface {
return
}
+ // A CLEAN BRAIN FLUSHES NOTHING, AND SAYS NOTHING. No write has been
+ // committed since the last flush, so every step below would re-persist
+ // state identical to what is already on disk — provider flushes, the
+ // watermark stamps, the generation counter, the entity-tree stamp — and
+ // print two lines announcing it. The witness is set by every committed
+ // write (see noteWriteForPersistence) and cleared here; a write landing
+ // DURING this flush sets it again, so it is never lost — the next flush
+ // does that write's work. This makes an unexplained flush FREE; it does
+ // not explain one (see _dirtySinceLastFlush).
+ if (!this._dirtySinceLastFlush) {
+ return
+ }
+ this._dirtySinceLastFlush = false
+ // An explicit flush IS a flush: tell the cadence so, or the very next
+ // write sees "30s since the last flush" (the cadence only counted its
+ // own) and kicks a background flush that has nothing left to do, and the
+ // idle timer fires two seconds later over writes this flush already
+ // persisted.
+ this._persistLastFlushAt = Date.now()
+ this._persistDirtyWrites = 0
+
console.log('Flushing Brainy indexes and caches to disk...')
const startTime = Date.now()
@@ -12040,6 +12497,18 @@ export class Brainy implements BrainyInterface {
* healed by `repairIndex()`, whose unconditional recount rebuilds the
* rollups from a canonical walk and re-stamps. Best-effort: a stamp-write
* fault warns loudly but never fails the flush that carried real data.
+ *
+ * THE SOURCE IS `committedGeneration()`, NEVER `generation()`. The latter is
+ * the ALLOCATED counter — a number a write in flight has claimed and may
+ * never commit. Stamping it made the stamp's generation label a claim about
+ * counts it was not taken at, and every crash inside a write window then
+ * produced a spurious verdict at the next open: either `sourceGeneration N
+ * is ahead of the log head N-1` (the allocated generation died with the
+ * process) or `rollup invariant 'nounCount': stamped X, observed Y` (the
+ * recovery fold folded facts the stamp's counts predate). MEASURED on the
+ * crash-consistency lane before this line changed: 4 of 11 SIGKILL cycles on
+ * a coherent store raised one of those two verdicts, each of them naming
+ * `repairIndex()` — a whole-store recount — as the cure for nothing.
*/
private async stampEntityTree(): Promise {
if (this.isReadOnly) return
@@ -12050,7 +12519,7 @@ export class Brainy implements BrainyInterface {
])
await writeFamilyStamp(this.storage, ENTITY_TREE_STAMP_PATH, {
family: 'entity-tree',
- sourceGeneration: this.generationStore.generation(),
+ sourceGeneration: this.generationStore.committedGeneration(),
members: { mode: 'rollup', invariants: { nounCount, verbCount } }
})
} catch (error) {
@@ -12063,16 +12532,24 @@ export class Brainy implements BrainyInterface {
/**
* @description Open-time coherence check for the entity tree's family stamp:
- * compare `sourceGeneration` against the log head and the stamped rollup
- * invariants against the live counters. Verdicts:
+ * compare `sourceGeneration` against the store's COMMITTED generation and
+ * the stamped rollup invariants against the live counters. Verdicts:
* - `coherent` / `absent` (legacy store; first flush stamps) → silent.
* - `behind` → benign for the tree (it is written BY the commit; only the
* stamp is stale — a crash landed between commit and flush). Refreshed at
* the next flush.
+ * - `torn` → a TORN GENERATION-LOG TAIL, handled by
+ * {@link demoteTornEntityTreeStamp}: terminal, never a wait.
* - `incoherent` → LOUD: the tree or its counters diverged from what was
* stamped — `repairIndex()` recounts from canonical and re-stamps.
* Never blocks open; a fault reading the stamp is surfaced as unverifiable,
* never conflated with absence.
+ *
+ * THE COMPARISON IS AGAINST `committedGeneration()`, matching what
+ * {@link stampEntityTree} writes and what every other open-time watermark in
+ * this class already reasons about (the fact-scan capability, the metadata /
+ * graph / HNSW watermark verdicts). Comparing against the allocated counter
+ * was the one place that disagreed, and disagreeing was the whole defect.
*/
private async verifyEntityTreeStamp(): Promise {
let stamp: FamilyStamp | null
@@ -12089,11 +12566,16 @@ export class Brainy implements BrainyInterface {
this.storage.getNounCount(),
this.storage.getVerbCount()
])
- const verdict = verifyFamilyStamp(stamp, this.generationStore.generation(), {
+ const verdict = verifyFamilyStamp(stamp, this.generationStore.committedGeneration(), {
nounCount,
verbCount
})
- if (verdict.state === 'incoherent') {
+ if (verdict.state === 'torn') {
+ await this.demoteTornEntityTreeStamp(stamp as FamilyStamp, verdict.stampSource, verdict.head, {
+ nounCount,
+ verbCount
+ })
+ } else if (verdict.state === 'incoherent') {
prodLog.warn(
`[Brainy] entity-tree stamp INCOHERENT at open: ${verdict.failures.join('; ')}. ` +
`The canonical tree or its counters diverged from the stamped state — run ` +
@@ -12107,6 +12589,92 @@ export class Brainy implements BrainyInterface {
}
}
+ /**
+ * @description THE TERMINAL VERDICT for a torn generation-log tail.
+ *
+ * A stamp whose `sourceGeneration` sits ABOVE the store's committed
+ * watermark witnesses a generation that is not in the log: the stamp's fsync
+ * outlived the tail's. By the time this runs, log-authority recovery has
+ * already folded every intact fact above the manifest and advanced the
+ * watermark to cover them — so if the stamp is STILL ahead, the generation
+ * it names is not merely late, it is GONE. There is nothing to wait for.
+ *
+ * That is the whole point of this method. A field report of this class
+ * (single-process store, abrupt termination mid-fold) described a reopen
+ * that narrated the tear and then held 100% CPU with zero log growth for
+ * eight minutes before an operator wiped the directory. A recovery that
+ * cannot say what it is waiting for has no business spinning; the honest
+ * answer here is a verdict, taken now, at O(1) cost.
+ *
+ * WHAT THE VERDICT DOES — the stamped surface is UNUSABLE, so it is
+ * discarded rather than believed: the stamped counts describe a generation
+ * that never became durable, and comparing them against live counters can
+ * only produce noise. The tree itself is not in question (it IS canonical —
+ * every commit writes it, and the fold re-applied every after-image the log
+ * still holds), so the demotion is a re-derivation of this family's verified
+ * surface at the generation the store can actually show:
+ *
+ * - WRITER open → re-stamp at `committedGeneration()` from the live
+ * counters — exactly what the next flush would write, taken now so the
+ * tear cannot re-narrate on every subsequent open. Both count sets are
+ * logged so an operator can see whether anything really moved.
+ * - READER open → a reader cannot re-stamp. Narrate the same terminal
+ * verdict with the named cure and carry on serving; a read-only inspector
+ * is never locked out of a store, and never left waiting either.
+ *
+ * BOUNDEDNESS: straight-line code. No loop, no retry, no await on any
+ * external progress signal — the two counter reads and one stamp write are
+ * the entire cost, and none of them scales with the store.
+ */
+ private async demoteTornEntityTreeStamp(
+ stamp: FamilyStamp,
+ stampSource: number,
+ head: number,
+ observed: { nounCount: number; verbCount: number }
+ ): Promise {
+ const stamped = stamp.members.mode === 'rollup' ? stamp.members.invariants : {}
+ const detail =
+ `[Brainy] TORN GENERATION-LOG TAIL at open: ${ENTITY_TREE_STAMP_PATH} witnesses source ` +
+ `generation ${stampSource} (stamped ${stamp.committedAt}), but the store's committed ` +
+ `generation is ${head} after crash recovery — the stamp's fsync outlived the log tail's, ` +
+ `and generation ${stampSource} is not in the log to arrive. Stamped rollups ` +
+ `${JSON.stringify(stamped)}; observed ${JSON.stringify(observed)}.`
+
+ if (this.isReadOnly) {
+ prodLog.warn(
+ `${detail} This open is READ-ONLY, so the stamp cannot be re-derived: the entity-tree ` +
+ `family stays UNVERIFIED for this session (reads are unaffected — the canonical tree ` +
+ `is the truth this stamp only describes). Cure: open the store with a writer, or run ` +
+ `brain.repairIndex() there, to recount from canonical and re-stamp.`
+ )
+ return
+ }
+
+ const startedAt = Date.now()
+ try {
+ await writeFamilyStamp(this.storage, ENTITY_TREE_STAMP_PATH, {
+ family: 'entity-tree',
+ sourceGeneration: head,
+ members: {
+ mode: 'rollup',
+ invariants: { nounCount: observed.nounCount, verbCount: observed.verbCount }
+ }
+ })
+ prodLog.warn(
+ `${detail} DEMOTED: the unusable stamp was re-derived at committed generation ${head} ` +
+ `from the live counters in ${Date.now() - startedAt}ms — terminal, not a wait. If the ` +
+ `observed counts above look wrong for your data, run brain.repairIndex() to recount ` +
+ `from canonical.`
+ )
+ } catch (error) {
+ prodLog.warn(
+ `${detail} The demotion's re-stamp FAILED (${(error as Error).message}) — the tear will ` +
+ `narrate again at the next open, which is the honest outcome; the store still serves ` +
+ `from canonical. Cure: run brain.repairIndex() to recount from canonical and re-stamp.`
+ )
+ }
+ }
+
/**
* Ask the writer process serving this data directory to flush its in-memory
* indexes to disk, so a read-only inspector can observe fresh state.
@@ -12431,21 +12999,49 @@ export class Brainy implements BrainyInterface {
const metadataStats = await this.metadataIndex.getStats()
const graphSize = await this.graphIndex.size()
- // 1. Index size parity. HNSW must hold at least one node per indexed entity.
- if (hnswSize === metadataStats.totalEntries) {
+ // 1. Index size parity. HNSW must hold one node per VECTORED noun — the
+ // vectored-noun ledger (`getCanonicalCounts().vectors.all`), NOT the raw
+ // metadata-entry count: every store's VFS root is PERMANENTLY unvectored
+ // (`vector: []` by design — a zero-norm/empty vector never crosses into
+ // the index, see AddToVectorIndexOperation/JsHnswVectorIndex.rebuild()'s
+ // matching belts), and a not-yet-landed deferred embed is unvectored
+ // too. Comparing against total entries counted the always-unvectored
+ // root as a permanent 1-node "drift" on every VFS-having store — a false
+ // warn on an otherwise perfectly healthy handoff. `vectors.all` is
+ // already the documented coverage denominator for exactly this
+ // comparison (see `CanonicalCounts.vectors`'s JSDoc). Falls back to the
+ // metadata-entry count when the ledger is unavailable or suspect (a
+ // storage adapter without the optional hook, or an unrecounted store) —
+ // never worse than the prior behavior in that case.
+ const vectorLedgerForParity = await this.storage.getCanonicalCounts?.()
+ const vectorParityTarget =
+ vectorLedgerForParity && !vectorLedgerForParity.suspect
+ ? vectorLedgerForParity.vectors.all
+ : metadataStats.totalEntries
+ if (hnswSize === vectorParityTarget) {
checks.push({
name: 'index-parity',
status: 'pass',
- message: `HNSW (${hnswSize}) and metadata index (${metadataStats.totalEntries}) agree.`,
- details: { hnswSize, metadataEntries: metadataStats.totalEntries, graphRelationships: graphSize }
+ message: `HNSW (${hnswSize}) and the vectored-noun ledger (${vectorParityTarget}) agree.`,
+ details: {
+ hnswSize,
+ vectoredNouns: vectorParityTarget,
+ metadataEntries: metadataStats.totalEntries,
+ graphRelationships: graphSize
+ }
})
} else {
- const drift = Math.abs(hnswSize - metadataStats.totalEntries)
+ const drift = Math.abs(hnswSize - vectorParityTarget)
checks.push({
name: 'index-parity',
- status: drift > Math.max(10, metadataStats.totalEntries * 0.01) ? 'fail' : 'warn',
- message: `HNSW (${hnswSize}) and metadata (${metadataStats.totalEntries}) differ by ${drift}. Run a rebuild if the gap is unexpected.`,
- details: { hnswSize, metadataEntries: metadataStats.totalEntries, drift }
+ status: drift > Math.max(10, vectorParityTarget * 0.01) ? 'fail' : 'warn',
+ message: `HNSW (${hnswSize}) and the vectored-noun ledger (${vectorParityTarget}) differ by ${drift}. Run a rebuild if the gap is unexpected.`,
+ details: {
+ hnswSize,
+ vectoredNouns: vectorParityTarget,
+ metadataEntries: metadataStats.totalEntries,
+ drift
+ }
})
}
@@ -16066,6 +16662,79 @@ export class Brainy implements BrainyInterface {
return !this.pluginRegistry.hasProvider('embeddings')
}
+ /**
+ * @description LEG C of the zero-norm/unvector-door law — migrate a
+ * legacy zero-norm VFS root BEFORE the vector-leg open gate
+ * ({@link rebuildIndexesIfNeeded}'s `vectorCoverageGap` check) ever
+ * compares the canonical vectored-noun count against the vector index's
+ * size. A pre-fix store may have persisted the VFS root (the fixed
+ * all-zeros UUID) with a REAL all-zero placeholder vector — lawful inside
+ * brainy (`cosineDistance` treats a zero-norm operand as MAXIMUM distance,
+ * see {@link isZeroNormVector}'s JSDoc) but never indexed (the index belt
+ * refuses to insert a zero-norm vector) and never meant to cross an
+ * engine boundary. Left unmigrated, the canonical ledger still counts it
+ * as vectored while the vector index correctly holds nothing for it — a
+ * near-empty store whose ONLY vectored row is this zero-norm root reads
+ * "canonical vectored 1, index size 0" and throws
+ * `VectorIndexNotReadyError` at open, going DARK instead of serving.
+ *
+ * THE LIFECYCLE LAW: nothing at open may scale with brain size. This step
+ * is safe under that law BECAUSE the VFS root lives at a FIXED,
+ * well-known id (`00000000-0000-0000-0000-000000000000` — mirrors
+ * `VirtualFileSystem.VFS_ROOT_ID`; kept as a literal here, the same
+ * convention as the other reserved-root literals in this file and in
+ * `db/factLog.ts`/`db/portableGraph.ts` — `brainy.ts` cannot import
+ * `VirtualFileSystem.ts`, which itself imports `Brainy`) — this is ONE
+ * direct canonical read by id (`storage.getNoun`, the same O(1)
+ * fixed-path lookup {@link unvectorNounForRootMigration} itself uses
+ * internally), NEVER a listing or a walk over `entities/nouns/**`. An
+ * absent root (a store that has never used the VFS) is a no-op, no error.
+ *
+ * Runs UNCONDITIONALLY at every open, independent of whether a
+ * `VirtualFileSystem` is ever constructed this session — the vector-leg
+ * gate this fixes runs during Brainy's OWN init, before any
+ * `VirtualFileSystem` instance exists to run its own lazy migration at
+ * `doInitializeRoot()` (kept in place as the second line of defense for a
+ * VFS actually opened this session — belt AND suspenders, never either
+ * alone).
+ */
+ private async migrateLegacyZeroNormVfsRootIfNeeded(): Promise {
+ const VFS_ROOT_ID = '00000000-0000-0000-0000-000000000000'
+ // TORN-TOLERANT: a torn root record is a recovery-walk healer's job
+ // (see tests/integration/recovery-walk-tolerance.test.ts — an init-time
+ // walk that meets a torn record narrates+counts, via the adapter's own
+ // loud floor at the read site, and heals PAST it; the open itself must
+ // still succeed), not this O(1) migration check's. Skip this open's
+ // migration attempt rather than aborting init(): this leg is a
+ // defensive EXTRA (the index belt + VirtualFileSystem's own
+ // doInitializeRoot() migration still stand as the other lines of
+ // defense), and it retries harmlessly at a later open once the root
+ // heals.
+ let root: HNSWNounWithMetadata | null
+ try {
+ root = await this.storage.getNoun(VFS_ROOT_ID)
+ } catch (err) {
+ if ((err as { code?: string }).code !== 'TORN_RECORD') throw err
+ prodLog.warn(
+ `[Brainy] open(): the VFS root's record is TORN — skipping the zero-norm root ` +
+ `migration check this open (the recovery walk is the healer; this migration ` +
+ `retries harmlessly once the root heals).`
+ )
+ return
+ }
+ if (!root || !Array.isArray(root.vector) || root.vector.length === 0) return
+ if (!isZeroNormVector(root.vector)) return
+ const migrated = await this.unvectorNounForRootMigration(VFS_ROOT_ID)
+ if (migrated) {
+ prodLog.warn(
+ `[Brainy] open(): migrated the VFS root's legacy all-zero placeholder vector to ` +
+ `the unvectored shape (zero-norm vectors never cross an engine boundary) — run ` +
+ `before the vector-leg open gate compares canonical-vectored-count against the ` +
+ `vector index, so a near-empty store never reads a false coverage gap.`
+ )
+ }
+ }
+
/**
* SANCTIONED, ONE-TIME MIGRATION HOOK — rewrite a canonical noun's
* persisted vector from a real (non-empty) vector to the "unvectored"
@@ -16075,13 +16744,18 @@ export class Brainy implements BrainyInterface {
* sanctioned {@link StorageAdapter.noteVectorUnlanded} hook — so the
* coverage ledger never silently drifts.
*
- * Exists SOLELY for the VFS root zero-norm migration (see
- * `VirtualFileSystem.doInitializeRoot()`, which detects a persisted root
- * whose vector is the legacy all-zero placeholder and calls this once per
- * store). This is NOT a general-purpose "clear my vector" API — ordinary
- * application data has no sanctioned path from vectored back to
- * unvectored (`update()` refuses an empty vector as a dimension mismatch,
- * by design). Never call this outside the VFS root migration.
+ * Exists SOLELY for the VFS root zero-norm migration, called from two
+ * sites that detect the same legacy shape (a persisted root whose vector
+ * is the legacy all-zero placeholder): {@link migrateLegacyZeroNormVfsRootIfNeeded}
+ * (this brain's own init sequence, BEFORE the vector-leg open gate — Leg
+ * C of the zero-norm/unvector-door law) and
+ * `VirtualFileSystem.doInitializeRoot()` (the second line of defense, for
+ * a VFS actually constructed this session). This is NOT the general-
+ * purpose unvector API — ordinary application data uses the sanctioned
+ * unvector DOOR instead (`update({ id, vector: [] })` / the same op inside
+ * `transact()`), which decrements the ledger and clears any pending
+ * deferred-embed marker inline; it does not call this method. Never call
+ * this outside a VFS root migration.
*
* Idempotent: a noun already unvectored (`vector.length === 0`) or absent
* is a no-op — safe to call on every `init()`.
@@ -16203,8 +16877,19 @@ export class Brainy implements BrainyInterface {
if (legacyEntityPaths.length === 0) {
// Already flat (root entities, no head-branch entities) → stamp the marker
// so future opens short-circuit. A genuinely empty/fresh dir gets no marker.
- const rootEntities = await probe.listRawObjects('entities')
- if (rootEntities.length > 0) {
+ // "Are there any entities?" is answered by ONE directory read, not by a
+ // recursive listing of every file in the tree: this runs on the open path
+ // of every store that does not yet carry the marker (a restore, a store
+ // built by an older release), and on a large store that listing walks the
+ // whole canonical tree to learn a boolean.
+ const oneLevel = (
+ probe as unknown as { listRawPrefixes?: (prefix: string) => Promise }
+ ).listRawPrefixes
+ const hasRootEntities =
+ typeof oneLevel === 'function'
+ ? (await oneLevel.call(probe, 'entities')).length > 0
+ : (await probe.listRawObjects('entities')).length > 0
+ if (hasRootEntities) {
await probe.writeRawObject('_system/migration-layout.json', {
layout: 'flat-v8',
version: 8,
@@ -16703,16 +17388,34 @@ export class Brainy implements BrainyInterface {
if (assessment.reasons.length > 0 && assessment.report != null) {
const generation = assessment.report.generation
- if (this._lastNarratedHealthGeneration.get(provider) !== generation) {
- this._lastNarratedHealthGeneration.set(provider, generation)
- prodLog.warn(
- `[Brainy] ${assessment.report.provider} health (generation ${generation}): ` +
- assessment.reasons.join('; ')
- )
+ // Dedupe by CONTENT, not by the provider's generation counter — see
+ // _lastNarratedHealth. The generation is still REPORTED (an operator
+ // wants to know which generation produced the verdict); it just no
+ // longer decides whether the line is worth saying.
+ const line =
+ `[Brainy] ${assessment.report.provider} health (generation ${generation}): ` +
+ assessment.reasons.join('; ')
+ const key = `${assessment.report.provider}\u0000${assessment.reasons.join('; ')}`
+ if (this._lastNarratedHealth.get(provider) !== key) {
+ this._lastNarratedHealth.set(provider, key)
+ prodLog.warn(line)
}
}
if (assessment.readiness === 'not-ready') {
+ // A provider REBUILDING ITSELF gets a refusal that says so, with its
+ // own progress: open deliberately did not wait for it, this door is
+ // temporarily closed, and it opens by itself. Distinct from a broken
+ // index, which needs an operator.
+ const rebuilding = assessProviderRebuild(provider)
+ if (rebuilding) {
+ throw new ErrorClass(
+ `${name} index is ${describeRebuildProgress(rebuilding)} and is not serving yet. ` +
+ `Reads of this family refuse rather than serve an empty result. The brain is open ` +
+ `and every other family is serving; this door opens by itself when the provider ` +
+ `reports serving — no action is needed.`
+ )
+ }
throw new ErrorClass(
`${name} index is not serving (via ${assessment.via}): ` +
`${assessment.reasons.join('; ') || 'not ready'}. Reads refuse rather than serve an ` +
@@ -17035,9 +17738,37 @@ export class Brainy implements BrainyInterface {
// by awaitMigrationLock meanwhile (nothing serves from a half-built index).
// Gated per-index, so a non-migrating sibling still rebuilds when it needs
// to; a migrating provider is skipped even under epoch-drift or size()===0.
- const metadataMigrating = this.providerIsMigrating(this.metadataIndex)
- const vectorMigrating = this.providerIsMigrating(this.index)
- const graphMigrating = this.providerIsMigrating(this.graphIndex)
+ // SELF-REBUILD DEFERENCE (the sibling of the migration lock, and the
+ // reason a production open took 641 seconds): a provider that reports
+ // `rebuildInProgress()` is ALREADY rebuilding its own index. Brainy must
+ // neither start a second rebuild nor WAIT for the provider's — init()
+ // returns, every other family serves, and that family's own doors refuse
+ // by name (carrying this progress) until the provider reports serving.
+ // A provider without the hook behaves exactly as before.
+ const metadataRebuilding = assessProviderRebuild(this.metadataIndex)
+ const vectorRebuilding = assessProviderRebuild(this.index)
+ const graphRebuilding = assessProviderRebuild(this.graphIndex)
+ for (const [leg, progress] of [
+ ['metadata', metadataRebuilding],
+ ['vector', vectorRebuilding],
+ ['graph', graphRebuilding]
+ ] as const) {
+ if (progress) {
+ prodLog.narrate(
+ `[Brainy] open(): the ${leg} provider is ${describeRebuildProgress(progress)} — ` +
+ `open does NOT wait for it. The brain opens now, every other family serves, and ` +
+ `${leg} reads refuse by name until the provider reports itself serving.`
+ )
+ }
+ }
+
+ const metadataMigrating =
+ this.providerIsMigrating(this.metadataIndex) || metadataRebuilding !== null
+ const vectorMigrating = this.providerIsMigrating(this.index) || vectorRebuilding !== null
+ const graphMigrating = this.providerIsMigrating(this.graphIndex) || graphRebuilding !== null
+ // The epoch stamp certifies EVERY derived index, so it must not advance
+ // while any family is still being built — by a migration lock or by the
+ // provider itself.
const anyMigrating = metadataMigrating || vectorMigrating || graphMigrating
// Per-leg decision, in precedence order: a migrating provider owns its
@@ -17249,6 +17980,15 @@ export class Brainy implements BrainyInterface {
// when the metadata provider holds the migration lock: a 0 count there
// reflects its in-place rebuild in progress, not a missed rebuild, so
// forcing a second rebuild would collide with the provider's own.
+ // THREE states, not two. `metadataMigrating` above is true for a
+ // provider holding the migration lock AND for one that reports it is
+ // rebuilding itself — a provider whose rebuild() returns once the
+ // rebuild is OWNED AND RUNNING (online, its doors refusing by name)
+ // legitimately reports 0 entries here, and calling that CRITICAL would
+ // print a false alarm and kick a redundant second rebuild on every
+ // first contact. The check's real class — a rebuild that ran to
+ // completion and produced nothing — is untouched: a provider reporting
+ // 0 entries with NO rebuild in progress still trips it.
if (metadataCountAfter === 0 && totalCount > 0 && !metadataMigrating) {
console.error(
`[Brainy] CRITICAL: Metadata index has 0 entries but storage has ${totalCount} entities. ` +
@@ -17807,17 +18547,93 @@ export class Brainy implements BrainyInterface {
* invariant-driven pass (it was already rebuilt unconditionally — a second,
* report-driven pass over the same family would be redundant at best).
*
+ * NARRATION IS PART OF THE CONTRACT. A repair on a production store ran for
+ * more than thirty minutes at a full core with NOT ONE log line between its
+ * start and its end while the doors kept serving; the operator could tell it
+ * was alive only from `top`. Every phase now announces itself before it
+ * works, a heartbeat names the phase still running every five seconds, and
+ * each phase reports its own wall — carried in the receipt as
+ * `durationMs` per family, so nobody has to infer progress from CPU.
+ *
* @param options.rebuild - Family name(s) to unconditionally rebuild, or `'all'` for all three (`'metadata' | 'graph' | 'vector'`).
- * @returns The full per-family receipt (see {@link RepairReport}); also narrated via `prodLog.warn`.
+ * @returns The full per-family receipt (see {@link RepairReport}); also narrated as it goes.
*/
async repairIndex(options?: { rebuild?: Array<'metadata' | 'graph' | 'vector'> | 'all' }): Promise {
await this.ensureInitialized()
+ // A repair recounts, prunes and rebuilds outside the commit paths; the
+ // dirty witness is set so a caller's flush after a repair does its normal
+ // work rather than finding the brain "clean".
+ this._dirtySinceLastFlush = true
const startedAt = Date.now()
const families: RepairFamilyReport[] = []
- const record = (family: string, entry: Omit): void => {
- families.push({ family, ...entry })
+
+ // THE REPAIR HEARTBEAT — the same law the open obeys: no stretch of work
+ // may be silent for more than REPAIR_HEARTBEAT_MS. Unref'd (it never holds
+ // a process open) and cleared in the `finally` below.
+ const REPAIR_HEARTBEAT_MS = 5_000
+ let currentPhase = 'starting'
+ let currentPhaseCause = 'preparing the repair'
+ let phaseStartedAt = Date.now()
+ const heartbeat = setInterval(() => {
+ prodLog.narrate(
+ `[Brainy] repairIndex: still in "${currentPhase}" after ` +
+ `${Math.round((Date.now() - phaseStartedAt) / 1000)}s ` +
+ `(${Math.round((Date.now() - startedAt) / 1000)}s into the repair) — ${currentPhaseCause}`
+ )
+ }, REPAIR_HEARTBEAT_MS)
+ if (typeof heartbeat.unref === 'function') heartbeat.unref()
+
+ /** Announce a phase before it does any work, and start its clock. */
+ const beginPhase = (name: string, cause: string): void => {
+ currentPhase = name
+ currentPhaseCause = cause
+ phaseStartedAt = Date.now()
+ prodLog.narrate(`[Brainy] repairIndex: "${name}" started — ${cause}`)
}
+ /**
+ * Close the current phase: stamp its wall into the receipt row and say
+ * what it did. Every family row carries its own `durationMs`.
+ */
+ const record = (family: string, entry: Omit): void => {
+ const durationMs = Date.now() - phaseStartedAt
+ families.push({ family, ...entry, durationMs })
+ prodLog.narrate(
+ `[Brainy] repairIndex: "${family}" finished in ${durationMs}ms — ` +
+ (entry.checked
+ ? `${entry.healed} heal(s)${entry.rebuilt ? ', rebuilt' : ''}` +
+ (entry.detail ? ` (${entry.detail})` : '')
+ : `skipped (${entry.skipped ?? entry.reason ?? 'no reason given'})`)
+ )
+ phaseStartedAt = Date.now()
+ }
+
+ try {
+ return await this.runRepairIndexPhases(options, families, record, beginPhase, startedAt)
+ } finally {
+ clearInterval(heartbeat)
+ }
+ }
+
+ /**
+ * @description The phases of {@link repairIndex}, separated so its heartbeat
+ * can live in a `finally` around them. Not a public door — see `repairIndex`
+ * for the contract.
+ * @param options - As `repairIndex`.
+ * @param families - The receipt rows being accumulated.
+ * @param record - Closes a phase: stamps its wall and narrates its outcome.
+ * @param beginPhase - Announces a phase before it works.
+ * @param startedAt - When the repair began, for the closing line.
+ * @returns The full receipt.
+ */
+ private async runRepairIndexPhases(
+ options: { rebuild?: Array<'metadata' | 'graph' | 'vector'> | 'all' } | undefined,
+ families: RepairFamilyReport[],
+ record: (family: string, entry: Omit) => void,
+ beginPhase: (name: string, cause: string) => void,
+ startedAt: number
+ ): Promise {
+
// Prune orphaned canonical containers left by the pre-8.3.1 partial-delete
// defect: a delete that removed the metadata (content) leg but left the
// vector leg + the entity directory (a "ghost"), or left an empty directory
@@ -17831,6 +18647,10 @@ export class Brainy implements BrainyInterface {
rebuildSubtypeCounts?: () => Promise
}
if (typeof pruner.pruneOrphanedEntities === 'function') {
+ beginPhase(
+ 'orphaned-containers',
+ 'walking every canonical id directory for ghost/scar containers left by a partial delete'
+ )
const orphans = await pruner.pruneOrphanedEntities()
const pruned = orphans.nouns.length + orphans.verbs.length
record('orphaned-containers', {
@@ -17841,7 +18661,7 @@ export class Brainy implements BrainyInterface {
: {})
})
if (pruned > 0) {
- prodLog.warn(
+ prodLog.narrate(
`[Brainy] repairIndex() pruned ${orphans.nouns.length} orphaned noun + ` +
`${orphans.verbs.length} orphaned verb container(s) left by a pre-8.3.1 ` +
`partial delete.`
@@ -17857,6 +18677,10 @@ export class Brainy implements BrainyInterface {
// correct itself. rebuildTypeCounts() recomputes EVERY counter rollup
// (scalar totals + per-type maps + type-statistics arrays) from one
// canonical walk and persists them.
+ beginPhase(
+ 'count-rollups',
+ 'ONE canonical walk recomputing every counter rollup — scalar totals, per-type maps, type statistics'
+ )
await pruner.rebuildTypeCounts?.()
await pruner.rebuildSubtypeCounts?.()
record('count-rollups', {
@@ -17879,6 +18703,10 @@ export class Brainy implements BrainyInterface {
// concurrent writers. Canonical metadata.path is the truth; only VFS
// containment edges are touched. Loud per repair.
if (this._vfsInitialized && this._vfs) {
+ beginPhase(
+ 'vfs-containment',
+ 'reconciling VFS containment edges against canonical metadata.path'
+ )
const containment = await this._vfs.repairContainment()
record('vfs-containment', {
checked: true,
@@ -17888,7 +18716,7 @@ export class Brainy implements BrainyInterface {
: {})
})
if (containment.removed + containment.restored > 0) {
- prodLog.warn(
+ prodLog.narrate(
`[Brainy] repairIndex() reconciled VFS containment: removed ${containment.removed} ` +
`stale/duplicate edge(s), restored ${containment.restored} missing edge(s).`
)
@@ -17899,17 +18727,25 @@ export class Brainy implements BrainyInterface {
record('vfs-containment', { checked: false, healed: 0, skipped: 'VFS not initialized' })
}
+ beginPhase(
+ 'metadata-corruption',
+ 'detect-and-repair pass over the metadata index'
+ )
await this.metadataIndex.detectAndRepairCorruption()
record('metadata-corruption', { checked: true, healed: 0, detail: 'detect-and-repair pass ran (see its own narration for repairs)' })
// Lift a failed-rollback write-quarantine: force a full rebuild so the
// derived indexes are provably reconciled with canonical, then clear the
// flag so writes resume.
if (this.storeInconsistency) {
+ beginPhase(
+ 'write-quarantine',
+ 'full derived-index rebuild to lift the quarantine set by a failed transaction rollback'
+ )
await this.rebuildIndexesIfNeeded(true)
const cleared = this.storeInconsistency
record('write-quarantine', { checked: true, healed: 1, detail: `lifted (${cleared.records.length} record(s) reconciled)` })
this.storeInconsistency = null
- prodLog.warn(
+ prodLog.narrate(
`[Brainy] repairIndex() reconciled the store and LIFTED the write-quarantine ` +
`set by a failed transaction rollback (${cleared.records.length} record(s) affected). ` +
`Writes are re-enabled.`
@@ -17940,9 +18776,9 @@ export class Brainy implements BrainyInterface {
record(`provider:${familyName}`, { checked: false, healed: 0, skipped: 'no rebuild() contract' })
continue
}
- prodLog.warn(
- `[Brainy] repairIndex(): explicit rebuild requested for '${familyName}' — ` +
- `rebuilding unconditionally (no invariant consulted).`
+ beginPhase(
+ `provider:${familyName}`,
+ `explicit rebuild requested — rebuilding '${familyName}' unconditionally, no invariant consulted`
)
// The metadata family routes through the online build-beside
// orchestrator (B3 D3) instead of the provider's own rebuild() —
@@ -17959,7 +18795,7 @@ export class Brainy implements BrainyInterface {
rebuilt: true,
reason: 'explicit rebuild requested'
})
- prodLog.warn(`[Brainy] repairIndex(): '${familyName}' rebuild complete.`)
+ prodLog.narrate(`[Brainy] repairIndex(): '${familyName}' rebuild complete.`)
continue
}
@@ -17968,11 +18804,16 @@ export class Brainy implements BrainyInterface {
rebuild?: () => Promise
} | null
if (!p || typeof p.validateInvariants !== 'function' || typeof p.rebuild !== 'function') {
+ beginPhase(`provider:${familyName}`, 'checking the provider contract')
record(`provider:${familyName}`, {
checked: false, healed: 0, skipped: 'no validateInvariants/rebuild contract'
})
continue
}
+ beginPhase(
+ `provider:${familyName}`,
+ `reading the '${familyName}' provider's own invariant report, then healing only what it asks for`
+ )
let report: ProviderInvariantReport
try {
report = await p.validateInvariants()
@@ -17989,7 +18830,7 @@ export class Brainy implements BrainyInterface {
checked: true, healed: 1,
detail: `rebuilt from canonical (failing: ${report.invariants.filter((i) => !i.holds).map((i) => i.name).join(', ')})`
})
- prodLog.warn(
+ prodLog.narrate(
`[Brainy] repairIndex(): provider '${report.provider}' has a failing invariant ` +
`requiring a rebuild — reconciling its derived state from canonical.`
)
@@ -18013,7 +18854,7 @@ export class Brainy implements BrainyInterface {
const failingRepairs = report.invariants
.filter((i) => !i.holds && i.heal === 'repair')
.map((i) => i.name)
- prodLog.warn(
+ prodLog.narrate(
`[Brainy] repairIndex(): provider '${report.provider}' asks for an incremental ` +
`repair (${failingRepairs.join(', ')}) — running its own repair().`
)
@@ -18052,6 +18893,7 @@ export class Brainy implements BrainyInterface {
// rebuild failure are now reconciled — clear the queryable degraded state
// and re-arm the read-path warning.
if (this._indexDegradedIds.size > 0 || this._indexRebuildFailed) {
+ beginPhase('degraded-read-state', 'clearing degraded ids and re-arming the read-path warning')
this._indexDegradedIds.clear()
this._indexRebuildFailed = null
this._degradedReadWarned = false
@@ -18060,11 +18902,13 @@ export class Brainy implements BrainyInterface {
const healedTotal = families.reduce((n, f) => n + f.healed, 0)
const report: RepairReport = { families, healedTotal, durationMs: Date.now() - startedAt }
- prodLog.warn(
+ prodLog.narrate(
`[Brainy] repairIndex complete in ${report.durationMs}ms — ` +
`${families.filter((f) => f.checked).length}/${families.length} families checked, ` +
`${healedTotal} heal(s): ` +
- families.map((f) => `${f.family}=${f.checked ? f.healed : 'skipped'}`).join(', ')
+ families
+ .map((f) => `${f.family}=${f.checked ? f.healed : 'skipped'}@${f.durationMs ?? 0}ms`)
+ .join(', ')
)
return report
}
@@ -18590,12 +19434,105 @@ export class Brainy implements BrainyInterface {
}
/**
- * Close and cleanup
+ * @description Close and clean up: flush every buffered component, stamp
+ * the durability markers, release resources, then give up the writer lock.
*
- * Now flushes HNSW dirty nodes before closing
- * This ensures deferred persistence mode data is saved
+ * TWO PARTS, AND THE SECOND IS UNCONDITIONAL. Everything that persists data
+ * runs in {@link closeDurableSteps}; the terminal releases — the flush-request
+ * watcher, the WRITER LOCK, the VFS timers, and the terminal `closed` flag —
+ * run whether those steps succeeded or not, in a `finally`. A close that
+ * threw halfway used to strand the writer lock on disk with this process's
+ * (soon dead) pid in it, so the next boot of every affected store announced
+ * `Overwriting stale writer lock … appears dead` after an orderly exit and
+ * an operator had to decide whether their database had crashed. A closed
+ * brain holds no lock — there is no failure for which the opposite is the
+ * safer answer.
+ *
+ * The original failure is never swallowed: it is narrated with what it costs
+ * the next open, then rethrown to the caller.
+ * @returns Nothing.
+ * @throws The first failure from the durable close steps, after the
+ * terminal releases have run.
*/
async close(): Promise {
+ let closeFailure: unknown = null
+ try {
+ await this.closeDurableSteps()
+ } catch (error) {
+ closeFailure = error
+ }
+
+ // ---- TERMINAL RELEASES: always, even after a failure above ----
+
+ // Stop the cross-process flush-request watcher (no-op if never started).
+ try {
+ if (this.storage && typeof this.storage.stopFlushRequestWatcher === 'function') {
+ this.storage.stopFlushRequestWatcher()
+ }
+ } catch (error) {
+ console.warn('[Brainy] close: stopping the flush-request watcher failed:', error)
+ }
+
+ // Release the writer lock. Runs after the metadata buffer drain in
+ // closeDurableSteps() — otherwise a pending write could land after a
+ // successor writer claimed the lock — and runs even if that drain threw:
+ // holding a lock from a process that is about to exit locks the store's
+ // next boot out of a clean verdict.
+ try {
+ if (this.storage && typeof this.storage.releaseWriterLock === 'function') {
+ await this.storage.releaseWriterLock()
+ }
+ } catch (error) {
+ console.warn('[Brainy] close: releasing the writer lock failed:', error)
+ }
+
+ // Shut down the VFS: stops its background maintenance interval and the
+ // PathResolver's — both are ref'd timers that would keep the process
+ // alive after the last brain closes (consumer-reported hang).
+ try {
+ if (this._vfs) {
+ await this._vfs.close()
+ }
+ } catch (error) {
+ console.warn('[Brainy] close: VFS shutdown failed:', error)
+ }
+
+ this.initialized = false
+ // close() is terminal: block lazy re-initialization on any subsequent
+ // operation (ensureInitialized() throws once this is set). Set even when
+ // the durable steps failed — a half-closed brain must not keep serving.
+ this.closed = true
+
+ // Drop this instance from the global registry, and when it was the last
+ // one, deregister the global shutdown hooks — their ref'd signal handles
+ // would otherwise keep the process alive after every brain is closed.
+ const instanceIndex = Brainy.instances.indexOf(this)
+ if (instanceIndex !== -1) {
+ Brainy.instances.splice(instanceIndex, 1)
+ }
+ Brainy.deregisterShutdownHooksIfIdle()
+
+ if (closeFailure !== null) {
+ console.error(
+ `[Brainy] close FAILED partway: ` +
+ `${closeFailure instanceof Error ? closeFailure.message : String(closeFailure)}\n` +
+ ` This brain is closed and holds no writer lock, but the clean-shutdown ` +
+ `marker may not have been written — the next open will run crash recovery ` +
+ `(a generation-log fold) and report its wall.`
+ )
+ throw closeFailure
+ }
+ }
+
+ /**
+ * @description The durable half of {@link close}: flush every component,
+ * persist the generation counter and its markers, close the components,
+ * deactivate plugins, drain the metadata write buffer. Separated from
+ * `close()` so the terminal releases there can run in a `finally` — see that
+ * method's contract.
+ * @returns Nothing.
+ */
+ private async closeDurableSteps(): Promise {
// Persistence cadence teardown: no background flush may fire after close
// begins (close() runs its own final flush).
if (this._persistIdleTimer) {
@@ -18746,38 +19683,6 @@ export class Brainy implements BrainyInterface {
}
}
- // Stop the cross-process flush-request watcher (no-op if never started).
- if (this.storage && typeof this.storage.stopFlushRequestWatcher === 'function') {
- this.storage.stopFlushRequestWatcher()
- }
-
- // Release the writer lock (no-op for readers and for backends that don't
- // hold a lock). Must run after the metadata buffer drain — otherwise a
- // pending write could land after a successor writer claimed the lock.
- if (this.storage && typeof this.storage.releaseWriterLock === 'function') {
- await this.storage.releaseWriterLock()
- }
-
- // Shut down the VFS: stops its background maintenance interval and the
- // PathResolver's — both are ref'd timers that would keep the process
- // alive after the last brain closes (consumer-reported hang).
- if (this._vfs) {
- await this._vfs.close()
- }
-
- this.initialized = false
- // close() is terminal: block lazy re-initialization on any subsequent
- // operation (ensureInitialized() throws once this is set).
- this.closed = true
-
- // Drop this instance from the global registry, and when it was the last
- // one, deregister the global shutdown hooks — their ref'd signal handles
- // would otherwise keep the process alive after every brain is closed.
- const instanceIndex = Brainy.instances.indexOf(this)
- if (instanceIndex !== -1) {
- Brainy.instances.splice(instanceIndex, 1)
- }
- Brainy.deregisterShutdownHooksIfIdle()
}
}
diff --git a/src/db/errors.ts b/src/db/errors.ts
index e20488f8..3b4c1af6 100644
--- a/src/db/errors.ts
+++ b/src/db/errors.ts
@@ -28,7 +28,7 @@
* speculative `with()` overlay; the canonical storage walk only ever answers
* "what is live right now."
*
- * All are exported from the package root (`@soulcraft/brainy`).
+ * All are exported from the package root (`@soulcraftlabs/brainy`).
*/
/**
diff --git a/src/db/familyStamp.ts b/src/db/familyStamp.ts
index 98342884..2f01e935 100644
--- a/src/db/familyStamp.ts
+++ b/src/db/familyStamp.ts
@@ -12,9 +12,11 @@
* the verified surface is a small set of rollup invariants (entity/
* relationship counts) plus `sourceGeneration`.
*
- * `sourceGeneration` is the generation of the source-of-truth log this
- * projection reflects — open-time coherence becomes a COMPARISON (stamp vs
- * log head), not a walk:
+ * `sourceGeneration` is the COMMITTED generation of the source-of-truth log
+ * this projection reflects — never the allocated counter, which names a
+ * generation that may never commit (see {@link StampVerdict.torn}) — so
+ * open-time coherence becomes a COMPARISON (stamp vs committed head), not a
+ * walk:
*
* - equal + invariants hold → coherent, serve.
* - behind → the projection missed the tail (crash between commit and stamp);
@@ -24,6 +26,9 @@
* - invariants FAIL at equal generation → genuine incoherence: loud, and the
* repair ritual (`repairIndex()`, whose recount rebuilds the rollups from a
* canonical walk) heals it.
+ * - AHEAD → a torn generation-log tail: the stamp's fsync outlived the log
+ * tail's. TERMINAL, never a wait — the generation the stamp names does not
+ * exist to arrive.
*
* Stamps are JSON on purpose — every incident gets debugged by reading a
* stamp in a terminal.
@@ -70,6 +75,12 @@ export type StampVerdict =
| { state: 'coherent' }
| { state: 'absent' } // legacy store — first stamp writes at the next flush
| { state: 'behind'; stampSource: number; head: number }
+ /**
+ * TORN GENERATION-LOG TAIL: the stamp witnesses a source generation the
+ * store's committed watermark can no longer show. TERMINAL — there is no
+ * generation to wait for, so the open demotes (or refuses) and never spins.
+ */
+ | { state: 'torn'; stampSource: number; head: number }
| { state: 'incoherent'; failures: string[] }
| { state: 'unverifiable'; reason: string } // a FAULT reading the stamp — never conflated with absence
@@ -118,12 +129,15 @@ export function verifyFamilyStamp(
): StampVerdict {
if (stamp === null) return { state: 'absent' }
if (stamp.sourceGeneration > head) {
- // A stamp AHEAD of the log claims state that never committed — the
- // projection was stamped against truth that a crash rolled back.
- return {
- state: 'incoherent',
- failures: [`sourceGeneration ${stamp.sourceGeneration} is ahead of the log head ${head}`]
- }
+ // A stamp AHEAD of committed truth witnesses a generation the store can no
+ // longer show: the stamp's fsync survived a crash that the log tail did
+ // not. This is the TORN GENERATION-LOG TAIL — its own class, never folded
+ // in with `incoherent` (a count that drifted at a generation both sides
+ // agree on), because the two have opposite cures: incoherence is recounted,
+ // a tear is DEMOTED. It is also terminal by construction — there is no
+ // generation the open can wait for, because the one the stamp names is
+ // gone.
+ return { state: 'torn', stampSource: stamp.sourceGeneration, head }
}
if (stamp.sourceGeneration < head) {
return { state: 'behind', stampSource: stamp.sourceGeneration, head }
diff --git a/src/db/generationSegments.ts b/src/db/generationSegments.ts
index 0c14b60c..91451281 100644
--- a/src/db/generationSegments.ts
+++ b/src/db/generationSegments.ts
@@ -147,6 +147,60 @@ export class GenerationSegmentStore {
return this.coveringSegment(gen) !== null
}
+ /**
+ * @description True when `meta` declares more generations than it holds
+ * frames — a segment sealed by a writer that folded across a hole. The
+ * manifest records `frames` at fold time, so this is an O(1) comparison
+ * against the declared span and needs no I/O.
+ */
+ private isSparse(meta: SegmentMeta): boolean {
+ return meta.lastGeneration - meta.firstGeneration + 1 !== meta.frames
+ }
+
+ /**
+ * @description The generations this tier ACTUALLY holds, as coalesced
+ * ascending intervals — not what the segments declare.
+ *
+ * Dense segments (every one a current writer produces) contribute their
+ * declared range with no I/O. A SPARSE segment — one sealed before the
+ * density law was enforced, whose declared range spans generations it has
+ * no frame for — has its real generation list read from its sidecar and
+ * contributed instead, with the discrepancy narrated once.
+ *
+ * This is what keeps a store that already carries the damage from wedging.
+ * `open()` seeds `committedRanges` from these intervals, so a hole is never
+ * re-admitted as a committed generation, and the auto-compaction pass that
+ * used to fail on every run with "packed history is damaged" simply never
+ * asks for the missing frame.
+ *
+ * @returns Ascending, non-overlapping `[first, last]` intervals.
+ */
+ async actualRanges(): Promise> {
+ const out: Array<[number, number]> = []
+ for (const meta of this.manifest.segments) {
+ if (!this.isSparse(meta)) {
+ out.push([meta.firstGeneration, meta.lastGeneration])
+ continue
+ }
+ const missing = meta.lastGeneration - meta.firstGeneration + 1 - meta.frames
+ prodLog.warn(
+ `[GenerationSegments] sealed segment ${meta.file} declares generations ` +
+ `${meta.firstGeneration}..${meta.lastGeneration} but holds only ${meta.frames} ` +
+ `frame(s) — ${missing} generation(s) in that span were never folded into it. ` +
+ `Serving the frames it actually holds; the declared span is not treated as ` +
+ `committed history. (Written by a pre-density-law writer that folded across a ` +
+ `gap; the segment itself is intact and no record is lost.)`
+ )
+ const idx = await this.sidecarFor(meta)
+ for (const [gen] of idx.generations) {
+ const last = out[out.length - 1]
+ if (last !== undefined && gen === last[1] + 1) last[1] = gen
+ else out.push([gen, gen])
+ }
+ }
+ return out
+ }
+
/**
* Fold consecutive generations into ONE new sealed segment + sidecar and
* append it to the manifest atomically. Caller guarantees: `gens` is
@@ -164,6 +218,38 @@ export class GenerationSegmentStore {
throw new Error('[GenerationSegments] fold() input must be strictly ascending')
}
}
+ // THE DENSITY LAW, MADE MECHANICAL.
+ //
+ // A sealed segment declares a CONTIGUOUS range [firstGeneration,
+ // lastGeneration] and every reader treats that range as containment:
+ // `coveringSegment` is an interval test, `hasGeneration` returns true for
+ // anything inside it, and `open()` seeds committedRanges from it. So a
+ // segment folded from a SPARSE input silently claims generations it does
+ // not hold, and the first read of one of those holes throws
+ // "inside sealed segment ... but has no frame — packed history is damaged".
+ //
+ // That is exactly how the damage was produced. `repackHistory` skipped
+ // generations mid-batch — ones absent from committedRanges, ones still in
+ // the pending buffer, ones whose tx.json would not read — and handed the
+ // survivors here, where the range was computed from the first and last of
+ // them. Worse, the mis-declared range was then merged back into
+ // committedRanges at the next open, which is what turned a quiet hole into
+ // a repeating auto-compaction failure on every subsequent run.
+ //
+ // Callers now split at discontinuities; this refusal is what keeps any
+ // future caller from reintroducing the class. A refusal here loses
+ // nothing — the generations stay in the live tier, readable, and the next
+ // pass folds them correctly.
+ for (let i = 1; i < gens.length; i++) {
+ if (gens[i].generation !== gens[i - 1].generation + 1) {
+ throw new Error(
+ `[GenerationSegments] fold() input is not contiguous: ${gens[i - 1].generation} → ` +
+ `${gens[i].generation} skips ${gens[i].generation - gens[i - 1].generation - 1} ` +
+ `generation(s). A sealed segment declares a dense range, so folding a sparse ` +
+ `batch would claim generations it does not hold. Split the batch at the gap.`
+ )
+ }
+ }
const last = this.manifest.segments[this.manifest.segments.length - 1]
if (last && gens[0].generation <= last.lastGeneration) {
throw new Error(
@@ -364,12 +450,37 @@ export class GenerationSegmentStore {
return this.decodeFrame(payload)
}
}
- // In the covering range but not present: the packed tier is dense by
- // construction (fold packs every generation it is handed, including
- // record-less ones) — absence inside a sealed range is damage.
+ // Inside the covering range but with no frame. Two very different causes,
+ // and conflating them is what made this class wedge every maintenance pass
+ // on the affected stores.
+ //
+ // (1) A SPARSE SEGMENT — the manifest's own `frames` count is smaller than
+ // the span it declares. That segment was sealed by a writer that
+ // folded across a hole (the class this file's density law now bars).
+ // The segment is INTACT and nothing is lost; it simply never held this
+ // generation. Answering "not packed" is the honest answer, and it lets
+ // the caller's two-tier read decide what a genuinely absent generation
+ // means, instead of every compaction pass dying on a repeating throw.
+ // `actualRanges()` keeps such holes out of committedRanges at open, so
+ // in a healed store nobody asks this question in the first place.
+ //
+ // (2) A DENSE SEGMENT missing a frame it says it has — the manifest and
+ // the sidecar disagree about a segment that claims to be complete.
+ // That IS damage, and it stays loud.
+ if (this.isSparse(meta)) {
+ prodLog.warn(
+ `[GenerationSegments] generation ${gen} falls inside sealed segment ${meta.file}'s ` +
+ `declared range ${meta.firstGeneration}..${meta.lastGeneration}, but that segment ` +
+ `holds ${meta.frames} frame(s) for a ${meta.lastGeneration - meta.firstGeneration + 1}` +
+ `-generation span — it was sealed across a gap and never held this generation. ` +
+ `Reporting it as unpacked rather than as damage; no record is lost.`
+ )
+ return null
+ }
throw new Error(
`[GenerationSegments] generation ${gen} is inside sealed segment ${meta.file}'s declared ` +
- `range but has no frame — packed history is damaged`
+ `range but has no frame, and that segment declares a complete ${meta.frames}-frame ` +
+ `span — the manifest and the sidecar disagree; packed history is damaged`
)
}
diff --git a/src/db/generationStore.ts b/src/db/generationStore.ts
index 81bed338..da21dc61 100644
--- a/src/db/generationStore.ts
+++ b/src/db/generationStore.ts
@@ -96,6 +96,35 @@ export const FOLD_CHECKPOINT_PATH = '_system/fold-checkpoint.json'
/** Storage-root-relative prefix of the per-generation record directories. */
export const GENERATIONS_PREFIX = '_generations'
+/**
+ * @description Split an ascending list of fold candidates into maximal
+ * CONTIGUOUS runs — `[7,8,9,12,13]` becomes `[[7,8,9],[12,13]]`.
+ *
+ * A sealed segment declares one dense range `[firstGeneration,
+ * lastGeneration]`, and every reader treats that range as containment. So a
+ * batch with a hole in it must never become one segment: it would claim a
+ * generation it does not hold, and the first read of that hole reports the
+ * packed history as damaged. One run, one segment — the ranges then describe
+ * exactly what the segments contain.
+ *
+ * @param gens - Fold candidates, strictly ascending by generation.
+ * @returns One array per contiguous run, in ascending order. Empty in, empty out.
+ */
+export function contiguousRuns(gens: FoldGeneration[]): FoldGeneration[][] {
+ const runs: FoldGeneration[][] = []
+ let run: FoldGeneration[] = []
+ for (const g of gens) {
+ const prev = run[run.length - 1]
+ if (prev !== undefined && g.generation !== prev.generation + 1) {
+ runs.push(run)
+ run = []
+ }
+ run.push(g)
+ }
+ if (run.length > 0) runs.push(run)
+ return runs
+}
+
/**
* @description Phases of the {@link GenerationStore.commitTransaction} commit
* protocol at which a test-only fault injector can simulate a process crash.
@@ -537,12 +566,29 @@ export class GenerationStore {
this.horizonGen = finiteGen(manifest?.horizon, 'manifest horizon')
this.counter = Math.max(finiteGen(counterFile?.generation, 'generation counter'), this.committed)
- // Discover existing generation record directories.
- const recordPaths = await this.storage.listRawObjects(GENERATIONS_PREFIX)
+ // Discover existing generation record directories — BY DIRECTORY NAME.
+ // This used to call listRawObjects(), which recurses the whole
+ // `_generations/` tree and returns every file in every generation, to
+ // extract a set of integers the top-level directory names already spell.
+ // MEASURED on a real store with an 11 GB generation history: the phase
+ // this sits in cost 55,538 ms of a WARM REOPEN after a clean close, with
+ // no fold to blame — this walk is what it was doing. An adapter without
+ // the one-level door falls back to the recursive listing, unchanged.
const seenGens = new Set()
- for (const p of recordPaths) {
- const gen = parseGenerationFromPath(p)
- if (gen !== null) seenGens.add(gen)
+ const oneLevel = (
+ this.storage as { listRawPrefixes?: (prefix: string) => Promise }
+ ).listRawPrefixes
+ if (typeof oneLevel === 'function') {
+ for (const name of await oneLevel.call(this.storage, GENERATIONS_PREFIX)) {
+ const gen = Number(name)
+ if (Number.isSafeInteger(gen) && gen >= 0) seenGens.add(gen)
+ }
+ } else {
+ const recordPaths = await this.storage.listRawObjects(GENERATIONS_PREFIX)
+ for (const p of recordPaths) {
+ const gen = parseGenerationFromPath(p)
+ if (gen !== null) seenGens.add(gen)
+ }
}
let rolledBack = 0
@@ -652,6 +698,7 @@ export class GenerationStore {
: 'WHOLE-LOG fold'
: 'above-manifest replay'
let replayed = 0
+ const foldStartedAt = Date.now()
const replayFact = async (fact: CommitFact): Promise => {
for (const op of fact.ops) {
let image: { metadata: unknown | null; vector: unknown | null }
@@ -697,9 +744,10 @@ export class GenerationStore {
}
replayed++
if (replayed % 1000 === 0) {
- prodLog.warn(
+ prodLog.narrate(
`[GenerationStore] recovery fold in progress — ${replayed} fact(s) folded ` +
- `(at generation ${fact.generation}); do not restart, the fold is finite`
+ `in ${Date.now() - foldStartedAt}ms (at generation ${fact.generation}); ` +
+ `do not restart, the fold is finite`
)
}
if (fact.generation > this.committed) {
@@ -714,7 +762,7 @@ export class GenerationStore {
}
}
if (uncleanOpen) {
- prodLog.warn(
+ prodLog.narrate(
`[GenerationStore] log-authority recovery: ${foldKind} beginning ` +
`(unclean shutdown detected) — streaming replay, bounded memory, ` +
`progress every 1000 facts. Do not restart the process; a restart ` +
@@ -737,9 +785,10 @@ export class GenerationStore {
}
await this.storage.writeRawObject(MANIFEST_PATH, manifest)
await this.storage.syncRawObjects([MANIFEST_PATH])
- prodLog.warn(
+ prodLog.narrate(
`[GenerationStore] log-authority recovery replayed ${replayed} fact(s) into ` +
- `canonical (${foldKind}; committed at ${this.committed}) — an acked write is never lost`
+ `canonical in ${Date.now() - foldStartedAt}ms (${foldKind}; committed at ` +
+ `${this.committed}) — an acked write is never lost`
)
}
// A recovery fold re-applied (and the barrier below re-syncs) every
@@ -764,9 +813,15 @@ export class GenerationStore {
if (storageSupportsFactLog(this.storage)) {
this.segments = new GenerationSegmentStore(this.storage)
await this.segments.open()
- const packedRanges = this.segments
- .segments()
- .map((s): [number, number] => [s.firstGeneration, Math.min(s.lastGeneration, this.committed)])
+ // ACTUAL ranges, not declared ones. A segment sealed by a pre-density-law
+ // writer can declare a span wider than the frames it holds; seeding
+ // committedRanges from the declared span re-admits those holes as
+ // committed generations, and every later maintenance pass then asks for a
+ // frame that was never written. `actualRanges()` reads the real
+ // generation list from the sidecar for exactly those segments (and does
+ // no I/O for the dense ones, which is all of them on a healthy store).
+ const packedRanges = (await this.segments.actualRanges())
+ .map((r): [number, number] => [r[0], Math.min(r[1], this.committed)])
.filter(([lo, hi]) => lo <= hi)
if (packedRanges.length > 0) {
// Merge packed (older) + live (newer) interval sets — both ascending;
@@ -3101,13 +3156,26 @@ export class GenerationStore {
foldInput.push({ generation: gen, timestamp: delta.timestamp, delta, records })
}
if (foldInput.length === 0) continue
- await segments.fold(foldInput)
- segmentsCreated++
- // Segment + manifest durable → the live copies retire.
- for (const g of foldInput) {
- await this.storage.removeRawPrefix(`${GENERATIONS_PREFIX}/${g.generation}`)
+ // SPLIT AT DISCONTINUITIES. `eligible` is NOT contiguous — three
+ // filters above punch holes in it: a generation missing from
+ // committedRanges never appears, one still in the pending buffer is
+ // skipped, and one whose tx.json will not read is skipped. A sealed
+ // segment declares a DENSE range, so folding across such a hole makes
+ // the segment claim a generation it does not hold; the next open
+ // merges that mis-declared range into committedRanges, and every
+ // subsequent auto-compaction pass then asks for the missing frame and
+ // fails with "packed history is damaged". Fold each contiguous RUN as
+ // its own segment instead — same bytes, honest ranges.
+ for (const run of contiguousRuns(foldInput)) {
+ if (deadline !== undefined && Date.now() >= deadline) break
+ await segments.fold(run)
+ segmentsCreated++
+ // Segment + manifest durable → the live copies retire.
+ for (const g of run) {
+ await this.storage.removeRawPrefix(`${GENERATIONS_PREFIX}/${g.generation}`)
+ }
+ folded += run.length
}
- folded += foldInput.length
}
if (folded > 0) {
prodLog.info(
diff --git a/src/db/types.ts b/src/db/types.ts
index 363de086..56bdef11 100644
--- a/src/db/types.ts
+++ b/src/db/types.ts
@@ -450,6 +450,21 @@ export interface GenerationStorage {
deleteRawObject(path: string): Promise
/** List raw object paths under a prefix (normalized, `.gz`-stripped). */
listRawObjects(prefix: string): Promise
+ /**
+ * OPTIONAL: the IMMEDIATE child directory names under a prefix — one level,
+ * no recursion, no file paths.
+ *
+ * Why it exists: discovering which generations are on disk needs only the
+ * top-level directory NAMES under `_generations/`, but the only door for it
+ * was `listRawObjects`, which recurses the whole tree and returns every file
+ * in every generation. On a store with a long history that is a full walk of
+ * the entire generation log, paid on EVERY open, to learn a set of integers
+ * the directory names already spell out.
+ *
+ * An adapter without this door keeps working — the caller falls back to the
+ * recursive listing.
+ */
+ listRawPrefixes?(prefix: string): Promise
/** Remove every object under a prefix (and the directory itself on disk). */
removeRawPrefix(prefix: string): Promise
/** Durability barrier: fsync the given object paths (no-op in memory). */
diff --git a/src/embeddings/wasm/modelLoader.ts b/src/embeddings/wasm/modelLoader.ts
index 45ffc4d3..b39d90ea 100644
--- a/src/embeddings/wasm/modelLoader.ts
+++ b/src/embeddings/wasm/modelLoader.ts
@@ -128,7 +128,7 @@ async function loadBunAssets(): Promise {
}
// Strategy 2: node_modules path relative to CWD (for installed packages)
- const nmPath = './node_modules/@soulcraft/brainy/assets/models/all-MiniLM-L6-v2'
+ const nmPath = './node_modules/@soulcraftlabs/brainy/assets/models/all-MiniLM-L6-v2'
pathsToTry.push([
`${nmPath}/model.safetensors`,
`${nmPath}/tokenizer.json`,
@@ -168,9 +168,9 @@ async function loadBunAssets(): Promise {
// If all strategies fail, provide helpful error message
throw new Error(
'Could not load model assets. For bun --compile, ensure model files are accessible:\n' +
- ' Option 1: Keep node_modules/@soulcraft/brainy/assets/ alongside your binary\n' +
+ ' Option 1: Keep node_modules/@soulcraftlabs/brainy/assets/ alongside your binary\n' +
' Option 2: Copy assets/ folder to your working directory\n' +
- ' Option 3: Use --asset flag: bun build --compile --asset="./node_modules/@soulcraft/brainy/assets/**/*"'
+ ' Option 3: Use --asset flag: bun build --compile --asset="./node_modules/@soulcraftlabs/brainy/assets/**/*"'
)
}
@@ -190,7 +190,7 @@ async function loadNodeAssets(): Promise {
if (!fs.existsSync(assetsDir)) {
throw new Error(
`Model assets not found: ${assetsDir}\n` +
- `Ensure @soulcraft/brainy is installed correctly.`
+ `Ensure @soulcraftlabs/brainy is installed correctly.`
)
}
diff --git a/src/errors/notFound.ts b/src/errors/notFound.ts
index 8eca9b2e..628797a6 100644
--- a/src/errors/notFound.ts
+++ b/src/errors/notFound.ts
@@ -14,7 +14,7 @@
* - {@link RelationNotFoundError} — a referenced relationship (verb) does
* not exist.
*
- * Both are exported from the package root (`@soulcraft/brainy`).
+ * Both are exported from the package root (`@soulcraftlabs/brainy`).
*/
/**
diff --git a/src/graph/graphAdjacencyIndex.ts b/src/graph/graphAdjacencyIndex.ts
index d002164e..ebd3b90c 100644
--- a/src/graph/graphAdjacencyIndex.ts
+++ b/src/graph/graphAdjacencyIndex.ts
@@ -1052,6 +1052,17 @@ export class GraphAdjacencyIndex implements GraphIndexProvider {
*/
private startAutoFlush(): void {
this.flushTimer = setInterval(async () => {
+ // NO PERIODIC WORK WITHOUT A CAUSE. Ask first, in two O(1) reads: an
+ // index nobody has written to since the last flush has nothing to
+ // write, and calling into the trees (and their logging) on a cadence
+ // over a quiet store is exactly the idle cost this law exists to
+ // remove.
+ if (
+ !this.lsmTreeVerbsBySource.hasPendingWrites() &&
+ !this.lsmTreeVerbsByTarget.hasPendingWrites()
+ ) {
+ return
+ }
await this.flush()
}, this.config.flushInterval)
// Background maintenance must never keep the host process alive —
diff --git a/src/graph/lsm/LSMTree.ts b/src/graph/lsm/LSMTree.ts
index e19ec145..b4f6052f 100644
--- a/src/graph/lsm/LSMTree.ts
+++ b/src/graph/lsm/LSMTree.ts
@@ -687,6 +687,17 @@ export class LSMTree {
}
}
+ /**
+ * @description Whether this tree holds anything a flush would write —
+ * the MemTable is non-empty. Synchronous and O(1), so a background cadence
+ * can ask before it does anything at all: the engine does no periodic work
+ * without a cause.
+ * @returns true when a flush would write; false when it would be a no-op.
+ */
+ hasPendingWrites(): boolean {
+ return !this.memTable.isEmpty()
+ }
+
async close(): Promise {
this.stopCompactionTimer()
diff --git a/src/hnsw/hnswIndex.ts b/src/hnsw/hnswIndex.ts
index 77e4f84d..8b9badc1 100644
--- a/src/hnsw/hnswIndex.ts
+++ b/src/hnsw/hnswIndex.ts
@@ -10,7 +10,7 @@ import {
Vector,
VectorDocument
} from '../coreTypes.js'
-import { euclideanDistance, calculateDistancesBatch } from '../utils/index.js'
+import { euclideanDistance, calculateDistancesBatch, isZeroNormVector } from '../utils/index.js'
import type { BaseStorage } from '../storage/baseStorage.js'
import { getGlobalCache, UnifiedCache } from '../utils/unifiedCache.js'
import { prodLog } from '../utils/logger.js'
@@ -64,6 +64,34 @@ export class HnswFlushError extends Error {
}
}
+/**
+ * @description Thrown by {@link JsHnswVectorIndex.addItem} / {@link
+ * JsHnswVectorIndex.updateItem} when handed a length-0 vector. A length-0
+ * vector is the sanctioned "unvectored" shape for a canonical noun record
+ * (class-J: a VFS-system row, a deferred embed not yet landed, or any other
+ * legitimately-vector-less row) — but it is NEVER a legal INDEX insert. The
+ * index itself has no concept of "unvectored"; deciding that a row is
+ * unvectored and therefore skippable is the FILL/REBUILD/LOAD consumer's job
+ * (see {@link JsHnswVectorIndex.rebuild}), done BEFORE ever calling addItem.
+ * A length-0 vector reaching this point is a caller bug: silently accepting
+ * it would pin `this.dimension = 0` on an empty index (poisoning every real
+ * insert thereafter with a dimension mismatch) or store a vector-less node
+ * that a distance calculation can never safely compare against. Loud errors,
+ * never quiet losses — this throws instead of either.
+ */
+export class EmptyVectorIndexError extends Error {
+ constructor(public readonly id: string, operation: 'addItem' | 'updateItem') {
+ super(
+ `${operation}(${id}): refusing to index a length-0 vector — a length-0 vector is the ` +
+ `sanctioned "unvectored" shape for a canonical row, but it is never a legal index ` +
+ `insert. Callers that fill/rebuild/load the index must skip vector.length === 0 rows ` +
+ `themselves (unvectored = nothing to index, not an error at that layer); reaching ` +
+ `here with one is a caller bug.`
+ )
+ this.name = 'EmptyVectorIndexError'
+ }
+}
+
/**
* Implements {@link VectorIndexProvider}: the vector-index surface Brainy calls
* on whatever the `'vector'` factory returns (its own `JsHnswVectorIndex`, or a native
@@ -580,6 +608,15 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
throw new Error('Vector is undefined or null')
}
+ // THE INDEX REFUSES A LENGTH-0 VECTOR (see EmptyVectorIndexError's JSDoc):
+ // an empty vector is the sanctioned "unvectored" shape at the canonical
+ // layer, never a legal index member. Refusing here — loudly, before the
+ // dimension pin below — means no future fill/rebuild/load path can ever
+ // poison `this.dimension` to 0 or park a vector-less node in the graph.
+ if (vector.length === 0) {
+ throw new EmptyVectorIndexError(id, 'addItem')
+ }
+
// Set dimension on first insert
if (this.dimension === null) {
this.dimension = vector.length
@@ -954,6 +991,13 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
return
}
+ // Same refusal as addItem (see EmptyVectorIndexError's JSDoc) — an
+ // in-place relink must never rewrite an already-indexed node down to the
+ // unvectored shape or poison the pinned dimension.
+ if (vector.length === 0) {
+ throw new EmptyVectorIndexError(id, 'updateItem')
+ }
+
if (this.dimension === null) {
this.dimension = vector.length
} else if (vector.length !== this.dimension) {
@@ -1555,7 +1599,15 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
}
const loaded = await this.storage.getNounVector(noun.id)
- if (!loaded) {
+ // `loaded` is a length-0 array (not null/undefined) for a canonical row
+ // that is legitimately unvectored — `![]` is FALSE (an empty array is
+ // truthy), so the bare `!loaded` check below would silently accept it
+ // as "found" and hand a dimension-0 vector to a distance calculation.
+ // A node only reaches this lazy-load path because it is a MEMBER of
+ // the live index (rebuild() now refuses to admit unvectored rows — see
+ // its JSDoc), so an empty vector here is never legitimate: treat it
+ // exactly like "not found", loudly.
+ if (!loaded || loaded.length === 0) {
throw new Error(`Vector not found for noun ${noun.id}`)
}
@@ -1765,9 +1817,56 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
totalCount = result.totalCount || result.items.length
+ // UNVECTORED ROWS ARE NOT AN INDEX MEMBER (the class-J law): a canonical
+ // noun whose vector leg is `[]` (a VFS-root-style system row, a
+ // deferred embed not yet landed, or a best-effort fallback for an
+ // unreadable vector leg) is a normal, enumerable, countable row — it
+ // is simply not indexed. `storage.getVectorIndexData()` derives its
+ // {level, connections} answer straight from the noun's OWN record, so
+ // it returns non-null for every existing noun regardless of whether
+ // that noun ever actually reached `addItem()` — it cannot be used to
+ // decide indexability. `nounData.vector.length === 0` is the one
+ // truthful signal (mirrors the `noun.vector.length > 0` guards in
+ // {@link getVectorSafe} / {@link getVectorSync}): skip here, counted
+ // once in a summary line, never per-row spam.
+ let skippedUnvectored = 0
+
// Process all nouns at once
for (const nounData of result.items) {
try {
+ if (!Array.isArray(nounData.vector) || nounData.vector.length === 0) {
+ skippedUnvectored++
+ continue
+ }
+ // THE ZERO-NORM LAW — bulk-rebuild leg: a persisted zero-norm
+ // vector (a pre-10.4.2 row the canonical write has not yet
+ // normalized) must never enter the index either, mirroring the
+ // belt AddToVectorIndexOperation enforces on the live write path.
+ // Only the canonical vector is authoritative here — persisted
+ // HNSW graph metadata (level/connections) can outlive an unvector.
+ if (isZeroNormVector(nounData.vector)) {
+ prodLog.warn(
+ `[HNSW] rebuild(): skipping entity ${nounData.id} — persisted vector is ` +
+ `zero-norm (a zero-norm vector is not a vector and never crosses an ` +
+ `engine boundary)`
+ )
+ continue
+ }
+
+ // Restore the pinned dimension from the first real vector this
+ // rebuild loads. `addItem`/`updateItem` only pin `this.dimension`
+ // on a LIVE insert — a fresh rebuild from storage never goes
+ // through either, so without this the pin stays `null` across a
+ // restart. A `null` pin means the very next insert (correct OR
+ // wrong length) silently BECOMES the new pin instead of being
+ // checked against the store's real dimension — the wrong-length
+ // case then fails much later and less clearly, inside a distance
+ // calculation against an already-loaded node, instead of here,
+ // immediately, with a named expected-vs-got mismatch.
+ if (this.dimension === null) {
+ this.dimension = nounData.vector.length
+ }
+
// Load HNSW graph data for this entity
const hnswData = await this.storage.getVectorIndexData(nounData.id)
@@ -1815,7 +1914,10 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
options.onProgress(loadedCount, totalCount)
}
- prodLog.info(`HNSW: Loaded ${loadedCount.toLocaleString()} nodes (${storageType})`)
+ prodLog.info(
+ `HNSW: Loaded ${loadedCount.toLocaleString()} nodes (${storageType})` +
+ (skippedUnvectored > 0 ? ` — ${skippedUnvectored.toLocaleString()} unvectored row(s) skipped` : '')
+ )
}
// Step 5: CRITICAL - Recover entry point if missing)
diff --git a/src/index.ts b/src/index.ts
index 973136a5..edc21809 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -184,6 +184,7 @@ export {
// Export version utilities
export { getBrainyVersion } from './utils/version.js'
+export { contractVersion, BRAINY_CONTRACT_VERSION } from './utils/version.js'
// Export plugin system
export type { BrainyPlugin, BrainyPluginContext, StorageAdapterFactory } from './plugin.js'
diff --git a/src/integrations/index.ts b/src/integrations/index.ts
index 757a9fe5..6a6734d9 100644
--- a/src/integrations/index.ts
+++ b/src/integrations/index.ts
@@ -9,7 +9,7 @@
*
* @example Enable integrations (recommended)
* ```typescript
- * import { Brainy } from '@soulcraft/brainy'
+ * import { Brainy } from '@soulcraftlabs/brainy'
*
* const brain = new Brainy({ integrations: true })
* await brain.init()
diff --git a/src/mcp/README.md b/src/mcp/README.md
index c69a3b24..092534a1 100644
--- a/src/mcp/README.md
+++ b/src/mcp/README.md
@@ -41,7 +41,7 @@ The `BrainyMCPService` has been refactored to separate the core functionality fr
### In Any Environment (Browser, Node.js, Server)
```typescript
-import { Brainy, BrainyMCPAdapter, MCPAugmentationToolset } from '@soulcraft/brainy'
+import { Brainy, BrainyMCPAdapter, MCPAugmentationToolset } from '@soulcraftlabs/brainy'
// Create a Brainy instance
const brainyData = new Brainy()
@@ -81,7 +81,7 @@ const toolResponse = await toolset.handleRequest({
### In Browser Environment (Core Functionality Only)
```typescript
-import { Brainy, BrainyMCPService } from '@soulcraft/brainy'
+import { Brainy, BrainyMCPService } from '@soulcraftlabs/brainy'
// Create a Brainy instance
const brainyData = new Brainy()
diff --git a/src/plugin.ts b/src/plugin.ts
index bfdc403a..b1aef8e0 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -22,7 +22,7 @@ import type { GraphIndexStats } from './graph/graphAdjacencyIndex.js'
// Re-export the provider contracts that already live closer to their
// implementations so a plugin author (Cor) can import the *entire*
-// provider surface from one stable entrypoint: `@soulcraft/brainy/plugin`.
+// provider surface from one stable entrypoint: `@soulcraftlabs/brainy/plugin`.
export type { ColumnStoreProvider } from './indexes/columnStore/types.js'
export type {
AggregationProvider,
@@ -41,7 +41,7 @@ export interface BrainyPlugin {
name: string
/**
- * Optional semver range of `@soulcraft/brainy` this plugin supports
+ * Optional semver range of `@soulcraftlabs/brainy` this plugin supports
* (e.g. `'>=8.0.0 <9.0.0'` or `'^8.0.0'`). When set and the running brainy is
* OUTSIDE the range, `init()` THROWS rather than silently falling back to the
* default JS engine. This is the version-coupling guard for the native
diff --git a/src/storage/adapters/fileSystemStorage.ts b/src/storage/adapters/fileSystemStorage.ts
index 817e5e36..5ec1d88e 100644
--- a/src/storage/adapters/fileSystemStorage.ts
+++ b/src/storage/adapters/fileSystemStorage.ts
@@ -14,11 +14,13 @@ import {
StorageBatchConfig,
SYSTEM_DIR,
STATISTICS_KEY,
- WriterLockInfo
+ WriterLockInfo,
+ WriterCloseRecord
} from '../baseStorage.js'
import { getBrainyVersion } from '../../utils/index.js'
import { isAbsentError } from '../../utils/errorClassification.js'
import { prodLog } from '../../utils/logger.js'
+import { isZeroNormVector } from '../../utils/distance.js'
import {
TornRecordError,
isUnparseablePayloadError,
@@ -98,7 +100,30 @@ export class FileSystemStorage extends BaseStorage {
// timer rewrites the lock every 10s so stale-lock detection can tell a dead
// writer from a slow one. The constant name matches the file path used.
private static readonly WRITER_LOCK_FILE = '_writer.lock'
- private static readonly WRITER_HEARTBEAT_MS = 10_000
+ /**
+ * The clean-close record at `locks/_writer.close` (see
+ * {@link WriterCloseRecord}). Written when the lock is released, consumed by
+ * the next claim, so an open can distinguish "the previous writer left" from
+ * "the previous writer died" without inferring either from a pid.
+ */
+ private static readonly WRITER_CLOSE_FILE = '_writer.close'
+ /**
+ * How often the lock file's `lastHeartbeat` is rewritten.
+ *
+ * THIS IS OBSERVABILITY ONLY, and the cadence follows from that. Staleness
+ * is decided by PID LIVENESS alone (see isWriterLockStale) and the fence
+ * compares pid + hostname — no decision anywhere reads this timestamp. It
+ * exists so an operator inspecting a lock file, or reading the
+ * BRAINY_WRITER_LOCKED error, can judge liveness themselves.
+ *
+ * At 10s it was a lock-file WRITE every ten seconds per brain, forever: 2.1
+ * writes/s across a production process holding 21 idle brains, for a
+ * human-readable timestamp nothing computes with. At 60s an operator still
+ * sees a heartbeat inside the minute, at a sixth of the cost. With the
+ * clean-close record now recording orderly releases explicitly, the
+ * heartbeat carries even less weight than it did.
+ */
+ private static readonly WRITER_HEARTBEAT_MS = 60_000
private static readonly WRITER_STALE_THRESHOLD_MS = 60_000
private writerLockHeartbeat?: NodeJS.Timeout
private writerLockInfo?: WriterLockInfo
@@ -111,6 +136,13 @@ export class FileSystemStorage extends BaseStorage {
*/
private writerHeartbeatInFlight?: Promise
+ /**
+ * The in-flight background count-ledger derivation, if one was needed at
+ * open. See {@link scheduleCountLedgerDerivation} — awaited only by
+ * {@link whenCountLedgerSettled}, never by a read.
+ */
+ private countLedgerDerivation?: Promise
+
// Flush-request RPC state. The writer polls `locks/_flush_requests/` for
// new `.req` files and emits `.ack` files in `locks/_flush_responses/` after
// flushing. Inspectors call `requestFlushOverFilesystem` to drop a request
@@ -119,9 +151,16 @@ export class FileSystemStorage extends BaseStorage {
private static readonly FLUSH_REQUEST_DIR = '_flush_requests'
private static readonly FLUSH_RESPONSE_DIR = '_flush_responses'
private static readonly FLUSH_WATCH_INTERVAL_MS = 500
+ /**
+ * The safety sweep behind the fs.watch: catches events an exotic filesystem
+ * dropped, and runs the stale-request GC. See startFlushRequestWatcher.
+ */
+ private static readonly FLUSH_SAFETY_SWEEP_MS = 30_000
private static readonly FLUSH_POLL_INTERVAL_MS = 100
private static readonly FLUSH_REQUEST_TTL_MS = 60_000
private flushWatcherInterval?: NodeJS.Timeout
+ /** The inotify-backed watch on the request directory, when the FS supports one. */
+ private flushWatcher?: import('node:fs').FSWatcher
private flushWatcherInFlight = false
private flushWatcherOnRequest?: () => Promise
@@ -670,6 +709,30 @@ export class FileSystemStorage extends BaseStorage {
return pruned
}
+ /**
+ * @description The IMMEDIATE child directory names under a prefix — ONE
+ * `readdir`, no recursion, no file paths. See the seam's JSDoc
+ * (`src/db/types.ts`) for what this replaced: discovering the generations on
+ * disk walked the entire generation log on every open, reading out every
+ * file in every generation, to learn the set of integers the top-level
+ * directory names already spell.
+ * @param prefix - Storage-root-relative directory prefix.
+ * @returns The child directory names (not paths); empty when the prefix does
+ * not exist.
+ */
+ public override async listRawPrefixes(prefix: string): Promise {
+ await this.ensureInitialized()
+ const fullPath = path.join(this.rootDir, prefix)
+ try {
+ const entries = await fs.promises.readdir(fullPath, { withFileTypes: true })
+ return entries.filter((e: { isDirectory: () => boolean }) => e.isDirectory())
+ .map((e: { name: string }) => e.name)
+ } catch (error: any) {
+ if (error?.code === 'ENOENT') return []
+ throw error
+ }
+ }
+
/**
* Primitive operation: List objects under path prefix
* All metadata operations use this internally via base class routing
@@ -1880,18 +1943,41 @@ export class FileSystemStorage extends BaseStorage {
}
}
+ // THE CLEAN-CLOSE RECORD IS READ BEFORE ANY VERDICT (see
+ // WriterCloseRecord). A lock file whose release was RECORDED is
+ // bookkeeping left by an orderly shutdown, not evidence of anything —
+ // and that is true whether the previous holder was another process or
+ // an earlier instance in THIS one. A production restart reported
+ // "Re-acquiring writer lock ... this is a bug" immediately after a clean
+ // close, sending an operator hunting for a leak that did not exist.
+ const closeRecord = existing ? await this.readWriterCloseRecord() : null
+ const releasedCleanly =
+ existing !== null &&
+ closeRecord !== null &&
+ this.closeRecordVouchesFor(closeRecord, existing)
+
if (existing) {
// Same-process re-open: a second Brainy instance in this Node process
// (e.g. test "simulate server restart" patterns, or a consumer that
// explicitly re-instantiates without closing first). This isn't the
// dangerous cross-process case the lock exists to prevent — the two
// instances share a memory space and can't silently diverge from each
- // other beyond what their callers already see. Warn and take over.
+ // other beyond what their callers already see. Warn and take over —
+ // unless the record proves the previous instance already let go, in
+ // which case there is nothing to warn about.
if (existing.pid === myPid && existing.hostname === hostname && !options?.force) {
- console.warn(
- `[brainy] Re-acquiring writer lock for ${this.rootDir} held by the same process (PID ${existing.pid}). ` +
- `If you intended to keep the previous Brainy instance alive, this is a bug — close it first.`
- )
+ if (releasedCleanly) {
+ console.warn(
+ `[brainy] Clearing the leftover writer lock for ${this.rootDir} — an earlier ` +
+ `instance in this process (PID ${existing.pid}) RELEASED it cleanly at ` +
+ `${closeRecord!.closedAt} but could not remove the file. Nothing to recover.`
+ )
+ } else {
+ console.warn(
+ `[brainy] Re-acquiring writer lock for ${this.rootDir} held by the same process (PID ${existing.pid}). ` +
+ `If you intended to keep the previous Brainy instance alive, this is a bug — close it first.`
+ )
+ }
const info: WriterLockInfo = {
pid: myPid,
hostname,
@@ -1901,11 +1987,18 @@ export class FileSystemStorage extends BaseStorage {
rootDir: this.rootDir
}
await this.writeFileAtomic(lockFile, JSON.stringify(info, null, 2))
+ await this.clearWriterCloseRecord()
this.installWriterLock(info)
return info
}
- const stale = !options?.force && (await this.isWriterLockStale(existing))
+ // A cleanly-released lock is stale by RECORD, not by inference. Only
+ // when no record vouches for this lock do we fall back to pid
+ // liveness, and then we say THAT honestly too: an unrecorded lock
+ // means the writer did not complete its close, so the store was not
+ // closed cleanly and this open pays recovery.
+ const stale =
+ releasedCleanly || (!options?.force && (await this.isWriterLockStale(existing)))
if (!options?.force && !stale) {
// Consumer-facing error contract: callers detect this case via
// err.code and read the holder's details from err.lockInfo.
@@ -1916,8 +2009,16 @@ export class FileSystemStorage extends BaseStorage {
options?.force
? `[brainy] Force-overwriting writer lock for ${this.rootDir} ` +
`(was held by PID ${existing.pid} on ${existing.hostname}).`
- : `[brainy] Overwriting stale writer lock for ${this.rootDir} ` +
- `(PID ${existing.pid} on ${existing.hostname} appears dead).`
+ : releasedCleanly
+ ? `[brainy] Clearing the leftover writer lock for ${this.rootDir} — ` +
+ `PID ${existing.pid} on ${existing.hostname} RELEASED it cleanly at ` +
+ `${closeRecord!.closedAt} but could not remove the file. ` +
+ `Nothing to recover.`
+ : `[brainy] Overwriting stale writer lock for ${this.rootDir} ` +
+ `(PID ${existing.pid} on ${existing.hostname} is gone and left NO ` +
+ `clean-close record — that writer did not finish closing, so this ` +
+ `store was not closed cleanly; open will run crash recovery and ` +
+ `report its wall).`
)
// Takeover: verify the file still holds the lock we judged (a live
// successor may have claimed meanwhile), then remove it and fall
@@ -1971,6 +2072,12 @@ export class FileSystemStorage extends BaseStorage {
await fs.promises.unlink(claimTmp).catch(() => {})
}
+ // CONSUME the previous writer's clean-close record. It described the
+ // lock generation that just ended; leaving it in place would let it
+ // vouch for OUR lock if this process later dies without closing —
+ // turning a real crash into a "closed cleanly" verdict. One unlink.
+ await this.clearWriterCloseRecord()
+
this.installWriterLock(info)
return info
}
@@ -2094,13 +2201,27 @@ export class FileSystemStorage extends BaseStorage {
return
}
const lockFile = path.join(this.lockDir, FileSystemStorage.WRITER_LOCK_FILE)
+ const released = this.writerLockInfo
try {
// Only delete if we still own it — avoid clobbering a successor that
// claimed the lock via force-override.
const current = await this.readWriterLock()
- if (current && current.pid === this.writerLockInfo.pid && current.hostname === this.writerLockInfo.hostname) {
+ const ours =
+ current === null ||
+ (current.pid === released.pid && current.hostname === released.hostname)
+ if (current && ours) {
await fs.promises.unlink(lockFile)
}
+ // THE CLEAN-CLOSE RECORD (see WriterCloseRecord). Written whenever this
+ // instance gives up a lock nobody else has taken — the unlink above
+ // having succeeded OR the file already being gone. The next open reads
+ // it instead of guessing from pid liveness: a recorded release is an
+ // orderly shutdown, an absent record is a writer that never finished
+ // closing. Not written when a successor holds the lock: our release is
+ // then a no-op and a record would slander their live lock.
+ if (ours) {
+ await this.writeWriterCloseRecord(released)
+ }
} catch (err: any) {
if (err.code !== 'ENOENT') {
console.warn('[brainy] Failed to release writer lock file:', err)
@@ -2110,6 +2231,97 @@ export class FileSystemStorage extends BaseStorage {
}
}
+ /**
+ * @description Read the clean-close record at `locks/_writer.close`, or
+ * `null` when it is absent or unparseable. A torn record is treated as
+ * absent — the conservative direction, since an unreadable record can
+ * vouch for nothing.
+ * @returns The record, or null.
+ */
+ public async readWriterCloseRecord(): Promise {
+ await this.ensureInitialized()
+ const recordFile = path.join(this.lockDir, FileSystemStorage.WRITER_CLOSE_FILE)
+ try {
+ const raw = await fs.promises.readFile(recordFile, 'utf-8')
+ const parsed = JSON.parse(raw) as WriterCloseRecord
+ if (
+ typeof parsed?.pid !== 'number' ||
+ typeof parsed?.hostname !== 'string' ||
+ typeof parsed?.startedAt !== 'string' ||
+ typeof parsed?.closedAt !== 'string'
+ ) {
+ return null
+ }
+ return parsed
+ } catch (err: any) {
+ if (err.code === 'ENOENT') return null
+ return null
+ }
+ }
+
+ /**
+ * @description Whether a clean-close record describes the very lock
+ * generation `lock` represents. The match is pid + hostname + `startedAt`:
+ * `startedAt` is the lock generation's identity, so a record can never
+ * vouch for a LATER lock taken by the same pid on the same host (the
+ * same-process re-open path mints a fresh `startedAt`).
+ * @param record - The clean-close record read from disk.
+ * @param lock - The lock file's contents.
+ */
+ private closeRecordVouchesFor(record: WriterCloseRecord, lock: WriterLockInfo): boolean {
+ return (
+ record.pid === lock.pid &&
+ record.hostname === lock.hostname &&
+ record.startedAt === lock.startedAt
+ )
+ }
+
+ /**
+ * @description Write the clean-close record for a lock this instance just
+ * released. Atomic (temp + rename) so a concurrent opener never reads half
+ * a record. A failure here costs the next open nothing but the honest
+ * fallback (pid liveness), so it warns rather than failing the close.
+ * @param released - The lock info this instance held.
+ */
+ private async writeWriterCloseRecord(released: WriterLockInfo): Promise {
+ const record: WriterCloseRecord = {
+ pid: released.pid,
+ hostname: released.hostname,
+ startedAt: released.startedAt,
+ closedAt: new Date().toISOString(),
+ version: released.version
+ }
+ const recordFile = path.join(this.lockDir, FileSystemStorage.WRITER_CLOSE_FILE)
+ try {
+ await this.writeFileAtomic(recordFile, JSON.stringify(record, null, 2))
+ } catch (err) {
+ // ENOENT = the lock directory is gone, i.e. the whole store was removed
+ // under us. There is no next open to inform.
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return
+ console.warn(
+ `[brainy] Failed to write the writer clean-close record for ${this.rootDir} — ` +
+ `the next open will fall back to pid liveness and may report this orderly ` +
+ `shutdown as a crash:`,
+ err
+ )
+ }
+ }
+
+ /**
+ * @description Remove the clean-close record. Called by every successful
+ * lock claim so a record never outlives the lock generation it describes.
+ */
+ private async clearWriterCloseRecord(): Promise {
+ const recordFile = path.join(this.lockDir, FileSystemStorage.WRITER_CLOSE_FILE)
+ try {
+ await fs.promises.unlink(recordFile)
+ } catch (err: any) {
+ if (err.code !== 'ENOENT') {
+ console.warn('[brainy] Failed to clear the writer clean-close record:', err)
+ }
+ }
+ }
+
public override async readWriterLock(): Promise {
await this.ensureInitialized()
const lockFile = path.join(this.lockDir, FileSystemStorage.WRITER_LOCK_FILE)
@@ -2196,36 +2408,115 @@ export class FileSystemStorage extends BaseStorage {
/**
* Start watching for cross-process flush requests. Called by Brainy.init()
- * in writer mode. Polls `locks/_flush_requests/` every
- * FLUSH_WATCH_INTERVAL_MS — each new `.req` file triggers the supplied
- * callback (`brain.flush()`), after which an `.ack` is written to
- * `locks/_flush_responses/` with the same request ID. Stale `.req` files
- * (>FLUSH_REQUEST_TTL_MS) are garbage-collected on every tick.
+ * in writer mode. Each new `.req` file in `locks/_flush_requests/` triggers
+ * the supplied callback (`brain.flush()`), after which an `.ack` is written
+ * to `locks/_flush_responses/` with the same request ID. Stale `.req` files
+ * (>FLUSH_REQUEST_TTL_MS) are garbage-collected on each sweep.
+ *
+ * THE WATCH IS EVENT-DRIVEN, NOT A POLL. It used to `readdir` the request
+ * directory every 500 ms, per brain, for the entire life of every writer —
+ * armed on every non-reader brain whether or not any inspector process
+ * existed. MEASURED on a production process holding 21 brains: 42 directory
+ * reads per second on a completely idle service, plus a stale-request GC
+ * pass on every one of them. The engine does no periodic work without a
+ * cause, and a request that has not been made is not a cause.
+ *
+ * `fs.watch` (inotify on Linux) delivers the arrival itself, so a request is
+ * seen SOONER than the old poll saw it. Two honest concessions ride with it:
+ * - a slow SAFETY SWEEP (FLUSH_SAFETY_SWEEP_MS) still runs, because
+ * `fs.watch` can miss events on network and fuse filesystems and because
+ * the stale-request GC needs some tick of its own. At 30s that is 0.7
+ * reads/s across 21 brains where the poll cost 42.
+ * - a filesystem that cannot watch at all falls back to the ORIGINAL
+ * 500 ms poll, narrated once, because correctness outranks idle cost:
+ * an inspector whose request is never seen waits forever.
*/
public override startFlushRequestWatcher(onRequest: () => Promise): void {
- if (this.flushWatcherInterval) return // already watching
+ // Already watching — or already ARMING. The arm is asynchronous (the
+ // request directory is created before it can be watched), so neither the
+ // watcher nor the interval exists yet during that window; the callback is
+ // the flag that covers it. Without this a second call in the window would
+ // leave two watchers and two sweeps running for the life of the store.
+ if (this.flushWatcherInterval || this.flushWatcher || this.flushWatcherOnRequest) return
this.flushWatcherOnRequest = onRequest
const reqDir = path.join(this.lockDir, FileSystemStorage.FLUSH_REQUEST_DIR)
const ackDir = path.join(this.lockDir, FileSystemStorage.FLUSH_RESPONSE_DIR)
- // Ensure both dirs exist up front so the first .req drop doesn't race with mkdir.
- this.ensureDirectoryExists(reqDir).catch(() => {})
- this.ensureDirectoryExists(ackDir).catch(() => {})
-
- this.flushWatcherInterval = setInterval(() => {
- if (this.flushWatcherInFlight) return // skip overlapping tick
+ const sweep = (): void => {
+ if (this.flushWatcherInFlight) return // skip overlapping sweep
this.flushWatcherInFlight = true
this.processFlushRequests(reqDir, ackDir).finally(() => {
this.flushWatcherInFlight = false
})
- }, FileSystemStorage.FLUSH_WATCH_INTERVAL_MS)
+ }
+
+ // Ensure both dirs exist up front so the first .req drop doesn't race with
+ // mkdir — and so there is a directory to watch.
+ void this.ensureDirectoryExists(reqDir)
+ .then(() => this.ensureDirectoryExists(ackDir))
+ .then(() => {
+ if (this.flushWatcherOnRequest !== onRequest) return // stopped meanwhile
+ try {
+ const watcher = fs.watch(reqDir, () => sweep())
+ this.flushWatcher = watcher
+ watcher.on('error', (err: Error) => {
+ // A watch that dies mid-life must not leave the door deaf.
+ console.warn(
+ `[brainy] Flush-request watch failed (${err.message}) — falling back to polling.`
+ )
+ this.flushWatcher?.close()
+ this.flushWatcher = undefined
+ // The SAFETY sweep must go first. It is already armed at 30s, and
+ // startFlushRequestPolling() declines to arm over an existing
+ // interval — so leaving it would quietly leave this store answering
+ // flush requests on a 30s cadence instead of the 500ms one the door
+ // promises. A degrade nobody asked for is still a degrade.
+ if (this.flushWatcherInterval) {
+ clearInterval(this.flushWatcherInterval)
+ this.flushWatcherInterval = undefined
+ }
+ this.startFlushRequestPolling(sweep)
+ })
+ if (typeof watcher.unref === 'function') watcher.unref()
+ // The safety sweep: missed events on exotic filesystems, and the
+ // stale-request GC.
+ this.flushWatcherInterval = setInterval(sweep, FileSystemStorage.FLUSH_SAFETY_SWEEP_MS)
+ if (typeof this.flushWatcherInterval.unref === 'function') {
+ this.flushWatcherInterval.unref()
+ }
+ // One sweep now: a request may have been dropped before the watch armed.
+ sweep()
+ } catch (err) {
+ console.warn(
+ `[brainy] Flush-request directory cannot be watched on this filesystem ` +
+ `(${(err as Error).message}) — polling every ` +
+ `${FileSystemStorage.FLUSH_WATCH_INTERVAL_MS}ms instead.`
+ )
+ this.startFlushRequestPolling(sweep)
+ }
+ })
+ .catch(() => {
+ // The request directory could not be created; nothing to watch. A
+ // cross-process flush request cannot be made either, so there is
+ // nothing to miss.
+ })
+ }
+
+ /** The original 500 ms poll — the fallback when a directory cannot be watched. */
+ private startFlushRequestPolling(sweep: () => void): void {
+ if (this.flushWatcherInterval) return
+ this.flushWatcherInterval = setInterval(sweep, FileSystemStorage.FLUSH_WATCH_INTERVAL_MS)
if (typeof this.flushWatcherInterval.unref === 'function') {
this.flushWatcherInterval.unref()
}
}
public override stopFlushRequestWatcher(): void {
+ if (this.flushWatcher) {
+ this.flushWatcher.close()
+ this.flushWatcher = undefined
+ }
if (this.flushWatcherInterval) {
clearInterval(this.flushWatcherInterval)
this.flushWatcherInterval = undefined
@@ -2622,25 +2913,29 @@ export class FileSystemStorage extends BaseStorage {
this.allCountsDerivedBy = undefined
this.allCountsSuspect = true
needsPersist = true
- prodLog.warn(
+ prodLog.narrate(
'[FileSystemStorage] canonical count ledger was derived under the legacy ' +
- 'container rule — marked suspect; a sanctioned recount (repairIndex) restores ' +
- 'exact denominators'
+ 'container rule — it counts one entity per id DIRECTORY, so every ghost/scar ' +
+ 'container inflates it. Marked suspect, and an honest recount is scheduled to ' +
+ 'run in the background after this open; until it lands, do not subtract ' +
+ 'against these ALL scalars.'
)
+ // A suspect ledger used to stay wrong for the life of the store,
+ // waiting for an operator to run repairIndex. A downstream index
+ // heal took its "remaining" figure from these inflated
+ // denominators and reported work that did not exist. The ledger
+ // now HEALS ITSELF — in the background, because a denominator is
+ // a derived scalar and no read is ever served from it.
+ this.scheduleCountLedgerDerivation('legacy container-rule ledger')
}
} else {
- const nouns = await this.scanCanonicalEntities('nouns')
- const verbs = await this.scanCanonicalEntities('verbs')
- this.totalNounCountAll = nouns.count
- this.totalVerbCountAll = verbs.count
- this.allCountsSuspect = false
- this.allCountsDerivedBy = 'identity-record'
- console.warn(
- `[FileSystemStorage] counts.json predates the ALL-visibility count ledger — ` +
- `derived once from the canonical id tree (${nouns.count} nouns, ${verbs.count} verbs, ` +
- `every tier) and persisted; no further scan.`
- )
- needsPersist = true
+ // No ALL scalars at all. There is nothing to serve in the meantime —
+ // a zero would read as an empty store — so the scalars stay unknown
+ // and SUSPECT until the background derivation lands. The open does
+ // not wait for it: an id-tree walk is O(ids) and this file has been
+ // the whole reason a 24k-id store opened in silence.
+ this.allCountsSuspect = true
+ this.scheduleCountLedgerDerivation('counts.json predates the ALL-visibility ledger')
}
// The vectored-noun scalar (shipped after the ALL scalars above — a
@@ -2653,14 +2948,12 @@ export class FileSystemStorage extends BaseStorage {
if (typeof counts.totalVectoredNounCount === 'number') {
this.totalVectoredNounCount = counts.totalVectoredNounCount
} else {
- const vectored = await this.scanVectoredNounCount()
- this.totalVectoredNounCount = vectored
- console.warn(
- `[FileSystemStorage] counts.json predates the vectored-noun count ledger — ` +
- `derived once by reading every noun's vectors.json (${vectored} vectored) and ` +
- `persisted; no further scan.`
- )
- needsPersist = true
+ // O(nouns) CONTENT reads — the most expensive derivation of the
+ // three, and the one most likely to have been the silent minutes at
+ // the front of a large store's open. Background, suspect until it
+ // lands, same as the ALL scalars.
+ this.allCountsSuspect = true
+ this.scheduleCountLedgerDerivation('counts.json predates the vectored-noun ledger')
}
if (needsPersist) {
await this.persistCounts()
@@ -2689,6 +2982,22 @@ export class FileSystemStorage extends BaseStorage {
* Initialize counts by scanning disk (only done once)
*/
private async initializeCountsFromDisk(): Promise {
+ const startedAt = Date.now()
+ // THIS ONE CANNOT LEAVE THE FOREGROUND, and the reason is worth stating:
+ // it derives `totalNounCount` / `totalVerbCount`, the scalars
+ // `getNounCount()` and `getVerbCount()` RETURN. Backgrounding it would
+ // make a populated store answer "0 entities" until the walk landed — a
+ // wrong answer, not a slow one, and the serving law grades a failure by
+ // whether an answer could be wrong. The ALL-visibility denominators, which
+ // no read is served from, DO run in the background (see
+ // scheduleCountLedgerDerivation). What this walk owes the operator instead
+ // is narration: it announces itself, and reports its wall.
+ prodLog.narrate(
+ `[FileSystemStorage] no usable counts.json — deriving the entity counters from ` +
+ `the canonical id tree now. This is O(ids) listings plus one vectors.json read ` +
+ `per noun, and it BLOCKS the open because getNounCount()/getVerbCount() are ` +
+ `served from it. It runs once; the result is persisted.`
+ )
try {
// Count the CANONICAL 8.0 layout (`entities////…`) —
// the tree saveNoun/getNouns actually read and write. The previous scan
@@ -2736,6 +3045,11 @@ export class FileSystemStorage extends BaseStorage {
}
await this.persistCounts()
+ prodLog.narrate(
+ `[FileSystemStorage] counter derivation from the canonical id tree finished in ` +
+ `${Date.now() - startedAt}ms: ${this.totalNounCount} nouns, ${this.totalVerbCount} verbs, ` +
+ `${this.totalVectoredNounCount} vectored nouns — persisted, stamped identity-record.`
+ )
} catch (error) {
console.error('Error initializing counts from disk:', error)
}
@@ -2757,6 +3071,118 @@ export class FileSystemStorage extends BaseStorage {
* directories (absolute paths) — nouns feed the type-distribution estimate
* above. An absent tree (fresh store) counts zero.
*/
+ /**
+ * @description Derive the ALL-visibility count ledger honestly — one entity
+ * per IDENTITY RECORD, never per id directory — IN THE BACKGROUND, once,
+ * and persist the result stamped `identity-record`.
+ *
+ * Why background: these scalars are DENOMINATORS. No read is served from
+ * them, so deriving them cannot be allowed to hold an open hostage — a
+ * store with 24,898 ids spent minutes of a production restart inside walks
+ * exactly like these, in silence, before serving anything. Why at all: a
+ * ledger derived under the old container rule stayed wrong for the life of
+ * the store, and a downstream index heal subtracted against it and reported
+ * remaining work that did not exist (measured on a real store: 14,231
+ * derived against 14,056 identity records — precisely the store's 25 noun
+ * scar directories; verbs 72,729 against 72,679, its 50 verb scars).
+ *
+ * Idempotent: a second call while one is in flight joins the first.
+ * @param reason - What made the ledger untrustworthy, quoted in narration.
+ * @returns Nothing; observe completion with {@link whenCountLedgerSettled}.
+ */
+ private scheduleCountLedgerDerivation(reason: string): void {
+ if (this.countLedgerDerivation) return
+ this.countLedgerDerivation = (async () => {
+ const startedAt = Date.now()
+ prodLog.narrate(
+ `[FileSystemStorage] count-ledger derivation started in the background ` +
+ `(${reason}) — counting identity records, not id directories; the open does ` +
+ `not wait for it and no read is served from these scalars.`
+ )
+ try {
+ const beforeNouns = this.totalNounCountAll
+ const beforeVerbs = this.totalVerbCountAll
+ const beforeVectored = this.totalVectoredNounCount
+ // A walk that RACED A WRITE cannot prove its number: a row that landed
+ // mid-walk may or may not have been in the shard the walk had already
+ // passed. Rather than persist a figure that might be off by one and
+ // stamp it "exact", the walk is repeated once on a quiet store, and if
+ // the store is never quiet the ledger stays SUSPECT and says so. One
+ // retry, never a spin.
+ let attempt = 0
+ let derived: { nouns: number; verbs: number; vectored: number } | null = null
+ while (attempt < 2 && derived === null) {
+ attempt++
+ const activityBefore = this.ledgerActivityStamp()
+ const nouns = await this.scanCanonicalEntities('nouns')
+ const verbs = await this.scanCanonicalEntities('verbs')
+ const vectored = await this.scanVectoredNounCount()
+ if (this.ledgerActivityStamp() === activityBefore) {
+ derived = { nouns: nouns.count, verbs: verbs.count, vectored }
+ }
+ }
+ if (derived === null) {
+ this.allCountsSuspect = true
+ prodLog.narrate(
+ `[FileSystemStorage] count-ledger derivation could not finish on a quiet store ` +
+ `after ${attempt} attempts (${Date.now() - startedAt}ms) — writes landed during ` +
+ `every walk. The ALL-visibility scalars stay SUSPECT and must not be subtracted ` +
+ `against; brain.repairIndex() derives them under a recount barrier.`
+ )
+ return
+ }
+ this.totalNounCountAll = derived.nouns
+ this.totalVerbCountAll = derived.verbs
+ this.totalVectoredNounCount = derived.vectored
+ this.allCountsDerivedBy = 'identity-record'
+ this.allCountsSuspect = false
+ await this.persistCounts()
+ prodLog.narrate(
+ `[FileSystemStorage] count-ledger derivation finished in ${Date.now() - startedAt}ms: ` +
+ `${derived.nouns} nouns / ${derived.verbs} verbs / ${derived.vectored} vectored nouns` +
+ (beforeNouns !== derived.nouns ||
+ beforeVerbs !== derived.verbs ||
+ beforeVectored !== derived.vectored
+ ? ` (corrected from ${beforeNouns} / ${beforeVerbs} / ${beforeVectored} — the ` +
+ `difference is ghost and scar containers the old rule counted as entities)`
+ : ' (unchanged)') +
+ ` — persisted, stamped identity-record, no longer suspect.`
+ )
+ } catch (error) {
+ // The ledger stays suspect and the next open retries. Loud: a
+ // denominator nobody can derive is a fact an operator must have.
+ this.allCountsSuspect = true
+ prodLog.error(
+ `[FileSystemStorage] count-ledger derivation FAILED after ` +
+ `${Date.now() - startedAt}ms — the ALL-visibility scalars remain SUSPECT ` +
+ `and must not be subtracted against; the next open retries:`,
+ error
+ )
+ }
+ })()
+ }
+
+ /**
+ * @description A cheap witness that the ledger changed while a walk was
+ * running. Every landed write moves one of these live counters, so an
+ * unchanged stamp across a walk means no write landed during it.
+ * @returns A value that differs whenever the live ALL scalars have moved.
+ */
+ private ledgerActivityStamp(): string {
+ return `${this.totalNounCountAll}:${this.totalVerbCountAll}:${this.totalVectoredNounCount}`
+ }
+
+ /**
+ * @description Resolve once any background count-ledger derivation has
+ * settled (succeeded or failed). Resolves immediately when none was needed.
+ * Exists so tests and operators can observe the ledger's honest value rather
+ * than race it; nothing in the read path waits on this.
+ * @returns A promise that settles with the derivation.
+ */
+ public async whenCountLedgerSettled(): Promise {
+ await this.countLedgerDerivation
+ }
+
private async scanCanonicalEntities(
kind: 'nouns' | 'verbs'
): Promise<{ count: number; sampleDirs: string[] }> {
@@ -2841,14 +3267,20 @@ export class FileSystemStorage extends BaseStorage {
}
/**
- * Count canonical nouns holding a REAL (non-empty) vector — the vectored-
- * noun ledger scalar. UNLIKE {@link scanCanonicalEntities}, presence
- * cannot be decided from the id-directory listing alone: a deferred-embed
- * noun's `vectors.json` EXISTS (written at `add()` time with `vector: []`)
- * until its embed LANDS, so this walk reads every noun's `vectors.json`
- * CONTENT — O(nouns) reads, not O(ids) listing. Used ONLY for a one-time
- * legacy-counts.json derivation or a lost/corrupted counts.json recovery;
- * the result is persisted so this scan never repeats.
+ * Count canonical nouns holding a REAL (non-empty, non-zero-norm) vector —
+ * the vectored-noun ledger scalar. UNLIKE {@link scanCanonicalEntities},
+ * presence cannot be decided from the id-directory listing alone: a
+ * deferred-embed noun's `vectors.json` EXISTS (written at `add()` time
+ * with `vector: []`) until its embed LANDS, so this walk reads every
+ * noun's `vectors.json` CONTENT — O(nouns) reads, not O(ids) listing.
+ * ZERO-NORM LAW: a real all-zero vector is not a vector — it never counts
+ * here either (Brainy's write paths normalize an explicit zero-norm
+ * vector to `[]` at write time, but a store created before that fix may
+ * still carry legacy all-zero rows on disk; this derivation must agree
+ * with the live ledger's definition of "vectored" regardless of when the
+ * row was written). Used ONLY for a one-time legacy-counts.json derivation
+ * or a lost/corrupted counts.json recovery; the result is persisted so
+ * this scan never repeats.
*/
private async scanVectoredNounCount(): Promise {
const base = path.join(this.rootDir, 'entities', 'nouns')
@@ -2862,7 +3294,12 @@ export class FileSystemStorage extends BaseStorage {
for (const entry of ids) {
if (!entry.isDirectory()) continue
const record = await this.readEntityVectorRaw(path.join(shardPath, entry.name))
- if (record && Array.isArray(record.vector) && record.vector.length > 0) {
+ if (
+ record &&
+ Array.isArray(record.vector) &&
+ record.vector.length > 0 &&
+ !isZeroNormVector(record.vector)
+ ) {
vectored++
}
}
@@ -2904,10 +3341,15 @@ export class FileSystemStorage extends BaseStorage {
lastUpdated: new Date().toISOString()
}
- await fs.promises.writeFile(
- this.countsFilePath,
- JSON.stringify(counts, null, 2)
- )
+ // ATOMIC (temp + rename), never a plain writeFile. A direct write
+ // truncates the file first, so every persist opened a window — measured
+ // at roughly 750ms after a flush or close on a real store — in which a
+ // concurrent reader saw counts.json EMPTY. An empty file is unparseable,
+ // and an unparseable ledger sends the next open down the full-rescan
+ // path: the cheapest file in the store was costing the most expensive
+ // recovery. The rename is atomic, so a reader sees the old ledger or the
+ // new one, never neither.
+ await this.writeFileAtomic(this.countsFilePath, JSON.stringify(counts, null, 2))
} catch (error) {
console.error('Error persisting counts:', error)
}
diff --git a/src/storage/baseStorage.ts b/src/storage/baseStorage.ts
index 5510f93b..d8bcb780 100644
--- a/src/storage/baseStorage.ts
+++ b/src/storage/baseStorage.ts
@@ -125,6 +125,36 @@ export interface WriterLockInfo {
rootDir?: string // Convenience for log lines / error messages
}
+/**
+ * THE CLEAN-CLOSE RECORD. Written by `releaseWriterLock()` at the instant it
+ * gives up the writer lock, naming the lock identity it released. The next
+ * `acquireWriterLock()` reads it and can then say — from a RECORD, not from a
+ * guess — whether the previous writer left on purpose.
+ *
+ * Why a record and not PID liveness: "the recorded PID is no longer alive" is
+ * true of every orderly restart AND of every crash, so the two were reported
+ * identically ("appears dead") and neither could be trusted. Worse, the same
+ * inference fails the other way when the operating system RECYCLES the pid —
+ * a live unrelated process makes a long-dead writer's lock look held, and the
+ * store refuses to open naming a pid that was never Brainy. A record settles
+ * both: matched → the previous writer closed cleanly, nothing to recover;
+ * absent → say so, and name what recovery the open will now run.
+ *
+ * Lifecycle: written at release, consumed (deleted) by the next successful
+ * lock claim — a record must never outlive the lock generation it describes,
+ * or it would vouch for a later crash.
+ */
+export interface WriterCloseRecord {
+ pid: number
+ hostname: string
+ /** `startedAt` of the lock this close released — the identity match key. */
+ startedAt: string
+ /** ISO timestamp at which the lock was released. */
+ closedAt: string
+ /** Brainy version that performed the close. */
+ version: string
+}
+
/**
* FNV-1a hash returning a 2-char hex bucket (00-ff).
* Distributes system keys across 256 sub-prefixes to avoid
@@ -1407,6 +1437,29 @@ export abstract class BaseStorage extends BaseStorageAdapter {
return this.listObjectsUnderPath(prefix)
}
+ /**
+ * @description The IMMEDIATE child directory names under a prefix — one
+ * level, no recursion. See the seam's JSDoc (`db/types.ts`) for why a
+ * separate door exists. This default derives them from the recursive
+ * listing, so it is never WRONG, only never faster; the filesystem adapter
+ * overrides it with a single directory read.
+ * @param prefix - Storage-root-relative directory prefix.
+ * @returns The child directory names (not paths), in listing order.
+ */
+ public async listRawPrefixes(prefix: string): Promise {
+ await this.ensureInitialized()
+ const paths = await this.listObjectsUnderPath(prefix)
+ const normalizedPrefix = prefix.endsWith('/') ? prefix : `${prefix}/`
+ const names = new Set()
+ for (const p of paths) {
+ const rest = p.startsWith(normalizedPrefix) ? p.slice(normalizedPrefix.length) : null
+ if (rest === null) continue
+ const slash = rest.search(/[/\\]/)
+ if (slash > 0) names.add(rest.slice(0, slash))
+ }
+ return [...names]
+ }
+
/**
* Remove every object under a storage-root-relative prefix. The filesystem
* adapter overrides this with a recursive directory removal; this default
diff --git a/src/transaction/operations/IndexOperations.ts b/src/transaction/operations/IndexOperations.ts
index cfee5074..1bbbca88 100644
--- a/src/transaction/operations/IndexOperations.ts
+++ b/src/transaction/operations/IndexOperations.ts
@@ -324,8 +324,17 @@ export class ReplaceInVectorIndexOperation implements Operation {
return async () => {
// Restore the declared before-state in place (see class JSDoc for
- // the item-did-not-exist posture).
- await index.updateItem!({ id: this.id, vector: this.oldVector }, generation)
+ // the item-did-not-exist posture). A length-0 oldVector means the row
+ // was never actually indexed before this op ran (a length-0 vector is
+ // never a legal index member — see EmptyVectorIndexError) — there is
+ // no in-place "restore to empty" for the provider to perform, so
+ // rollback removes the row instead, leaving the same "not indexed"
+ // state the row was in before execute().
+ if (this.oldVector.length > 0) {
+ await index.updateItem!({ id: this.id, vector: this.oldVector }, generation)
+ } else {
+ await this.index.removeItem(this.id, generation)
+ }
}
}
@@ -336,9 +345,14 @@ export class ReplaceInVectorIndexOperation implements Operation {
return async () => {
// updateItem-style restore via the same adjacent pair, back to the
- // declared before-state.
+ // declared before-state. Same length-0 carve-out as the updateItem
+ // path above: an empty oldVector was never a legal index member, so
+ // rollback just leaves the row removed rather than attempting an
+ // illegal empty re-add.
await this.index.removeItem(this.id, generation)
- await this.index.addItem({ id: this.id, vector: this.oldVector }, generation)
+ if (this.oldVector.length > 0) {
+ await this.index.addItem({ id: this.id, vector: this.oldVector }, generation)
+ }
}
}
}
diff --git a/src/types/brainy.types.ts b/src/types/brainy.types.ts
index 63356828..a0d55c1e 100644
--- a/src/types/brainy.types.ts
+++ b/src/types/brainy.types.ts
@@ -215,7 +215,7 @@ export interface ScoreExplanation {
*
* @example
* ```ts
- * declare module '@soulcraft/brainy' {
+ * declare module '@soulcraftlabs/brainy' {
* interface SubtypeRegistry {
* // For NounType.Person, subtype 'employee':
* 'person:employee': { employeeId: string; department: string }
@@ -1217,6 +1217,13 @@ export interface RepairFamilyReport {
skipped?: string
/** Why the outcome is what it is when neither `detail` nor `skipped` says it. */
reason?: string
+ /**
+ * The phase's own wall, in milliseconds. A repair on a production store ran
+ * for over thirty minutes without a single line of output; an operator had
+ * to read `top` to know it was alive. A receipt that cannot say WHERE the
+ * time went is not a receipt — every row carries its own.
+ */
+ durationMs?: number
}
/** The full receipt returned by repairIndex(). */
diff --git a/src/types/reservedFields.ts b/src/types/reservedFields.ts
index 15b585c5..ce2108f8 100644
--- a/src/types/reservedFields.ts
+++ b/src/types/reservedFields.ts
@@ -65,7 +65,7 @@
* | `_rev` | system-managed revision counter — pass `ifRev` to `update()` for CAS |
*
* @example
- * import { RESERVED_ENTITY_FIELDS } from '@soulcraft/brainy'
+ * import { RESERVED_ENTITY_FIELDS } from '@soulcraftlabs/brainy'
* const isReserved = (key: string) =>
* (RESERVED_ENTITY_FIELDS as readonly string[]).includes(key)
*/
diff --git a/src/utils/brainyTypes.ts b/src/utils/brainyTypes.ts
index 7db469bb..a008a5e6 100644
--- a/src/utils/brainyTypes.ts
+++ b/src/utils/brainyTypes.ts
@@ -6,7 +6,7 @@
*
* @example
* ```typescript
- * import { BrainyTypes } from '@soulcraft/brainy'
+ * import { BrainyTypes } from '@soulcraftlabs/brainy'
*
* // Get all available types
* const nounTypes = BrainyTypes.nouns // ['Person', 'Organization', ...]
diff --git a/src/utils/indexReadiness.ts b/src/utils/indexReadiness.ts
index 498f2003..f1b52e3b 100644
--- a/src/utils/indexReadiness.ts
+++ b/src/utils/indexReadiness.ts
@@ -153,3 +153,83 @@ export function assessProviderHealth(provider: unknown): ProviderHealthAssessmen
reasons: readiness === 'not-ready' ? ['isReady() returned false'] : []
}
}
+
+/**
+ * @description A provider's self-report that it is REBUILDING ITS OWN index
+ * right now. Returned by the optional `rebuildInProgress()` hook.
+ *
+ * The distinction this exists to make: a provider reporting `serving: false`
+ * because it is BROKEN and a provider reporting `serving: false` because it is
+ * BUSY BUILDING ITSELF look identical through `healthReport()` alone, and
+ * brainy treated both the same way — it called `rebuild()` and waited for it,
+ * on the foreground of `init()`. A production store whose metadata provider
+ * had to rebuild paid 641 SECONDS of that wait before `init()` returned, with
+ * every other family idle behind it.
+ *
+ * A provider that reports progress here owns its own rebuild: brainy neither
+ * starts one nor waits for it, `init()` returns, the other families serve, and
+ * THAT family's doors refuse by name — carrying this progress — until the
+ * provider reports itself serving.
+ *
+ * Every field but `phase` is optional and every field is a MEASUREMENT: a
+ * provider reports only what it actually tracks, never an estimate dressed as
+ * a fact.
+ */
+export interface ProviderRebuildProgress {
+ /** The provider's own name for what it is doing. Quoted verbatim in refusals. */
+ phase: string
+ /** Units completed so far, if the provider counts them. */
+ done?: number
+ /** Units expected in total, if the provider knows it. */
+ total?: number
+ /** Epoch millis when this rebuild started, if the provider tracks it. */
+ startedAt?: number
+}
+
+/** A provider that can report a rebuild it is running itself. */
+interface MaybeRebuildingProvider {
+ rebuildInProgress?: () => ProviderRebuildProgress | null
+}
+
+/**
+ * @description Ask a provider whether it is rebuilding itself right now.
+ * Synchronous, O(1), feature-detected: a provider without the hook reports
+ * nothing and is treated exactly as before.
+ * @param provider - Any index provider, or `null`/`undefined`.
+ * @returns The provider's progress, or `null` when it is not rebuilding (or
+ * does not implement the hook).
+ */
+export function assessProviderRebuild(provider: unknown): ProviderRebuildProgress | null {
+ const p = provider as MaybeRebuildingProvider | null | undefined
+ if (p == null || typeof p.rebuildInProgress !== 'function') return null
+ try {
+ const progress = p.rebuildInProgress()
+ if (!progress || typeof progress.phase !== 'string' || progress.phase.length === 0) {
+ return null
+ }
+ return progress
+ } catch {
+ // A throwing hook says nothing trustworthy about a rebuild; fall through to
+ // the ordinary health verdict rather than inventing one.
+ return null
+ }
+}
+
+/**
+ * @description Render a rebuild progress report as one operator-facing clause,
+ * for a refusal message. Includes only what the provider actually measured.
+ * @param progress - The provider's report.
+ * @returns A clause such as `rebuilding ("metadata shadow build", 4,096/14,056, 12s elapsed)`.
+ */
+export function describeRebuildProgress(progress: ProviderRebuildProgress): string {
+ const parts: string[] = [`"${progress.phase}"`]
+ if (typeof progress.done === 'number' && typeof progress.total === 'number') {
+ parts.push(`${progress.done.toLocaleString()}/${progress.total.toLocaleString()}`)
+ } else if (typeof progress.done === 'number') {
+ parts.push(`${progress.done.toLocaleString()} done`)
+ }
+ if (typeof progress.startedAt === 'number') {
+ parts.push(`${Math.round((Date.now() - progress.startedAt) / 1000)}s elapsed`)
+ }
+ return `rebuilding (${parts.join(', ')})`
+}
diff --git a/src/utils/logger.ts b/src/utils/logger.ts
index 5154d4fd..0d6b6594 100644
--- a/src/utils/logger.ts
+++ b/src/utils/logger.ts
@@ -266,6 +266,26 @@ export const prodLog = {
console.error(message, ...args)
},
+ /**
+ * THE NARRATION CHANNEL — always visible, exactly like `error`.
+ *
+ * `warn`/`info`/`log` below are clamped to ERROR in any environment that
+ * looks like production (see isProductionEnvironment), which is the right
+ * default for chatter and the wrong one for the two things an operator is
+ * entitled to hear from a database no matter what: WHY IT IS SLOW and WHAT
+ * IT IS DOING ABOUT IT. A production service opening a 16 GB store spent
+ * three minutes emitting nothing at all — the phase timings that would have
+ * named the slow phase were written to `warn` and thrown away by the log
+ * level. Progress and cost narration goes here; it is never a per-record
+ * line, always a phase, a wall, or a bounded-cadence heartbeat.
+ *
+ * `silent: true` still silences it — that is the consumer's explicit
+ * request, not a cost default.
+ */
+ narrate: (message?: any, ...args: any[]) => {
+ console.warn(message, ...args)
+ },
+
// These are suppressed in production unless BRAINY_LOG_LEVEL is set
warn: (message?: any, ...args: any[]) => smartConsole.warn(message, ...args),
info: (message?: any, ...args: any[]) => smartConsole.info(message, ...args),
diff --git a/src/utils/metadataIndex.ts b/src/utils/metadataIndex.ts
index 3cc56b2e..3e0e3d17 100644
--- a/src/utils/metadataIndex.ts
+++ b/src/utils/metadataIndex.ts
@@ -2241,6 +2241,74 @@ export class MetadataIndexManager implements MetadataIndexProvider {
break
}
+ // ===== ARRAY SET OPERATORS =====
+ // An element-indexed array field makes all three exact on the
+ // index path. They were previously ABSENT from this switch, so
+ // `fieldResults` kept its initial `[]` and the whole find()
+ // returned an empty page — a documented, matcher-implemented
+ // operator answering silently wrong. Served here instead.
+
+ // hasAll: [a, b] — the field's array contains EVERY operand:
+ // the intersection of each element's posting set.
+ case 'hasAll': {
+ if (!Array.isArray(operand)) {
+ fieldResults = []
+ break
+ }
+ if (operand.length === 0) {
+ // Vacuously true of every row that HAS the field.
+ const anyBitmap = (this.columnStore && this.columnStore.hasField(field))
+ ? await this.columnStore.rangeQuery(field)
+ : await this.getExistsBitmapLegacy(field)
+ fieldResults = this.idMapper.intsIterableToUuids(anyBitmap)
+ break
+ }
+ let intersection: Set | null = null
+ for (const item of operand) {
+ const ids = new Set(await this.getIds(field, item))
+ if (intersection === null) {
+ intersection = ids
+ } else {
+ for (const id of [...intersection]) {
+ if (!ids.has(id)) intersection.delete(id)
+ }
+ }
+ if (intersection.size === 0) break
+ }
+ fieldResults = intersection ? [...intersection] : []
+ break
+ }
+
+ // noneOf: [a, b] — the field's value is NONE of the operands:
+ // the complement of their union.
+ case 'noneOf': {
+ if (!Array.isArray(operand)) {
+ fieldResults = []
+ break
+ }
+ const excludeInts: number[] = []
+ for (const value of operand) {
+ for (const uuid of await this.getIds(field, value)) {
+ const intId = this.idMapper.getInt(uuid)
+ if (intId !== undefined) excludeInts.push(intId)
+ }
+ }
+ fieldResults = this.complementIds(excludeInts)
+ break
+ }
+
+ // excludes: value — the field's array does NOT contain the value:
+ // the complement of `contains`.
+ case 'excludes': {
+ const excludeInts: number[] = []
+ for (const uuid of await this.getIds(field, operand)) {
+ const intId = this.idMapper.getInt(uuid)
+ if (intId !== undefined) excludeInts.push(intId)
+ }
+ fieldResults = this.complementIds(excludeInts)
+ break
+ }
+
// ===== MISSING OPERATOR =====
// missing: boolean - equivalent to exists: !boolean
case 'missing': {
@@ -2257,6 +2325,27 @@ export class MetadataIndexManager implements MetadataIndexProvider {
}
break
}
+
+ // ===== EVERYTHING ELSE: REFUSED BY NAME, NEVER ANSWERED EMPTY ====
+ // An equality/range posting index cannot evaluate a substring, a
+ // pattern or an array length without reading every row, and this
+ // path exists precisely to avoid that. It used to fall out of the
+ // switch with `fieldResults` still `[]`, so `find({ where: { name:
+ // { startsWith: 'a' } } })` returned an empty page and looked like
+ // an answer. An accepted operator either works or refuses — the
+ // matcher's own support for these operators governs in-memory
+ // filtering, never an index-backed find().
+ default:
+ throw new BrainyError(
+ `Filter operator "${op}" on field "${rawField}" cannot be served by the ` +
+ `metadata index: an equality/range posting index cannot evaluate substrings, ` +
+ `patterns or array lengths without reading every row. It is REFUSED rather ` +
+ `than answered with an empty page. Filter on an indexable operator ` +
+ `(equals/eq, notEquals/ne, oneOf/in, noneOf, greaterThan/gt, ` +
+ `greaterThanOrEqual/gte, lessThan/lt, lessThanOrEqual/lte, between, contains, ` +
+ `excludes, hasAll, exists, missing) and narrow the rest in your own code.`,
+ 'INVALID_QUERY'
+ )
}
// Intersect this operator's matches with the running set (AND semantics
// for multiple operators on the same field).
diff --git a/src/utils/paramValidation.ts b/src/utils/paramValidation.ts
index 606516ac..00790a4a 100644
--- a/src/utils/paramValidation.ts
+++ b/src/utils/paramValidation.ts
@@ -613,6 +613,17 @@ export function validateUpdateParams(params: UpdateParams): void {
// null/undefined means "no new data was given".
const hasData = params.data !== undefined && params.data !== null
if ((params as UpdateParams & { deferEmbedding?: boolean }).deferEmbedding === true) {
+ if (params.vector && params.vector.length === 0) {
+ // The nonsensical combination Leg D of the zero-norm/unvector-door law
+ // refuses: `vector: []` is the SANCTIONED UNVECTOR DOOR — an explicit
+ // instruction to remove the vector NOW, never "please embed" — so it
+ // cannot be paired with a request to defer an embed.
+ throw new Error(
+ `update(): 'vector: []' (the unvector door) cannot be combined with ` +
+ `'deferEmbedding: true' — an unvector is an explicit instruction to remove ` +
+ `the vector now, not a request to defer an embed. Drop one of the two.`
+ )
+ }
if (params.vector) {
throw new Error(
`update(): deferEmbedding cannot be combined with an explicit 'vector' — ` +
@@ -649,8 +660,16 @@ export function validateUpdateParams(params: UpdateParams): void {
throw new Error(`invalid NounType: ${params.type}`)
}
- // Validate vector dimensions if provided
- if (params.vector) {
+ // Validate vector dimensions if provided. A length-0 vector is the
+ // SANCTIONED UNVECTOR DOOR (see brainy.ts update()'s matching comment): an
+ // explicit `vector: []` — or a real all-zero vector, normalized to `[]`
+ // upstream by the zero-norm law — carries no dimension information,
+ // exactly like validateAddParams's identical exemption, so it is exempt
+ // from the dimension check rather than refused as a "0-dimensional
+ // vector". (The `deferEmbedding` combination above already refuses
+ // `vector: []` paired with `deferEmbedding: true` — an empty array is
+ // truthy, so that guard fires unconditionally on any explicit `vector`.)
+ if (params.vector && params.vector.length > 0) {
const config = ValidationConfig.getInstance()
if (params.vector.length !== config.maxVectorDimensions) {
throw new Error(`vector must have exactly ${config.maxVectorDimensions} dimensions`)
diff --git a/src/utils/version.ts b/src/utils/version.ts
index d616cee3..327f923c 100644
--- a/src/utils/version.ts
+++ b/src/utils/version.ts
@@ -1,6 +1,6 @@
/**
* @module utils/version
- * @description Resolves the running `@soulcraft/brainy` package version. Brainy 8.0
+ * @description Resolves the running `@soulcraftlabs/brainy` package version. Brainy 8.0
* targets Node-like runtimes only (Node.js, Bun, Deno — all expose `node:fs`), so the
* version is read **synchronously** from `package.json` on first call and cached.
*
@@ -83,3 +83,27 @@ export function getAugmentationVersion(service: string): { augmentation: string;
version: getBrainyVersion()
}
}
+
+/**
+ * The API-contract version this build implements — a single integer that two
+ * engines can compare without probing prototypes.
+ *
+ * A MINOR release is ADDITIVE: doors and error codes may be added, never
+ * removed or narrowed, and the contract integer does not move. A MAJOR release
+ * is what a REQUIRED door's removal or a behavioural narrowing costs, and it
+ * bumps this integer. A consumer pinning `brainyContract` in a peer range is
+ * therefore pinning "what I may call", not "which build I run".
+ *
+ * Declared in package.json as `"brainyContract"` so a manifest, a tool, or a
+ * sibling package can read it without importing the engine, and returned here
+ * so a running process can state its own.
+ */
+export const BRAINY_CONTRACT_VERSION = 1 as const
+
+/**
+ * @description The API-contract version this build implements.
+ * @returns The contract integer — see {@link BRAINY_CONTRACT_VERSION}.
+ */
+export function contractVersion(): number {
+ return BRAINY_CONTRACT_VERSION
+}
diff --git a/src/vfs/VirtualFileSystem.ts b/src/vfs/VirtualFileSystem.ts
index 59a16be4..46c6a12d 100644
--- a/src/vfs/VirtualFileSystem.ts
+++ b/src/vfs/VirtualFileSystem.ts
@@ -6,6 +6,7 @@
*/
import { Readable, Writable } from 'stream'
+import { prodLog } from '../utils/logger.js'
import crypto from 'crypto'
import { v4 as uuidv4 } from '../universal/uuid.js'
import { Brainy } from '../brainy.js'
@@ -66,6 +67,20 @@ export class VirtualFileSystem implements IVirtualFileSystem {
private config: Required> & { rootEntityId?: string }
private rootEntityId?: string
private initialized = false
+ /**
+ * The one-time old-root sweep, in flight. See {@link sweepOldRootsIfNeeded}.
+ */
+ private rootSweep?: Promise
+ /**
+ * Where the completed old-root sweep is recorded. Engine plumbing under
+ * `_system/`, like every other marker there — never enumerated as data.
+ */
+ private static readonly ROOT_SWEEP_MARKER_PATH = '_system/vfs-root-sweep.json'
+ /**
+ * Below this wall, a sweep that removed nothing says nothing — see
+ * {@link sweepOldRootsIfNeeded}.
+ */
+ private static readonly ROOT_SWEEP_NARRATE_MS = 1_000
private currentUser: string = 'system' // Track current user for collaboration
// Knowledge Layer features available via augmentation (brain.use('knowledge'))
@@ -143,8 +158,17 @@ export class VirtualFileSystem implements IVirtualFileSystem {
// Create or find root entity
this.rootEntityId = await this.initializeRoot()
- // Clean up old UUID-based roots (one-time migration)
- await this.cleanupOldRoots()
+ // Clean up old UUID-based roots — ONCE PER STORE, BEHIND THE DOORS.
+ // This is a migration sweep for roots created before the fixed root id
+ // existed. It ran on EVERY open, forever: a filtered find over the whole
+ // store hunting for duplicates that a store has either always had or
+ // never will. MEASURED on a 14,056-noun / 72,679-verb store: the phase it
+ // dominates cost 43-53 SECONDS of every open, warm reopens included.
+ // Now: a durable marker records that the sweep has run, and a store
+ // carrying it never sweeps again; a store without one sweeps in the
+ // BACKGROUND (the sweep only removes duplicate roots — nothing serves
+ // from them — and it was always declared non-critical).
+ this.rootSweep = this.sweepOldRootsIfNeeded()
// Initialize projection registry with auto-discovery of built-in projections
this.projectionRegistry = new ProjectionRegistry()
@@ -394,7 +418,100 @@ export class VirtualFileSystem implements IVirtualFileSystem {
*
* This is a one-time migration helper that can be removed in future versions.
*/
- private async cleanupOldRoots(): Promise {
+ /**
+ * @description Run the old-root sweep at most once per store, in the
+ * background, and record that it ran. See the call site in {@link init} for
+ * the measurement that made this necessary.
+ * @returns A promise that settles when the sweep has finished (or was
+ * skipped); nothing in the read path awaits it.
+ */
+ private async sweepOldRootsIfNeeded(): Promise {
+ const store = this.rawObjectStore()
+ if (store === null) {
+ // A storage adapter with no raw-object door cannot carry the marker.
+ // Sweep every open, as before — correctness over cost.
+ await this.cleanupOldRoots()
+ return
+ }
+ try {
+ const marker = await store.readRawObject(VirtualFileSystem.ROOT_SWEEP_MARKER_PATH)
+ if (marker !== null && marker !== undefined) return
+ } catch {
+ // Unreadable marker: sweep, and rewrite it below.
+ }
+ // NARRATION HAS A THRESHOLD, like every other line this engine emits on the
+ // always-visible channel. On a fresh or small store this sweep finds
+ // nothing and costs a millisecond, and announcing it — twice — on a
+ // channel a production log level deliberately CANNOT silence would train
+ // operators to ignore the one channel that exists to be impossible to
+ // ignore. It speaks when it has something to say: duplicates removed, or a
+ // wall long enough that somebody watching a slow first open deserves to
+ // know what is running. Otherwise it does its work and stays quiet.
+ const startedAt = Date.now()
+ const duplicatesRemoved = await this.cleanupOldRoots()
+ const elapsedMs = Date.now() - startedAt
+ try {
+ await store.writeRawObject(VirtualFileSystem.ROOT_SWEEP_MARKER_PATH, {
+ sweptAt: new Date().toISOString(),
+ durationMs: elapsedMs
+ })
+ if (duplicatesRemoved > 0 || elapsedMs >= VirtualFileSystem.ROOT_SWEEP_NARRATE_MS) {
+ prodLog.narrate(
+ `[VFS] one-time old-root sweep complete in ${elapsedMs}ms` +
+ (duplicatesRemoved > 0
+ ? `, ${duplicatesRemoved} pre-fixed-id root(s) removed`
+ : '') +
+ ' and recorded — no future open pays for it.'
+ )
+ }
+ } catch (error) {
+ // Unrecorded sweep = the next open sweeps again. Conservative, and said
+ // out loud rather than quietly repeated forever.
+ prodLog.narrate(
+ `[VFS] old-root sweep finished in ${Date.now() - startedAt}ms but could NOT be ` +
+ `recorded (${(error as Error).message}) — the next open will sweep again.`
+ )
+ }
+ }
+
+ /**
+ * @description Settle once the background old-root sweep has finished.
+ * Resolves immediately when the store already carried the marker. Exists so
+ * tests and operators can observe the sweep instead of racing it; no read
+ * path waits on it.
+ * @returns A promise that settles with the sweep.
+ */
+ public async whenRootSweepSettled(): Promise {
+ await this.rootSweep
+ }
+
+ /**
+ * @description The brain's storage adapter, narrowed to the raw-object door
+ * this migration marker needs. Boundary: `Brainy.storage` is private, and
+ * this is the same reach-in the engine uses elsewhere for exactly this kind
+ * of engine-internal artifact. Returns null when the adapter has no
+ * raw-object door.
+ */
+ private rawObjectStore(): {
+ readRawObject: (key: string) => Promise
+ writeRawObject: (key: string, value: unknown) => Promise
+ } | null {
+ const storage = (this.brain as unknown as { storage?: Record }).storage
+ if (
+ storage &&
+ typeof storage.readRawObject === 'function' &&
+ typeof storage.writeRawObject === 'function'
+ ) {
+ return storage as unknown as {
+ readRawObject: (key: string) => Promise
+ writeRawObject: (key: string, value: unknown) => Promise
+ }
+ }
+ return null
+ }
+
+ private async cleanupOldRoots(): Promise {
+ let removed = 0
try {
// Find any old VFS roots with UUID-based IDs (not our fixed ID)
const oldRoots = await this.brain.find({
@@ -416,6 +533,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
for (const duplicate of duplicates) {
try {
await this.brain.remove(duplicate.id)
+ removed++
console.log(`VFS: Deleted old root ${duplicate.id.substring(0, 8)}`)
} catch (error) {
console.warn(`VFS: Failed to delete old root ${duplicate.id}:`, error)
@@ -428,6 +546,7 @@ export class VirtualFileSystem implements IVirtualFileSystem {
// Non-critical error - log and continue
console.warn('VFS: Cleanup of old roots failed (non-critical):', error)
}
+ return removed
}
/**
diff --git a/tests/integration/count-ledger-identity-record.test.ts b/tests/integration/count-ledger-identity-record.test.ts
new file mode 100644
index 00000000..1066213a
--- /dev/null
+++ b/tests/integration/count-ledger-identity-record.test.ts
@@ -0,0 +1,251 @@
+/**
+ * @module tests/integration/count-ledger-identity-record
+ * @description THE COUNT LEDGER COUNTS RECORDS, NOT DIRECTORIES — and heals
+ * itself when it was derived the other way.
+ *
+ * Measured on a real store: the ALL-visibility ledger read 14,231 nouns
+ * against 14,056 identity records, and 72,729 verbs against 72,679 — exactly
+ * that store's 25 noun and 50 verb SCAR directories (empty `/` containers
+ * left by a pre-8.3.1 partial delete). Two copies of the SAME archive derived
+ * different numbers, because each had been persisted at a different moment
+ * under the old container rule. A downstream index heal subtracted against
+ * those denominators and reported remaining work that did not exist.
+ *
+ * The membership predicate is the IDENTITY RECORD (the metadata content leg).
+ * The scan already applies it; what is pinned here is that a ledger persisted
+ * under the OLD rule does not go on lying — it is corrected in the background,
+ * without blocking the open, and two copies of one archive agree.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest'
+import {
+ mkdtempSync,
+ mkdirSync,
+ rmSync,
+ writeFileSync,
+ readFileSync,
+ cpSync,
+ existsSync
+} from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Brainy } from '../../src/brainy.js'
+import { NounType } from '../../src/types/graphTypes.js'
+import { FileSystemStorage as FileSystemStorageClass } from '../../src/storage/adapters/fileSystemStorage.js'
+import type { FileSystemStorage } from '../../src/storage/adapters/fileSystemStorage.js'
+
+const NOUN_COUNT = 6
+const NOUN_SCARS = 3
+const VERB_SCARS = 2
+/** A REAL two-hex shard — the scan skips any directory that is not one. */
+const SCAR_SHARD = 'ab'
+
+function makeTempDir(): string {
+ return mkdtempSync(join(tmpdir(), 'brainy-count-ledger-'))
+}
+
+/** The FileSystemStorage behind a brain. */
+function storageOf(brain: Brainy): FileSystemStorage {
+ return (brain as unknown as { storage: FileSystemStorage }).storage
+}
+
+/**
+ * Add `count` empty `/` container directories under
+ * `entities///` — scars, exactly as a partial delete leaves them.
+ */
+function addScarContainers(dir: string, kind: 'nouns' | 'verbs', count: number): void {
+ for (let i = 0; i < count; i++) {
+ const id = `${SCAR_SHARD}5ca4000-0000-0000-0000-00000000000${i}`
+ mkdirSync(join(dir, 'entities', kind, SCAR_SHARD, id), { recursive: true })
+ }
+}
+
+/** Add one GHOST container: a `vectors.json` leg with no identity record. */
+function addGhostContainer(dir: string): void {
+ const id = `${SCAR_SHARD}9405700-0000-0000-0000-000000000000`
+ const idDir = join(dir, 'entities', 'nouns', SCAR_SHARD, id)
+ mkdirSync(idDir, { recursive: true })
+ writeFileSync(join(idDir, 'vectors.json'), JSON.stringify({ id, vector: [0.1, 0.2] }))
+}
+
+/**
+ * Rewrite counts.json into the LEGACY shape: ALL scalars inflated by the
+ * containers, and no `allCountsDerivedBy` stamp — exactly what a store carried
+ * when it was last written by a build that counted directories.
+ */
+function writeLegacyCountsLedger(dir: string, inflateNouns: number, inflateVerbs: number): void {
+ const file = join(dir, '_system', 'counts.json')
+ const counts = JSON.parse(readFileSync(file, 'utf-8'))
+ counts.totalNounCountAll = (counts.totalNounCountAll ?? 0) + inflateNouns
+ counts.totalVerbCountAll = (counts.totalVerbCountAll ?? 0) + inflateVerbs
+ delete counts.allCountsDerivedBy
+ delete counts.allCountsSuspect
+ writeFileSync(file, JSON.stringify(counts, null, 2))
+}
+
+/**
+ * Seed a store and return the HONEST ledger it holds when freshly written —
+ * the baseline the correction must return to. Read from the engine rather than
+ * hardcoded: an open creates its own rows (the VFS root), and a pin that
+ * asserts a literal would be pinning that incidental fact instead of the rule.
+ */
+async function seedStore(dir: string): Promise<{ nouns: number; verbs: number }> {
+ const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ await brain.init()
+ const ids: string[] = []
+ for (let i = 0; i < NOUN_COUNT; i++) {
+ ids.push(await brain.add({ data: `entity number ${i}`, type: NounType.Concept }))
+ }
+ await brain.relate({ from: ids[0], to: ids[1], type: 'relatedTo' } as never)
+ await brain.relate({ from: ids[1], to: ids[2], type: 'relatedTo' } as never)
+ await brain.flush()
+ const ledger = await storageOf(brain).getCanonicalCounts()
+ const baseline = { nouns: ledger.nouns.all, verbs: ledger.verbs.all }
+ await brain.close()
+ return baseline
+}
+
+/**
+ * Make the ledger walk take `ms` so a test can observe the open completing
+ * WITHOUT it. Patches the prototype before any brain is constructed; returns
+ * the restore function.
+ */
+function slowTheLedgerWalk(ms: number): () => void {
+ const proto = (
+ FileSystemStorageClass as unknown as {
+ prototype: Record Promise>
+ }
+ ).prototype
+ const real = proto.scanCanonicalEntities
+ proto.scanCanonicalEntities = async function slow(this: unknown, ...args: unknown[]) {
+ await new Promise((r) => setTimeout(r, ms))
+ return real.apply(this, args)
+ }
+ return () => { proto.scanCanonicalEntities = real }
+}
+
+describe('the canonical count ledger', () => {
+ const dirs: string[] = []
+
+ afterEach(() => {
+ for (const d of dirs.splice(0)) {
+ try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ }
+ }
+ })
+
+ function trackDir(): string {
+ const dir = makeTempDir()
+ dirs.push(dir)
+ return dir
+ }
+
+ it('corrects a legacy container-rule ledger in the background, counting identity records', async () => {
+ const dir = trackDir()
+ const baseline = await seedStore(dir)
+
+ // Scars and a ghost: containers with no identity record.
+ addScarContainers(dir, 'nouns', NOUN_SCARS)
+ addScarContainers(dir, 'verbs', VERB_SCARS)
+ addGhostContainer(dir)
+ // The ledger as the old rule left it: every container counted.
+ writeLegacyCountsLedger(dir, NOUN_SCARS + 1, VERB_SCARS)
+
+ const restore = slowTheLedgerWalk(1_500)
+ let brain: Brainy
+ try {
+ const openStarted = Date.now()
+ brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ await brain.init()
+ const openMs = Date.now() - openStarted
+ const storage = storageOf(brain)
+
+ // THE OPEN DID NOT WAIT. Two walks of 1.5s each would have added 3s.
+ expect(openMs).toBeLessThan(2_500)
+ // And while it runs, the scalars say so instead of being subtracted against.
+ const atOpen = await storage.getCanonicalCounts()
+ expect(atOpen.suspect).toBe(true)
+ expect(atOpen.nouns.all).toBe(baseline.nouns + NOUN_SCARS + 1)
+
+ await storage.whenCountLedgerSettled()
+ } finally {
+ restore()
+ }
+ const storage = storageOf(brain!)
+
+ const healed = await storage.getCanonicalCounts()
+ expect(healed.nouns.all).toBe(baseline.nouns)
+ expect(healed.verbs.all).toBe(baseline.verbs)
+ expect(healed.suspect).toBe(false)
+
+ // And it is PERSISTED with the honest stamp — the correction survives a
+ // reopen instead of being re-derived (or re-lost) every time.
+ await brain!.close()
+ const persisted = JSON.parse(readFileSync(join(dir, '_system', 'counts.json'), 'utf-8'))
+ expect(persisted.totalNounCountAll).toBe(baseline.nouns)
+ expect(persisted.totalVerbCountAll).toBe(baseline.verbs)
+ expect(persisted.allCountsDerivedBy).toBe('identity-record')
+
+ const reopened = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ await reopened.init()
+ const afterReopen = await storageOf(reopened).getCanonicalCounts()
+ expect(afterReopen.nouns.all).toBe(baseline.nouns)
+ expect(afterReopen.suspect).toBe(false)
+ await reopened.close()
+ }, 180_000)
+
+ it('derives the same number from two copies of one archive', async () => {
+ const source = trackDir()
+ const baseline = await seedStore(source)
+ addScarContainers(source, 'nouns', NOUN_SCARS)
+ addGhostContainer(source)
+
+ // Two copies of the SAME bytes, each carrying a DIFFERENT legacy ledger —
+ // the situation that made one archive report 14,231 and its twin 14,081.
+ const copyA = trackDir()
+ const copyB = trackDir()
+ cpSync(source, copyA, { recursive: true })
+ cpSync(source, copyB, { recursive: true })
+ writeLegacyCountsLedger(copyA, NOUN_SCARS + 1, 0)
+ writeLegacyCountsLedger(copyB, 1, 0)
+
+ const derived: number[] = []
+ for (const dir of [copyA, copyB]) {
+ const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ await brain.init()
+ const storage = storageOf(brain)
+ await storage.whenCountLedgerSettled()
+ derived.push((await storage.getCanonicalCounts()).nouns.all)
+ await brain.close()
+ }
+ expect(derived[0]).toBe(derived[1])
+ expect(derived[0]).toBe(baseline.nouns)
+ }, 180_000)
+
+ it('writes counts.json atomically — no reader ever sees it empty', async () => {
+ const dir = trackDir()
+ await seedStore(dir)
+ const file = join(dir, '_system', 'counts.json')
+ expect(existsSync(file)).toBe(true)
+
+ const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ await brain.init()
+ const storage = storageOf(brain)
+
+ // Watch the ledger across many persists. A truncating write leaves a
+ // window in which the file parses as nothing; a temp+rename never does.
+ let sawUnparseable = 0
+ const watcher = setInterval(() => {
+ try {
+ JSON.parse(readFileSync(file, 'utf-8'))
+ } catch {
+ sawUnparseable++
+ }
+ }, 1)
+ for (let i = 0; i < 40; i++) {
+ await (storage as unknown as { persistCounts: () => Promise }).persistCounts()
+ }
+ clearInterval(watcher)
+ await brain.close()
+ expect(sawUnparseable).toBe(0)
+ }, 180_000)
+})
diff --git a/tests/integration/entity-tree-stamp.test.ts b/tests/integration/entity-tree-stamp.test.ts
index deefc5e6..23cc0a15 100644
--- a/tests/integration/entity-tree-stamp.test.ts
+++ b/tests/integration/entity-tree-stamp.test.ts
@@ -57,7 +57,11 @@ describe('entity-tree family stamp', () => {
const invariants = (stamp.members as any).invariants
expect(invariants.nounCount).toBe(await brain.storage.getNounCount())
expect(invariants.verbCount).toBe(await brain.storage.getVerbCount())
- expect(stamp.sourceGeneration).toBe(brain.generation())
+ // THE SOURCE IS COMMITTED TRUTH, never the allocated counter. Stamping the
+ // counter labelled the stamp with a generation a write in flight had merely
+ // claimed, so every crash inside a write window produced a spurious verdict
+ // at the next open (see the torn-tail pins below).
+ expect(stamp.sourceGeneration).toBe(brain.generationStore.committedGeneration())
expect(stamp.generation).toBeGreaterThanOrEqual(1)
})
@@ -112,6 +116,96 @@ describe('entity-tree family stamp', () => {
expect(stillIncoherent).toEqual([])
})
+ /**
+ * Rewrite the on-disk stamp so its `sourceGeneration` sits ABOVE the store's
+ * committed watermark — the durable shape a torn generation-log tail leaves
+ * behind (the stamp's fsync outlived the tail's). Fabricated rather than
+ * crash-produced so the pin is deterministic; the seeded-SIGKILL lane
+ * (`scripts/crash-consistency.mjs` in the engine repo) produces the same
+ * shape from a real abrupt termination.
+ */
+ const fabricateTear = (ahead: number): FamilyStamp => {
+ const file = path.join(dir, `${ENTITY_TREE_STAMP_PATH}.gz`)
+ const zlib = require('node:zlib')
+ const raw = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf-8')) as FamilyStamp
+ const torn: FamilyStamp = { ...raw, sourceGeneration: raw.sourceGeneration + ahead }
+ fs.writeFileSync(file, zlib.gzipSync(JSON.stringify(torn)))
+ return torn
+ }
+
+ it('a torn generation-log tail is a TERMINAL VERDICT at open: narrated, demoted, never a wait', async () => {
+ for (let i = 0; i < 3; i++)
+ await brain.add({ data: `torn${i}`, type: 'document', metadata: { i } })
+ await brain.close()
+ const torn = fabricateTear(5)
+
+ const warn = vi.spyOn(prodLog, 'warn')
+ const startedAt = Date.now()
+ brain = await open()
+ const openMs = Date.now() - startedAt
+
+ const tearLines = warn.mock.calls.filter((c) => String(c[0]).includes('TORN GENERATION-LOG TAIL'))
+ expect(tearLines.length).toBe(1)
+ const said = String(tearLines[0][0])
+ // Narrated PRECISELY: both generations, the file, and the named cure.
+ expect(said).toContain(`source generation ${torn.sourceGeneration}`)
+ expect(said).toContain(`committed generation ${brain.generationStore.committedGeneration()}`)
+ expect(said).toContain(ENTITY_TREE_STAMP_PATH)
+ expect(said).toContain('DEMOTED')
+ expect(said).toMatch(/repairIndex\(\)/)
+ // Terminal, not a wait: the demotion is O(1) straight-line work, so a tear
+ // cannot turn an open into the 8-minute spin this class was reported as.
+ expect(openMs).toBeLessThan(30_000)
+
+ // The store SERVES — a tear in a stamp never locks an owner out of the
+ // canonical tree the stamp merely describes.
+ expect((await brain.find({ type: 'document', limit: 100 })).length).toBe(3)
+
+ // The demotion CONVERGED: the stamp now names committed truth, and the
+ // next open is quiet. A verdict that re-narrates every open is a wait
+ // wearing a different hat.
+ const restamped = (await readFamilyStamp(brain.storage, ENTITY_TREE_STAMP_PATH)) as FamilyStamp
+ expect(restamped.sourceGeneration).toBe(brain.generationStore.committedGeneration())
+ await brain.close()
+ const warn2 = vi.spyOn(prodLog, 'warn')
+ brain = await open()
+ expect(warn2.mock.calls.filter((c) => String(c[0]).includes('TORN'))).toEqual([])
+ })
+
+ it('a READ-ONLY open on a torn tail refuses to guess: terminal verdict + named cure, no re-stamp', async () => {
+ await brain.add({ data: 'ro', type: 'document', metadata: {} })
+ await brain.close()
+ const torn = fabricateTear(3)
+
+ const warn = vi.spyOn(prodLog, 'warn')
+ const reader: any = await Brainy.openReadOnly({
+ requireSubtype: false,
+ storage: { type: 'filesystem', path: dir },
+ silent: true,
+ dimensions: 384
+ })
+ const tearLines = warn.mock.calls.filter((c) => String(c[0]).includes('TORN GENERATION-LOG TAIL'))
+ expect(tearLines.length).toBe(1)
+ const said = String(tearLines[0][0])
+ expect(said).toContain('READ-ONLY')
+ expect(said).toContain('UNVERIFIED')
+ expect(said).toMatch(/repairIndex\(\)/)
+ await reader.close()
+
+ // A reader never rewrites the store: read the bytes back off disk (not
+ // through a writer open, which would demote them) — the torn stamp is
+ // exactly as it was found.
+ const onDisk = JSON.parse(
+ require('node:zlib')
+ .gunzipSync(fs.readFileSync(path.join(dir, `${ENTITY_TREE_STAMP_PATH}.gz`)))
+ .toString('utf-8')
+ ) as FamilyStamp
+ expect(onDisk.sourceGeneration).toBe(torn.sourceGeneration)
+ expect(onDisk.generation).toBe(torn.generation)
+
+ brain = await open()
+ })
+
it('the one verifier handles both member modes', () => {
const rollup: FamilyStamp = {
family: 'x',
@@ -127,7 +221,13 @@ describe('entity-tree family stamp', () => {
stampSource: 5,
head: 9
})
- expect(verifyFamilyStamp(rollup, 3, { nounCount: 10 }).state).toBe('incoherent') // ahead of head
+ // AHEAD is its own class — a torn generation-log tail, never folded in
+ // with `incoherent`: the two have opposite cures (recount vs demote).
+ expect(verifyFamilyStamp(rollup, 3, { nounCount: 10 })).toEqual({
+ state: 'torn',
+ stampSource: 5,
+ head: 3
+ })
expect(verifyFamilyStamp(null, 5, {})).toEqual({ state: 'absent' })
const enumerated: FamilyStamp = {
diff --git a/tests/integration/filter-operator-conformance.test.ts b/tests/integration/filter-operator-conformance.test.ts
new file mode 100644
index 00000000..628017e7
--- /dev/null
+++ b/tests/integration/filter-operator-conformance.test.ts
@@ -0,0 +1,151 @@
+/**
+ * @module tests/integration/filter-operator-conformance
+ * @description THE OPERATOR SET, AND WHAT EACH TOKEN DOES ON THE INDEX PATH.
+ *
+ * The contract-1 manifest splits this engine's `where` operators three ways —
+ * served, served-beyond-baseline, refused-by-name — and two engines must agree
+ * token for token. This lane is the machine-checkable side of that agreement:
+ * it asserts the EXACT accepted set (so a manifest can be diffed against a run
+ * rather than against prose), and it pins each of the three classes.
+ *
+ * The defect it closes: the metadata index's operator switch had no default
+ * case, so an operator it does not implement — `hasAll`, `noneOf`, `excludes`,
+ * `startsWith`, `endsWith`, `matches`, `length` — left the field's match set at
+ * its initial `[]` and `find()` returned an empty page. A documented operator,
+ * implemented in the in-memory matcher, answering silently wrong. Three of the
+ * seven are now SERVED on the index path; the other four are REFUSED BY NAME,
+ * because an equality/range posting index cannot evaluate a substring, a
+ * pattern or an array length without reading every row.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest'
+import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Brainy } from '../../src/brainy.js'
+import { NounType } from '../../src/types/graphTypes.js'
+import { contractVersion, BRAINY_CONTRACT_VERSION } from '../../src/utils/version.js'
+
+/** The accepted `where` value-operator tokens, as a sorted list. */
+const ACCEPTED_OPERATORS = [
+ 'between', 'contains', 'endsWith', 'eq', 'equals', 'excludes', 'exists',
+ 'greaterThan', 'greaterThanOrEqual', 'gt', 'gte', 'hasAll', 'in', 'length',
+ 'lessThan', 'lessThanOrEqual', 'lt', 'lte', 'matches', 'missing', 'ne',
+ 'noneOf', 'notEquals', 'oneOf', 'startsWith'
+] as const
+
+/** Served on the index path with exact posting-set semantics. */
+const SERVED_ON_INDEX = [
+ 'between', 'contains', 'eq', 'equals', 'exists', 'greaterThan',
+ 'greaterThanOrEqual', 'gt', 'gte', 'in', 'lessThan', 'lessThanOrEqual',
+ 'lt', 'lte', 'missing', 'ne', 'notEquals', 'oneOf',
+ 'excludes', 'hasAll', 'noneOf'
+] as const
+
+/** Accepted by name, refused by the index path — never answered empty. */
+const REFUSED_BY_INDEX = ['endsWith', 'length', 'matches', 'startsWith'] as const
+
+describe('filter operator conformance', () => {
+ const dirs: string[] = []
+ const brains: Brainy[] = []
+
+ afterEach(async () => {
+ for (const b of brains.splice(0)) {
+ try { await b.close() } catch { /* already closed */ }
+ }
+ for (const d of dirs.splice(0)) {
+ try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ }
+ }
+ })
+
+ async function seeded(): Promise {
+ const dir = mkdtempSync(join(tmpdir(), 'brainy-operators-'))
+ dirs.push(dir)
+ const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ brains.push(brain)
+ await brain.init()
+ await brain.add({
+ data: 'a document about ferrets',
+ type: NounType.Document,
+ metadata: { tags: ['ferret', 'small', 'furry'], team: 'alpha' }
+ })
+ await brain.add({
+ data: 'a document about whales',
+ type: NounType.Document,
+ metadata: { tags: ['whale', 'large'], team: 'beta' }
+ })
+ await brain.flush()
+ return brain
+ }
+
+ it('the accepted operator set is exactly these 25 tokens', async () => {
+ const brain = await seeded()
+ // The engine names its own valid set in the refusal it raises for an
+ // unknown token — the honest place to read it from.
+ let message = ''
+ try {
+ await brain.find({ where: { team: { notIn: ['alpha'] } } } as never)
+ } catch (err) {
+ message = (err as Error).message
+ }
+ expect(message).toMatch(/Unknown filter operator "notIn"/)
+ const listed = (message.match(/Valid operators: ([^.]+)\./)?.[1] ?? '')
+ .split(',')
+ .map((t) => t.trim())
+ .filter(Boolean)
+ .sort()
+ expect(listed).toEqual([...ACCEPTED_OPERATORS].sort())
+ expect(listed.length).toBe(25)
+ // Four tokens a sibling manifest listed as served aliases are NOT in this
+ // engine's set and never have been — they raise INVALID_QUERY.
+ for (const absent of ['is', 'isNot', 'greaterEqual', 'lessEqual']) {
+ expect(listed).not.toContain(absent)
+ await expect(
+ brain.find({ where: { team: { [absent]: 'alpha' } } } as never)
+ ).rejects.toThrow(/Unknown filter operator/)
+ }
+ }, 120_000)
+
+ it('serves hasAll, noneOf and excludes on the index path — never an empty page', async () => {
+ const brain = await seeded()
+
+ const hasAll = await brain.find({ where: { tags: { hasAll: ['ferret', 'furry'] } } } as never)
+ expect(hasAll.length).toBe(1)
+ expect((hasAll[0] as { metadata?: Record }).metadata?.team).toBe('alpha')
+
+ const noneOf = await brain.find({ where: { team: { noneOf: ['alpha'] } } } as never)
+ expect(noneOf.length).toBe(1)
+ expect((noneOf[0] as { metadata?: Record }).metadata?.team).toBe('beta')
+
+ const excludes = await brain.find({ where: { tags: { excludes: 'whale' } } } as never)
+ expect(excludes.length).toBe(1)
+ expect((excludes[0] as { metadata?: Record }).metadata?.team).toBe('alpha')
+
+ // hasAll with an operand nothing carries is EMPTY because it is empty —
+ // the honest zero, reached by evaluating the operator.
+ const none = await brain.find({ where: { tags: { hasAll: ['ferret', 'whale'] } } } as never)
+ expect(none.length).toBe(0)
+ }, 120_000)
+
+ it('refuses the four index-unserveable operators BY NAME', async () => {
+ const brain = await seeded()
+ for (const op of REFUSED_BY_INDEX) {
+ const operand = op === 'length' ? 3 : 'a'
+ await expect(
+ brain.find({ where: { team: { [op]: operand } } } as never),
+ `${op} must refuse, never answer an empty page`
+ ).rejects.toThrow(new RegExp(`Filter operator "${op}".*cannot be served by the metadata index`, 's'))
+ }
+ }, 120_000)
+
+ it('declares its contract version in code and in package.json', async () => {
+ expect(contractVersion()).toBe(1)
+ expect(BRAINY_CONTRACT_VERSION).toBe(1)
+ const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'))
+ expect(pkg.brainyContract).toBe(contractVersion())
+ })
+
+ it('the three classes partition the accepted set', () => {
+ expect([...SERVED_ON_INDEX, ...REFUSED_BY_INDEX].sort()).toEqual([...ACCEPTED_OPERATORS].sort())
+ })
+})
diff --git a/tests/integration/flush-watcher-event-driven.test.ts b/tests/integration/flush-watcher-event-driven.test.ts
new file mode 100644
index 00000000..4b2e80c4
--- /dev/null
+++ b/tests/integration/flush-watcher-event-driven.test.ts
@@ -0,0 +1,94 @@
+/**
+ * @module tests/integration/flush-watcher-event-driven
+ * @description THE FLUSH-REQUEST WATCH IS EVENT-DRIVEN.
+ *
+ * It used to `readdir` the request directory every 500 ms, per brain, for the
+ * life of every writer — armed on every non-reader brain whether or not any
+ * inspector process existed. MEASURED on a production process holding 21
+ * brains: 42 directory reads per second on a completely idle service, plus a
+ * stale-request GC pass on every one of them.
+ *
+ * The law: a request that has not been made is not a cause. The arrival itself
+ * wakes the watcher, so the request is seen SOONER than the poll saw it, and a
+ * slow safety sweep covers filesystems that drop watch events and the GC.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest'
+import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
+import * as nodeFs from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Brainy } from '../../src/brainy.js'
+import { NounType } from '../../src/types/graphTypes.js'
+
+describe('the flush-request watcher', () => {
+ const dirs: string[] = []
+ const brains: Brainy[] = []
+
+ afterEach(async () => {
+ for (const b of brains.splice(0)) {
+ try { await b.close() } catch { /* already closed */ }
+ }
+ for (const d of dirs.splice(0)) {
+ try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ }
+ }
+ vi.restoreAllMocks()
+ })
+
+ async function openWriter(): Promise<{ brain: Brainy; dir: string }> {
+ const dir = mkdtempSync(join(tmpdir(), 'brainy-flush-watch-'))
+ dirs.push(dir)
+ const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ brains.push(brain)
+ await brain.init()
+ await brain.add({ data: 'a row', type: NounType.Concept })
+ await brain.flush()
+ return { brain, dir }
+ }
+
+ it('does not poll the request directory on an idle writer', async () => {
+ const { dir } = await openWriter()
+ const reqDir = join(dir, 'locks', '_flush_requests')
+
+ // Count real reads of the request directory over a window far longer than
+ // the old 500ms poll (which would have made ~16 of them).
+ const realReaddir = nodeFs.promises.readdir
+ let requestDirReads = 0
+ const spy = vi
+ .spyOn(nodeFs.promises, 'readdir')
+ .mockImplementation((async (p: unknown, ...rest: unknown[]) => {
+ if (String(p) === reqDir) requestDirReads++
+ return (realReaddir as unknown as (...a: unknown[]) => Promise)(p, ...rest)
+ }) as typeof nodeFs.promises.readdir)
+
+ await new Promise((r) => setTimeout(r, 8_000))
+ spy.mockRestore()
+
+ // The old poll: 500ms → ~16 reads. The safety sweep is 30s → 0 in this window.
+ expect(requestDirReads).toBeLessThanOrEqual(1)
+ }, 120_000)
+
+ it('answers a request that arrives, without waiting for the sweep', async () => {
+ const { brain, dir } = await openWriter()
+ const reqDir = join(dir, 'locks', '_flush_requests')
+ const ackDir = join(dir, 'locks', '_flush_responses')
+ mkdirSync(reqDir, { recursive: true })
+
+ // Drop a request exactly as an out-of-process inspector does.
+ const id = 'test-request-0001'
+ writeFileSync(join(reqDir, `${id}.req`), JSON.stringify({ at: Date.now() }))
+
+ // The ack must land far sooner than the 30s safety sweep.
+ const deadline = Date.now() + 10_000
+ let acked = false
+ while (Date.now() < deadline) {
+ try {
+ const entries = await nodeFs.promises.readdir(ackDir)
+ if (entries.some((e) => e.startsWith(id))) { acked = true; break }
+ } catch { /* dir not created yet */ }
+ await new Promise((r) => setTimeout(r, 100))
+ }
+ expect(acked, 'the watcher must answer an arriving request').toBe(true)
+ void brain
+ }, 120_000)
+})
diff --git a/tests/integration/health-gate.test.ts b/tests/integration/health-gate.test.ts
index 1c9a642d..f2952116 100644
--- a/tests/integration/health-gate.test.ts
+++ b/tests/integration/health-gate.test.ts
@@ -188,7 +188,7 @@ describe('health gate (b) — unledgered is unknown: never blocks a serving prov
describe('health gate (c) — degraded-but-serving narrates once per generation', () => {
// PER-FAMILY LAW (10.4.1): a metadata find() consults the METADATA leg only — the
// degraded report lives on the family the read actually consults.
- it('a heal:"repair" failure serves; narrates once per generation, twice across a generation bump', async () => {
+ it('a heal:"repair" failure serves; narrates once per DISTINCT VERDICT, not once per generation bump', async () => {
const brain = new Brainy(createTestConfig({ silent: true }))
await brain.init()
brains.push(brain)
@@ -197,12 +197,13 @@ describe('health gate (c) — degraded-but-serving narrates once per generation'
const internals = internalsOf(brain)
let generation = 1
+ let detail = 'counter drift'
internals.metadataIndex.healthReport = () =>
healthReport({
provider: 'vector',
serving: true,
healthy: false,
- invariants: [invariant({ name: 'stale-vector-counter', holds: false, heal: 'repair', detail: 'counter drift' })],
+ invariants: [invariant({ name: 'stale-vector-counter', holds: false, heal: 'repair', detail })],
generation
})
@@ -212,11 +213,22 @@ describe('health gate (c) — degraded-but-serving narrates once per generation'
await expect(brain.find({ where: { team: 'atlas' } })).resolves.toHaveLength(1)
await expect(brain.find({ where: { team: 'atlas' } })).resolves.toHaveLength(1)
- expect(countNarrations()).toBe(1) // same generation both times — one narration
+ expect(countNarrations()).toBe(1) // same verdict both times — one narration
+ // THE DEDUPE KEY IS THE VERDICT, NOT THE COUNTER. A provider's `generation`
+ // bumps on every ledger mutation and every rebuild boundary, so keying the
+ // narration on it re-printed an UNCHANGED health line on every read that
+ // consulted a busy provider — and, in the other direction, let a provider
+ // that never bumped suppress a line whose reasons had genuinely changed.
+ // An unchanged verdict is silent however the counter moves:
generation = 2
await expect(brain.find({ where: { team: 'atlas' } })).resolves.toHaveLength(1)
- expect(countNarrations()).toBe(2) // generation bumped — a second narration
+ expect(countNarrations()).toBe(1) // generation bumped, verdict identical — still silent
+
+ // ...and a CHANGED verdict is always heard, bump or no bump:
+ detail = 'counter drift widened to 12 rows'
+ await expect(brain.find({ where: { team: 'atlas' } })).resolves.toHaveLength(1)
+ expect(countNarrations()).toBe(2) // the reasons changed — a new narration
delete internals.metadataIndex.healthReport
})
diff --git a/tests/integration/history-repacking.test.ts b/tests/integration/history-repacking.test.ts
index 2bcee038..bb07268d 100644
--- a/tests/integration/history-repacking.test.ts
+++ b/tests/integration/history-repacking.test.ts
@@ -16,6 +16,7 @@ import { describe, it, expect, afterEach } from 'vitest'
import * as fs from 'node:fs'
import * as path from 'node:path'
import * as os from 'node:os'
+import * as zlib from 'node:zlib'
import { Brainy } from '../../src/brainy.js'
import { NounType } from '../../src/types/graphTypes.js'
import { GenerationStore } from '../../src/db/generationStore.js'
@@ -57,6 +58,107 @@ describe('history repacking — the two-tier lifecycle', () => {
}
})
+ /**
+ * THE HOLE, END TO END — the shape a real store carries.
+ *
+ * A forensic fixture was measured with generation directories 1..2503
+ * present except for exactly one: 1416. Its fact-log segment already showed
+ * the tell — `seg-...1410.bfl` declaring firstGeneration 1410, lastGeneration
+ * 1940 (531 generations) while recording only 530 facts.
+ *
+ * Before the fix, repacking such a store folded ACROSS that hole: the batch
+ * skipped 1416 (no readable delta) and the sealed segment declared a range
+ * spanning it anyway. The next open merged that declared range back into
+ * committedRanges, re-admitting 1416 as committed history, and every
+ * subsequent auto-compaction pass then asked the packed tier for a frame
+ * that was never written — producing, on EVERY run, the non-fatal narration
+ *
+ * Auto-compaction of generational history failed (non-fatal): generation
+ * N is inside sealed segment seg-....bgs's declared range but has no frame
+ * — packed history is damaged
+ *
+ * This pin removes a generation directory to make the same hole, then
+ * requires repack + reopen + compaction to complete cleanly.
+ */
+ it('a missing generation directory does not poison the packed tier', async () => {
+ const dir = tempDir()
+ // `retention: 'all'` throughout: close() otherwise auto-compacts the
+ // history away, and this pin needs the cold generations still on disk so
+ // there is something to punch a hole in. The live window stays at its
+ // production default for the build phase, so nothing folds yet.
+ const archival = async (): Promise => {
+ const b = new Brainy({
+ requireSubtype: false,
+ storage: { type: 'filesystem', path: dir },
+ embeddingFunction: stub,
+ retention: 'all'
+ })
+ await b.init()
+ return b
+ }
+ const brain = await archival()
+
+ const id = await brain.add({
+ data: 'holed-entity',
+ type: NounType.Document,
+ metadata: { v: 0 }
+ })
+ // One flush per update: single-op writes coalesce inside a flush window,
+ // so a history deep enough to have a middle needs the windows separated.
+ for (let v = 1; v <= 12; v++) {
+ await brain.update({ id, metadata: { v } })
+ await brain.flush()
+ }
+ await brain.close()
+
+ // Punch the hole: delete ONE generation directory in the middle of the
+ // cold range, exactly as the real store presents it.
+ const genRoot = path.join(dir, '_generations')
+ const numeric = fs
+ .readdirSync(genRoot, { withFileTypes: true })
+ .filter((e) => e.isDirectory() && /^\d+$/.test(e.name))
+ .map((e) => Number(e.name))
+ .sort((a, b) => a - b)
+ expect(numeric.length).toBeGreaterThan(6)
+ const victim = numeric[Math.floor(numeric.length / 2)]
+ fs.rmSync(path.join(genRoot, String(victim)), { recursive: true, force: true })
+
+ // Now shrink the live window and reopen. close() repacks automatically
+ // (brainy.ts phase 0b), so this is the production sequence exactly: a
+ // store with a hole in its history gets folded by ordinary housekeeping,
+ // with nobody asking for it.
+ ;(GenerationStore as any).REPACK_LIVE_WINDOW = 3
+ const reopened = await archival()
+ const result = await reopened.repackHistory()
+ expect(result.foldedGenerations).toBeGreaterThan(0)
+
+ const segDir = path.join(dir, SEGMENTS_PREFIX)
+ const manifestPath = ['manifest.json', 'manifest.json.gz']
+ .map((f) => path.join(segDir, f))
+ .find((p) => fs.existsSync(p))!
+ const raw = manifestPath.endsWith('.gz')
+ ? zlib.gunzipSync(fs.readFileSync(manifestPath)).toString('utf8')
+ : fs.readFileSync(manifestPath, 'utf8')
+ const manifest = JSON.parse(raw) as {
+ segments: Array<{ firstGeneration: number; lastGeneration: number; frames: number }>
+ }
+
+ // THE LAW: every sealed segment declares exactly as many generations as it
+ // holds frames, and none of them spans the victim.
+ for (const s of manifest.segments) {
+ expect(s.lastGeneration - s.firstGeneration + 1).toBe(s.frames)
+ expect(victim >= s.firstGeneration && victim <= s.lastGeneration).toBe(false)
+ }
+
+ await reopened.close()
+
+ // And the pass that used to fail on every run now completes: reopen (which
+ // re-seeds committedRanges from the packed tier) then compact history.
+ const third = await openBrain(dir)
+ await expect(third.compactHistory({ maxGenerations: 2 })).resolves.toBeDefined()
+ await third.close()
+ })
+
it('repack preserves every historical read across cold reopen; folded dirs are gone', async () => {
;(GenerationStore as any).REPACK_LIVE_WINDOW = 3
const dir = tempDir()
diff --git a/tests/integration/hybrid-search-vfs.test.ts b/tests/integration/hybrid-search-vfs.test.ts
index c881fa97..219c4c83 100644
--- a/tests/integration/hybrid-search-vfs.test.ts
+++ b/tests/integration/hybrid-search-vfs.test.ts
@@ -21,10 +21,16 @@ describe('Hybrid Search with VFS', () => {
testDir = path.join(os.tmpdir(), `brainy-hybrid-vfs-test-${Date.now()}`)
fs.mkdirSync(testDir, { recursive: true })
+ // `storage.path`, NOT the pre-8.0 `options.basePath` alias. That alias was
+ // removed at the 8.0 major and configures nothing, so this suite silently
+ // opened the DEFAULT store instead of its own temp directory — sharing one
+ // on-disk brain with every other run on the machine, accumulating tens of
+ // thousands of rows, and eventually failing on that shared store's graph
+ // adjacency rather than on anything it was written to test.
brain = new Brainy({ requireSubtype: false,
storage: {
type: 'filesystem',
- options: { basePath: testDir }
+ path: testDir
}
})
await brain.init()
diff --git a/tests/integration/idle-costs-nothing.test.ts b/tests/integration/idle-costs-nothing.test.ts
new file mode 100644
index 00000000..b5c386cf
--- /dev/null
+++ b/tests/integration/idle-costs-nothing.test.ts
@@ -0,0 +1,152 @@
+/**
+ * @module tests/integration/idle-costs-nothing
+ * @description AN IDLE BRAIN DOES NO WORK.
+ *
+ * A flush used to re-persist state identical to what was already on disk —
+ * the provider flushes, the watermark stamps, the generation counter, the
+ * entity-tree stamp, roughly 28 writes — because `flush()` never asked whether
+ * anything had changed.
+ *
+ * The field observation that started this: a production process holding 21
+ * brains printed "All indexes flushed to disk in 216–601ms" per brain every
+ * ~35 seconds and idled at 1.26 cores, with no writes for ten minutes. This
+ * engine's cadence is WRITE-DRIVEN, so that observation is NOT explained by
+ * the cadence and is not claimed to be fixed here — what is fixed is that such
+ * a call now costs nothing. Who was calling flush() remains open.
+ *
+ * The laws pinned here:
+ * (a) the persistence cadence arms only on a write — a brain nobody writes
+ * to flushes zero times, however long it is left open;
+ * (b) a flush on a clean brain is O(1): no provider is called, nothing is
+ * written, and nothing is printed;
+ * (c) one write earns exactly one flush's worth of work, and no more.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Brainy } from '../../src/brainy.js'
+import { NounType } from '../../src/types/graphTypes.js'
+
+/** Wait for any in-flight background flush, then let the idle timer settle. */
+async function drainCadence(brain: Brainy): Promise {
+ const inner = brain as unknown as { _persistBackgroundFlight: Promise | null }
+ await new Promise((r) => setTimeout(r, 3_000))
+ await (inner._persistBackgroundFlight ?? Promise.resolve())
+ await new Promise((r) => setTimeout(r, 500))
+}
+
+/** How long an idle brain is watched. Longer than the 30s flush interval. */
+const IDLE_WATCH_MS = 90_000
+
+describe('an idle brain costs nothing', () => {
+ const dirs: string[] = []
+ const brains: Brainy[] = []
+
+ afterEach(async () => {
+ for (const b of brains.splice(0)) {
+ try { await b.close() } catch { /* already closed */ }
+ }
+ for (const d of dirs.splice(0)) {
+ try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ }
+ }
+ vi.restoreAllMocks()
+ })
+
+ async function openBrain(): Promise {
+ const dir = mkdtempSync(join(tmpdir(), 'brainy-idle-'))
+ dirs.push(dir)
+ const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ brains.push(brain)
+ await brain.init()
+ return brain
+ }
+
+ it('flushes zero times over 90 idle seconds, and prints nothing', async () => {
+ const brain = await openBrain()
+ // One write and one flush to reach a clean, settled state — then nothing.
+ await brain.add({ data: 'the only write this test performs', type: NounType.Concept })
+ await brain.flush()
+
+ const logged: string[] = []
+ const origLog = console.log
+ console.log = ((...a: unknown[]) => { logged.push(a.map(String).join(' ')) }) as typeof console.log
+
+ // Watch the providers directly: a flush that runs calls all of them.
+ const storage = (brain as unknown as { storage: { flushCounts: () => Promise } }).storage
+ const metadataIndex = (brain as unknown as { metadataIndex: { flush: () => Promise } }).metadataIndex
+ const graphIndex = (brain as unknown as { graphIndex: { flush: () => Promise } }).graphIndex
+ const countsSpy = vi.spyOn(storage, 'flushCounts')
+ const metadataSpy = vi.spyOn(metadataIndex, 'flush')
+ const graphSpy = vi.spyOn(graphIndex, 'flush')
+
+ try {
+ await new Promise((r) => setTimeout(r, IDLE_WATCH_MS))
+ } finally {
+ console.log = origLog
+ }
+
+ // (a) + (b): nothing ran, nothing was said.
+ expect(logged.filter((l) => /All indexes flushed to disk/.test(l))).toEqual([])
+ expect(logged.filter((l) => /Flushing Brainy indexes/.test(l))).toEqual([])
+ expect(countsSpy).not.toHaveBeenCalled()
+ expect(metadataSpy).not.toHaveBeenCalled()
+ expect(graphSpy).not.toHaveBeenCalled()
+ }, 180_000)
+
+ it('an explicit flush over a clean brain calls no provider and prints nothing', async () => {
+ const brain = await openBrain()
+ await brain.add({ data: 'one write', type: NounType.Concept })
+ await brain.flush() // this one does the work
+
+ const storage = (brain as unknown as { storage: { flushCounts: () => Promise } }).storage
+ const metadataIndex = (brain as unknown as { metadataIndex: { flush: () => Promise } }).metadataIndex
+ const countsSpy = vi.spyOn(storage, 'flushCounts')
+ const metadataSpy = vi.spyOn(metadataIndex, 'flush')
+ const logged: string[] = []
+ const origLog = console.log
+ console.log = ((...a: unknown[]) => { logged.push(a.map(String).join(' ')) }) as typeof console.log
+ try {
+ await brain.flush() // ...and this one has nothing to do
+ await brain.flush()
+ await brain.flush()
+ } finally {
+ console.log = origLog
+ }
+
+ expect(countsSpy).not.toHaveBeenCalled()
+ expect(metadataSpy).not.toHaveBeenCalled()
+ expect(logged.filter((l) => /All indexes flushed to disk/.test(l))).toEqual([])
+ }, 120_000)
+
+ it('one write earns exactly one flush', async () => {
+ const brain = await openBrain()
+ await brain.add({ data: 'first', type: NounType.Concept })
+ await brain.flush()
+ // Settle: the first write also kicked a BACKGROUND flush, which is not
+ // awaited by design. Drain it before counting, or its provider calls land
+ // inside this test's window and are attributed to the write below.
+ await drainCadence(brain)
+
+ // Count the flushes that actually RAN. (Provider spies cannot answer this:
+ // the storage adapter's own count ledger is write-through, so a write calls
+ // flushCounts() on its own account, with no flush involved.)
+ const logged: string[] = []
+ const origLog = console.log
+ console.log = ((...a: unknown[]) => { logged.push(a.map(String).join(' ')) }) as typeof console.log
+ const ran = () => logged.filter((l) => /All indexes flushed to disk/.test(l)).length
+ try {
+ await brain.add({ data: 'second — this is the cause', type: NounType.Concept })
+ await brain.flush()
+ expect(ran()).toBe(1)
+
+ // No further cause, no further work.
+ await brain.flush()
+ await brain.flush()
+ expect(ran()).toBe(1)
+ } finally {
+ console.log = origLog
+ }
+ }, 120_000)
+})
diff --git a/tests/integration/index-skips-unvectored.test.ts b/tests/integration/index-skips-unvectored.test.ts
new file mode 100644
index 00000000..c65aaa01
--- /dev/null
+++ b/tests/integration/index-skips-unvectored.test.ts
@@ -0,0 +1,253 @@
+/**
+ * @module tests/integration/index-skips-unvectored
+ * @description THE UNVECTORED-ROW CURE — two integration tests
+ * (`tests/lifecycle/biography.test.ts`'s Ch4/5/6 chapter and
+ * `tests/integration/clear-persistence.test.ts`'s multi-cycle test) started
+ * failing after a canonical-storage change made a vector-less row (the
+ * class-J shape: `vector: []`, e.g. the VFS root, a deferred embed not yet
+ * landed, or any other legitimately-unvectored canonical record) VISIBLE to
+ * the enumeration walk `getNounsWithPagination()` for the first time — before
+ * that change such rows were simply invisible to the walk. `hnswIndex.ts`'s
+ * `rebuild()` never guarded against that shape: it inserted every row the
+ * walk yielded into the live in-memory index, including ones with a length-0
+ * vector, because `storage.getVectorIndexData()` derives its {level,
+ * connections} answer straight from the noun's OWN record — it returns
+ * non-null for ANY existing noun, whether or not that noun was ever actually
+ * indexed via `addItem()`. A vector-less node admitted into the graph could
+ * become the entry point (or occupy any graph position), and the very next
+ * real-vectored `addItem()` then ran a distance calculation against it —
+ * `cosineDistance` throws "Vectors must have the same dimensions" the moment
+ * one operand is a length-0 array.
+ *
+ * THE FIX, at two layers (`src/hnsw/hnswIndex.ts`):
+ * (1) FILL/REBUILD/LOAD consumers treat `vector.length === 0` as "unvectored —
+ * nothing to index" and skip the row (normal, not an error; one summary
+ * count line, never per-row spam) — `rebuild()`'s loop now checks this
+ * BEFORE ever creating a graph node, so an unvectored row can never
+ * become an index member, entry point, or dimension-setter.
+ * (2) THE INDEX ITSELF refuses a length-0 vector in `addItem()` /
+ * `updateItem()` with a typed `EmptyVectorIndexError`, loudly, instead of
+ * ever pinning `dimension = 0` or storing a vector-less node — so no
+ * future fill/rebuild/load path can silently poison the index even if it
+ * forgets law (1).
+ *
+ * Four legs pinned here:
+ * (a) `rebuild()` over a store mixing real-vectored rows and `vector: []`
+ * rows indexes ONLY the vectored ones — size === vectored count,
+ * dimension pinned to the real (non-zero) length.
+ * (b) `clear()` then real adds afterward never trip a dimension mismatch —
+ * the exact `clear-persistence.test.ts` regression shape, reproduced
+ * directly against the index/storage seam this module owns.
+ * (c) `index.addItem({ id, vector: [] })` throws `EmptyVectorIndexError`
+ * (and `updateItem` does too, for an existing node).
+ * (d) crash -> repair: the crashed generation's entities survive, the ledger
+ * recounts honestly, and a fresh real-vectored add afterward never trips
+ * a dimension mismatch against a leftover vector-less phantom.
+ */
+import { describe, it, expect, afterEach } from 'vitest'
+import * as fs from 'node:fs'
+import * as os from 'node:os'
+import * as path from 'node:path'
+import { Brainy } from '../../src/index.js'
+import { NounType } from '../../src/types/graphTypes.js'
+import { EmptyVectorIndexError } from '../../src/hnsw/hnswIndex.js'
+import { abandonAsCrashed, openBrain as openKillMatrixBrain, uid, vec } from '../helpers/durabilityKillMatrix.js'
+
+const tmpDirs: string[] = []
+function mkTmp(): string {
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-index-skips-unvectored-'))
+ tmpDirs.push(d)
+ return d
+}
+afterEach(() => {
+ for (const d of tmpDirs.splice(0)) {
+ try {
+ fs.rmSync(d, { recursive: true, force: true })
+ } catch {
+ /* best-effort cleanup */
+ }
+ }
+})
+
+/** A filesystem-backed brain with explicit vectors (no embedder needed) and
+ * manual persistence — mirrors `durabilityKillMatrix.ts`'s `openBrain` so
+ * every write in this module is explicit and provably durable. */
+function openBrain(dir: string): any {
+ return new Brainy({
+ requireSubtype: false,
+ storage: { type: 'filesystem', path: dir },
+ silent: true,
+ persistence: { policy: 'manual' }
+ })
+}
+
+describe('HNSW index skips unvectored rows', () => {
+ it('(a) rebuild() indexes only vectored rows: size === vectored count, dimension pinned to the real length', async () => {
+ const dir = mkTmp()
+ let brain = openBrain(dir)
+ await brain.init()
+
+ // 5 real-vectored rows.
+ const vectoredIds: string[] = []
+ for (let i = 0; i < 5; i++) {
+ const id = uid(`vectored-${i}`)
+ await brain.add({ id, data: `real entity ${i}`, type: NounType.Document, vector: vec(i) })
+ vectoredIds.push(id)
+ }
+ // 3 explicit unvectored rows — the class-J "vector: []" shape, a normal,
+ // enumerable, countable canonical row that must never reach the index.
+ const unvectoredIds: string[] = []
+ for (let i = 0; i < 3; i++) {
+ const id = uid(`unvectored-${i}`)
+ await brain.add({ id, data: `unvectored entity ${i}`, type: NounType.Document, vector: [] })
+ unvectoredIds.push(id)
+ }
+ await brain.flush()
+
+ // The canonical ledger already agrees before any rebuild: nouns.all
+ // counts every row (8 + the VFS root); vectors.all counts only the real
+ // ones (5) — the VFS root and the 3 explicit unvectored rows are excluded.
+ const ledgerBeforeReopen = await brain.storage.getCanonicalCounts()
+ expect(ledgerBeforeReopen.vectors.all).toBe(5)
+ expect(ledgerBeforeReopen.nouns.all).toBe(9) // 5 vectored + 3 unvectored + 1 VFS root
+
+ await brain.close()
+
+ // Reopen: open()'s index build IS hnswIndex.rebuild() run fresh from
+ // storage — this is the exact path that used to admit unvectored rows.
+ brain = openBrain(dir)
+ await brain.init()
+
+ const status = await brain.getIndexStatus()
+ expect(status.hnswIndex.size, 'the rebuilt index must contain ONLY the 5 real-vectored rows').toBe(5)
+
+ // Dimension is pinned to the REAL embedded length (384 via `vec()`), not
+ // 0 — adding a wrong-length vector must be refused naming that real
+ // dimension, proving no vector-less row ever set it.
+ const realDimension = vec(0).length
+ let mismatchMessage: string | undefined
+ try {
+ await brain.index.addItem({ id: uid('dimension-probe'), vector: vec(0).slice(0, realDimension - 1) })
+ expect.fail('expected a dimension mismatch error')
+ } catch (err) {
+ mismatchMessage = (err as Error).message
+ }
+ expect(mismatchMessage).toContain(`expected ${realDimension}`)
+
+ // Every unvectored row is still a normal, enumerable, readable canonical
+ // record — class-J semantics survive the rebuild fix untouched.
+ for (const id of unvectoredIds) {
+ const entity = await brain.get(id, { includeVectors: true })
+ expect(entity, `unvectored entity ${id} must remain readable`).not.toBeNull()
+ expect(entity.vector).toEqual([])
+ }
+ // A correct-dimension add succeeds cleanly against the pinned dimension.
+ const freshId = uid('post-reopen-fresh')
+ await expect(brain.add({ id: freshId, data: 'fresh', type: NounType.Document, vector: vec(50) })).resolves.toBe(
+ freshId
+ )
+
+ await brain.close()
+ })
+
+ it('(b) clear() then real adds afterward never trip a dimension mismatch (the clear-persistence regression shape)', async () => {
+ const dir = mkTmp()
+ let brain = openBrain(dir)
+ await brain.init() // the VFS root (vector: []) is the store's only row
+
+ await brain.clear()
+ await brain.close()
+
+ // Reopen over a store whose only surviving row is the recreated,
+ // unvectored VFS root — this is exactly the shape that used to poison
+ // the entry point / dimension in `clear-persistence.test.ts`.
+ brain = openBrain(dir)
+ await brain.init()
+ expect((await brain.getIndexStatus()).hnswIndex.size).toBe(0)
+
+ const id1 = uid('after-clear-1')
+ await expect(brain.add({ id: id1, data: 'after clear 1', type: NounType.Document, vector: vec(1) })).resolves.toBe(
+ id1
+ )
+ const id2 = uid('after-clear-2')
+ await expect(brain.add({ id: id2, data: 'after clear 2', type: NounType.Document, vector: vec(2) })).resolves.toBe(
+ id2
+ )
+ expect((await brain.getIndexStatus()).hnswIndex.size).toBe(2)
+
+ await brain.close()
+ })
+
+ it('(c) index.addItem/updateItem refuse a length-0 vector with EmptyVectorIndexError', async () => {
+ const dir = mkTmp()
+ const brain = openBrain(dir)
+ await brain.init()
+
+ await expect(brain.index.addItem({ id: uid('empty-add'), vector: [] })).rejects.toThrow(EmptyVectorIndexError)
+
+ // updateItem on an EXISTING (real-vectored) node must refuse the same way.
+ const existingId = uid('existing-for-update')
+ await brain.add({ id: existingId, data: 'existing', type: NounType.Document, vector: vec(9) })
+ await expect(brain.index.updateItem({ id: existingId, vector: [] })).rejects.toThrow(EmptyVectorIndexError)
+
+ // The index was never disturbed by either refused call.
+ expect((await brain.getIndexStatus()).hnswIndex.size).toBe(1)
+
+ await brain.close()
+ })
+
+ it('(d) crash -> repair: the crashed generation survives, the ledger recounts honestly, and a fresh add afterward never trips a dimension mismatch', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-index-skips-unvectored-crash-'))
+ try {
+ let brain = await openKillMatrixBrain(dir, { logAuthority: 'adopt' })
+
+ // Baseline: real-vectored entities, durably flushed.
+ const baselineIds: string[] = []
+ for (let i = 0; i < 5; i++) {
+ const id = uid(`baseline-${i}`)
+ await brain.add({ id, data: `baseline entity ${i}`, type: NounType.Document, vector: vec(i) })
+ baselineIds.push(id)
+ }
+ await brain.flush()
+
+ // Crash window: at-ack writes that are never flushed before the crash.
+ const crashedIds: string[] = []
+ for (let i = 0; i < 4; i++) {
+ const id = uid(`crashed-${i}`)
+ await brain.add({ id, data: `crash-window entity ${i}`, type: NounType.Document, vector: vec(100 + i) })
+ crashedIds.push(id)
+ }
+ await abandonAsCrashed(brain)
+
+ // Reopen — logAuthority: 'adopt' replays the at-ack log for the crash window.
+ brain = await openKillMatrixBrain(dir, { logAuthority: 'adopt' })
+ for (const id of [...baselineIds, ...crashedIds]) {
+ expect(await brain.get(id), `entity ${id} must survive the crash`).not.toBeNull()
+ }
+
+ // Repair — must not disturb any entity, and must recount the ledger honestly.
+ const report = await brain.repairIndex()
+ expect(report.families.length).toBeGreaterThan(0)
+ for (const id of [...baselineIds, ...crashedIds]) {
+ expect(await brain.get(id), `entity ${id} must survive repair`).not.toBeNull()
+ }
+
+ const ledger = await brain.storage.getCanonicalCounts()
+ expect(ledger.suspect).toBe(false)
+ expect(ledger.vectors.all).toBe(baselineIds.length + crashedIds.length)
+
+ // Second life: a fresh real-vectored add must never trip a dimension
+ // mismatch against a vector-less phantom left in the index — the exact
+ // mechanism `clear-persistence.test.ts` and the biography lane hit.
+ const secondLifeId = uid('second-life')
+ await expect(
+ brain.add({ id: secondLifeId, data: 'second life entity', type: NounType.Document, vector: vec(200) })
+ ).resolves.toBe(secondLifeId)
+ expect(await brain.get(secondLifeId)).not.toBeNull()
+
+ await brain.close()
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/tests/integration/ledger-derivation-identity.test.ts b/tests/integration/ledger-derivation-identity.test.ts
index cb19af4a..7d09e893 100644
--- a/tests/integration/ledger-derivation-identity.test.ts
+++ b/tests/integration/ledger-derivation-identity.test.ts
@@ -11,14 +11,22 @@
* (1) IDENTITY, NOT CONTAINER — the derivation counts one entity per
* metadata content leg (`metadata.json` or `.json.gz`), the same test
* `pruneOrphanedEntities()` uses, so the two agree by construction.
- * (2) THE STAMP NAMES SUSPECT COUNTS LOUDLY, AT O(1) — a counts.json that
- * carries the ALL scalars but no `allCountsDerivedBy: 'identity-record'`
- * stamp predates this fix; loading it marks `suspect = true` from a
- * single field read alone, never a directory walk, and warns exactly
- * once naming the cause.
- * (3) THE SANCTIONED RECOUNT CLEARS IT — `repairIndex()` prunes the orphaned
- * containers, recounts from the canonical metadata.json walk, and
- * re-stamps — suspect clears and the ALL scalar is exact again.
+ * (2) THE STAMP NAMES SUSPECT COUNTS LOUDLY, AND THE OPEN NEVER WALKS — a
+ * counts.json that carries the ALL scalars but no
+ * `allCountsDerivedBy: 'identity-record'` stamp predates this fix;
+ * loading it marks `suspect = true` from a single field read alone and
+ * warns exactly once naming the cause. The open itself never pays a
+ * directory walk.
+ * (2b) AND IT HEALS ITSELF. The ledger used to stay wrong for the life of the
+ * store, waiting for an operator to run `repairIndex()` — and a
+ * downstream index heal subtracted against the inflated denominator and
+ * reported work that did not exist. An honest derivation now runs in the
+ * BACKGROUND after the open (never blocking it, observable via
+ * `whenCountLedgerSettled()`), and refuses to stamp a number it derived
+ * while writes were landing.
+ * (3) THE SANCTIONED RECOUNT ALSO CLEARS IT — `repairIndex()` prunes the
+ * orphaned containers, recounts from the canonical metadata.json walk,
+ * and re-stamps — the ALL scalar is exact and the containers are gone.
* (4) A FRESH STORE IS NEVER SUSPECT — the one-time derivation for a store
* with no counts.json stamps as it writes, so a brand-new store never
* carries the legacy signature.
@@ -115,29 +123,43 @@ describe('ledger derivation identity — the ALL scalar is the identity-record p
delete raw.allCountsDerivedBy
fs.writeFileSync(countsPath(dir), JSON.stringify(raw, null, 2))
- const warnSpy = vi.spyOn(prodLog, 'warn')
- // The two derivation walks live on FileSystemStorage's prototype —
- // spying here (rather than on fs.promises.readdir globally) isolates
- // THIS code path's behavior from unrelated walks elsewhere in the open
- // sequence (a separate, pre-existing engine's own O(store) cost — not
- // this fix's concern, and not something this pin should be sensitive
- // to). Neither derivation method may run: the stamp check is a field
- // read on the already-parsed counts.json, nothing more.
- const scanEntitiesSpy = vi.spyOn(FileSystemStorage.prototype as any, 'scanCanonicalEntities')
- const scanVectoredSpy = vi.spyOn(FileSystemStorage.prototype as any, 'scanVectoredNounCount')
+ const narrateSpy = vi.spyOn(prodLog, 'narrate')
+ // The derivation walks live on FileSystemStorage's prototype. Slow them
+ // deliberately: the OPEN must not wait for them, and on a two-row store a
+ // real walk finishes too fast to tell "not awaited" from "instant".
+ const proto = FileSystemStorage.prototype as any
+ const realScanEntities = proto.scanCanonicalEntities
+ let scanEntitiesCalls = 0
+ proto.scanCanonicalEntities = async function slow(this: any, ...args: any[]) {
+ scanEntitiesCalls++
+ await new Promise((r) => setTimeout(r, 1_200))
+ return realScanEntities.apply(this, args)
+ }
+ try {
+ const openStarted = Date.now()
+ brain = await open()
+ const openMs = Date.now() - openStarted
- brain = await open()
+ // THE OPEN DID NOT WALK: two slowed walks would have added 2.4s to it.
+ expect(openMs).toBeLessThan(2_000)
- const ledger = await brain.storage.getCanonicalCounts()
- expect(ledger.suspect).toBe(true)
+ // The stamp check itself is an O(1) field read, and it names the cause.
+ const atOpen = await brain.storage.getCanonicalCounts()
+ expect(atOpen.suspect).toBe(true)
+ const stampWarnings = narrateSpy.mock.calls.filter(
+ ([msg]: any[]) => String(msg).includes('legacy') && String(msg).includes('container rule')
+ )
+ expect(stampWarnings.length).toBe(1) // exactly one, loud
- const stampWarnings = warnSpy.mock.calls.filter(
- ([msg]) => String(msg).includes('legacy') && String(msg).includes('container rule')
- )
- expect(stampWarnings.length).toBe(1) // exactly one, loud
-
- expect(scanEntitiesSpy).not.toHaveBeenCalled() // O(1) field read only, no re-derivation walk
- expect(scanVectoredSpy).not.toHaveBeenCalled()
+ // ...and the honest derivation is already running behind the open.
+ await brain.storage.whenCountLedgerSettled()
+ expect(scanEntitiesCalls).toBeGreaterThan(0)
+ const healed = await brain.storage.getCanonicalCounts()
+ expect(healed.suspect).toBe(false)
+ expect(healed.nouns.all).toBe(raw.totalNounCountAll)
+ } finally {
+ proto.scanCanonicalEntities = realScanEntities
+ }
await brain.close()
})
@@ -163,7 +185,14 @@ describe('ledger derivation identity — the ALL scalar is the identity-record p
fs.writeFileSync(countsPath(dir), JSON.stringify(raw, null, 2))
brain = await open()
- expect((await brain.storage.getCanonicalCounts()).suspect).toBe(true) // named suspect at load
+ // Named suspect at load, then healed in the background WITHOUT the
+ // operator asking — the inflated container count is corrected to the
+ // identity-record population, though the orphaned containers themselves
+ // are still on disk (only repairIndex() removes those).
+ await brain.storage.whenCountLedgerSettled()
+ let healed = await brain.storage.getCanonicalCounts()
+ expect(healed.suspect).toBe(false)
+ expect(healed.nouns.all).toBe(realTotal)
await brain.repairIndex()
diff --git a/tests/integration/open-does-not-wait-for-a-rebuilding-provider.test.ts b/tests/integration/open-does-not-wait-for-a-rebuilding-provider.test.ts
new file mode 100644
index 00000000..a46ad6a5
--- /dev/null
+++ b/tests/integration/open-does-not-wait-for-a-rebuilding-provider.test.ts
@@ -0,0 +1,192 @@
+/**
+ * @module tests/integration/open-does-not-wait-for-a-rebuilding-provider
+ * @description OPEN DOES NOT WAIT FOR A PROVIDER THAT IS REBUILDING ITSELF.
+ *
+ * Measured on a production store: a metadata provider that had to rebuild made
+ * `init()` pay the ENTIRE rebuild on the foreground — 641 seconds — with every
+ * other family idle behind it, because a provider reporting `serving: false`
+ * because it is BUSY BUILDING and one reporting `serving: false` because it is
+ * BROKEN were indistinguishable, and both were answered the same way: call
+ * `rebuild()`, and wait.
+ *
+ * The law: a provider that reports `rebuildInProgress()` owns its own rebuild.
+ * `init()` returns; every other family serves; THAT family's doors refuse by
+ * name, carrying the provider's own progress; and the doors open by themselves
+ * when the provider reports serving. Nothing is ever served empty.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Brainy } from '../../src/brainy.js'
+import { NounType } from '../../src/types/graphTypes.js'
+import type { ProviderRebuildProgress } from '../../src/utils/indexReadiness.js'
+
+/** How long the stub provider claims to be rebuilding. */
+const REBUILD_MS = 6_000
+
+describe('a provider rebuilding itself never blocks open', () => {
+ const dirs: string[] = []
+ const brains: Brainy[] = []
+
+ afterEach(async () => {
+ for (const b of brains.splice(0)) {
+ try { await b.close() } catch { /* already closed */ }
+ }
+ for (const d of dirs.splice(0)) {
+ try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ }
+ }
+ })
+
+ it('init() returns in milliseconds, the family refuses by name, then answers', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'brainy-rebuilding-provider-'))
+ dirs.push(dir)
+
+ // Seed a store so the open has something to (not) rebuild.
+ const seed = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ await seed.init()
+ await seed.add({ data: 'a row with a plain field', type: NounType.Concept, metadata: { kind: 'report' } })
+ await seed.flush()
+ await seed.close()
+
+ const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ brains.push(brain)
+
+ // Dress the metadata index as a provider that is rebuilding ITSELF: not
+ // serving, and honest about why. `init()` wires the real index first, so
+ // the hooks are installed on the instance as soon as it exists — the gate
+ // reads them by feature detection, exactly as it would a native provider's.
+ const rebuildStartedAt = Date.now()
+ const stillRebuilding = () => Date.now() - rebuildStartedAt < REBUILD_MS
+ let rebuildCalls = 0
+
+ const inner = brain as unknown as {
+ metadataIndex: Record
+ setupIndex?: unknown
+ }
+ // Install on the prototype-free instance right after construction by
+ // patching the property the moment init() assigns it.
+ const install = (target: Record) => {
+ const realRebuild = target.rebuild as () => Promise
+ target.rebuildInProgress = (): ProviderRebuildProgress | null =>
+ stillRebuilding()
+ ? { phase: 'metadata shadow build', done: 4_096, total: 14_056, startedAt: rebuildStartedAt }
+ : null
+ target.healthReport = () => ({
+ provider: 'metadata',
+ healthy: !stillRebuilding(),
+ serving: !stillRebuilding(),
+ generation: 1,
+ invariants: [],
+ unledgered: []
+ })
+ target.rebuild = async () => {
+ rebuildCalls++
+ return realRebuild.call(target)
+ }
+ }
+
+ // init() constructs the metadata index; patch as soon as it exists, before
+ // the gate consults it. A microtask hop after the index is assigned is
+ // enough because the gate runs later in the same init.
+ const initPromise = (async () => {
+ const originalEnsure = (brain as unknown as { setupIndex?: () => unknown }).setupIndex
+ void originalEnsure
+ return brain.init()
+ })()
+ // Patch on the first tick the index exists.
+ const patcher = setInterval(() => {
+ if (inner.metadataIndex && !inner.metadataIndex.rebuildInProgress) {
+ install(inner.metadataIndex)
+ }
+ }, 1)
+ const startedAt = Date.now()
+ try {
+ await initPromise
+ } finally {
+ clearInterval(patcher)
+ }
+ const openMs = Date.now() - startedAt
+
+ // If the patch did not land before the gate ran, this test proves nothing —
+ // say so loudly rather than passing vacuously.
+ expect(
+ typeof inner.metadataIndex.rebuildInProgress,
+ 'the stub provider was never installed — the test is vacuous'
+ ).toBe('function')
+
+ // 1. The open did not wait out the rebuild.
+ expect(openMs).toBeLessThan(REBUILD_MS)
+ // 2. And brainy did not start a rebuild of its own on top of the provider's.
+ expect(rebuildCalls).toBe(0)
+
+ // 3. The family's door refuses BY NAME, carrying the provider's progress.
+ let refusal: Error | null = null
+ try {
+ await brain.find({ where: { kind: 'report' } } as never)
+ } catch (err) {
+ refusal = err as Error
+ }
+ expect(refusal, 'a not-serving metadata family must refuse, never serve empty').not.toBeNull()
+ expect(refusal!.message).toMatch(/metadata shadow build/i)
+ expect(refusal!.message).toMatch(/4,096\/14,056/)
+ expect(refusal!.message).toMatch(/no action is needed/i)
+
+ // 4. Other families keep serving — the brain is open.
+ const all = await brain.getNouns?.({ pagination: { limit: 1 } } as never)
+ expect(all ?? true).toBeTruthy()
+
+ // 5. When the provider reports itself serving, the door opens by itself.
+ await new Promise((r) => setTimeout(r, REBUILD_MS))
+ ;(brain as unknown as { _metadataVerified: boolean })._metadataVerified = false
+ await expect(brain.find({ where: { kind: 'report' } } as never)).resolves.toBeDefined()
+ }, 180_000)
+
+ it('a rebuilding provider reporting 0 entries is not a CRITICAL, and gets no second rebuild', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'brainy-rebuilding-critical-'))
+ dirs.push(dir)
+ const seed = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ await seed.init()
+ await seed.add({ data: 'a stored entity', type: NounType.Concept })
+ await seed.flush()
+ await seed.close()
+
+ const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ brains.push(brain)
+
+ let rebuildCalls = 0
+ const errors: string[] = []
+ const origError = console.error
+ console.error = ((...a: unknown[]) => { errors.push(a.map(String).join(' ')) }) as typeof console.error
+
+ const inner = brain as unknown as { metadataIndex: Record }
+ const patcher = setInterval(() => {
+ if (inner.metadataIndex && !inner.metadataIndex.rebuildInProgress) {
+ const target = inner.metadataIndex
+ target.rebuildInProgress = () => ({ phase: 'online metadata rebuild', startedAt: Date.now() })
+ target.healthReport = () => ({
+ provider: 'metadata', healthy: false, serving: false,
+ generation: 1, invariants: [], unledgered: []
+ })
+ // The shape the native engine now has: the index reports NOTHING while
+ // its rebuild runs online behind refusing doors.
+ target.getStats = async () => ({ totalEntries: 0 })
+ target.rebuild = async () => { rebuildCalls++ }
+ }
+ }, 1)
+ try {
+ await brain.init()
+ } finally {
+ clearInterval(patcher)
+ console.error = origError
+ }
+
+ expect(
+ typeof inner.metadataIndex.rebuildInProgress,
+ 'the stub provider was never installed — the test is vacuous'
+ ).toBe('function')
+ expect(errors.filter((l) => /CRITICAL: Metadata index has 0 entries/.test(l))).toEqual([])
+ expect(rebuildCalls).toBe(0)
+ }, 180_000)
+})
diff --git a/tests/integration/open-narration.test.ts b/tests/integration/open-narration.test.ts
new file mode 100644
index 00000000..95aba9f1
--- /dev/null
+++ b/tests/integration/open-narration.test.ts
@@ -0,0 +1,114 @@
+/**
+ * @module tests/integration/open-narration
+ * @description THE OPEN IS NEVER SILENT.
+ *
+ * A production service opened a 16 GB store and logged nothing at all for
+ * three minutes before its first line of work. Two defects made that possible
+ * and both are pinned here:
+ *
+ * 1. The phase breakdown was written to `prodLog.warn`, which every
+ * environment that looks like production clamps away. The narration
+ * channel (`prodLog.narrate`) is always visible, like `error`.
+ * 2. Nothing spoke DURING a phase — only after the whole open finished, if
+ * at all. A heartbeat now names the phase currently running and its
+ * elapsed wall while the open is still happening.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Brainy } from '../../src/brainy.js'
+import { NounType } from '../../src/types/graphTypes.js'
+import { FileSystemStorage } from '../../src/storage/adapters/fileSystemStorage.js'
+import { prodLog, configureLogger, LogLevel } from '../../src/utils/logger.js'
+
+function makeTempDir(): string {
+ return mkdtempSync(join(tmpdir(), 'brainy-open-narration-'))
+}
+
+/** Capture console.warn lines emitted while `fn` runs. */
+async function captureWarn(fn: () => Promise): Promise<{ result: T; lines: string[] }> {
+ const lines: string[] = []
+ const orig = console.warn
+ console.warn = ((...args: unknown[]) => {
+ lines.push(args.map((a) => String(a)).join(' '))
+ }) as typeof console.warn
+ try {
+ return { result: await fn(), lines }
+ } finally {
+ console.warn = orig
+ }
+}
+
+describe('open narration', () => {
+ let dir: string
+ let brain: Brainy | null = null
+
+ beforeEach(() => { dir = makeTempDir() })
+
+ afterEach(async () => {
+ if (brain) {
+ try { await brain.close() } catch { /* already closed */ }
+ brain = null
+ }
+ try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
+ })
+
+ it('narrate() survives the production log clamp that silences warn()', async () => {
+ // Exactly what isProductionEnvironment() does to the logger: level ERROR.
+ configureLogger({ level: LogLevel.ERROR })
+ try {
+ const { lines } = await captureWarn(async () => {
+ prodLog.warn('[Brainy] this line is chatter and may be clamped')
+ prodLog.narrate('[Brainy] this line is why the database is slow')
+ })
+ expect(lines.some((l) => /why the database is slow/.test(l))).toBe(true)
+ expect(lines.some((l) => /chatter/.test(l))).toBe(false)
+ } finally {
+ configureLogger({ level: LogLevel.INFO })
+ }
+ })
+
+ it('names a slow phase as it ends, and heartbeats while it is still running', async () => {
+ // Seed a store, then reopen it with a deliberately slow storage init so
+ // the first phase crosses both the heartbeat and the narrate thresholds.
+ brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ await brain.init()
+ await brain.add({ data: 'seed entity', type: NounType.Concept })
+ await brain.flush()
+ await brain.close()
+ brain = null
+
+ const realInit = FileSystemStorage.prototype.init
+ FileSystemStorage.prototype.init = async function slowInit(this: FileSystemStorage) {
+ await new Promise((r) => setTimeout(r, 6_500))
+ return realInit.call(this)
+ }
+ // Clamped to ERROR for the whole open: the narration must survive it.
+ configureLogger({ level: LogLevel.ERROR })
+ try {
+ const { result, lines } = await captureWarn(async () => {
+ const next = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ await next.init()
+ return next
+ })
+ brain = result
+
+ // The heartbeat spoke DURING the phase, naming the phase and its cause.
+ const heartbeats = lines.filter((l) => /open: still in phase 1\/5 "storage-init"/.test(l))
+ expect(heartbeats.length).toBeGreaterThanOrEqual(1)
+ expect(heartbeats[0]).toMatch(/loading its count ledger/)
+
+ // And the phase named its own wall as it ended.
+ const ended = lines.filter((l) => /open: phase 1\/5 "storage-init" finished in \d+ms/.test(l))
+ expect(ended.length).toBe(1)
+
+ // The whole-open breakdown is on the same always-visible channel.
+ expect(lines.some((l) => /slow open: \d+ms total \(.*storage-init=/.test(l))).toBe(true)
+ } finally {
+ FileSystemStorage.prototype.init = realInit
+ configureLogger({ level: LogLevel.INFO })
+ }
+ }, 120_000)
+})
diff --git a/tests/integration/repair-narration.test.ts b/tests/integration/repair-narration.test.ts
new file mode 100644
index 00000000..1fbe15e4
--- /dev/null
+++ b/tests/integration/repair-narration.test.ts
@@ -0,0 +1,119 @@
+/**
+ * @module tests/integration/repair-narration
+ * @description A REPAIR NARRATES ITSELF, AND ITS RECEIPT SAYS WHERE THE TIME
+ * WENT.
+ *
+ * On a production store (14,647 nouns / 73,070 verbs) a `repairIndex()` ran
+ * for more than thirty minutes at roughly a full core with ZERO log lines
+ * between its start and its end, while the read doors kept serving. The
+ * operator could tell it was alive only from `top`, and could not tell which
+ * of its single-threaded walks it was inside. The law pinned here:
+ *
+ * - every phase announces itself BEFORE it works, naming what it is about
+ * to walk;
+ * - a heartbeat names the phase still running, at a bounded cadence, for as
+ * long as it runs;
+ * - every phase reports its own wall, and that wall is carried in the typed
+ * receipt (`RepairFamilyReport.durationMs`) — not only in a log line.
+ *
+ * All of it on the narration channel, which production's log clamp cannot
+ * silence (see tests/integration/open-narration.test.ts).
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Brainy } from '../../src/brainy.js'
+import { NounType } from '../../src/types/graphTypes.js'
+import { FileSystemStorage } from '../../src/storage/adapters/fileSystemStorage.js'
+import { prodLog, configureLogger, LogLevel } from '../../src/utils/logger.js'
+
+describe('repairIndex narration', () => {
+ const dirs: string[] = []
+ const brains: Brainy[] = []
+
+ afterEach(async () => {
+ for (const b of brains.splice(0)) {
+ try { await b.close() } catch { /* already closed */ }
+ }
+ for (const d of dirs.splice(0)) {
+ try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ }
+ }
+ configureLogger({ level: LogLevel.INFO })
+ })
+
+ async function seededBrain(): Promise {
+ const dir = mkdtempSync(join(tmpdir(), 'brainy-repair-narration-'))
+ dirs.push(dir)
+ const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ brains.push(brain)
+ await brain.init()
+ for (let i = 0; i < 5; i++) {
+ await brain.add({ data: `repair subject ${i}`, type: NounType.Concept })
+ }
+ await brain.flush()
+ return brain
+ }
+
+ it('announces every phase, reports its wall, and carries that wall in the receipt', async () => {
+ const brain = await seededBrain()
+ const narrateSpy = vi.spyOn(prodLog, 'narrate')
+
+ const report = await brain.repairIndex()
+
+ const lines = narrateSpy.mock.calls.map(([m]) => String(m))
+
+ // Every family that ran has BOTH a start line and a finish line naming it.
+ for (const family of report.families) {
+ const started = lines.filter((l) => l.includes(`"${family.family}" started —`))
+ const finished = lines.filter((l) =>
+ new RegExp(`"${family.family}" finished in \\d+ms`).test(l)
+ )
+ expect(finished.length, `no finish line for ${family.family}`).toBeGreaterThanOrEqual(1)
+ // A skipped family may be recorded without a start line only if it never
+ // began; every family that began must have announced itself.
+ if (family.checked) {
+ expect(started.length, `no start line for ${family.family}`).toBeGreaterThanOrEqual(1)
+ }
+ // THE RECEIPT CARRIES THE WALL — not only the log.
+ expect(typeof family.durationMs, `${family.family} has no durationMs`).toBe('number')
+ expect(family.durationMs).toBeGreaterThanOrEqual(0)
+ }
+
+ // The closing line accounts for the whole repair, per family.
+ const closing = lines.filter((l) => /repairIndex complete in \d+ms/.test(l))
+ expect(closing.length).toBe(1)
+ expect(closing[0]).toMatch(/@\d+ms/)
+ }, 180_000)
+
+ it('heartbeats while a single phase is still walking', async () => {
+ const brain = await seededBrain()
+
+ // Make one phase long enough to cross the heartbeat cadence, exactly as a
+ // multi-minute canonical walk does on a real store.
+ const proto = FileSystemStorage.prototype as unknown as Record<
+ string,
+ (...args: unknown[]) => Promise
+ >
+ const realPrune = proto.pruneOrphanedEntities
+ proto.pruneOrphanedEntities = async function slow(this: unknown, ...args: unknown[]) {
+ await new Promise((r) => setTimeout(r, 6_500))
+ return realPrune.apply(this, args)
+ }
+ // Clamped as production clamps it: the narration must survive.
+ configureLogger({ level: LogLevel.ERROR })
+ const narrateSpy = vi.spyOn(prodLog, 'narrate')
+ try {
+ await brain.repairIndex()
+ } finally {
+ proto.pruneOrphanedEntities = realPrune
+ }
+
+ const beats = narrateSpy.mock.calls
+ .map(([m]) => String(m))
+ .filter((l) => /repairIndex: still in "orphaned-containers" after \d+s/.test(l))
+ expect(beats.length).toBeGreaterThanOrEqual(1)
+ expect(beats[0]).toMatch(/ghost\/scar containers/)
+ }, 180_000)
+})
diff --git a/tests/integration/vfs-root-sweep-once.test.ts b/tests/integration/vfs-root-sweep-once.test.ts
new file mode 100644
index 00000000..cac59b70
--- /dev/null
+++ b/tests/integration/vfs-root-sweep-once.test.ts
@@ -0,0 +1,133 @@
+/**
+ * @module tests/integration/vfs-root-sweep-once
+ * @description THE OLD-ROOT SWEEP RUNS ONCE PER STORE, NOT ONCE PER OPEN.
+ *
+ * The VFS bootstrap ran a filtered `find()` over the whole store on EVERY
+ * open, hunting for root directories created before the fixed root id existed
+ * — duplicates a store has either always had or never will. MEASURED on a
+ * 14,056-noun / 72,679-verb store: the phase it dominates cost 43–53 SECONDS
+ * of every open, warm reopens included.
+ *
+ * The law: a migration sweep is caused by the store's state, not by the clock
+ * or the open count. It runs behind the doors, records that it ran, and a
+ * store carrying that record never sweeps again.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest'
+import { mkdtempSync, rmSync, existsSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Brainy } from '../../src/brainy.js'
+import { NounType } from '../../src/types/graphTypes.js'
+import { VirtualFileSystem } from '../../src/vfs/VirtualFileSystem.js'
+import { prodLog } from '../../src/utils/logger.js'
+
+describe('the VFS old-root sweep', () => {
+ const dirs: string[] = []
+ const brains: Brainy[] = []
+
+ afterEach(async () => {
+ for (const b of brains.splice(0)) {
+ try { await b.close() } catch { /* already closed */ }
+ }
+ for (const d of dirs.splice(0)) {
+ try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ }
+ }
+ vi.restoreAllMocks()
+ })
+
+ async function open(dir: string): Promise {
+ const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ brains.push(brain)
+ await brain.init()
+ return brain
+ }
+
+ it('sweeps on the first open, records it, and never sweeps again', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'brainy-root-sweep-'))
+ dirs.push(dir)
+
+ const sweepSpy = vi.spyOn(
+ VirtualFileSystem.prototype as unknown as { cleanupOldRoots: () => Promise },
+ 'cleanupOldRoots'
+ )
+
+ const first = await open(dir)
+ await (first.vfs as unknown as { whenRootSweepSettled: () => Promise }).whenRootSweepSettled()
+ expect(sweepSpy).toHaveBeenCalledTimes(1)
+ // The record is durable engine plumbing under _system/, like every other marker.
+ expect(
+ existsSync(join(dir, '_system', 'vfs-root-sweep.json')) ||
+ existsSync(join(dir, '_system', 'vfs-root-sweep.json.gz'))
+ ).toBe(true)
+
+ await first.add({ data: 'a row so the store is not trivially empty', type: NounType.Concept })
+ await first.flush()
+ await first.close()
+ brains.splice(brains.indexOf(first), 1)
+
+ sweepSpy.mockClear()
+ const second = await open(dir)
+ await (second.vfs as unknown as { whenRootSweepSettled: () => Promise }).whenRootSweepSettled()
+ expect(sweepSpy).not.toHaveBeenCalled()
+
+ await second.close()
+ brains.splice(brains.indexOf(second), 1)
+
+ // ...and a third open, to prove it is the record and not a one-off.
+ sweepSpy.mockClear()
+ const third = await open(dir)
+ await (third.vfs as unknown as { whenRootSweepSettled: () => Promise }).whenRootSweepSettled()
+ expect(sweepSpy).not.toHaveBeenCalled()
+ }, 180_000)
+
+ it('a sweep that removes nothing on a fresh store says nothing', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'brainy-root-sweep-quiet-'))
+ dirs.push(dir)
+
+ // The always-visible channel cannot be silenced by a log level, so a line
+ // on it has to earn its place. A fresh store's sweep finds no duplicate
+ // roots and costs a millisecond — it must do its work, record its marker,
+ // and stay quiet, or it trains operators to ignore the one channel that
+ // exists to be impossible to ignore.
+ const narrated: string[] = []
+ const spy = vi.spyOn(prodLog, 'narrate').mockImplementation(((...args: unknown[]) => {
+ narrated.push(args.map((a) => String(a)).join(' '))
+ }) as typeof prodLog.narrate)
+
+ const brain = await open(dir)
+ await (brain.vfs as unknown as { whenRootSweepSettled: () => Promise }).whenRootSweepSettled()
+ spy.mockRestore()
+
+ expect(narrated.filter((l) => /old-root sweep/i.test(l))).toEqual([])
+ // ...and it still did the work: the marker is recorded, so no future open sweeps.
+ expect(
+ existsSync(join(dir, '_system', 'vfs-root-sweep.json')) ||
+ existsSync(join(dir, '_system', 'vfs-root-sweep.json.gz'))
+ ).toBe(true)
+ }, 180_000)
+
+ it('the open does not wait for the sweep', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'brainy-root-sweep-async-'))
+ dirs.push(dir)
+
+ const proto = VirtualFileSystem.prototype as unknown as Record<
+ string,
+ (...args: unknown[]) => Promise
+ >
+ const real = proto.cleanupOldRoots
+ proto.cleanupOldRoots = async function slow(this: unknown, ...args: unknown[]) {
+ await new Promise((r) => setTimeout(r, 4_000))
+ return real.apply(this, args)
+ }
+ try {
+ const startedAt = Date.now()
+ const brain = await open(dir)
+ const openMs = Date.now() - startedAt
+ expect(openMs).toBeLessThan(3_000)
+ await (brain.vfs as unknown as { whenRootSweepSettled: () => Promise }).whenRootSweepSettled()
+ } finally {
+ proto.cleanupOldRoots = real
+ }
+ }, 180_000)
+})
diff --git a/tests/integration/vfs-root-zero-norm.test.ts b/tests/integration/vfs-root-zero-norm.test.ts
index 00260eb4..577ae7ee 100644
--- a/tests/integration/vfs-root-zero-norm.test.ts
+++ b/tests/integration/vfs-root-zero-norm.test.ts
@@ -18,9 +18,16 @@
* harness reproduces exactly what a pre-fix store looked like on disk)
* is rewritten to `[]` on the next `init()`, the ledger is decremented
* through the sanctioned path, and a second `init()` is a no-op.
- * (c) THE BELT at the live provider-write seam: an entity added with an
- * EXPLICIT all-zero vector (any dimension) still lands its canonical
- * write, but the vector-index insert is refused loudly.
+ * (c) THE CANONICAL-WRITE NORMALIZATION (Leg A of the follow-up
+ * zero-norm/unvector-door fix): an entity added with an EXPLICIT
+ * all-zero vector (any dimension) is normalized to the "unvectored"
+ * `[]` shape BEFORE the canonical write, the ledger flag, and the index
+ * ops ever see it — the canonical write still succeeds, loudly, and the
+ * vector-index insert never happens (nothing to index). Supersedes the
+ * original "canonical keeps the zero vector, only the index refuses"
+ * shape: a downstream engine's health-report gate reads the canonical
+ * ledger directly, so leaving a zero-norm vector on the canonical side
+ * re-opened the exact false-attractor risk this whole fix closes.
* (d) the migrated root never surfaces in `find()` results (it was already
* hidden behind `visibility: 'system'` — this pin holds regardless).
*/
@@ -142,7 +149,7 @@ describe('VFS root zero-norm cure', () => {
await brain.close()
})
- it('(c) the live-write belt: an entity added with an explicit all-zero vector lands its canonical write, but the vector-index insert is refused loudly', async () => {
+ it('(c) canonical-write normalization: an entity added with an explicit all-zero vector persists UNVECTORED ([]), loudly, and never reaches the vector index', async () => {
const dir = mkTmp()
const brain = openBrain(dir)
await brain.init()
@@ -150,20 +157,26 @@ describe('VFS root zero-norm cure', () => {
const warnSpy = vi.spyOn(prodLog, 'warn')
const sizeBefore = (await brain.getIndexStatus()).hnswIndex.size
+ const ledgerBefore = await brain.storage.getCanonicalCounts()
const zeroVector = new Array(384).fill(0)
const id = await brain.add({ data: 'poisoned entity', type: NounType.Document, vector: zeroVector })
- // The canonical write succeeded — the entity is fully readable with its
- // (real, all-zero) vector intact.
+ // The canonical write succeeded — but the zero-norm vector was
+ // normalized to the "unvectored" `[]` shape BEFORE it was persisted
+ // (Leg A: a zero-norm vector is not a vector — it never crosses an
+ // engine boundary, canonical side included).
const entity = await brain.get(id, { includeVectors: true })
expect(entity).not.toBeNull()
- expect(entity.vector).toEqual(zeroVector)
+ expect(entity.vector).toEqual([])
- // The vector-index insert was skipped — the index size never moved.
+ // Nothing to index — the vector-index size never moved, and the
+ // vectored-noun ledger never counted this row.
const sizeAfter = (await brain.getIndexStatus()).hnswIndex.size
expect(sizeAfter).toBe(sizeBefore)
+ const ledgerAfter = await brain.storage.getCanonicalCounts()
+ expect(ledgerAfter.vectors.all).toBe(ledgerBefore.vectors.all)
- // The refusal was LOUD and named the entity.
+ // The normalization was LOUD and named the entity.
const loudCall = warnSpy.mock.calls.find(
(call) => typeof call[0] === 'string' && call[0].includes(id) && call[0].toLowerCase().includes('zero-norm')
)
diff --git a/tests/integration/writer-lock-clean-close.test.ts b/tests/integration/writer-lock-clean-close.test.ts
new file mode 100644
index 00000000..7d9c59d6
--- /dev/null
+++ b/tests/integration/writer-lock-clean-close.test.ts
@@ -0,0 +1,250 @@
+/**
+ * @module tests/integration/writer-lock-clean-close
+ * @description THE CLEAN-CLOSE CONTRACT for the writer lock.
+ *
+ * A production restart made this lane necessary: a service stopped with exit
+ * code 0, having awaited `close()` on every pooled brain, and its next boot
+ * announced `[brainy] Overwriting stale writer lock … appears dead` for every
+ * store it owned. "The pid is gone" is equally true of an orderly restart and
+ * of a crash, so the message could not tell an operator which one they had.
+ *
+ * The contract pinned here:
+ * 1. A completed close leaves NO lock file and DOES leave a clean-close
+ * record; the next open says nothing about staleness.
+ * 2. The next lock claim CONSUMES that record — it may never outlive the
+ * lock generation it describes, or a later crash would read as clean.
+ * 3. A close whose durable steps FAIL still releases the lock (and still
+ * rethrows the failure).
+ * 4. A killed process (SIGKILL, no close at all) leaves the lock behind with
+ * NO record, and the next open says exactly that — crash, recovery ahead.
+ * 5. A host application with its own SIGTERM handler is never force-exited
+ * out from under its own shutdown by Brainy's handler.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
+import { spawn } from 'node:child_process'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Brainy } from '../../src/brainy.js'
+import { NounType } from '../../src/types/graphTypes.js'
+
+const REPO_ROOT = process.cwd()
+const TSX = join(REPO_ROOT, 'node_modules', '.bin', 'tsx')
+
+function makeTempDir(): string {
+ return mkdtempSync(join(tmpdir(), 'brainy-clean-close-'))
+}
+
+/**
+ * Write a child script to disk and start it under tsx. A file (not `tsx -e`)
+ * because the eval form compiles to CommonJS, which has no top-level await.
+ * The script imports Brainy by ABSOLUTE path, so its own dependency
+ * resolution still happens from inside the repository.
+ */
+function startChild(dir: string, body: string): ReturnType {
+ const scriptPath = join(dir, 'child-process.mts')
+ writeFileSync(scriptPath, body)
+ // `detached` puts the child in its own process GROUP: tsx runs the script in
+ // a grandchild process, and only a group-wide signal reaches the process
+ // that actually holds the writer lock.
+ return spawn(TSX, [scriptPath], {
+ cwd: REPO_ROOT,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ detached: true
+ })
+}
+
+/** Capture every console.warn/error line emitted while `fn` runs. */
+async function captureConsole(fn: () => Promise): Promise<{ result: T; lines: string[] }> {
+ const lines: string[] = []
+ const origWarn = console.warn
+ const origError = console.error
+ const sink = (...args: unknown[]) => {
+ lines.push(args.map((a) => String(a)).join(' '))
+ }
+ console.warn = sink as typeof console.warn
+ console.error = sink as typeof console.error
+ try {
+ const result = await fn()
+ return { result, lines }
+ } finally {
+ console.warn = origWarn
+ console.error = origError
+ }
+}
+
+/**
+ * Run a child process that opens `dir`, writes one row, prints `READY`, and
+ * then waits forever. Resolves with the child once READY is seen.
+ */
+function spawnHoldingChild(dir: string): Promise<{
+ child: ReturnType
+ output: () => string
+}> {
+ const script = `
+ import { Brainy } from ${JSON.stringify(join(REPO_ROOT, 'src', 'brainy.ts'))}
+ const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: ${JSON.stringify(dir)} } })
+ await brain.init()
+ await brain.add({ data: 'row from the child', type: 'concept' })
+ await brain.flush()
+ console.log('READY')
+ setInterval(() => {}, 1000)
+ `
+ const child = startChild(dir, script)
+ let out = ''
+ child.stdout.on('data', (d) => { out += String(d) })
+ child.stderr.on('data', (d) => { out += String(d) })
+ return new Promise((resolvePromise, rejectPromise) => {
+ const timer = setTimeout(() => rejectPromise(new Error(`child never became READY:\n${out}`)), 120_000)
+ child.stdout.on('data', () => {
+ if (out.includes('READY')) {
+ clearTimeout(timer)
+ resolvePromise({ child, output: () => out })
+ }
+ })
+ child.on('exit', (code) => {
+ clearTimeout(timer)
+ if (!out.includes('READY')) rejectPromise(new Error(`child exited ${code} before READY:\n${out}`))
+ })
+ })
+}
+
+describe('writer lock — the clean-close contract', () => {
+ let dir: string
+ let brain: Brainy | null = null
+
+ beforeEach(() => { dir = makeTempDir() })
+
+ afterEach(async () => {
+ if (brain) {
+ try { await brain.close() } catch { /* may already be closed */ }
+ brain = null
+ }
+ try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
+ })
+
+ const lockPath = () => join(dir, 'locks', '_writer.lock')
+ const recordPath = () => join(dir, 'locks', '_writer.close')
+
+ it('a completed close leaves no lock, leaves a record, and the reopen is silent about staleness', async () => {
+ brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ await brain.init()
+ expect(existsSync(lockPath())).toBe(true)
+
+ await brain.add({ data: 'seed entity', type: NounType.Concept })
+ await brain.flush()
+ await brain.close()
+ brain = null
+
+ // 1. The lock is gone and the release is RECORDED.
+ expect(existsSync(lockPath())).toBe(false)
+ expect(existsSync(recordPath())).toBe(true)
+ const record = JSON.parse(readFileSync(recordPath(), 'utf-8'))
+ expect(record.pid).toBe(process.pid)
+ expect(typeof record.closedAt).toBe('string')
+ expect(typeof record.startedAt).toBe('string')
+
+ // 2. The reopen says nothing about a stale lock.
+ const { result: reopened, lines } = await captureConsole(async () => {
+ const next = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ await next.init()
+ return next
+ })
+ brain = reopened
+ expect(lines.filter((l) => /stale writer lock|appears dead/i.test(l))).toEqual([])
+
+ // 3. The claim CONSUMED the record — it must not outlive its lock generation.
+ expect(existsSync(recordPath())).toBe(false)
+ expect(existsSync(lockPath())).toBe(true)
+ }, 120_000)
+
+ it('releases the writer lock even when a durable close step fails — and still rethrows', async () => {
+ brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ await brain.init()
+ await brain.add({ data: 'seed entity', type: NounType.Concept })
+ await brain.flush()
+ expect(existsSync(lockPath())).toBe(true)
+
+ // Inject a failure into a durable close step (the counts flush).
+ const storage = (brain as unknown as { storage: { flushCounts: () => Promise } }).storage
+ const boom = new Error('injected: counts flush failed during close')
+ storage.flushCounts = async () => { throw boom }
+
+ await expect(brain.close()).rejects.toThrow(/injected: counts flush failed/)
+ brain = null
+
+ // The lock is released regardless: a process on its way out holds nothing.
+ expect(existsSync(lockPath())).toBe(false)
+
+ // And the next writer opens without a stale-lock verdict.
+ const { lines } = await captureConsole(async () => {
+ const next = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ await next.init()
+ await next.close()
+ })
+ expect(lines.filter((l) => /appears dead/i.test(l))).toEqual([])
+ }, 120_000)
+
+ it('a SIGKILLed writer leaves the lock with no record, and the next open names the crash', async () => {
+ const { child } = await spawnHoldingChild(dir)
+ expect(existsSync(lockPath())).toBe(true)
+ expect(existsSync(recordPath())).toBe(false)
+
+ // Group-wide: the lock holder is tsx's grandchild, not the spawned pid.
+ process.kill(-(child.pid as number), 'SIGKILL')
+ await new Promise((r) => child.on('exit', () => r()))
+ // The grandchild's death is asynchronous with the wrapper's exit event.
+ await new Promise((r) => setTimeout(r, 500))
+
+ // The lock survives the kill — a dead process releases nothing.
+ expect(existsSync(lockPath())).toBe(true)
+ expect(existsSync(recordPath())).toBe(false)
+
+ const { lines } = await captureConsole(async () => {
+ const next = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } })
+ await next.init()
+ await next.close()
+ })
+ const verdict = lines.filter((l) => /Overwriting stale writer lock/i.test(l))
+ expect(verdict.length).toBe(1)
+ // The verdict must name the ABSENT record and the recovery it implies —
+ // not merely that a pid is gone.
+ expect(verdict[0]).toMatch(/NO\s+clean-close record/i)
+ expect(verdict[0]).toMatch(/crash recovery/i)
+ }, 180_000)
+
+ it("does not force-exit a host application that owns its own SIGTERM handler", async () => {
+ const script = `
+ import { Brainy } from ${JSON.stringify(join(REPO_ROOT, 'src', 'brainy.ts'))}
+ const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: ${JSON.stringify(dir)} } })
+ await brain.init()
+ await brain.add({ data: 'row from the host app', type: 'concept' })
+ await brain.flush()
+ // The host application's OWN graceful shutdown, registered after Brainy's.
+ process.on('SIGTERM', async () => {
+ await new Promise((r) => setTimeout(r, 1500))
+ console.log('APP-CLOSE-DONE')
+ process.exit(0)
+ })
+ console.log('READY')
+ setInterval(() => {}, 1000)
+ `
+ const child = startChild(dir, script)
+ let out = ''
+ child.stdout.on('data', (d) => { out += String(d) })
+ child.stderr.on('data', (d) => { out += String(d) })
+ await new Promise((r, reject) => {
+ const timer = setTimeout(() => reject(new Error(`child never became READY:\n${out}`)), 120_000)
+ child.stdout.on('data', () => { if (out.includes('READY')) { clearTimeout(timer); r() } })
+ child.on('exit', () => { clearTimeout(timer); if (!out.includes('READY')) reject(new Error(`child died:\n${out}`)) })
+ })
+
+ process.kill(-(child.pid as number), 'SIGTERM')
+ const code = await new Promise((r) => child.on('exit', (c) => r(c)))
+ expect(code).toBe(0)
+ // The host's own shutdown ran to completion — Brainy's handler did not
+ // exit the process out from under it.
+ expect(out).toContain('APP-CLOSE-DONE')
+ }, 180_000)
+})
diff --git a/tests/integration/zero-norm-unvector-door.test.ts b/tests/integration/zero-norm-unvector-door.test.ts
new file mode 100644
index 00000000..d3290010
--- /dev/null
+++ b/tests/integration/zero-norm-unvector-door.test.ts
@@ -0,0 +1,399 @@
+/**
+ * @module tests/integration/zero-norm-unvector-door
+ * @description THE SEAM LAW, GENERALIZED: "a zero-norm vector is not a
+ * vector — it never crosses an engine boundary." `tests/integration/
+ * vfs-root-zero-norm.test.ts` pins the VFS-root-specific cure; this file
+ * pins the follow-up that generalizes it to every write path plus the
+ * sanctioned door for shedding a vector on purpose.
+ *
+ * Four legs pinned here:
+ * (A) THE CANONICAL WRITE NORMALIZES ZERO-NORM TO `[]` — `add()` (single and
+ * `transact()`) persists an explicit real all-zero vector as the
+ * "unvectored" `[]` shape, loudly, before the ledger flag/dimension
+ * pin/index ops ever see it. The canonical write still succeeds.
+ * (B) THE LEGACY DERIVATION IS ZERO-NORM-AWARE — a lost/corrupted
+ * `counts.json`'s one-time re-derivation walk excludes a persisted
+ * zero-norm row from the vectored-noun scalar, matching the live
+ * ledger's definition of "vectored".
+ * (C) THE LEGACY VFS ROOT MIGRATES AT OPEN, BEFORE THE GATE, IN O(1) — a
+ * store whose ONLY vectored row is a legacy all-zero VFS root opens
+ * clean (no `VectorIndexNotReadyError`), via one fixed-path read, never
+ * a listing.
+ * (D) THE UNVECTOR DOOR — `update({ id, vector: [] })` (and the same op
+ * inside `transact()`) is the sanctioned, idempotent way to shed a
+ * vector on purpose: ledger decrement exactly once, index removal, no
+ * re-embed, and a pending deferred-embed marker is cleared rather than
+ * left to re-vectorize the row later.
+ */
+import { describe, it, expect, afterEach, vi } from 'vitest'
+import * as fs from 'node:fs'
+import * as os from 'node:os'
+import * as path from 'node:path'
+import { Brainy } from '../../src/index.js'
+import { NounType } from '../../src/types/graphTypes.js'
+import { prodLog } from '../../src/utils/logger.js'
+import { JsHnswVectorIndex } from '../../src/hnsw/hnswIndex.js'
+import { BaseStorage } from '../../src/storage/baseStorage.js'
+
+const ROOT_ID = '00000000-0000-0000-0000-000000000000'
+
+process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
+
+const tmpDirs: string[] = []
+function mkTmp(): string {
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-zero-norm-unvector-'))
+ tmpDirs.push(d)
+ return d
+}
+afterEach(() => {
+ vi.restoreAllMocks()
+ for (const d of tmpDirs.splice(0)) fs.rmSync(d, { recursive: true, force: true })
+})
+
+function openBrain(dir: string): any {
+ return new Brainy({
+ requireSubtype: false,
+ storage: { type: 'filesystem', path: dir },
+ silent: true,
+ dimensions: 384
+ })
+}
+
+const countsPath = (root: string) => path.join(root, '_system', 'counts.json')
+
+describe('zero-norm canonical write + the sanctioned unvector door', () => {
+ it('(A1) add() with an explicit all-zero vector persists [], warns loudly, never indexes, and the ledger is unchanged', async () => {
+ const dir = mkTmp()
+ const brain = openBrain(dir)
+ await brain.init()
+
+ const ledgerBefore = await brain.storage.getCanonicalCounts()
+ const sizeBefore = (await brain.getIndexStatus()).hnswIndex.size
+ const warnSpy = vi.spyOn(prodLog, 'warn')
+
+ const zeroVector = new Array(384).fill(0)
+ const id = await brain.add({ data: 'zero-norm add', type: NounType.Document, vector: zeroVector })
+
+ const entity = await brain.get(id, { includeVectors: true })
+ expect(entity).not.toBeNull()
+ expect(entity.vector).toEqual([])
+
+ const ledgerAfter = await brain.storage.getCanonicalCounts()
+ expect(ledgerAfter.vectors.all).toBe(ledgerBefore.vectors.all)
+
+ const sizeAfter = (await brain.getIndexStatus()).hnswIndex.size
+ expect(sizeAfter).toBe(sizeBefore)
+
+ const loud = warnSpy.mock.calls.find(
+ (c) => typeof c[0] === 'string' && c[0].includes(id) && c[0].toLowerCase().includes('zero-norm')
+ )
+ expect(loud).toBeDefined()
+
+ await brain.close()
+ })
+
+ it('(A2) transact() add with an explicit all-zero vector — the same canonical normalization', async () => {
+ const dir = mkTmp()
+ const brain = openBrain(dir)
+ await brain.init()
+
+ const ledgerBefore = await brain.storage.getCanonicalCounts()
+ const warnSpy = vi.spyOn(prodLog, 'warn')
+ const zeroVector = new Array(384).fill(0)
+ const id = 'aaaaaaaa-0000-4000-8000-000000000001'
+
+ await brain.transact([
+ { op: 'add', id, type: NounType.Document, data: 'zero-norm transact add', vector: zeroVector }
+ ])
+
+ const entity = await brain.get(id, { includeVectors: true })
+ expect(entity).not.toBeNull()
+ expect(entity.vector).toEqual([])
+
+ const ledgerAfter = await brain.storage.getCanonicalCounts()
+ expect(ledgerAfter.vectors.all).toBe(ledgerBefore.vectors.all)
+
+ const loud = warnSpy.mock.calls.find(
+ (c) => typeof c[0] === 'string' && c[0].includes(id) && c[0].toLowerCase().includes('zero-norm')
+ )
+ expect(loud).toBeDefined()
+
+ await brain.close()
+ })
+
+ it('(B) the legacy counts.json derivation excludes a persisted zero-norm row from the vectored-noun scalar', async () => {
+ const dir = mkTmp()
+ let brain = openBrain(dir)
+ await brain.init()
+
+ // The VFS root alone (unvectored — []) — the floor.
+ const baseline = (await brain.storage.getCanonicalCounts()).vectors.all
+
+ const realId = await brain.add({ data: 'a real vectored document', type: NounType.Document })
+
+ // Plant the legacy all-zero shape BY HAND: a genuine identity record
+ // (via add(), so it has real metadata) whose vector leg is then
+ // overwritten directly through the raw storage primitive — bypassing
+ // Leg A's canonical-write normalization entirely (brain.storage.saveNoun
+ // is not Brainy.add()/update()'s normalized path) — reproducing exactly
+ // what a pre-fix store could have persisted on disk.
+ const zeroId = await brain.add({ data: 'a legacy zero-norm document', type: NounType.Document })
+ const zeroVector = new Array(384).fill(0)
+ await brain.storage.saveNoun({ id: zeroId, vector: zeroVector, connections: new Map(), level: 0 })
+
+ await brain.flush()
+ await brain.close()
+
+ // Remove counts.json so the next open re-derives from scratch (the
+ // one-time legacy/lost-file derivation path — Leg B).
+ fs.rmSync(countsPath(dir), { force: true })
+
+ brain = openBrain(dir)
+ await brain.init()
+ const ledger = await brain.storage.getCanonicalCounts()
+ // Only realId counts; zeroId's persisted all-zero vector does not.
+ expect(ledger.vectors.all).toBe(baseline + 1)
+
+ await brain.close()
+ })
+
+ it('(C) a legacy all-zero VFS root as the ONLY vectored row: open succeeds with no not-ready error, via an O(1) fixed-path read (no entities-tree readdir), and the ledger is 0 after open', async () => {
+ const dir = mkTmp()
+
+ // SESSION 1 — build the legacy shape: the root is a REAL all-zero
+ // 384-dim vector, genuinely indexed and genuinely ledgered — exactly
+ // what a pre-fix store's root looked like on disk (see
+ // vfs-root-zero-norm.test.ts pin (b) for the identical harness).
+ // `index.addItem` is called directly (bypassing the transactional
+ // zero-norm belt) because the pre-fix code path had no such belt — this
+ // harness must match history, not the cure. No other entity is added,
+ // so the root is the store's ONLY vectored row.
+ let brain = openBrain(dir)
+ await brain.init()
+ const oldVector = new Array(384).fill(0)
+ await brain.storage.saveNoun({ id: ROOT_ID, vector: oldVector, connections: new Map(), level: 0 })
+ await brain.index.addItem({ id: ROOT_ID, vector: oldVector })
+ await brain.storage.noteVectorLanded(ROOT_ID)
+ await brain.storage.persistCounts()
+ await brain.flush()
+ expect((await brain.storage.getCanonicalCounts()).vectors.all).toBe(1)
+ await brain.close()
+
+ // SESSION 2 — reopen with a FAKE native vector provider that claims
+ // `serving: true` at `size()===0` (the exact shape a downstream
+ // engine's own health report can legitimately carry — same technique as
+ // tests/integration/vector-leg-open-build.test.ts). This is the ONLY
+ // codepath where the vector-leg open gate's FAIL-TYPED throw
+ // (VectorIndexNotReadyError) can fire; the built-in JS engine alone
+ // never reaches it (the size-heuristic branch just rebuilds instead) —
+ // so this is the faithful reproduction of the incident Leg C closes.
+ const readdirCalls: string[] = []
+ const originalReaddir = fs.promises.readdir.bind(fs.promises)
+ vi.spyOn(fs.promises, 'readdir').mockImplementation(((...args: any[]) => {
+ readdirCalls.push(String(args[0]))
+ return (originalReaddir as any)(...args)
+ }) as any)
+
+ // Spy at the PROTOTYPE level (BaseStorage.getNoun) — the new brain's
+ // storage instance does not exist until init() runs, so an
+ // instance-level spy cannot be installed beforehand. Records the
+ // readdir-call delta across the FIRST call made with the root id —
+ // Leg C's own fixed-path read — proving it needs no directory listing.
+ let readdirDeltaDuringRootRead: number | null = null
+ const originalGetNoun = BaseStorage.prototype.getNoun
+ vi.spyOn(BaseStorage.prototype, 'getNoun').mockImplementation(async function (
+ this: unknown,
+ id: string
+ ) {
+ const before = readdirCalls.length
+ const result = await originalGetNoun.call(this as BaseStorage, id)
+ if (id === ROOT_ID && readdirDeltaDuringRootRead === null) {
+ readdirDeltaDuringRootRead = readdirCalls.length - before
+ }
+ return result
+ })
+
+ brain = openBrain(dir)
+ brain.use({
+ name: 'fake-native-vector-unledgered-coverage',
+ activate: async (ctx: any) => {
+ ctx.registerProvider('vector', (config: any, distance: any, options: any) => {
+ const real = new JsHnswVectorIndex(config, distance, options)
+ let rebuilt = false
+ const originalRebuild = real.rebuild.bind(real)
+ ;(real as any).rebuild = async (...args: any[]) => {
+ const r = await originalRebuild(...args)
+ rebuilt = true
+ return r
+ }
+ const originalSize = real.size.bind(real)
+ ;(real as any).size = () => (rebuilt ? originalSize() : 0)
+ ;(real as any).healthReport = () => ({
+ provider: 'vector',
+ healthy: true,
+ serving: true,
+ invariants: [],
+ checkedAt: Date.now(),
+ durationMs: 0,
+ generation: 1,
+ unledgered: ['vector-coverage']
+ })
+ return real
+ })
+ return true
+ }
+ })
+
+ // Must NOT throw VectorIndexNotReadyError (or anything else) — a
+ // near-empty store whose only vectored row is the zero-norm root must
+ // never go dark.
+ await brain.init()
+
+ const migratedRoot = await brain.get(ROOT_ID, { includeVectors: true })
+ expect(migratedRoot.vector).toEqual([])
+
+ const ledgerAfter = await brain.storage.getCanonicalCounts()
+ expect(ledgerAfter.vectors.all).toBe(0)
+
+ expect(readdirDeltaDuringRootRead).toBe(0)
+
+ await brain.close()
+ })
+
+ describe('the sanctioned unvector door', () => {
+ it('(D1) update({ id, vector: [] }) unvectors a real vectored row — canonical [], removed from the index, ledger decremented by exactly 1, no embed call', async () => {
+ const dir = mkTmp()
+ const brain = openBrain(dir)
+ await brain.init()
+
+ const id = await brain.add({ data: 'a real document', type: NounType.Document })
+ await brain.flush()
+
+ const ledgerBefore = await brain.storage.getCanonicalCounts()
+ const sizeBefore = (await brain.getIndexStatus()).hnswIndex.size
+
+ const embedSpy = vi.spyOn(brain, 'embed')
+ await brain.update({ id, vector: [] })
+ expect(embedSpy).not.toHaveBeenCalled()
+
+ const entity = await brain.get(id, { includeVectors: true })
+ expect(entity.vector).toEqual([])
+
+ const ledgerAfter = await brain.storage.getCanonicalCounts()
+ expect(ledgerAfter.vectors.all).toBe(ledgerBefore.vectors.all - 1)
+
+ const sizeAfter = (await brain.getIndexStatus()).hnswIndex.size
+ expect(sizeAfter).toBe(sizeBefore - 1)
+
+ await brain.close()
+ })
+
+ it('(D2) idempotent: a second update({ id, vector: [] }) on an already-unvectored row is a true no-op — no error, no further decrement', async () => {
+ const dir = mkTmp()
+ const brain = openBrain(dir)
+ await brain.init()
+
+ const id = await brain.add({ data: 'a real document', type: NounType.Document })
+ await brain.flush()
+
+ await brain.update({ id, vector: [] })
+ const ledgerAfterFirst = await brain.storage.getCanonicalCounts()
+
+ await brain.update({ id, vector: [] })
+ const ledgerAfterSecond = await brain.storage.getCanonicalCounts()
+ expect(ledgerAfterSecond.vectors.all).toBe(ledgerAfterFirst.vectors.all)
+
+ const entity = await brain.get(id, { includeVectors: true })
+ expect(entity.vector).toEqual([])
+
+ await brain.close()
+ })
+
+ it('(D3) a PENDING deferred-embed row: the unvector door clears the marker; awaitPendingEmbeds() then leaves it unvectored', async () => {
+ const dir = mkTmp()
+ const brain = openBrain(dir)
+ await brain.init()
+
+ // Prevent the background worker from ever actually running — it is
+ // fire-and-forget from add(), and a real run would race this test's
+ // own assertions (see tests/integration/vector-leg-open-build.test.ts
+ // for the same concern). This isolates exactly the marker-clearing
+ // behavior under test.
+ vi.spyOn(brain as any, 'kickEmbedWorker').mockImplementation(() => {})
+
+ const id = await brain.add({
+ data: 'deferred content, never embedded',
+ type: NounType.Document,
+ deferEmbedding: true
+ })
+ expect(brain.pendingEmbedCount()).toBe(1)
+
+ const warnSpy = vi.spyOn(prodLog, 'warn')
+ await brain.update({ id, vector: [] })
+
+ expect(brain.pendingEmbedCount()).toBe(0)
+ const clearedWarn = warnSpy.mock.calls.find(
+ (c) => typeof c[0] === 'string' && c[0].includes(id) && c[0].toLowerCase().includes('pending')
+ )
+ expect(clearedWarn).toBeDefined()
+
+ // The barrier must not hang and must not re-vectorize the row — the
+ // worker (still mocked to a no-op) never runs again.
+ await brain.awaitPendingEmbeds()
+
+ const entity = await brain.get(id, { includeVectors: true })
+ expect(entity.vector).toEqual([])
+
+ await brain.close()
+ })
+
+ it('(D4) update({ vector: [], deferEmbedding: true }) is a typed refusal — the unvector door cannot be paired with a deferred embed', async () => {
+ const dir = mkTmp()
+ const brain = openBrain(dir)
+ await brain.init()
+
+ const id = await brain.add({ data: 'a real document', type: NounType.Document })
+ const before = await brain.get(id, { includeVectors: true })
+
+ await expect(
+ brain.update({ id, vector: [], deferEmbedding: true })
+ ).rejects.toThrow(/unvector door/i)
+
+ // Refused before any write — the row is untouched.
+ const after = await brain.get(id, { includeVectors: true })
+ expect(after.vector).toEqual(before.vector)
+
+ await brain.close()
+ })
+
+ it('(D5) the transact() twin of the unvector door decrements the ledger exactly once, and is idempotent on a second call', async () => {
+ const dir = mkTmp()
+ const brain = openBrain(dir)
+ await brain.init()
+
+ const id = await brain.add({ data: 'a real document for transact unvector', type: NounType.Document })
+ await brain.flush()
+
+ const ledgerBefore = await brain.storage.getCanonicalCounts()
+ const sizeBefore = (await brain.getIndexStatus()).hnswIndex.size
+
+ await brain.transact([{ op: 'update', id, vector: [] }])
+
+ const entity = await brain.get(id, { includeVectors: true })
+ expect(entity.vector).toEqual([])
+
+ const ledgerAfter = await brain.storage.getCanonicalCounts()
+ expect(ledgerAfter.vectors.all).toBe(ledgerBefore.vectors.all - 1)
+
+ const sizeAfter = (await brain.getIndexStatus()).hnswIndex.size
+ expect(sizeAfter).toBe(sizeBefore - 1)
+
+ // Idempotent through transact() too.
+ await brain.transact([{ op: 'update', id, vector: [] }])
+ const ledgerAfterSecond = await brain.storage.getCanonicalCounts()
+ expect(ledgerAfterSecond.vectors.all).toBe(ledgerAfter.vectors.all)
+
+ await brain.close()
+ })
+ })
+})
diff --git a/tests/lifecycle/biography.test.ts b/tests/lifecycle/biography.test.ts
index 8b274fce..274f0ef0 100644
--- a/tests/lifecycle/biography.test.ts
+++ b/tests/lifecycle/biography.test.ts
@@ -382,9 +382,17 @@ describe.sequential('lifecycle — the working store', () => {
},
// Every noun this biography ever adds carries an explicit/computed
// vector (the harness never defers an embed), so the vectored-noun
- // scalar tracks nouns.all exactly.
+ // scalar tracks nouns.all exactly EXCEPT for the VFS root counted
+ // in `vfsBaselineNouns`: the root is deliberately persisted with
+ // `vector: []` (the sanctioned "unvectored" shape — see
+ // VirtualFileSystem.doInitializeRoot()'s zero-norm-avoidance
+ // comment) so it never pays the WASM engine's cold-compile cost and
+ // never crosses an engine boundary as a false attractor. It is the
+ // ONE hidden-tier record `vfsBaselineNouns` represents (see
+ // biographyHarness's module header), so it is excluded here even
+ // though it counts toward `nouns.all`.
vectors: {
- all: aliveEntities.length + model.vfsFileNouns + model.vfsBaselineNouns
+ all: aliveEntities.length + model.vfsFileNouns
},
suspect: false
})
diff --git a/tests/unit/brainy/migration-deference.test.ts b/tests/unit/brainy/migration-deference.test.ts
index 31b9b216..b5817c3d 100644
--- a/tests/unit/brainy/migration-deference.test.ts
+++ b/tests/unit/brainy/migration-deference.test.ts
@@ -15,7 +15,7 @@
* - Hook 2: the public `brain.stampBrainFormat()` the provider calls once its
* background migration has verified-and-swapped, authoring the shared
* `_system/brain-format.json` marker.
- * - Hook 3: the marker module is re-exported at `@soulcraft/brainy/brain-format`
+ * - Hook 3: the marker module is re-exported at `@soulcraftlabs/brainy/brain-format`
* so cor reads the SAME `EXPECTED_INDEX_EPOCH` / `CURRENT_DATA_FORMAT` constants
* (single source of truth, no duplicated value).
*
@@ -242,7 +242,7 @@ describe('rc.8 no-freeze migration deference (isMigrating / stampBrainFormat / b
// --- Hook 3: marker module export ----------------------------------------
it('the brain-format marker module exports the compiled epoch + data-format constants', () => {
- // cor imports these from '@soulcraft/brainy/brain-format' (Hook 3) so both
+ // cor imports these from '@soulcraftlabs/brainy/brain-format' (Hook 3) so both
// sides share ONE source of truth — no duplicated constant to drift.
// Epoch 3: the namespace-law key split (bare user keys · literal
// 'system.' scalars, 2026-08-03) — every brain rebuilds onto the
diff --git a/tests/unit/db/generation-segments.test.ts b/tests/unit/db/generation-segments.test.ts
index 27ab85cb..f16e67b3 100644
--- a/tests/unit/db/generation-segments.test.ts
+++ b/tests/unit/db/generation-segments.test.ts
@@ -147,4 +147,119 @@ describe('db/GenerationSegmentStore — the D1+D3 packed tier', () => {
await expect(store.fold([gen(4), gen(4)])).rejects.toThrow(/strictly ascending/)
await expect(store.fold([])).rejects.toThrow(/at least one generation/)
})
+
+ // ==========================================================================
+ // THE DENSITY LAW
+ // ==========================================================================
+ //
+ // A sealed segment declares a CONTIGUOUS range and every reader treats that
+ // range as containment. Folding a sparse batch therefore makes the segment
+ // claim generations it does not hold — and because `open()` merges declared
+ // ranges back into committedRanges, the hole is re-admitted as committed
+ // history and every later maintenance pass fails asking for a frame that was
+ // never written. That is the "generation N is inside sealed segment
+ // seg-....bgs's declared range but has no frame — packed history is damaged"
+ // narration seen on every run of the affected stores.
+
+ it('fold REFUSES a batch with a hole — a dense range may not be declared over sparse input', async () => {
+ await expect(store.fold([gen(1), gen(2), gen(4)])).rejects.toThrow(
+ /not contiguous: 2 → 4 skips 1 generation/
+ )
+ // The refusal loses nothing: no segment was sealed, so the generations
+ // stay in the live tier and the next pass folds them correctly.
+ expect(store.segments()).toHaveLength(0)
+ expect(store.hasGeneration(1)).toBe(false)
+ })
+
+ it('a wider gap names how many generations it would have swallowed', async () => {
+ await expect(store.fold([gen(10), gen(20)])).rejects.toThrow(
+ /not contiguous: 10 → 20 skips 9 generation\(s\)/
+ )
+ })
+
+ it('two contiguous runs folded separately declare honest ranges', async () => {
+ // What the caller now does instead of folding across the gap.
+ const a = await store.fold([gen(1), gen(2), gen(3)])
+ const b = await store.fold([gen(7), gen(8)])
+ expect(a).toMatchObject({ firstGeneration: 1, lastGeneration: 3, frames: 3 })
+ expect(b).toMatchObject({ firstGeneration: 7, lastGeneration: 8, frames: 2 })
+ // The gap is honestly outside the packed tier.
+ for (const g of [4, 5, 6]) expect(store.hasGeneration(g)).toBe(false)
+ for (const g of [1, 2, 3, 7, 8]) expect(store.hasGeneration(g)).toBe(true)
+ expect(await store.actualRanges()).toEqual([
+ [1, 3],
+ [7, 8]
+ ])
+ })
+
+ it('actualRanges() is exact and I/O-free for dense segments', async () => {
+ await store.fold([gen(1), gen(2)])
+ await store.fold([gen(3), gen(4)])
+ // Adjacent dense segments each contribute their declared range.
+ expect(await store.actualRanges()).toEqual([
+ [1, 2],
+ [3, 4]
+ ])
+ })
+
+ // ---- pre-existing damage: a store sealed by the old writer ----------------
+
+ /**
+ * Seal a SPARSE segment the way the pre-fix writer did: write the bytes and
+ * sidecar for a contiguous run, then rewrite the manifest so the segment
+ * declares a wider range than the frames it holds. This reproduces on disk
+ * exactly what the affected stores carry, without needing the old code.
+ */
+ const sealSparseSegment = async (): Promise => {
+ await store.fold([gen(1), gen(2), gen(3)])
+ const manifest = (await storage.readRawObject(`${SEGMENTS_PREFIX}/manifest.json`)) as any
+ // Declare 1..5 while holding frames for 1..3 — generations 4 and 5 become
+ // holes inside a sealed range.
+ manifest.segments[0].lastGeneration = 5
+ await storage.writeRawObject(`${SEGMENTS_PREFIX}/manifest.json`, manifest)
+ }
+
+ it('a pre-existing sparse segment reports its holes as UNPACKED, not as damage', async () => {
+ await sealSparseSegment()
+ const reopened = new GenerationSegmentStore(storage as any)
+ await reopened.open()
+
+ // The frames it really holds still serve, byte-faithfully.
+ expect((await reopened.readDelta(2))?.timestamp).toBe(1_700_000_000_002)
+ expect(await reopened.readRecords(3)).toHaveLength(2)
+
+ // The holes answer "not packed" instead of throwing. This is the fix for
+ // the wedge: the old reader threw here on EVERY maintenance pass.
+ expect(await reopened.readDelta(4)).toBeNull()
+ expect(await reopened.readRecords(5)).toBeNull()
+ })
+
+ it('actualRanges() excludes the holes so they are never re-admitted as committed', async () => {
+ await sealSparseSegment()
+ const reopened = new GenerationSegmentStore(storage as any)
+ await reopened.open()
+ // Declared 1..5; actually holds 1..3. The store seeds committedRanges from
+ // THIS, so generations 4 and 5 never become committed history again.
+ expect(await reopened.actualRanges()).toEqual([[1, 3]])
+ })
+
+ it('a DENSE segment missing a frame is still loud damage', async () => {
+ // The other side of the branch: when the manifest claims a complete span,
+ // a missing frame means the manifest and sidecar disagree — real damage,
+ // and it must not be quietly downgraded to "unpacked".
+ await store.fold([gen(1), gen(2), gen(3)])
+ const idxPath = `${SEGMENTS_PREFIX}/seg-${String(1).padStart(20, '0')}.idx`
+ const raw = (await storage.readRawBytes(idxPath))!
+ const { decode, encode } = await import('@msgpack/msgpack')
+ const idx = decode(raw) as any
+ // Drop generation 2's entry while the manifest still declares 3 frames.
+ idx.generations = idx.generations.filter(([g]: [number]) => g !== 2)
+ await storage.writeRawBytes(idxPath, encode(idx))
+
+ const reopened = new GenerationSegmentStore(storage as any)
+ await reopened.open()
+ await expect(reopened.readDelta(2)).rejects.toThrow(
+ /manifest and the sidecar disagree; packed history is damaged/
+ )
+ })
})