diff --git a/.claude/skills/architecture.md b/.claude/skills/architecture.md index 4de3c287..de046b17 100644 --- a/.claude/skills/architecture.md +++ b/.claude/skills/architecture.md @@ -2,7 +2,7 @@ ## What Is Brainy -@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. +@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. ## Core Architecture diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml deleted file mode 100644 index da5887f6..00000000 --- a/.forgejo/workflows/ci.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: CI - -# Branch pushes only — a release TAG deliberately does not re-run CI: the -# tagged commit's CI already ran on its branch push, and the runner is -# sequential, so tag-triggered matrix jobs (~22 min) would queue AHEAD of the -# tag's publish-source run and starve every release (observed on 8.10.3 and -# 9.0.0: the publish sat behind the tag's own redundant CI). -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -on: - push: - branches: ['**'] - pull_request: - -jobs: - node: - name: Node ${{ matrix.node-version }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node-version: ['22', '24'] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: npm - - run: npm ci - - run: npm run test:unit - - # The correctness plant's full gate: integration + conformance run here on - # dedicated iron, on every push, so a release never depends on any other - # machine being up. Verdicts live in this run's log (never inferred). - integration: - name: Integration + conformance (Node 22) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - - run: npm ci - - run: npm run test:ci-integration - - run: npx vitest run tests/conformance - - bun: - name: Bun (latest) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: npm ci - # test:bun imports the built dist/, so build first. - - run: npm run build - # Bun as a runtime is the supported Bun story (`bun add` / `bun run`). - - run: npm run test:bun diff --git a/.forgejo/workflows/delta-gate.yml b/.forgejo/workflows/delta-gate.yml deleted file mode 100644 index c320594e..00000000 --- a/.forgejo/workflows/delta-gate.yml +++ /dev/null @@ -1,148 +0,0 @@ -name: Delta Gate - -# On-demand candidate-vs-control gate on the capped functional CI lane -# (label: gate-functional). That lane is Bun-only host-mode — there is no -# Node.js runtime available to it, so this workflow deliberately avoids every -# JS-based action (checkout/setup-node/setup-bun/upload-artifact all require -# one) and does everything with plain git + bun in shell steps instead. -# -# Verdict lines a caller should grep for in the run log: -# COLLECTED patch= control= — collection-truncation guard inputs -# NEW-RED-COUNT: — failures on candidate absent from control -# DELTA-GATE: CLEAN | NEW REDS | INVALID | STOPPED-BY-REGISTRY-TRIPWIRE -# -# The lane's own housekeeping stops the runner and drops a marker file when -# host pressure (I/O, registry latency, disk budget) trips — never ours to -# interpret as a red or a green. The final step checks for that marker before -# it says anything about pass/fail. - -on: - workflow_dispatch: - inputs: - candidate: - description: 'Candidate ref (branch or sha) to gate' - required: true - type: string - control: - description: 'Control sha to diff against' - required: true - type: string - # workflow_dispatch needs Actions-unit write on the dispatching credential; - # push does not (it runs from the pushed ref's own tree), so a plain push - # to a release or CI branch is the fallback trigger while that grant is - # outstanding — see the ref-resolution step below for what it gates against. - push: - branches: ['rel/**', 'ci/**'] - -concurrency: - group: delta-gate - cancel-in-progress: false - -jobs: - delta-gate: - name: Delta gate — candidate vs control - runs-on: gate-functional - timeout-minutes: 120 - steps: - - name: Resolve candidate/control refs - id: refs - run: | - candidate="${{ github.event.inputs.candidate }}" - control="${{ github.event.inputs.control }}" - # workflow_dispatch supplies both explicitly; a push event carries - # neither — fall back to the pushed commit as candidate and the - # last released, known-good tip (10.4.9) as control, so a plain - # push still produces a meaningful gate instead of an empty ref. - if [ -z "$candidate" ]; then candidate="${{ github.sha }}"; fi - if [ -z "$control" ]; then control="eec90bdd"; fi - echo "candidate=$candidate" >> "$GITHUB_OUTPUT" - echo "control=$control" >> "$GITHUB_OUTPUT" - echo "Resolved (trigger=${{ github.event_name }}): candidate=$candidate control=$control" - - - name: Clean any residue from a prior run - run: rm -rf "ob-cand-${{ github.run_id }}" "ob-ctrl-${{ github.run_id }}" "/tmp/ob-${{ github.run_id }}-"* - - - name: Clone + test — candidate - id: patch - run: | - set -o pipefail - git clone --quiet "https://source.soulcraft.com/soulcraftlabs/open-brainy.git" "ob-cand-${{ github.run_id }}" - cd "ob-cand-${{ github.run_id }}" - git checkout --quiet "${{ steps.refs.outputs.candidate }}" - git log --oneline -1 - bun install - rc=0 - bun x vitest run > "/tmp/ob-${{ github.run_id }}-patch.log" 2>&1 || rc=$? - echo "PATCH-RC:$rc" - grep -aE "Tests .*(passed|failed)" "/tmp/ob-${{ github.run_id }}-patch.log" | tail -1 - grep -aE "^ FAIL |^\s+×" "/tmp/ob-${{ github.run_id }}-patch.log" | sed -E "s/ [0-9]+ms$//" | sed -E "s/^\s+//" | sort -u > "/tmp/ob-${{ github.run_id }}-patch.fail" - echo "PATCH-FAILING:$(wc -l < "/tmp/ob-${{ github.run_id }}-patch.fail")" - - - name: Clone + test — control - id: control - run: | - set -o pipefail - git clone --quiet "https://source.soulcraft.com/soulcraftlabs/open-brainy.git" "ob-ctrl-${{ github.run_id }}" - cd "ob-ctrl-${{ github.run_id }}" - git checkout --quiet "${{ steps.refs.outputs.control }}" - git log --oneline -1 - bun install - rc=0 - bun x vitest run > "/tmp/ob-${{ github.run_id }}-control.log" 2>&1 || rc=$? - echo "CONTROL-RC:$rc" - grep -aE "Tests .*(passed|failed)" "/tmp/ob-${{ github.run_id }}-control.log" | tail -1 - grep -aE "^ FAIL |^\s+×" "/tmp/ob-${{ github.run_id }}-control.log" | sed -E "s/ [0-9]+ms$//" | sed -E "s/^\s+//" | sort -u > "/tmp/ob-${{ github.run_id }}-control.fail" - echo "CONTROL-FAILING:$(wc -l < "/tmp/ob-${{ github.run_id }}-control.fail")" - - - name: Delta gate verdict - if: always() - run: | - set -o pipefail - - # The lane's own tripwire wins over anything we would otherwise say: - # a bare failure/timeout above with this marker present is host - # pressure, never a real red and never a real green. - if [ -f /srv/gate-lane/TRIPWIRE-STOPPED ]; then - echo "DELTA-GATE: STOPPED-BY-REGISTRY-TRIPWIRE" - head -1 /srv/gate-lane/TRIPWIRE-STOPPED - exit 3 - fi - - patch_log="/tmp/ob-${{ github.run_id }}-patch.log" - control_log="/tmp/ob-${{ github.run_id }}-control.log" - patch_fail="/tmp/ob-${{ github.run_id }}-patch.fail" - control_fail="/tmp/ob-${{ github.run_id }}-control.fail" - - if [ ! -s "$patch_log" ] || [ ! -s "$control_log" ]; then - echo "DELTA-GATE: INVALID — a leg produced no log (see the two steps above for the real cause)" - exit 2 - fi - - pt=$(grep -aoE "\(([0-9]+)\)$" "$patch_log" | tail -1 | tr -d "()") - ct=$(grep -aoE "\(([0-9]+)\)$" "$control_log" | tail -1 | tr -d "()") - echo "COLLECTED patch=${pt:-0} control=${ct:-0}" - if [ "${pt:-0}" -lt 3000 ] || [ "${ct:-0}" -lt 3000 ]; then - echo "DELTA-GATE: INVALID — truncated collection" - exit 2 - fi - - echo "=== NEW REDS ===" - comm -23 "$patch_fail" "$control_fail" - new=$(comm -23 "$patch_fail" "$control_fail" | wc -l) - echo "NEW-RED-COUNT:$new" - - echo "=== full candidate fail list ===" - cat "$patch_fail" - echo "=== full control fail list ===" - cat "$control_fail" - - if [ "$new" -eq 0 ]; then - echo "DELTA-GATE: CLEAN" - else - echo "DELTA-GATE: NEW REDS" - exit 1 - fi - - - name: Clean up (mind the lane's disk budget) - if: always() - run: rm -rf "ob-cand-${{ github.run_id }}" "ob-ctrl-${{ github.run_id }}" "/tmp/ob-${{ github.run_id }}-"* diff --git a/.forgejo/workflows/publish-source.yml b/.forgejo/workflows/publish-source.yml deleted file mode 100644 index 6bd42b2a..00000000 --- a/.forgejo/workflows/publish-source.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Publish (The Source) - -# Datacenter-side publish to The Source (source.soulcraft.com — our -# self-hosted Forgejo; never call it "the forge", Forge is a different -# product), moved off the laptop: an 87MB tarball PUT over the laptop's WAN -# times out; The Source's own runner does it in seconds. -# scripts/release.sh tags + pushes, then polls this workflow's result (npm -# view against The Source's registry) before it ever touches the npmjs leg — -# see the "delegation contract" in scripts/release.sh's home-publish step. - -on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - ref_reason: - description: 'why this manual run (e.g. tag event dropped)' - required: false - -jobs: - publish: - name: Publish to The Source registry - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - - run: npm ci - - run: npm run build - - name: Publish + readback-verify on The Source registry - env: - # The stored repo-settings secret keeps its historical name. - FORGE_NPM_TOKEN: ${{ secrets.FORGE_NPM_TOKEN }} - run: | - set -eo pipefail - - 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' — - # every consumer resolving 'latest' from this registry would otherwise - # be handed a release candidate. Same rule scripts/release.sh applies - # to the storefront leg. - NPM_TAG="latest" - case "$VERSION" in - *-*) NPM_TAG="rc" ;; - esac - echo "Publishing @soulcraftlabs/brainy@${VERSION} to The Source registry (dist-tag: ${NPM_TAG})..." - - TMPRC="$(mktemp)" - chmod 600 "$TMPRC" - { - 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 - # this tag's checkout already carries the version being published — - # nothing here re-derives it from the tag name. - PUBLISH_OK=true - if ! npm publish --tag "$NPM_TAG" --userconfig "$TMPRC"; then - PUBLISH_OK=false - fi - - # Readback verify is the source of truth, run regardless of the publish - # 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 "@soulcraftlabs/brainy@${VERSION}" version --userconfig "$TMPRC" 2>/dev/null || echo "")" - rm -f "$TMPRC" - - if [ "$LANDED_VERSION" != "$VERSION" ]; then - echo "::error::Readback verify FAILED — The Source registry reports version '${LANDED_VERSION:-}', expected '${VERSION}'. This is a genuine publish failure, not a benign duplicate." - exit 1 - fi - - if [ "$PUBLISH_OK" = true ]; then - echo "Published and verified @soulcraftlabs/brainy@${VERSION} on The Source registry." - else - 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/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..cdb2ab14 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + pull_request: + +jobs: + node: + name: Node ${{ matrix.node-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: ['22', '24'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - run: npm run test:unit + + bun: + name: Bun (latest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: npm ci + # test:bun imports the built dist/, so build first. + - run: npm run build + # Bun as a runtime is the supported Bun story (`bun add` / `bun run`). + - run: npm run test:bun diff --git a/CHANGELOG.md b/CHANGELOG.md index fc577c1d..b6dd91fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,307 +2,6 @@ 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.12](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.11...v10.4.12) (2026-09-03) - -- Mixed-kind fields index exactly, arrays to 256, a drained loop is not a shutdown, and finds project from the column store -- fix(index): a metadata field holds every value kind it was written with — one posting column per (field, kind); an equality filter reads the query value's own kind, a range routes by its bounds; nothing is refused and nothing is silently dropped; an index written by the old shape opens unchanged (a128f0ed) -- fix(metadata): metadata arrays index up to 256 elements; a longer array refuses at write time by name (MetadataArrayTooLargeError) — a vector parked in metadata now throws; move it to `vector` (e435da78) -- fix(shutdown): beforeExit runs a non-closing flush only — a script that never calls close() exits with the writer lock on disk and no clean-shutdown marker, and the next open evicts the stale lock and folds the log, bounded; SIGTERM and SIGINT are unchanged (6baa4d7f) -- feat(find): field projection — find({fields}) and get({fields}) resolve scalars from the column store on every leg, including vector-leg finds; absent fields stay absent (ad0f493f) -- fix(find): orderBy is the order on every find path, not only the metadata-only one (5e720d17) -- fix(metadata): the legacy sparse range path orders values, or refuses by name — never ranks by hash (a7eb7f52) -- fix(close): a read-only brain writes nothing under `_system/` (f27a7776) -- fix(contract): the flush gate's internals are private, not doors (72c8ee6a) -- test(hygiene): the triple-intelligence correctness cases sit in the gate; the idle and connected-find pins name the brain they measure (28083981) -- ci(release): the rail writes its own wall entry into the shared releases repo — never hand-written again (adcb883e) - -### [10.4.11](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.9...v10.4.11) (2026-09-02) - -- ci: superseded pushes cancel their own runs (concurrency per ref) (6053f6d4) -- test(batch): the batch-size-limit tests add unvectored items — they test batching, not embedding (a1423c6d) -- fix(flush): the gate settles its waiter from the machine, never from a chain (dea3ec20) -- test(batch): the batch-vs-individual timing assertion runs in the perf lane, not the correctness gate (ebb3a4bf) -- test(gate): the coverage guard counts the perf lane's config as a gate (2c5e3474) -- chore(contract): emit the 10.4.11 manifest (4142f368) -- fix(close): a read-only brain writes no clean-shutdown evidence — the marker is the writer's word about itself (367ca721) -- fix(generation-store): commitTransaction refuses while single-ops are pending — the order invariant is enforced, not assumed (a79db434) -- test(shutdown): pin one owner per brain — real processes, real signals (da951990) -- fix(shutdown): one owner per brain — the signal handler defers to close(), and flush is single-flight (ec644bde) -- fix(vfs): a path-scoped search is a served range over the path, not a refused prefix match (65493ba2) -- ci(test): perf and scale benchmarks leave the correctness gate (dee46b35) -- test(open): pin the pending-embed checkpoint — stuck id, crash matrix, torn fallback (1fb51093) -- perf(open): the pending-embed fold is bounded by a checkpoint of the SET, not an empty-only mark (15d4f65d) -- perf(open): a sealed segment the manifest proves is below the bound is never read (bc70c43d) -- fix(find): a page the metadata block already cut is not cut again (905c267c) -- fix(find): the hybrid legs rank inside the filter, and only the page is read (b1c70544) -- ci(delta-gate): add a push fallback trigger alongside workflow_dispatch (67ae0046) -- ci: add the delta-gate workflow for the capped functional lane (9922631d) -- docs(plugin): the planner door's hiddenIds contract is the answer, not the mechanism (2633e8d5) -- feat(engine): a protected factory for the generation store — a subclass may substitute one that keeps the contract (f763317a) -- fix(find): near() searches around the anchor's own vector, and refuses by name without one (a8c5fbf9) -- Merge remote-tracking branches 'origin/fix/planner-provider-door' and 'origin/fix/containment-batching' into rel/10.4.10-candidate (34f1886f) -- feat(plugin): an optional planFindPage door — an index that can plan a find answers it in one call (4d5f823f) -- perf(vfs): repairContainment's reconcile is one paged edge walk, not one graph call per file (3e60aded) - - -### [10.4.9](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.6...v10.4.9) (2026-09-02) - -- Merge branch 'fix/pending-embed-low-water' into rel/10.4.9-candidate (2648f56d) -- fix(open): pending-embed recovery keeps the crash-recovery contract — foreground, bounded by the mark (8a2ebacf) -- Merge branches 'fix/connected-find-order', 'fix/pending-embed-low-water' and 'fix/related-verb-array' into rel/10.4.9-candidate (d5147ed6) -- fix(graph): the verb fast paths honour every requested type, source, and target (6a89adc4) -- perf(open): pending-embed recovery is bounded by a low-water mark and runs behind the doors (88e79729) -- fix(find): connected finds are graph-first — neighbours, then the filter over those ids, then the page (077cbc0b) -- fix(storage): counts persistence is single-flight, coalesced, and never races its own temp file (5e3b343a) - - -### [10.4.6](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.5...v10.4.6) (2026-08-31) - -- fix(transact): metadata-index ops take their JSON-safe view at the crossing, not at construction (73500e7d) - - -### [10.4.5](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.4...v10.4.5) (2026-08-31) - -- build(release): the docs-push step retires — this engine documents itself in its own repository (d6bcb14f) -- fix(generations): a sealed segment may only declare the generations it holds (a963a744) -- fix(recovery): a torn generation-log tail is a terminal verdict, never a wait (c9930871) - - -### [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/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/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/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) -- fix(add): empty string is real data, not a missing field (258e9042) -- feat(vfs): implement readdir's recursive option — typed since 7.30, never read (fc516da6) -- 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/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) -- fix(storage): an unknown nested storage config can never silently land on the shared default root (ddd5e719) -- docs(release): the 10.4.0 entry, the index-health concept doc, and the API surfaces — written from the tree, not the plan (8cced871) -- fix(plugins): the silent-degrade doors close — a broken accelerator install can never read as absent (b9ba50fb) -- feat(recovery): the catchup verdict is consumed; verb rows go live; the metadata rebuild goes online (18f172e0) -- 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/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) -- test(fold-checkpoint): the ARM-AT-FLIP pin arms its crash instead of racing the pending-flush timer (4176439b) -- fix(health): one contract for a throwing probe — heal is none, serving is not withheld; repair report gains missing/rebuilt/reason (116550eb) -- feat(storage): the canonical count ledger — ALL-visibility scalars, unclamped totals, suspect-on-unprovable-delete (7c8c8be3) -- fix(delete): the null-metadata skip closes — index legs run id-keyed or narrate, never silently strand postings (607e9f54) -- feat(repair): repairIndex returns the per-family receipt and narrates its summary (8d45f964) -- fix(reads): the readiness gate guards every index read surface — serving empty from a not-ready provider is unrepresentable (40e7119b) -- ci(gate): the machine-health preflight and the truncation verdict guard (1e046aa1) - - -### [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/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) -- test(budgets): iron-honest wall-clock budgets — 3x the worst honest-iron measurement (314e0e6c) -- fix(locks): live writers are never auto-evicted; evicted writers are fenced at every commit barrier (292e7c04) -- feat(log): system commits carry their origin; the attested per-id reconcile door (9ac9e706) - - -### [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/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) -- feat(recovery): the fold-checkpoint bound — crash folds (checkpoint, head], never the whole log twice (ff43de1a) -- fix(log): pad-frame construction is total; the at-ack sync-failure compensation splits by phase — a production adoption's two write-path defects, cured at their roots (cbe34d11) -- 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/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) -- fix(recovery): walks are healers — the typed/tolerant boundary redrawn where block-layer fault injection proved it belonged (0e3facf4) -- feat(log): log authority is the fleet default — adopt-at-open, oracle-gated; plus the power-cut throw-site cures and the loud torn-record contract (214c98b4) -- fix(durability): three block-layer power-loss findings from the first fault-injection box run — all cured, matrix 15/15 (67c606be) -- docs: RELEASES.md frames the release as 10.0.0 — honest major (log format v2 forward-only); comment wording cleanup (d1698fa5) -- fix(persistence): the idle flush trigger debounces under load — deferred to the floor, never dropped, never a flush-per-gap amplifier (a50726e6) -- feat(reprojection): the one doors-open machinery — budget-capped, yielding, foreground-preempted, atomic-swap; poison records quarantine typed (d1651f98) -- feat(embedding): deferred-embed markers become log records — the sidecar recovery path is deleted (b47787bb) -- feat(conformance): the golden-log fold oracle — encoder bytes and fold semantics pinned by content hash (c95bea88) -- feat(engine): the wiring wave — stamps ride every flush, provider generations, waitForIndexed, adopt-backfill, match-all serves (b53e6e89) -- feat(index): watermark stamps on every TS projection — adopt/catchup/rescan verdicts at load, stamp-after-data (b35d87a7) -- feat(log): v2 is the LIVE write format — envelope records with minted ints, genesis, sector seals; v1 readable forever (26c60251) -- docs: RELEASES.md — the unreleased write-path and lifecycle entry (consumer-facing draft; version set at cut) (73eb88d4) -- feat(temporal): as-of semantic recall joins the release contract — past vectors byte-exact, pinned (f7ca0d26) -- fix(log): acked writes survive power loss; rejected writes never silently commit — the kill-matrix goes 11/11 with zero .fails debt (13022c51) -- feat(plugin): every provider write surface carries the real committed generation (2d532684) -- feat(log): fact-log format v2 codec — record envelope, type registry, genesis, sector seals; fault-injection shim (34841074) -- feat(log): the guarded log-authority core — group-commit durable-at-ack, the per-brain switch, the verification oracle (65953097) -- docs: Path Registry rows DP6/DP8/MT5 flip to contracted+pinned — the deferred-embedding and atomic-update train landed with cited tests (9fda6d95) -- feat(embedding): MT5 — deferred embedding with durable markers; write acks never wait on a neural net (287384cf) -- fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table (ebe06cdf) -- feat(persistence): the engine owns its flush cadence — callers never call flush() in hot paths again (3236a01b) -- fix(aggregation): the lifecycle cluster — flush stamps, behind-stamp catches up incrementally, the native rebuild finally gets invoked, deletes are never silently skipped (1dc861d2) -- perf(sort): ordered reads never do per-row storage round-trips — the 199-317s production scan class dies structurally (607b6b56) -- chore: the home registry is The Source, never 'the forge' — sweep the misnomer out of the release rail, workflows, and release notes (Forge is a different product; the stored CI secret keeps its historical name) (09352c2b) -- ci: tags stop triggering the CI matrix (redundant re-run of already-tested commits starved every release's publish run on the sequential runner) + release.sh forge poll window 20→50 min (c6c6ea6b) -- 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/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) -- docs: v9.0.0 release notes — the field-addressing law migration ledger; retitle the shipped 8.11.0 canonical-enumeration entry (header went stale at its cut) (55a7512c) -- feat(namespace): merge the field-addressing law train — no special names, system.* scalars, nested-bag storage, epoch-3 index keys (19b477ae) -- feat(namespace): NO SPECIAL NAMES + storage fidelity — the ruled completion of the field-addressing law (24bf6cdb) -- feat(namespace): write-door forgery refusal (user metadata keys may never start 'system.') + refusal messages name both spellings in every branch (the non-colliding case marks system. honestly as NOT valid) — cross-engine message pin alignment (48a6130a) -- feat(namespace): conformance green 19/19 — data-aware did-you-mean on unindexed bare addresses, ordering contract on the column top-K path (never drop, nulls last, ties by id), shape-complete addressed reads (entity views AND raw storage shapes, shadow-proof both scopes), per-key source matching for dotted addresses; refusal classes unified under UnresolvableFieldError (8e962dab) -- feat(namespace): aggregation reads under the law + epoch 3 (the key-split rebuild) + THE ARMING COMMIT — the capability constant, the law module, and the typed refusals export from the package root; both engines' conformance suites light on this signal (7492b6cb) -- feat(namespace): egress guard + validation speak the law — whereMatcher's resolver reads system.* from the record and bare names from the metadata bag only (the bare-system switch is dead); validateFindParams refuses cursor/includeRelations/writeOnly typed (accepted-and-ignored dies as a class), validates order, and parses every orderBy address (c2fb28a2) -- fix(namespace): noun-record updates preserve legacy inline HNSW adjacency — the placeholder-adjacency write stamped out pre-codec records' stored connections (crash-window unreachability); codec-era records were never at risk (empty field is the blob marker); pin covers the legacy shape (4679c894) -- feat(namespace): find's own filter builders speak the frozen keys — params.type/subtype/service become system.* index keys at every construction site (three pipelines + the canonical buildMetadataFilter); the where.type→noun alias is dead (bare 'type' belongs to the user now) (7a28a946) -- feat(namespace): the index speaks the frozen keys — record-frame scalars index under literal 'system.' (legacy 'noun' spelling folds into system.type; plumbing never indexed from a record frame), user fields stay bare in every shape; filter + sorted paths route every address through parseFieldAddress; storage fallbacks read the addressed side of the record (11c724bc) -- docs(namespace): the d.ts JSDoc wave — the sealed field-addressing law on the full find + aggregation surface, present-tense, with the refusal semantics and migration note inline (comment-only; verified zero code lines changed) (fcb24ab6) -- test(namespace): unit pins for the pure law — the ruled maps verbatim (incl. the relation mirror, unpinnable via public API), plumbing refusals both kinds, did-you-mean text (5502abcd) -- fix(namespace): the JS sorted fallback honors the ruled ordering contract — nulls last in BOTH directions (was nulls-first on desc) + deterministic id-ascending tie-break (56deb2e8) -- test(namespace)+docs: the cross-engine conformance suite (self-arming — skips until the resolver exports land) + the public field-addressing docs page; sidebar order deconflicted to 7 (d8d0b55f) -- feat(namespace): the one field-addressing law as a single source of truth — parseFieldAddress + the ruled ten-scalar system maps + plumbing invisibility + refusal builders (module only; query surfaces wire in next) (8f9a9989) -- docs: port the 8.10.3 backport-release changelog entry to main (f6b14d21) -- docs: port the 8.10.2 backport-release changelog entry to main — release branches carry the version bump, main carries the durable record (0b059ac5) -- 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 (1a09be06) -- 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 (cb717be2) -- fix(release): double the forge-publish poll budget — the runner executes jobs sequentially and the publish run queues behind the ci matrix (64049631) -- Merge branch 'release/8.11.0' (1865f60a) -- Merge branch 'release/8.10.1' (fc9f0d72) -- chore: the forge is the address — retire the archived mirror from every live surface (415e824a) -- Merge remote-tracking branch 'origin/main' (069a8894) -- Merge branch 'release/8.10.0' (d918c060) -- ci: run the pipeline on the forge (9a5a9ccc) -- feat: two-tier history reads + the repacker + generationDigest — D1+D3 wired end-to-end (1201e255) -- feat: generation-segment store — the D1+D3 packed-tier file format (d8acb377) -- feat: scanFacts liveness contract — first batch or loud failure within a documented bound (f8e6da2b) - - -### [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) -- feat(release): the forge publish leg moves to CI on the tag push; the laptop verifies by readback and keeps the abort-before-storefront guard (3e4a17dc) -- feat: canonical enumeration mode for export — storage-walked, canon-complete, with an index-drift report (4d196af4) -- ci: run the pipeline on the forge (999d0ebb) - - -### [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/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/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) -- fix: transaction timeouts are a typed no-hot-retry contract; engine-side non-retry pinned; dead transaction path removed (003e2a74) -- chore: the forge is the address — retire the archived mirror from every live surface (22702b81) - - ### [8.10.0](https://github.com/soulcraftlabs/brainy/compare/v8.9.0...v8.10.0) (2026-07-23) - docs: adoption storefront — contributing guide, security policy, README support + cor section (9a99a7b) diff --git a/CLAUDE.md b/CLAUDE.md index 56df0b72..c7336a18 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 @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` +**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` --- ## 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 `@soulcraftlabs/brainy` on The Source (source.soulcraft.com registry) 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 `@soulcraft/brainy` on npm under the MIT license. ## Getting Started @@ -91,7 +91,7 @@ test: add/update tests (patch version bump) ## Docs Pipeline — soulcraft.com/docs -Docs in `docs/**/*.md` are published with the npm package (included in `files`) and go live on soulcraft.com/docs via the docs ingest API: the release script's `scripts/push-docs.js` step POSTs every public doc to `https://soulcraft.com/api/docs/ingest` (auth: `DOCS_INGEST_SECRET` in the environment). No separate deploy step is involved (the old deploy-to-publish flow was retired in a platform change, 2026-08). Frontmatter controls what appears publicly. +Docs in `docs/**/*.md` are published with the npm package (included in `files`) and synced to soulcraft.com/docs on every portal deploy. Frontmatter controls what appears publicly. ### Docs check triggers @@ -161,9 +161,9 @@ npm run release:major # Breaking changes (rare, manual decision) The script: verifies clean git state, builds, tests, bumps version, updates CHANGELOG.md, commits, tags, pushes, publishes to npm, and creates a GitHub release. After a successful release, remind the user: -> "Published. Docs are live on soulcraft.com/docs (pushed via the ingest API during the release) — spot-check a changed page with curl." +> "Published. Deploy portal to pick up the new docs → go to the portal project and deploy." -There is no separate deploy step anymore. If the docs push failed (the script warns loudly), re-run `node scripts/push-docs.js` with `DOCS_INGEST_SECRET` set. +Do NOT deploy portal from here. Portal is always deployed separately from within the portal project. ## Closed-Source Product Names — HARD RULE diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c58520b7..ef9c4a51 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,10 +6,14 @@ may find elsewhere in the repo's history. ## Where the project lives -The source of truth is a self-hosted forge: **source.soulcraft.com/soulcraftlabs/open-brainy**. +The source of truth is a self-hosted forge: **source.soulcraft.com/soulcraft/brainy**. It's anonymously readable and cloneable — no account needed to browse, clone, or build. +**github.com/soulcraftlabs/brainy** is a public read-only mirror. It's a fine +place to read code or star the project, but issues and pull requests opened +there won't be picked up — please use one of the paths below instead. + ## How to contribute **Found a bug, or have an idea?** Email **brainy@soulcraft.com**. No account, @@ -31,7 +35,7 @@ fine) to talk through the approach saves everyone rework. ## Development setup ```bash -git clone https://source.soulcraft.com/soulcraftlabs/open-brainy.git +git clone https://source.soulcraft.com/soulcraft/brainy.git cd brainy npm install npm run build @@ -41,20 +45,6 @@ npm test Tests run on [Vitest](https://vitest.dev/). `npm test` runs the unit suite; see `package.json` for `test:integration`, `test:coverage`, and friends. -## Test gate - -The release gate is a bare `vitest run` (no `--config` flag) — the same -command the delta gate and CI's checks invoke. It carries the full -correctness suite and nothing else: wall-clock/scale benchmarks -(`tests/performance/**`, `tests/critical-performance-benchmark.test.ts`, -`tests/api/performance-benchmarks.test.ts`) and the two tests whose outcome -depends on the host machine or network rather than the code -(`tests/package-size-limit.test.ts` shells out to the `npm` CLI; -`tests/model-loading.test.ts` makes a real network call to download a model) -are excluded from it, because a timing threshold or a flaky network call has -no business failing a correctness check. That whole family runs on demand, -in its own exclusive slot, via `npm run test:perf`. - ## Standards - **Strict TypeScript.** No `any` escape hatches to dodge the type checker. @@ -71,17 +61,6 @@ in its own exclusive slot, via `npm run test:perf`. 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 762c9ec3..2fc42060 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Brainy + Brainy

Brainy

@@ -11,9 +11,9 @@

- Package on The Source - Repository - CI + npm version + npm downloads + CI Documentation MIT License TypeScript @@ -30,8 +30,6 @@ --- -**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 | @@ -47,14 +45,12 @@ It runs **inside your process** — no server, no Docker, nothing to operate — ## Quick start ```bash -bun add @soulcraftlabs/brainy # Bun ≥ 1.1 — recommended -npm install @soulcraftlabs/brainy # Node.js ≥ 22 +bun add @soulcraft/brainy # Bun ≥ 1.1 — recommended +npm install @soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy, NounType, VerbType } from '@soulcraft/brainy' const brain = new Brainy() // in-memory; one line swaps to disk await brain.init() diff --git a/RELEASES.md b/RELEASES.md index c875cb26..89bf38e7 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,14 +1,7 @@ # @soulcraft/brainy — Release Notes for Consumers -Machine-readable release notes are published at -https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/open-brainy.json -(this engine) and -https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/brainy.json -(the product engine) — read by HQ's `/hq/releases` door, and the source of -truth ahead of this file. - This file is the **quick reference for downstream sessions** tracking Brainy changes. -Full auto-generated changelog: `CHANGELOG.md` · Releases: https://source.soulcraft.com/soulcraftlabs/open-brainy/releases +Full auto-generated changelog: `CHANGELOG.md` · Releases: https://github.com/soulcraftlabs/brainy/releases **How to use:** Brainy is the underlying data engine for downstream applications. Read this when: - Upgrading `@soulcraft/brainy` in your application @@ -38,778 +31,6 @@ 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 -by sampling itself instead of by exact accounting. This release replaces every -sampled self-probe with ledger-derived truth, and a read against an unhealthy index -now refuses loudly instead of guessing. - -- **The canonical count ledger.** Storage now tracks two scalars per family - (nouns/verbs) on the write path: the user-facing `counted` total — unchanged, - still what `getNounCount()` / `getVerbCount()` return — and a new ALL-visibility - `all` total covering every tier, the real denominator a derived index's own - coverage math needs. The unfiltered storage-level `totalCount` returned by - `getNouns()` / `getVerbs()` is now this unclamped ALL scalar; previously it could - only ever move up (`Math.max(scalar, scanned)`), so an inflated counter could - never self-correct. A delete that cannot prove the record it removed actually - existed (no canonical read, no prior image available) no longer decrements on - faith — it marks the ledger `suspect` (narrated once per session) instead of - silently drifting, and the next `repairIndex()` clears the flag with a real - recount. -- **One contract for a throwing health probe.** A provider's `validateInvariants()` - is documented to never throw — but if one does anyway (a bug, a transient fault), - it is now read the same way everywhere: `heal: 'none'`, the error named in the - report, never synthesized into a rebuild trigger and never swallowed into "looks - fine." A flaky check can no longer buy itself a rebuild. `repairIndex()`'s - per-family receipt also gains `missing` (an exact count plus a capped id sample), - `rebuilt` (a full rebuild ran, vs. an incremental heal), and `reason`. -- **The named health report; reads refuse instead of rebuilding.** Any index - provider may now expose a synchronous, O(1) `healthReport()` — composed from the - provider's own exact ledgers, never a sample — and this is the one signal - Brainy's read gate trusts. The first-query lazy-build path is gone: `brain.init()` - now runs every needed rebuild to completion before it returns, always, regardless - of dataset size. A read that lands on a provider whose health report says it - isn't serving throws a typed error instead of triggering a rebuild mid-query — - `GraphIndexNotReadyError`, `MetadataIndexNotReadyError`, or - `VectorIndexNotReadyError` (all exported from `@soulcraft/brainy`), naming the - reasons. `repairIndex({ rebuild: ['metadata' | 'graph' | 'vector'] | 'all' })` is - the new explicit operator door: it rebuilds the named family unconditionally, no - health check consulted — reach for it when you have independent reason to - distrust a family regardless of what it self-reports. Bare `repairIndex()` is - unchanged in spirit: report-driven, heals only what its own checks say needs it. -- New concept doc: [Index Health](docs/concepts/index-health.md) walks the whole - story from a consumer's side — degraded-but-serving vs. not-ready, what - `repairIndex()` checks and heals per family, what `suspect` counts mean. - -**Nothing to change to adopt this.** No API removed, no signature narrowed — -`repairIndex()` gains an optional options bag and its return value gains fields, -both additive. The honest notes: if your code ever relied on a `find()` against a -cold/not-yet-built index quietly triggering a rebuild and returning results a beat -later, that behavior is gone — it now throws one of the three typed -`*NotReadyError` classes instead (catch them if you need to distinguish "not ready -yet" from "no results"). And `disableAutoRebuild: true` no longer defers index -construction to the first query — a needed rebuild always runs at `open()` now; -the flag has no effect on timing. Full manual control still lives in -`repairIndex({ rebuild: [...] })`. - -- **Crash-reopen catchup.** After an unclean shutdown, the metadata index now - folds the exact fact window it missed — `find()` serves every acked write on - reopen, closing the gap where canonical reads and counts recovered a - crash-window write but the index kept serving its pre-crash state until the - next full rebuild. Related root-cause fixed alongside: `close()` never - stamped the index watermarks (only `flush()` did), so a close without a - prior flush caused a needless full rescan verdict on the next open. -- **Relation rows are live in the metadata index.** Previously verb rows - entered the metadata index only during a rebuild — so a rebuilt store's - relation postings went stale from the first `relate()` after it. Relations - are now posted and retracted on the live write path (relate / unrelate / - updateRelation / remove's cascade, and their `transact()` forms), in the - same commit as the graph leg. -- **The metadata rebuild is online.** `rebuild()` for the metadata family no - longer clears and rebuilds in place (reads went empty for the duration): it - builds a complete replacement beside the serving index, mirrors concurrent - writes to both, swaps atomically, and persists once after the swap. Reads - never observe a partial index. `repairIndex({ rebuild: ['metadata'] })` uses - it automatically. -- **Incremental heal is routed.** A provider invariant that asks for the - incremental heal (`heal: 'repair'`) now routes to the provider's own - `repair()` when it exposes one — re-posting exactly what its ledger names, - never a store-sized rebuild — and the post-heal re-read of the report decides - success; a repair that doesn't converge is recorded with the escalation named. -- **The vector family joins the count ledger.** `getCanonicalCounts()` gains - `vectors: { all }` — the count of canonical entities holding a real vector - (deferred-embed entities count when their vector lands). And the open gate - closes the vector leg: a store whose canonical rows hold vectors but whose - derived vector index is empty now builds at `open()` (or refuses with the - typed error) instead of silently serving empty vector-search results. -- **An unknown storage config shape fails loudly.** A nested `config` object - carrying a path-shaped key (a shape that was never supported) used to fall - through silently to the default shared directory — every instance writing one - store while callers believed each had its own. It now throws, naming the - canonical `path` key. -- **Relation index rows are JSON-safe.** Internal endpoint identifiers can no - longer ride the metadata-index crossing (a native provider serializes it); - they stay on the graph operations where they belong. -- **A broken accelerator install can never read as "not installed."** The - auto-detection free pass now requires the resolution error to name the - accelerator package itself, exactly — a missing platform-binary sibling - package, an inner file path, or a dependency failure is a broken install and - `init()` throws loudly. And a plugin that declines activation is narrated on - the always-on log channel, so `silent: true` can no longer hide a fallback - to the default engines. - ---- - -## v10.3.1 — 2026-08-18 (the fold that behaves) - -Three recovery cures from one production first-boot incident (a brain's first -process restart after a live storage-authority flip looked hung and was -restarted three times mid-recovery). **Adopt this version before flipping -brains with existing history** — it is the intended adoption target for -fleets moving to the crash-safe authority. - -- **Recovery streams.** The boot-time log fold now consumes the generation - log one segment-batch at a time — memory stays bounded at one segment for - any log size. Previously it materialized every fact into one array, which - on a ~7k-fact log produced multi-GB allocation pressure and a process that - looked wedged while it worked. -- **Recovery narrates.** The fold announces itself before the work begins - ("recovery fold beginning — do not restart, the fold is finite") and prints - progress every thousand facts. A visible fold gets to finish; a silent one - gets killed by a well-meaning operator, and each kill makes the next boot - pay the whole fold again. -- **Bounded recovery from the flip itself.** Adopting the log authority now - founds the recovery checkpoint at the moment of the flip (one paged - canonical sync, bounded memory, then the stamp) — so even the FIRST unclean - shutdown after a flip replays only the log's tail. Previously the bound - could only establish itself at a completed crash recovery, which is exactly - the recovery the incident kept interrupting. - ---- - -## v10.3.0 — 2026-08-18 (the trust-and-provenance release) - -Four consumer-driven cures. Pairs with the same native accelerator line -(>=4.1.0); adopt alongside the accelerator's 4.2.0 for its paired fixes. - -- **Writer-lock fencing.** A live writer is never auto-evicted (staleness now - requires the holding process to be dead — a >60s stall is a slow writer, not - a dead one); the lock claim is atomic (no empty-file window a racer can - misread as torn); and every flush commit and transact barrier verifies lock - ownership first, so a forced-out or lock-deleted writer fails typed - (`BRAINY_WRITER_FENCED`) instead of writing on unaware — the split-brain - class a shared dev store hit is dead at all three roots. The documented - same-process re-open ("warn and take over") stays benign: ownership is - per-process. Consumers that raised stop-timeouts as mitigation can retire - them. -- **Transaction-log provenance.** `TxLogEntry` gains an optional `origin` - field — absent means a user write (existing consumers unchanged); - engine-originated commits stamp themselves (`system:embed-landing`, - `system:adoption-backfill`, `system:reconcile`), and the same stamp rides - the commit fact's meta. Activity feeds filter on fact instead of guessing; - a reported "double tick" (the deferred vector landing indistinguishable from - a user save) is cured without collapsing genuine rapid saves. -- **The attested reconcile door.** `reconcileLogDivergence(id, {attest})` - resolves the one adoption-refusing divergence class - (`log-live-canonical-absent`) with a human's word: `'deleted'` mints the - tombstone the log always lacked; `'restore'` folds the log's only copy back - into canonical; wrong-class calls refuse typed with nothing written. Loud, - narrated, single-row. -- **Iron-honest test budgets.** The wall-clock micro-budgets are recalibrated - as order-of-magnitude guards (3x the worst measurement across three machine - classes) so honest hardware differences can never again read as failures; - real performance enforcement lives in the dedicated perf lanes. - ---- - -## v10.2.0 — 2026-08-17 (adoption completes in one call) - -One fix, headline-sized for large stores. Pairs with the same native accelerator -version as 10.1.0 — no accelerator bump needed. - -- **The adoption backfill runs to completion.** Adopting the crash-safe storage - authority first re-commits every row the log never saw (a one-time baseline - backfill). That backfill had a fixed ceiling of 800 rows per - `adoptLogAuthority()` call — sized for small drift, not for a large pre-existing - store — so a store with a 12,700-row baseline advanced 800 rows per call and - stayed on the prior authority across restarts (a production deployment's - report). Now one call adopts a baseline of any size: the backfill sees the - entire curable set at once, cures all of it, and loops only until green — the - no-progress guard is the sole stop. Pace rides the write path (~100 rows/s - measured end to end, versus ~1.7 rows/s under the old page-per-scan shape), - and progress is narrated so an operator watching a live service sees motion. - Stores that already adopted are unaffected; stores still on the prior authority - flip in a single call on their next open or on an explicit - `adoptLogAuthority()`. -- Verification report unchanged on the wire (still lists at most 200 mismatches; - counts remain complete) — only the adoption path reads the full set. - ---- - -## v10.1.0 — 2026-08-13 (the bounded-recovery and write-path-cure release) - -The theme: **crash recovery is bounded, restores are durably founded, and two -production-reported write-path defects are cured at their roots.** Ships together -with the matching native accelerator version; adopt as a pair. - -- **Bounded crash recovery (the fold-checkpoint bound).** Recovery after an unclean - shutdown now replays only the log segment above a durably-stamped checkpoint - instead of the whole log. The checkpoint advances only after a canonical-sync - barrier makes every touched record durable (deletes included), so the bound can - lag but can never overstate durability. Existing stores converge automatically at - their first recovery — zero operator steps; recovery cost stops scaling with - store age. -- **Restores are unclean events, by construction.** `restore()` now runs its swap - fully quiesced (no background flush can race the directory replacement — a - consumer-reported `ENOTEMPTY` crash class is dead), and a snapshot's durability - stamps never survive the restore: the reopen folds the restored log, re-syncs - what it re-applied, and stamps fresh. Restored state is durably founded at - restore time instead of inheriting assertions about bytes the disk never synced. -- **Write-path cures from a production report.** (1) Log pad-frame construction is - total — a size-class boundary hole could previously kill a sync with "pad frame - not constructible". (2) The at-ack sync-failure compensation now splits by phase: - the generation counter can never re-mint a number the log may already carry, so - the non-monotonic append refusal loop reported by a downstream deployment cannot - recur. Both pinned with the reporter's exact shapes. -- **Operator-truthful sparse queries.** `where` on a field no store row has ever - carried now serves the honest answer (`eq`/`in`/range → empty; `ne`/`exists:false` - → all rows; `exists:true` → empty) with a throttled did-you-mean warning, instead - of refusing. `orderBy` on unknown fields and ambiguous spellings keep their typed - refusals. -- **Cross-package error identity.** `UnresolvableFieldError` thrown across package - boundaries is re-normalized so `instanceof` checks in consuming applications - match regardless of duplicated dependency trees. -- Release tooling: publishes now push the tag before the branch (the publish - workflow can no longer queue behind a redundant CI run) and verify registry - byte-identity with a propagation-tolerant raw-registry probe. - ---- - -## v10.0.0 — 2026-08-10 (the write-path and lifecycle release) - -The theme: **writes ack fast and honestly, startup adopts instead of rebuilding, and -every query path serves, announces, or refuses — never silently degrades.** Ships as -one release together with the matching native accelerator version. - -**The storage-authority posture (the release's headline):** a NEW brain's default -is **durable-at-ack log authority** — the generation log is the source of truth, -every write acknowledgment is covered by a group-committed fsync, and crash -recovery is a replay of the log (an acked write survives power loss, proven by -fault-injection tests). An EXISTING brain adopts at its first open under 10.0.0, -gated by a verification oracle: the log is replayed and diffed against stored -truth record-by-record; curable gaps are backfilled; the brain flips only on a -green verdict and a brain that cannot verify stays on the previous posture and -says so loudly. The explicit opt-out is `logAuthority: 'defer'` in the config -(no automatic adoption; flip later with `adoptLogAuthority()`). - -**Why a major:** the generation log gains write format v2 — new segments carry typed, -versioned records with integrity seals. A 9.x build refuses a v2 segment with a clear -version-naming error (never a misread), which means **a brain written by 10.x cannot -be opened by 9.x**. Existing v1 history stays readable forever; upgrading requires no -migration and no data touch — the format moves forward only as you write. - -### New capabilities - -- **`deferEmbedding: true`** on `add()`/`update()`: the write acks at durability; the - embedding runs on a crash-safe background worker and the vector swaps in atomically. - The row is id/metadata-findable immediately; semantic recall converges when the embed - lands. Barriers and gauges: `awaitPendingEmbeds()`, `waitForIndexed('semantic')`, - `getIndexStatus().pendingEmbeds`. VFS file writes adopt this end to end — file-write - ack no longer waits on a neural net (measured ~50× faster serial writes on a - production-shaped corpus). -- **`waitForIndexed(path?, { generation?, timeoutMs? })`** — the one honest read - barrier for write-then-recall flows. Typed timeout error naming what was still - pending; never a silent partial wait. -- **Engine-owned persistence cadence** (`persistence.policy: 'auto'`, now the default): - the engine flushes on write-count/interval/idle triggers in the background, - single-flight. **Delete `flush()` calls from hot paths** — `flush()` remains as an - awaitable durability barrier. A hung flush can never block a write ack. -- **Time-travel recall contract**: `asOf(G).find()` serves vectors exactly as they - stood at G — a later update never leaks into an earlier pin; deleted rows mask; - beyond-head pins refuse typed. -- **Log-authority storage (opt-in, per brain)**: `verifyLogAuthority()` audits the - generation log against stored truth record-by-record and names every divergence; - `adoptLogAuthority()` flips a brain to log-authoritative storage only on a green - audit (self-healing curable divergences first), enabling durable-at-ack writes: - concurrent writers share one fsync and an acked write survives power loss, by - construction (crash-recovery replay is pinned by fault-injection tests). - -### Behaviour changes - -- **`find({ where: {} })` now serves match-all** (previously returned an empty result - silently — warm and cold). Same fix applies to count, streaming, and graph-scoped - seeding paths. -- **`removeMany({ where: {} })` now refuses with a typed error** — a match-all bulk - delete must be explicit, never inherited from an empty filter object. -- **Aggregations always answer**: state persists at every `flush()` (not only close), - an unclean exit reconciles incrementally instead of rescanning the store, and - deletes without a before-image flag a loud rescan instead of silently skipping. -- **Vector updates are atomic in place** — a row is never transiently absent from - search during an update (the "flicker" class is gone); type-only re-index of an - unchanged vector is a no-op. - -### Format note - -- The generation log gains **format v2** (typed, versioned records with integrity - seals). v1 segments remain readable forever; new segments write v2. Older brainy - builds refuse v2 segments with a clear version-naming error rather than misreading - them. Records reserve encryption fields for a future release — zero behaviour today. - -## v8.11.0 — 2026-07-27 (canonical enumeration mode for export — storage-walked, canon-complete) - -From a fleet data-migration program's requirement for whole-brain exports that are -provably canon-complete: `export()`'s default enumeration for a whole-brain/predicate -selector is a generation-correct paginated `find()` walk — a projection query riding -the metadata index as an acceleration structure. Production has documented both of the -index's failure classes: a lost/stale posting can silently OMIT a canonical record from -an export, and a stale posting can silently INCLUDE a phantom row. Neither is visible -to the caller today. - -- **New: `export(selector, { enumeration: 'canonical' })`** (default remains `'index'` — - unchanged behavior on this release). Canonical mode walks every live noun/verb - directly off the storage adapter's canonical shard layout (`storage.getNouns()` / - `getVerbs()` — the same primitive `repairIndex()`'s recount and every index-heal - walk use) instead of the metadata/graph indexes, then applies the selector as a - plain predicate over the walked records. This guarantees canon-completeness — index - corruption cannot hide a live record from the export — at the cost of an O(N) walk - regardless of selector selectivity. Relations are also walked canonically in this - mode, for every selector, not just the whole-brain case. Requires the LIVE current - generation: called on a historical `asOf()` view or a speculative `with()` overlay it - throws `CanonicalEnumerationUnavailableError` rather than silently mixing generations - or missing an overlay's own entities — `enumeration: 'index'` (the default) is - unaffected and still composes with `asOf()`/`with()` as before. -- **New: `export(selector, { enumeration: 'canonical', reportIndexDrift: true })`** — - also runs the index-based enumeration and diffs it against canonical ground truth, - attaching `PortableGraph.drift: { canonicalOnly: string[], indexOnly: string[] }` - (canon-present ids the index missed; index-visible ids canon-absent — phantoms). - Migration-audit evidence, not a repair: nonzero drift is reported loudly - (`console.warn` with the counts) and nothing is auto-healed — run `brain.repairIndex()` - to reconcile the metadata index once drift is confirmed. -- **New: `export(selector, { includeHidden: true })`** (default: false — unchanged - behavior). Without it, a whole-brain/predicate export could never carry a - `visibility:'internal'` or `'system'` row, in EITHER `enumeration` mode — a real gap - for a bulk-migration fold auditing per-visibility-tier, where a hidden tier is real - user data, not noise to drop. `includeHidden` admits both tiers into candidacy in - both modes (and implies `includeSystem`; `includeSystem` alone keeps its narrower, - pre-existing meaning). **Migration-grade exports set `includeHidden: true`** — a - complete-canon export must carry every visibility tier; consumer-facing exports - leave it off. -- **Ops note (consumer-invisible): the release pipeline's home-registry publish (The - Source, source.soulcraft.com) now runs on CI**, triggered by the release tag, instead of PUTting the tarball from the laptop - over WAN — no change to what gets published or how a consumer installs it. - -## v9.0.0 — 2026-08-04 (the field-addressing law: your names and system.*, nothing in between) - -**Major.** One law now governs every field name, on every surface: - -> **Data is either in main space — where you can use ANY name — or it is in -> `system.*`.** - -Read `docs/concepts/field-addressing.md` (published on the docs site) for the -full contract; this entry is the migration ledger. - -### Breaking — query surfaces (`where` / `orderBy` / `groupBy` / aggregation) - -- **A bare field name ALWAYS addresses your metadata.** `orderBy: 'createdAt'` - no longer silently means the engine timestamp — it now refuses with a typed - `UnresolvableFieldError` naming both candidates unless you actually have a - user field of that name. Engine scalars are addressed explicitly: - `system.id`, `system.type`, `system.subtype`, `system.createdAt`, - `system.updatedAt`, `system.confidence`, `system.weight`, - `system.visibility`, `system.service`, `system.createdBy` (relations mirror - with `system.verb`/`system.sourceId`/`system.targetId`). - **Sweep list:** `where: { subtype: … }` → `where: { 'system.subtype': … }` · - `orderBy: 'createdAt'` → `'system.createdAt'` · `groupBy: ['noun']` → - `['system.type']` · any bare `visibility`/`service`/`confidence` filter that - meant the engine value → its `system.*` spelling. Every missed site fails - LOUDLY with the correction in the error message — nothing silently changes - meaning without telling you. -- **Unimplemented `find()` options refuse** (`cursor`, `includeRelations`, - `writeOnly` → `UnsupportedFindOptionError`); `order` is validated; - accepted-and-ignored is dead as a class. -- **The ordering contract is pinned cross-engine:** missing/null `orderBy` - values sort LAST in both directions, ties break by id ascending, and rows - are never dropped from an ordered read. - -### Breaking — write surfaces - -- **There are no reserved metadata names anymore.** `metadata: { confidence, - type, id, level, data, content, … }` are ordinary user fields — stored - verbatim, indexed, filterable, sortable, aggregatable, faithful across - restarts, index rebuilds, and `asOf()` time travel. The 8.x - reserved-key-in-bag throw is GONE; code that relied on it (or on the - `'warn'`/`'remap'` lift) must set engine scalars via their dedicated params - (`confidence`, `weight`, `subtype`, `visibility`, …) — the bag never touches - them now. -- **`reservedFieldPolicy` is removed.** Passing it throws at construction with - the migration note. `RESERVED_ENTITY_FIELDS`/`RESERVED_RELATION_FIELDS` - remain exported but now describe the stored record's engine half, not a ban - list; the `NoReservedEntityKeys`/`NoReservedRelationKeys` types are no-op - (deprecated). -- **The one refused spelling:** a metadata key literally starting `system.` - (namespace forgery) — typed error on `add`/`update`/`relate`/`updateRelation`. -- **Name-based index exclusions are gone.** Fields named `content`, `data`, - `id`, `vector`, … in your bag now INDEX like everything else (they were - silently un-indexed before — `where` on them returned `[]` with no error). - Value-shape rules stay, uniform across all names: arrays >10 never become - posting scalars; long values index hashed. -- **Migration transforms receive one normalized view** (engine fields - top-level, your bag nested under `metadata`) regardless of how old the - stored record is, and must return the same shape — a stray non-engine - top-level key refuses with the fix in the message. - -### Storage format (automatic, no action) - -- New/updated records persist as **nested-bag records** (engine fields - top-level, your bag verbatim under `metadata`, sealed by a format stamp) — - the shape that makes collider names lossless. Old flat records stay - readable forever; nothing rewrites your data in place. -- **Index epoch 3:** derived-index keys split the namespaces (bare user keys · - literal `system.` keys; the legacy `noun` column is gone). Every - brain rebuilds its derived indexes from canonical once, at first open — - observable via `getIndexStatus()`, no manual step. Pair this release with - the same-day native-accelerator release (its peer floor rises to `>=9`). -- Raw-record consumers (fact-log scanners, export tooling): read bags through - the exported shape-aware splitters (`splitNounMetadataRecord` / - `splitVerbMetadataRecord`) — they handle both record eras. - -### Fixed in the same train - -- Default visibility exclusion was a silent no-op under the new addressing on - pre-release builds (internal/system-tier rows could leak into default - reads) — now pinned by conformance tests at every lifecycle boundary. -- Per-type count surfaces (`getStats()`, count-by-type) read the new type - column, with a legacy fallback for pre-rebuild reads. -- Aggregation `source.where` evaluated dotted keys as nested paths — dotted - addresses now match per-key, and the internal per-type counts aggregate - rebuilds itself onto the new keys automatically. - -### Conformance - -Both engines ship a shared self-arming conformance suite (the law cases, the -ordering contract, and the reopen-collider fidelity case: every collider name -written as user data, verified verbatim through live reads, reopen, a forced -epoch rebuild, and time travel). Capability signal: -`FIELD_ADDRESSING_CAPABILITY = 'field-addressing/v1'` plus the typed error -classes, exported from the package root. - -## v8.10.3 — 2026-08-03, 8.10-line backport (natural field names stop colliding with engine internals) - -From a production report: sorting by a user metadata field named `level` silently -returned insertion order — the engine's internal HNSW node layer (also called -`level`) shadowed the user's field in every by-name read, and the indexing path -stamped a hardcoded `0` into the same index column (multi-valued poison). `level` -is a perfectly natural field name (game characters, priorities, floors); the -engine was wrong, not the caller. - -- **`level` is user data now, everywhere.** Engine plumbing no longer resolves by - name, never shadows metadata, and never enters the indexed views. `orderBy: - 'level'`, `where: { level: 9 }`, `groupBy: ['level']` all read YOUR field. - Regression pins: `tests/integration/level-field-shadow.test.ts` (the reporting - consumer's exact repro rows). -- **Index epoch 2.** The derived posting set changed, so every existing brain - rebuilds its metadata index from canonical at first open — poisoned columns - heal automatically; no manual step. First open after upgrade pays one rebuild - (observable via `getIndexStatus()`); pair this release with the same-day - native-accelerator release, which makes `level` indexable on the native path. -- **`transact()` metadata-only updates stop rewriting the vector record** — the - v8.10.2 write-granularity law now covers the batch/plan path too (it was - fixed for `update()` but the transact plan builder still staged the - unconditional save). If you batch stat touches through `transact()`, this is - your write-amplification fix. -- (The "coming next" note this entry carried shipped as v9.0.0 — the - field-addressing law above.) - ---- - -## v8.10.2 — 2026-07-29 (metadata-only updates stop rewriting the vector record) - -From a production incident on a large deployment: a read-heavy sweep that bumped -per-entity stats (metadata-only `update()` calls) saturated the disk — 5.8GB written -in 40 minutes — because every `update()` unconditionally re-persisted the WHOLE noun -record, unchanged vector included, fsynced. - -- **`update()` write granularity fixed at the core.** A metadata-only update (no new - `data`, `vector`, or `type`) now writes the metadata leg and index deltas ONLY — - the vector-bearing noun record is never rewritten. Vector-side writes and HNSW - reindexing still happen exactly when the vector side actually changed. Regression - pins: `tests/integration/update-write-granularity.test.ts`. -- **Consumer guidance:** per-entity stat touches are now cheap, but batch them anyway - (one `transact()` instead of N `update()` calls) — granularity fixes the cost per - touch; batching fixes the count. -- Idle VFS `PathResolver` no longer logs `NaN% hit rate` once a minute (stats log - only on new traffic, at debug level). -- Native graph providers' `graph-lsm-*` storage keys are recognized as system - resources — the per-boot `Unknown key format` warning for them is gone. - -Pairs with the native accelerator's same-day patch release; adopt as one bump. - ---- - -## v8.10.1 — 2026-07-24 (the no-hot-retry contract + warm()'s metadata surface under native providers) - -From a production incident: a native-provider op ground 38-40s inside a transaction, -blew the ~32s apply-phase budget, was rolled back (zero loss, by design), and a -downstream pipeline hot-retried the identical operation into a 6-minute, 100%-CPU -storm. Investigation confirmed Brainy itself never auto-retries a timed-out -transaction — the storm was entirely the consumer's own retry loop, driven by a -"retryable" doc-prose claim with no machine-readable contract to branch on. This -release closes that contract gap and, separately, fixes a real `warm()` reporting gap -surfaced by the same investigation. - -- **`TransactionTimeoutError` is now a machine-readable no-hot-retry contract.** Two - new typed, always-`true` fields replace prose-only guidance: - - `retryable: true` — the operation MAY succeed on a later attempt, once the - underlying slowness resolves or the budget is deliberately raised - (`transactionBudgetFloorMs`, or a batch's own `timeoutMs` override). - - `hotRetryUnsafe: true` — an immediate, identical retry re-pays the FULL cost of - the work that just timed out (it does not resume partway) and can cascade into - exactly the CPU storm above. **Never loop on this error.** The documented pattern - is a latch, not a retry loop: - ``` - on TransactionTimeoutError: - record { at: Date.now(), error } - rethrow loudly to your own caller - hold a cooldown window before any re-attempt - clear the latch only on a subsequent success - ``` - - `context` (unchanged, now fully documented) carries the backoff inputs: - `timeoutMs`, `operationIndex`, `elapsedMs`, `totalOperations`, `operationName`. - - Every "retryable" doc-prose site referencing this error (`transact()`'s - `timeoutMs` option, `transactionBudgetFloorMs`, `Transaction.execute()`) now - points at these fields instead of bare prose. - - Regression-pinned: the engine never internally re-drives a timed-out operation - (verified via an execution counter through both the single-op write path and - `add()`'s upsert-race retry loop), so this has always been true — it is now - provable and typed. -- **Dead code removed**: `TransactionManager.executeTransactionWithResult()` had zero - callers in this codebase and is deleted. -- **`brain.warm()`'s metadata surface now routes through the ACTIVE provider.** A - production deployment's warm report showed `metadata: 'unavailable'` under a native - metadata provider — the previous logic only duck-typed the built-in JS manager's - `hydrateAll()` method, which a native provider has no reason to implement. The - metadata provider contract (`MetadataIndexProvider`, `src/plugin.ts`) gains an - optional `warm?(): Promise` hook, mirroring the existing vector and graph - provider hooks. `brain.warm()` now checks the active provider's own `warm()` FIRST, - falls back to the JS manager's `hydrateAll()` when absent, and only reports - `'unavailable'` when neither exists — never `init()` as a stand-in, since a native - provider's `init()` may be a cheap verify rather than a real warm. A native - provider lights this surface up the same way `@soulcraft/cor` already lights the - vector and graph surfaces: implement `warm()` on its metadata provider. -- **New: `brain.maintenanceDebt()`** — the observability seam so an operator sees a - provider's outstanding background maintenance work (pending bytes/items, last pass - outcome, whether it's converging) BEFORE it grinds into the kind of budget-busting - op this release's timeout contract exists for, instead of discovering it as a CPU - storm. It is a pure passthrough: brainy applies no thresholds, no polling, and no - estimation — it calls each active provider's own optional `maintenanceDebt?()` hook - (vector, metadata, graph — the same three contracts `warm?()` lives on) and reports - the payload verbatim, or `'unavailable'` when a surface's provider doesn't track - debt. Useful as a pre-warm/post-warm check or a boot gate. `@soulcraft/cor` does not - yet implement the hook as of this release — expect it on cor's next release; until - then all three surfaces honestly report `'unavailable'`. - ## Unreleased (the warm contract: cold-restart writes stop paying demand-load latency) From a production deployment's cold-restart incident: the FIRST writes after every diff --git a/SECURITY.md b/SECURITY.md index 91d40d49..1f3c4732 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,7 +30,7 @@ commit to backporting fixes to unsupported lines. ## Scope -This policy covers the `@soulcraftlabs/brainy` package itself — the code in +This policy covers the `@soulcraft/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 90a35e98..4e9aedb8 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 @soulcraftlabs/brainy + * This is the entry point after npm install @soulcraft/brainy * It runs the compiled TypeScript CLI code */ diff --git a/bun.lock b/bun.lock index 1e3e66e2..c31b3865 100644 --- a/bun.lock +++ b/bun.lock @@ -3,7 +3,7 @@ "configVersion": 0, "workspaces": { "": { - "name": "@soulcraftlabs/brainy", + "name": "@soulcraft/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 b2d22fb1..4134ae63 100644 --- a/docs/DEVELOPER_LEARNING_PATH.md +++ b/docs/DEVELOPER_LEARNING_PATH.md @@ -25,13 +25,13 @@ ### Prerequisites ```bash -npm install @soulcraftlabs/brainy +npm install @soulcraft/brainy ``` ### Your First Neural Database ```typescript -import { Brainy, NounType } from '@soulcraftlabs/brainy' +import { Brainy, NounType } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy, NounType, VerbType } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy, NounType, VerbType } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy, NounType, VerbType } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy, NounType } from '@soulcraft/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 6aa33515..1cc38ce9 100644 --- a/docs/FIND_SYSTEM.md +++ b/docs/FIND_SYSTEM.md @@ -369,71 +369,6 @@ return results.slice(offset, offset + limit) // → Auto-correction: Use most likely alternative based on affinity data ``` -## Field Projection (`fields`) - -`find()` and `get()` accept a `fields` list. Without it they return the whole -record; with it they return only the fields you name — and, where the index can -supply them, without opening the canonical record at all. - -```ts -// A list page: two user fields and one engine scalar. No document bodies. -await brain.find({ - where: { kind: 'post' }, - fields: ['title', 'slug', 'system.createdAt'], - limit: 50 -}) - -await brain.get(id, { fields: ['title'] }) -``` - -### Why it exists - -A list view that renders a title and a date does not need the body, but without -a projection every row hydrates its full record and throws almost all of it -away. On a posts list that is the dominant cost of the query. - -### The rules - -| | | -|---|---| -| **`fields` absent** | The full record, byte-identical to before. Nothing changes. | -| **Field names** | The one addressing law: a bare name is user metadata (`'title'`), `system.*` is an engine scalar (`'system.createdAt'`). | -| **A field the row lacks** | Simply **absent** from the result. Never an error. | -| **Identity** | Every row keeps its `id` (and `score` on `find`) regardless — a row you cannot identify is not a row. | -| **Where values come from** | The **column store**, which holds raw values. Never the sparse index, which buckets timestamps for range queries. | -| **A field the column cannot serve** | The canonical record is read for that field only. Correct, just not free. | - -### Missing fields are absent, not errors - -This is deliberate and differs from `orderBy`, which throws -`UnresolvableFieldError` for an unknown field. A typo in `orderBy` silently -changes the ordering, so it must be loud. A projection asks "give me these if -you have them", and an optional field must not turn a list into a failure — so -`fields` uses the permissive path. - -### Cost - -When every named field is column-served, a projected page performs **zero** -canonical reads. When one is not, only that read happens and the rest still come -from the index. Both are pinned by counting reads rather than timing them, in -`tests/integration/find-fields-projection.test.ts`. - -### `related()` takes no `fields` - -A `Relation` carries `from` and `to` as **ids** and hydrates no entity record, -so there is nothing for a projection to trim. Projecting the endpoints would be -a new capability rather than a projection of an existing one. - -### For engine implementers - -Projection is served through an optional provider door, -`getScalarsForIds(ids, fields)` on `MetadataIndexProvider`. The contract is in -`src/plugin.ts`; the short version is **return only what you can serve exactly, -and say what you served**. The caller diffs the answer against the request and -reads records for the remainder, so omission costs a read while a wrong value is -a wrong answer nobody can see. An engine without the door still works — every -field falls back to the record. - ## Performance Characteristics ### Query Performance by Type @@ -1282,7 +1217,7 @@ where: { await brain.find({ type: 'Document' }) // ✅ Correct: Use NounType enum -import { NounType } from '@soulcraftlabs/brainy' +import { NounType } from '@soulcraft/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 680b6928..29c409ac 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 @soulcraftlabs/brainy@latest +npm install @soulcraft/brainy@latest ``` **Check your version:** ```bash -npm list @soulcraftlabs/brainy -# Should show: @soulcraftlabs/brainy@4.0.0 +npm list @soulcraft/brainy +# Should show: @soulcraft/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 @soulcraftlabs/brainy@^3.50.0 +npm install @soulcraft/brainy@^3.50.0 # Restart application ``` @@ -389,7 +389,7 @@ rm -rf ./data cp -r ./data-backup ./data # Reinstall v3 -npm install @soulcraftlabs/brainy@^3.50.0 +npm install @soulcraft/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 @soulcraftlabs/brainy@latest`) +- ✅ Update npm package (`npm install @soulcraft/brainy@latest`) - ✅ Restart application (automatic migration) - ✅ Verify data integrity - ✅ Enable lifecycle policies diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index b543e84a..248a2c70 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -323,24 +323,58 @@ Only the graph adjacency index carries a committed scale assertion: - ✅ **Single-Node by Design**: One process owns one `path`; scale out at the service layer - ✅ **Zero Stubs**: Every line of code is production-ready -## Index Build at Open (10.4+) +## Lazy Loading Performance -As of 10.4, `brain.init()` runs every needed index rebuild to completion before -it returns — always, regardless of dataset size. There is no lazy, -first-query rebuild path: a brain either finishes opening healthy, or `init()` -fails loudly. `disableAutoRebuild` no longer defers index construction to a -first query; it has no effect on *when* a rebuild runs. Manual control over -rebuilds is `repairIndex({ rebuild: [...] })`. See -[Index Health](concepts/index-health.md) for the full read-gate contract -(providers self-report readiness via `healthReport()`; a read against a -not-serving provider throws a typed `*NotReadyError` rather than rebuilding -mid-query). +Brainy supports two initialization modes for optimal performance across different use cases: - +### Mode 1: Auto-Rebuild (Default) + +```javascript +const brain = new Brainy() +await brain.init() // Rebuilds indexes during init (~500ms-3s for 10K entities) +``` + +**Performance:** +- Init time: 500ms-3s (depends on dataset size) +- First query: Instant (indexes already loaded) +- Use case: Traditional applications, long-running servers + +### Mode 2: Lazy Loading + +```javascript +const brain = new Brainy({ disableAutoRebuild: true }) +await brain.init() // Returns instantly (0-10ms) + +const results = await brain.find({ limit: 10 }) // First query triggers rebuild (~50-200ms) +const more = await brain.find({ limit: 100 }) // Subsequent queries instant (0ms check) +``` + +**Performance:** +- Init time: 0-10ms (instant) +- First query: 50-200ms (includes index rebuild for 1K-10K entities) +- Subsequent queries: 0ms check (instant) +- Concurrent queries: Wait for same rebuild (mutex prevents duplicates) + +**Concurrency Safety:** +```javascript +// 100 concurrent queries immediately after init +await brain.init() + +const promises = Array.from({ length: 100 }, () => + brain.find({ limit: 10 }) +) + +const results = await Promise.all(promises) +// ✅ Only 1 rebuild triggered (mutex) +// ✅ All 100 queries return correct results +// ✅ Total time: ~60ms (not 6000ms!) +``` + +**Use Cases for Lazy Loading:** +- **Serverless/Edge**: Minimize cold start time (0-10ms init) +- **Development**: Faster restarts during development +- **Large datasets**: Defer index loading until needed +- **Read-heavy workloads**: Writes don't wait for index rebuild ## Zero Configuration Required @@ -350,6 +384,10 @@ Brainy is designed to be **smart enough to tune itself dynamically**. No configu // That's it. Brainy handles everything. const brain = new Brainy() await brain.init() + +// Or with lazy loading for serverless +const brain = new Brainy({ disableAutoRebuild: true }) +await brain.init() // Instant (0-10ms) ``` ### Automatic Self-Tuning @@ -357,6 +395,7 @@ await brain.init() - **Metadata Index**: Auto-builds sorted indices for range queries on first use - **Graph Index**: Auto-flushes every 30 seconds - **Default Tuning**: Research-based vector index defaults +- **Lazy Loading**: Indices built only when needed - **Cache Management**: LRU caches with TTL ### Intelligent Defaults diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 238d2252..d9a4d3e7 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -10,7 +10,7 @@ next: - guides/storage-adapters --- -# Plugin System +# Plugin Development Guide Brainy has a plugin system that allows third-party packages to replace internal subsystems with custom implementations. This is how `@soulcraft/cor` provides optional native acceleration, and it's the same system available to any developer. @@ -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 '@soulcraftlabs/brainy/plugin' +import type { BrainyPlugin, BrainyPluginContext } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' import myPlugin from './my-plugin.js' const brain = new Brainy() @@ -200,30 +200,15 @@ members so a warm reopen never pays a redundant rebuild-from-canonical: - **`init?(): Promise`** — eager cold-load. Brainy awaits it once during `brain.init()`, after the metadata provider's `init()` (the id-mapper hydrates first) and **before the rebuild gate**. -- **`healthReport?(): HealthReport`** — the PREFERRED signal (10.4+). A named, - synchronous, O(1) verdict derived from the provider's own exact ledgers — never a - sample, never I/O, must never throw for a well-formed provider. Brainy's read gate - (`assessProviderHealth()`) reads this INSTEAD of `isReady()` / size heuristics when - present: `serving: false` refuses the read with a typed `*NotReadyError` rather than - triggering a rebuild — a read never starts a store walk. `healthy` marks every - *verified* invariant holding; a family named in `unledgered` counts as neither - healthy nor broken. See `HealthReport` / `LedgerInvariantResult` / - `InvariantSource` in `src/plugin.ts`, and - [Index Health](concepts/index-health.md) for the consumer-facing story. -- **`isReady?(): boolean`** — honest durability signal, the fallback when - `healthReport()` is absent. `true` ⇔ the persisted index is loaded (or cheaply - demand-loadable) and consistent with what was last persisted. When exposed, the - gate defers to this signal **instead of** the `size() === 0` / `totalEntries === 0` - heuristics — a disk-native index may report 0 resident entries while fully durable. - Never return `true` if the durable state failed to load: the signal is honest in - both directions, and a not-ready provider gets its rebuild even when `size() > 0`. +- **`isReady?(): boolean`** — honest durability signal. `true` ⇔ the persisted index is + loaded (or cheaply demand-loadable) and consistent with what was last persisted. When + exposed, the rebuild gate defers to this signal **instead of** the `size() === 0` / + `totalEntries === 0` heuristics — a disk-native index may report 0 resident entries + while fully durable. Never return `true` if the durable state failed to load: the + signal is honest in both directions, and a not-ready provider gets its rebuild even + when `size() > 0`. - **`isMigrating?(): boolean`** — while `true`, the provider owns its index (background migration); brainy skips its rebuild entirely. -- **`validateInvariants?(): Promise`** — the async DEEP - diagnostic (full scans allowed), distinct from the bounded, sync `healthReport()`. - Must never throw — a failure is `healthy: false` data, not an exception; a provider - that throws anyway is read as a loud, unverified failure (never as "healthy") by - every caller, never silently retried into a rebuild. Providers that implement none of these keep the size/count heuristics — correct for engines whose `rebuild()` *is* their load path (like brainy's built-in JS vector index). @@ -272,10 +257,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 `@soulcraftlabs/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 `@soulcraft/brainy/internals`). ```typescript -import type { UnifiedCache } from '@soulcraftlabs/brainy/internals' +import type { UnifiedCache } from '@soulcraft/brainy/internals' context.registerProvider('cache', myNativeCache) ``` @@ -325,8 +310,8 @@ Plugins can register custom storage backends that users reference by name. ### Implementing a Storage Adapter ```typescript -import type { StorageAdapterFactory } from '@soulcraftlabs/brainy/plugin' -import type { StorageAdapter } from '@soulcraftlabs/brainy' +import type { StorageAdapterFactory } from '@soulcraft/brainy/plugin' +import type { StorageAdapter } from '@soulcraft/brainy' class MyStorageAdapter implements StorageAdapter { async init(): Promise { /* ... */ } @@ -360,9 +345,9 @@ Brainy provides three entry points for plugin developers: | Import Path | Contents | Stability | |-------------|----------|-----------| -| `@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) | +| `@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) | ## Diagnostics @@ -440,7 +425,7 @@ A minimal but useful plugin that provides SIMD-accelerated distance calculations ```typescript // simd-distance-plugin/src/plugin.ts -import type { BrainyPlugin, BrainyPluginContext } from '@soulcraftlabs/brainy/plugin' +import type { BrainyPlugin, BrainyPluginContext } from '@soulcraft/brainy/plugin' // Hypothetical native module import { simdCosineDistance } from './native.js' @@ -470,7 +455,7 @@ export default simdDistancePlugin "main": "./dist/plugin.js", "types": "./dist/plugin.d.ts", "peerDependencies": { - "@soulcraftlabs/brainy": ">=7.0.0" + "@soulcraft/brainy": ">=7.0.0" } } ``` @@ -478,7 +463,7 @@ export default simdDistancePlugin Usage: ```typescript -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 ad4a4a40..4568cd31 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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' let brain: Brainy | null = null diff --git a/docs/README.md b/docs/README.md index ddb37d20..3290001f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,7 @@ ## Quick Start ```typescript -import { Brainy, NounType, VerbType } from '@soulcraftlabs/brainy' +import { Brainy, NounType, VerbType } from '@soulcraft/brainy' const brain = new Brainy() await brain.init() diff --git a/docs/RELEASE-GUIDE.md b/docs/RELEASE-GUIDE.md index 4b120bd8..94c6a6cb 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 @soulcraftlabs/brainy@X.X.X "Incorrect version - use Y.Y.Y" +npm deprecate @soulcraft/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 054d2096..e9ae1136 100644 --- a/docs/SCALING.md +++ b/docs/SCALING.md @@ -13,7 +13,7 @@ ### In-Memory ```typescript -import Brainy from '@soulcraftlabs/brainy' +import Brainy from '@soulcraft/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 `@soulcraftlabs/brainy` with no native +open-core (pure-TypeScript) path — what you get from `@soulcraft/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 deleted file mode 100644 index c4f4e056..00000000 --- a/docs/api-contract.json +++ /dev/null @@ -1,1633 +0,0 @@ -{ - "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": "captureEmbedCheckpoint", - "kind": "method", - "arity": 0 - }, - { - "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": "createGenerationStore", - "kind": "method", - "arity": 1 - }, - { - "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": "demoteTornEntityTreeStamp", - "kind": "method", - "arity": 4 - }, - { - "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": "executeProximitySearch", - "kind": "method", - "arity": 1 - }, - { - "name": "executeTextSearch", - "kind": "method", - "arity": 2 - }, - { - "name": "executeTextSearchScored", - "kind": "method", - "arity": 3 - }, - { - "name": "executeVectorSearch", - "kind": "method", - "arity": 3 - }, - { - "name": "executeVectorSearchScored", - "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": "filterIdsWithinBelted", - "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": "hydrateResultPage", - "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": "isClosed", - "kind": "accessor" - }, - { - "name": "isClosing", - "kind": "accessor" - }, - { - "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": "maybeWriteEmbedCheckpoint", - "kind": "method", - "arity": 0 - }, - { - "name": "maybeWriteEmbedLowWater", - "kind": "method", - "arity": 0 - }, - { - "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": "noteEmbedCheckpointCadence", - "kind": "method", - "arity": 0 - }, - { - "name": "noteWriteForPersistence", - "kind": "method", - "arity": 0 - }, - { - "name": "now", - "kind": "method", - "arity": 0 - }, - { - "name": "onChange", - "kind": "method", - "arity": 1 - }, - { - "name": "pageConnectedIds", - "kind": "method", - "arity": 2 - }, - { - "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": "pendingResult", - "kind": "method", - "arity": 2 - }, - { - "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": "readPendingEmbedBound", - "kind": "method", - "arity": 0 - }, - { - "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": "resolveConnectedIds", - "kind": "method", - "arity": 1 - }, - { - "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": 3 - }, - { - "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": "textIdsWithinBelted", - "kind": "method", - "arity": 2 - }, - { - "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 - }, - { - "name": "writeEmbedCheckpoint", - "kind": "method", - "arity": 0 - }, - { - "name": "writeEmbedLowWater", - "kind": "method", - "arity": 0 - } - ], - "errors": [ - "BrainyError", - "DerivedArtifactMissingError", - "GraphIndexNotReadyError", - "MetadataArrayTooLargeError", - "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 ba49ff48..4ca84364 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -24,7 +24,7 @@ next: ## Quick Start ```typescript -import { Brainy, NounType, VerbType } from '@soulcraftlabs/brainy' +import { Brainy, NounType, VerbType } from '@soulcraft/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 `@soulcraftlabs/brainy`: +All exported from `@soulcraft/brainy`: | Error | Thrown by | Meaning | |---|---|---| @@ -1451,34 +1451,6 @@ const count = await brain.getVerbCount() --- -### The canonical count ledger (`StorageAdapter.getCanonicalCounts()`) - -An OPTIONAL method on the `StorageAdapter` interface (implemented by both -built-in adapters), not a method on `Brainy` itself — relevant if you're -writing a custom storage adapter or composing a provider's own -`healthReport()`. O(1), no I/O. Per family (`nouns`/`verbs`): - -```typescript -interface CanonicalCounts { - nouns: { counted: number; all: number } - verbs: { counted: number; all: number } - suspect: boolean -} -``` - -- `counted` mirrors `getNounCount()` / `getVerbCount()` (public + internal tiers). -- `all` is the ALL-visibility scalar — every tier, including system/internal - records — the denominator a derived index's own coverage math is measured - against. -- `suspect` is `true` when an unprovable delete has left `all` unverified since - the last recount; `brain.repairIndex()` clears it with a real canonical walk. - -Adapters without the ledger omit the method; treat absence as "no -denominator," never as zero. See -**[Index Health](../concepts/index-health.md)** for the full story. - ---- - ### Subtype & facet APIs Full guide: **[Subtypes & Facets](../guides/subtypes-and-facets.md)**. @@ -1859,104 +1831,6 @@ const semanticOnly = await brain.getStats({ excludeVFS: true }) --- -### `repairIndex(options?)` → `Promise` - -The ceremony door for index repair. Bare `repairIndex()` is report-driven: it -prunes orphaned containers, recomputes count rollups, reconciles VFS -containment, and rebuilds only a derived-index family whose own health check -asks for it. Pass `options.rebuild` to force one or more families to rebuild -UNCONDITIONALLY — no health check is consulted — when an operator has -independent reason to reconcile a family regardless of what it self-reports. - -```typescript -// Report-driven: only heals what actually needs it -const report = await brain.repairIndex() -console.log(report.healedTotal, report.families) - -// Explicit: force the graph adjacency to rebuild from canonical, unconditionally -await brain.repairIndex({ rebuild: ['graph'] }) - -// Explicit: force all three derived indexes to rebuild -await brain.repairIndex({ rebuild: 'all' }) -``` - -**`RepairReport`:** -- `families: RepairFamilyReport[]` — one row per family checked -- `healedTotal: number` — items healed across every family -- `durationMs: number` - -**`RepairFamilyReport`** (one row): -- `family: string` — e.g. `'orphaned-containers'`, `'count-rollups'`, - `'vfs-containment'`, `'metadata-corruption'`, `'provider:metadata'`, - `'provider:graph'`, `'provider:vector'` -- `checked: boolean` — was this family actually examined (`false` ⇒ see `skipped`) -- `healed: number` — items re-posted/corrected in place (the incremental heal count) -- `missing?: { count: number; sample: string[] }` — exact count plus a capped id - sample when the check can name what diverged (never the full list) -- `rebuilt?: boolean` — a full generational rebuild ran (vs. an incremental heal) -- `detail?: string` / `reason?: string` — narration -- `skipped?: string` — why the family wasn't checked - -Full walkthrough — what each family checks, degraded-but-serving vs. not-ready, -and what `suspect` counts mean — in -**[Index Health](../concepts/index-health.md)**. - ---- - -### Index readiness: typed errors, `healthReport()`, `disableAutoRebuild` - -Every derived-index provider (vector, graph, metadata) may expose a named, -synchronous, O(1) `healthReport()` composed from its own exact ledgers — the -signal Brainy's read gate trusts over sampling or size heuristics. `init()` -brings every provider to serving before it returns; there is no first-query -lazy-rebuild path. A read that reaches a provider whose health report says it -isn't serving throws instead of rebuilding mid-query: - -| Error | Thrown by | Meaning | -|---|---|---| -| `GraphIndexNotReadyError` | `find({ connected })`, `neighbors()`, `related()` | Graph adjacency isn't serving | -| `MetadataIndexNotReadyError` | `find({ where })` | Metadata/field index isn't serving | -| `VectorIndexNotReadyError` | `find({ query })`, `similar()` | Vector index isn't serving | - -All three are exported from `@soulcraftlabs/brainy`. Catch them to distinguish -"index not ready" from a genuine empty result: - -```typescript -import { MetadataIndexNotReadyError } from '@soulcraftlabs/brainy' - -try { - const rows = await brain.find({ where: { status: 'active' } }) -} catch (err) { - if (err instanceof MetadataIndexNotReadyError) { - // reconcile: await brain.repairIndex(), then retry - } else { - throw err - } -} -``` - -**`disableAutoRebuild`** no longer defers index construction to the first -query. A needed rebuild always runs at `open()`, regardless of this flag or -dataset size; the flag has no effect on *when* a rebuild runs. Full manual -control lives in `repairIndex({ rebuild: [...] })`, above. - -### `validateIndexConsistency()` → `Promise<...>` - -The deep, async diagnostic counterpart to `healthReport()` — safe to run on a -live brain, but does more work (a provider's `validateInvariants()` may run a -full scan, not just read a ledger). Aggregates the JS metadata index's own -consistency check with every derived-index provider's invariant report. - -```typescript -const validation = await brain.validateIndexConsistency() -if (!validation.healthy) { - console.log(validation.recommendation) // what to run, e.g. repairIndex() - console.log(validation.providers) // each provider's own invariant report, when exposed -} -``` - ---- - ## Lifecycle ### Initialization @@ -2208,7 +2082,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:** [@soulcraftlabs/brainy](https://www.npmjs.com/package/@soulcraftlabs/brainy) +- **📦 NPM:** [@soulcraft/brainy](https://www.npmjs.com/package/@soulcraft/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 12398747..48064757 100644 --- a/docs/architecture/data-storage-architecture.md +++ b/docs/architecture/data-storage-architecture.md @@ -217,40 +217,6 @@ membership queries at scale: `__words__` for tokenized text…). - `_blobs/_column_index/{field}/L0-NNNNNN.bin` — the actual level-0 run segments, stored through the shared `_blobs/.bin` binary convention. -- `_column_index/{field}/k/{kind}/…` — the same two files again, for a - **second value kind** on the same field (see below). Absent for a field that - holds one kind, which is nearly all of them. - -### One posting column per (field, kind) - -A field is not obliged to hold one type of value. `category` may carry -`'electronics'` on some rows and `5` on others, and both are real values of -that field. A segment, though, has one encoding — i64, f64, UTF-8, or boolean -— so a field that holds several kinds gets **one column per kind**: - -- The first kind a field ever sees owns the plain `_column_index/{field}/` - layout above. A single-kind field is therefore byte-identical to what earlier - versions wrote, and an index written before typed postings opens unchanged. -- Every later kind gets its own column beside it at - `_column_index/{field}/k/{kind}/`, where `{kind}` is `number`, `string` or - `boolean`. - -What that buys at query time: - -| | | -|---|---| -| **Equality** | Answered from the column matching the **query value's own kind**. `where {category: 5}` reads the number postings; `where {category: '5'}` reads the string postings. Neither borrows the other's rows — a row written with the number `5` is not a row whose category is the text `'5'`. | -| **A kind the field never held** | Matches nothing. That is the true answer, not a coerced one. | -| **Ranges** | Routed by the kind of the bounds: numeric bounds read the numeric postings and ignore the field's strings. An **unbounded** range is the "has any value here" probe behind `exists`, and reads every kind. | -| **`orderBy`** | A number and a string have no order between them, so a mixed field orders by kind first (number, string, boolean) and by value within a kind. A single-kind field sorts exactly as it always did. | -| **Numbers** | One kind, one column: an integer column is written as i64 and widens to f64 the first time a non-integer arrives, so `4.5` is stored as itself rather than rounded. | - -`null` and `undefined` are not kinds and are never posted; their absence is -what the `exists` / `missing` operators read. - -Older readers are unaffected by the additional columns: they see the field's -primary column exactly where it has always been, and a `k/{kind}` directory is -simply a name they never query. Sparse per-field indexes, roaring-bitmap chunks, and zone-map/bloom segments additionally live as bucketed keys under `_system/idx/` (see §3). Which path @@ -302,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 -`@soulcraftlabs/brainy/plugin`) may replace any of the JS implementations; the +`@soulcraft/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 48a8b1fe..76492ee5 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 '@soulcraftlabs/brainy' +import { Brainy, NounType, VerbType } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { NaturalLanguageProcessor } from '@soulcraft/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 '@soulcraftlabs/brainy' +} from '@soulcraft/brainy' // Get all available noun types const nounTypes = getNounTypes() diff --git a/docs/architecture/index-architecture.md b/docs/architecture/index-architecture.md index 6a754b56..8b3dc540 100644 --- a/docs/architecture/index-architecture.md +++ b/docs/architecture/index-architecture.md @@ -723,14 +723,6 @@ async stats(): Promise { ### 5. Index Rebuilding (Lazy Loading Support) -> **Stale as of 10.4 — "Mode 2: Lazy Loading on First Query" below is -> RETIRED.** `disableAutoRebuild` no longer defers index construction to a -> first query; `brain.init()` now runs every needed rebuild to completion -> before it returns, unconditionally, and a read against a not-serving -> provider throws a typed `*NotReadyError` instead of rebuilding mid-query. -> See `docs/concepts/index-health.md` for the current contract. Left below -> as historical background on the rebuild mechanics. - **Two modes of index loading:** #### Mode 1: Auto-Rebuild on init() (default) diff --git a/docs/architecture/initialization-and-rebuild.md b/docs/architecture/initialization-and-rebuild.md index e19bdd9f..a1645744 100644 --- a/docs/architecture/initialization-and-rebuild.md +++ b/docs/architecture/initialization-and-rebuild.md @@ -1,15 +1,5 @@ # Initialization and Rebuild Processes -> **Stale as of 10.4 — "Mode 2: Lazy Loading on First Query" below is RETIRED.** -> `disableAutoRebuild` no longer defers index construction to a first query; -> `brain.init()` now runs every needed rebuild to completion before it -> returns, unconditionally. A read against a not-serving provider throws a -> typed `*NotReadyError` instead of rebuilding mid-query. See -> `docs/concepts/index-health.md` for the current contract; this document's -> line-number references to `src/brainy.ts` also predate the file's current -> size and are unreliable. Left as historical background on the rebuild -> mechanics, not as a current API description. - This document explains how Brainy's four indexes (MetadataIndex, vector index, GraphAdjacencyIndex, DeletedItemsIndex) initialize and rebuild from persisted storage. ## Core Principle: All Indexes Are Disk-Based diff --git a/docs/architecture/multiprocess-storage-mixin.md b/docs/architecture/multiprocess-storage-mixin.md index 1593bf8f..46f98398 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 `@soulcraftlabs/brainy` peerDep range expected by +6. Major-version-bump the `@soulcraft/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 3dac6892..286464be 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 '@soulcraftlabs/brainy' +import { Brainy, NounType, VerbType } from '@soulcraft/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 `@soulcraftlabs/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 `@soulcraft/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 a35e6416..d42d6784 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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 deleted file mode 100644 index d24dd66b..00000000 --- a/docs/concepts/field-addressing.md +++ /dev/null @@ -1,233 +0,0 @@ ---- -title: Field addressing: your fields and system fields -slug: concepts/field-addressing -public: true -category: concepts -template: concept -order: 7 -description: The one rule for every query-surface field name — a bare name always means your metadata, system. reaches the ten engine scalars explicitly, and anything else refuses by name. -next: - - guides/namespace-migration - - concepts/consistency-model ---- - -# Field addressing: your fields and system fields - -Every query surface in Brainy — `find()`'s `where`, `orderBy`, aggregation -`groupBy`, and aggregation `source.where` — resolves field names by one rule, -with no exceptions: - -> **A bare field name always means your metadata. `system.` reaches an -> engine scalar, and only when you spell it explicitly.** - -```typescript -await brain.find({ orderBy: 'level' }) // reads entity.metadata.level — YOUR field -await brain.find({ orderBy: 'system.createdAt' }) // reads the engine's createdAt scalar -await brain.find({ orderBy: 'metadata.level' }) // identical to bare 'level' — explicit scope -``` - -There is no priority list, no "try the system field, fall back to metadata" -behavior, and no name that resolves differently depending on what else -happens to exist on your entities. A field called `level`, `score`, -`createdAt`, or `type` in your own `metadata` is read as *your* field, every -time, by its bare name. - -## Why this rule exists - -An internal report from a production deployment found that a user metadata -field literally named `level` was being silently shadowed by the engine's -own internal index layer field of the same name — every sort by `level` -returned insertion order, with no error raised. This rule makes that class of -bug structurally impossible: bare names belong to you, unconditionally, and -anything that isn't yours has to be spelled out. - -## The system scalars - -`system.` addresses exactly ten scalars on an entity — no more, no -fewer: - -| System field | What it is | -|---|---| -| `system.id` | The entity's id | -| `system.type` | The entity's `NounType` | -| `system.subtype` | The per-app sub-classification passed to `add()` | -| `system.createdAt` | When the entity was created | -| `system.updatedAt` | When the entity was last written | -| `system.confidence` | The `confidence` param (0–1) | -| `system.weight` | The `weight` param | -| `system.visibility` | `'public'` / `'internal'` (see the visibility tiers in [Consistency Model](./consistency-model.md)) | -| `system.service` | The multi-tenancy `service` tag | -| `system.createdBy` | Who/what created the entity | - -Relationships mirror the same eight shared scalars (`subtype`, `createdAt`, -`updatedAt`, `confidence`, `weight`, `visibility`, `service`, `createdBy`) -plus three of their own: - -| System field (relationship) | What it is | -|---|---| -| `system.verb` | The relationship's `VerbType` | -| `system.sourceId` | The id of the entity the relationship starts from | -| `system.targetId` | The id of the entity the relationship points to | - -Anything not on these two lists is not a system scalar — `system.` for -any other name refuses (see "Refusal semantics" below), even if that name -sounds like it should be engine-owned. - -## Invisible plumbing — never addressable, in either spelling - -Five names are pure engine internals. They are not reachable as a bare name, -and not reachable as `system.` either — they simply have no place on -the query surface: - -- **`vector`** — the stored embedding. It participates in similarity search - (`query`, `near`, vector `find()`), never in `where`/`orderBy`/`groupBy`. -- **`connections`** — graph adjacency. Reached through `connected` and - `brain.related()`, not through field addressing. -- **`level`** — the internal index layer number used by the nearest-neighbor - graph. It is pure index plumbing with no query-surface meaning at all — - which is exactly why a user field of the same name must never be shadowed - by it. `level` as a bare name is always yours; there is no engine-owned - spelling of it to compete with. -- **`data`** — your entity's content payload, not a scalar. It can be a - string, a number, or an arbitrary object, so sorting or filtering it as a - single comparable value would lie about its actual shape. Content is - reached through the content/text-search APIs (`query`, `searchMode: - 'text'`), not through `where`/`orderBy`. -- **`_rev`** — the per-entity revision counter used for optimistic - concurrency (`ifRev`). It is a CAS token, not a queryable dimension. - -`system.level`, `system.vector`, and `system.data` all refuse for the same -reason: they are not in the ten-scalar system map, full stop. - -## `metadata.` — the explicit spelling of "mine" - -Prefix any field with `metadata.` to say the same thing a bare name already -says, spelled out. The two are interchangeable everywhere a field name is -accepted, including `orderBy`: - -```typescript -await brain.find({ where: { 'customer.tier': 'gold' } }) -await brain.find({ where: { 'metadata.customer.tier': 'gold' } }) // identical -await brain.find({ orderBy: 'metadata.score', order: 'desc' }) // identical to orderBy: 'score' -``` - -Reach for the explicit spelling when it reads more clearly next to a -`system.` field in the same query — for example, sorting by your own `score` -while filtering on `system.confidence`. - -## No special names — the write side - -The same law governs writes: - -> **Data is either in main space, where developers can use anything, or it -> is in `system.*`.** - -There are **no reserved metadata names**. A field called `confidence`, -`type`, `id`, `data`, `content`, or anything else inside your `metadata` bag -is an ordinary user field: it is stored verbatim, indexed, filterable, -sortable, aggregatable, and it survives restarts, index rebuilds, and -time-travel (`asOf`) reads exactly as written — even when an engine scalar -shares its spelling. The engine's values are written only through their -dedicated params (`confidence`, `weight`, `subtype`, `visibility`, …) and -read at `system.`; your bag can never touch them and they can never -shadow your bag. - -```typescript -const id = await brain.add({ - data: 'Ada Lovelace', - type: NounType.Person, - confidence: 0.9, // the ENGINE scalar - metadata: { confidence: 'self-rated' } // YOUR field, same spelling — both live -}) - -await brain.find({ where: { confidence: 'self-rated' } }) // finds it (yours) -await brain.find({ where: { 'system.confidence': 0.9 } }) // finds it (engine's) -``` - -The one spelling a write refuses is a metadata key that literally starts -with `system.` — the explicit address namespace cannot be forged as a user -field name. That refusal is typed and names the fix. - -Value **shape** rules still apply uniformly to every name (they are not name -carve-outs): arrays longer than 10 elements are not turned into posting-list -scalars, and very long values are indexed by hash. - -## Refusal semantics - -A name that resolves to neither your metadata nor a system scalar is a typed -refusal, not a silent empty result and not a guess. Refusals name **both** -candidates, so the fix is always in the error text: - -```typescript -await brain.find({ orderBy: 'createdAt' }) -// UnresolvableFieldError: no metadata field 'createdAt' — did you mean -// system.createdAt or metadata.createdAt? -``` - -`UnresolvableFieldError` is exported from the package root: - -```typescript -import { UnresolvableFieldError } from '@soulcraftlabs/brainy' - -try { - await brain.find({ orderBy: 'createdAt' }) -} catch (err) { - if (err instanceof UnresolvableFieldError) { - // err.message names both candidates — usually enough to fix the call site. - } -} -``` - -A handful of `find()` options are not implemented yet: `cursor`, -`includeRelations`, and `writeOnly`. Rather than accepting them and quietly -ignoring the option, `find()` refuses with `UnsupportedFindOptionError` — -also exported from the package root — so a call site can never believe an -unimplemented option took effect when it didn't. - -## The ordering contract - -`orderBy` behaves identically regardless of which engine (the pure-TypeScript -path or a native accelerator) is serving the query: - -- An entity missing the `orderBy` field, or holding `null` on it, sorts - **LAST — in both `asc` and `desc`**. It is never treated as "smaller than - everything" in one direction and "larger than everything" in the other; it - is simply last, either way. -- Rows are **never dropped** from an ordered read because they lack the - field — a missing value changes position, never presence. -- Ties on the `orderBy` field break by **id ascending**, regardless of the - primary sort direction. - -```typescript -// employees: [{ score: 9 }, { score: 5 }, { /* no score field */ }] -await brain.find({ orderBy: 'score', order: 'desc' }) // [9, 5, missing] — missing is last -await brain.find({ orderBy: 'score', order: 'asc' }) // [5, 9, missing] — missing is STILL last -``` - -## Migrating existing call sites - -If you have call sites written before this rule shipped that rely on a bare -system name — `orderBy: 'createdAt'`, `where: { confidence: { greaterThan: -0.8 } }`, and similar — they now refuse instead of silently resolving to the -engine field. The fix is always in the error: swap the bare name for -`system.` (or `metadata.` if you actually meant your own field -of that name, and it happens to share a name with a system scalar): - -```typescript -// Before: bare 'createdAt' silently meant the engine's timestamp. -await brain.find({ orderBy: 'createdAt' }) - -// After: say which one you meant. -await brain.find({ orderBy: 'system.createdAt' }) // the engine timestamp -await brain.find({ orderBy: 'metadata.createdAt' }) // your own field named createdAt, if you have one -``` - -There is no silent migration path by design — every ambiguous call site -surfaces as a refusal naming its own fix, once, the first time it runs -against the new rule. - -## Where to go next - -- [Consistency Model](./consistency-model.md) — visibility tiers, revision - counters, and the rest of the read/write contract this page's - read-time addressing rule. diff --git a/docs/concepts/index-health.md b/docs/concepts/index-health.md deleted file mode 100644 index 923267df..00000000 --- a/docs/concepts/index-health.md +++ /dev/null @@ -1,217 +0,0 @@ ---- -title: Index Health -slug: concepts/index-health -public: true -category: concepts -template: concept -order: 8 -description: How Brainy knows whether a derived index can be trusted — exact accounting instead of sampling, the named health report, degraded-but-serving vs. not-ready, and what repairIndex() checks, heals, and rebuilds. -next: - - concepts/generation-fact-log - - guides/inspection ---- - -# Index Health - -Brainy keeps one **canonical** copy of every entity and relationship, and three -**derived** indexes built from it — vector, metadata, and graph — so `find()` can -answer semantically, by filter, and by traversal without re-deriving the answer from -scratch on every query. A derived index is a cache with a serving structure: it can -be present but stale, present but only partially loaded, or fully out of sync with -canonical after a crash. This page is about how Brainy decides whether to trust one, -what it does when it can't, and how you reconcile the two. - -## Exact accounting instead of sampling - -Older health checks worked by inference: does `size()` return something greater -than zero, does a spot-check on one known item come back correct. Both are proxies. -A cold index can report a nonzero count while its actual serving structure never -loaded, and a spot-check only proves the one item it happened to ask about. - -Every derived-index provider may now expose a named, synchronous, O(1) -`healthReport()` — composed from the provider's own **exact ledgers** (real counters -it already maintains on the write path), never a sample or a walk. This is the one -signal Brainy's read gate consults. A provider that doesn't yet expose one falls -back to an honest `isReady()` boolean, and finally to a size heuristic for engines -with neither — but wherever a `healthReport()` exists, it wins. - -Underneath, storage itself keeps an analogous **canonical count ledger**: a -`counted` scalar (the user-facing total — what `getNounCount()` / `getVerbCount()` -return) and an `all` scalar (every tier, including internal records a derived -index's own coverage math needs to compare against). This is the real denominator -a provider's `healthReport()` measures itself by, rather than a total that can only -ever ratchet upward. See [What `suspect` counts mean](#what-suspect-counts-mean) -below for the one case that ledger can't stay exact through on its own. - -## The named report - -A `HealthReport` carries, per provider (`'vector'` / `'graph'` / `'metadata'`): - -- **`healthy`** — `true` iff every *verified* invariant holds. An invariant whose - family has no ledger yet is `unledgered`, never counted either way — unknown, - not passing. -- **`serving`** — can this provider answer a query right now. A failing invariant - graded `heal: 'repair'` or `heal: 'none'` still leaves `serving: true` — this is - **degraded-but-serving**: something is off (say, a stale rollup on an - `employee` record's relationship count) but reads keep working. Only a failure - graded `heal: 'rebuild'` flips `serving` to `false` — **not-ready** — because the - provider itself is telling you its serving structure cannot answer correctly. -- **`invariants`** — each checked condition, with its provenance - (`source: 'ledger'` — an exact count; `'deep'` — a full scan, diagnostic-only; - `'unledgered'` — not yet tracked) and, for a failing one, an exact `missing` - count plus a capped sample of the affected ids — a verdict, never a dump. -- **`generation`** — bumps on every ledger mutation and rebuild, so a caller can - cache a verdict per generation instead of re-deriving it. - -The distinction that matters day to day: `healthy: false` can be entirely benign — -a maintenance window, a divergence `repairIndex()` will clean up on its own -schedule. `serving: false` is not benign. It means this provider is refusing to -answer, on its own word, right now. - -**How a failure gets its grade — the serving law.** A provider grades `heal` by -one question only: *could an answer be wrong?* — never *how expensive is the -fix?* A missing-postings shortfall, however large, is `heal: 'repair'` (re-post -exactly what the ledger names, reads serving throughout); it can never withhold -serving just because healing it takes work. `serving` is withheld only by a -small, named set of rebuild-graded conditions — the index not initialized, its -durable state absent, a manifest naming files that are not resident, a replay -that did not complete cleanly — the states in which an answer could genuinely be -wrong. And a read is only ever refused by the family it actually consults: a -metadata filter is answered by the metadata index alone, vector search by the -vector index, traversal by the graph index — one family's refusal never blocks -another family's reads. - -## Reads refuse — they never rebuild - -A query that reaches a not-serving provider does not trigger a rebuild from inside -the read. Brainy retired that path deliberately: a rebuild kicked off by an ordinary -`find({ where: { status: 'active' } })` call is a dark, unpredictable cost hiding -behind a request that looks like a cheap read. Instead, the read throws a typed, -catchable error naming the reason: - -| Error | Thrown when | Meaning | -|---|---|---| -| `GraphIndexNotReadyError` | `find({ connected })`, `neighbors()`, `related()` | The graph adjacency index isn't serving — traversal would otherwise return `[]` indistinguishable from "no relationships" | -| `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 `@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 -provider to serving before `init()` returns — see the next section) or by calling -`repairIndex()` explicitly. - -```typescript -try { - const active = await brain.find({ where: { status: 'active' } }) -} catch (err) { - if (err instanceof MetadataIndexNotReadyError) { - // not a "no results" — the index itself refused; alert or retry after repair - } else { - throw err - } -} -``` - -### Rebuilds happen at open, not on first query - -`brain.init()` runs every needed rebuild to completion **before it returns**, -unconditionally, regardless of dataset size. There is no lazy, first-query -rebuild path anymore — a brain either finishes opening healthy, or it fails -open loudly. `disableAutoRebuild: true` no longer defers index construction to -the first query: it has no effect on *when* a needed rebuild runs. Full manual -control over rebuilds is `repairIndex({ rebuild: [...] })` (below), not this flag. - -## `repairIndex()` — checking and healing - -Bare `repairIndex()` is **report-driven**: it only heals what its own checks say -actually needs it, and it always returns a full per-family receipt. - -```typescript -const report = await brain.repairIndex() -report.healedTotal // total items healed across every family -report.durationMs -report.families // one row per family checked -``` - -Each `RepairFamilyReport` row names what happened: - -- **`checked`** — was this family actually examined (`false` means skipped — - see `skipped` for why). -- **`healed`** — items re-posted or corrected in place. -- **`missing`** — when the check can name what diverged: an exact `count` plus a - capped `sample` of ids. -- **`rebuilt`** — a full generational rebuild ran (as opposed to an incremental - heal). -- **`detail`** / **`reason`** / **`skipped`** — the receipt's narration; a row is - always either checked or explains why it wasn't. Nothing is silent. - -On every call, bare `repairIndex()`: - -1. Prunes orphaned canonical containers left by a partial delete. -2. Recomputes the count rollups from one canonical walk (unconditional — this is - also what clears a `suspect` ledger; see below). -3. Reconciles VFS containment edges, if the VFS is initialized. -4. Runs the metadata index's own corruption detection pass. -5. Consults each of the three derived-index providers' own health check and - rebuilds only a family whose failing invariant actually asks for it - (`heal: 'rebuild'`) — never a provider that reports `healthy` or a lesser - grade. - -### The explicit rebuild door - -`options.rebuild` skips the health check and rebuilds one or more families -**unconditionally** — the operator override for when you have independent reason -to distrust a family regardless of what it self-reports (a suspicious deploy, a -storage-layer incident, a support ticket that doesn't match what the health report -says): - -```typescript -// Force the graph adjacency to rebuild from canonical, no invariant consulted -await brain.repairIndex({ rebuild: ['graph'] }) - -// Force all three derived indexes -await brain.repairIndex({ rebuild: 'all' }) -``` - -A family named this way is recorded with `rebuilt: true` and -`reason: 'explicit rebuild requested'`, and is skipped by the normal -health-driven pass in the same call — it was already rebuilt unconditionally. - -Reach for the explicit door when you need certainty regardless of self-report; -reach for bare `repairIndex()` for routine maintenance and after any incident -where you're not sure which family (if any) needs it. - -## What `suspect` counts mean - -Storage's canonical count ledger increments the ALL-visibility total on every new -record and decrements it on every *proven* delete — one where the record was read, -or the caller supplied its prior image. A delete that cannot prove what it removed -existed doesn't guess: it flags the ledger `suspect` (an operator-visible -`console.warn`, narrated once per session, not once per delete) rather than risk -decrementing a total that was never incremented for that record in the first -place. This is intentionally rare — it's a defensive fallback for callers on an -unusual removal path, not a per-delete cost. - -`suspect` is not directly exposed on any `Brainy` method today — it lives on the -`StorageAdapter`'s optional `getCanonicalCounts()`, primarily consulted by -`repairIndex()`'s recount step and by custom storage adapters composing their own -`healthReport()`. What matters for an application: a `suspect` ledger is not -incorrect, just *unverified since the last recount* — and `repairIndex()`'s -unconditional count-rollup step (step 2, above) recomputes the ALL scalars from a -real canonical walk on every call, clearing the flag with proof either way. - -## Practical guidance - -- **On a normal restart**, do nothing — `init()` brings every provider to - serving before it returns, or fails loudly. -- **On a `*NotReadyError`** from a live read, reconcile with `repairIndex()` - (report-driven is almost always sufficient) and retry. -- **After an incident** where you distrust a specific family regardless of what - it reports healthy — a storage-layer fault, a suspicious restore — use the - explicit door: `repairIndex({ rebuild: ['metadata' | 'graph' | 'vector'] })`. -- **To audit before trusting a report**, `brain.auditGraph()` walks every stored - relationship and proves (or disproves) that reads return canonical truth, - independent of what any provider self-reports — see - [Inspecting a Live Brainy](../guides/inspection.md). diff --git a/docs/concepts/multi-process.md b/docs/concepts/multi-process.md index d698eee8..8fda315f 100644 --- a/docs/concepts/multi-process.md +++ b/docs/concepts/multi-process.md @@ -95,15 +95,8 @@ The heartbeat interval rewrites the lock file every 10 seconds. The timer is unref'd, so it does not keep the event loop alive on its own. On normal shutdown the writer releases the lock in `close()`. The shutdown -hooks Brainy registers for `SIGTERM` and `SIGINT` close every live brain by -that same `close()`, so a container restart doesn't strand the directory. - -`beforeExit` is not one of them. Node emits it whenever the event loop has -no ref'd work left — a state a healthy script reaches routinely, because -Brainy's own idle and cadence timers are unref'd — and a drained event loop -is not a shutdown. That hook only persists derived state with a non-closing -`flush()`: it closes nothing, releases no lock, and leaves every brain open -and usable. If you want a shutdown, call `close()` or send `SIGTERM`. +hooks Brainy registers for `SIGTERM`, `SIGINT`, and `beforeExit` also +release the lock so a container restart doesn't strand the directory. ## How to inspect a live writer diff --git a/docs/concepts/storage-adapters.md b/docs/concepts/storage-adapters.md index 82aa01e8..af6d068f 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 '@soulcraftlabs/brainy' +import { FileSystemStorage } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { BaseStorage } from '@soulcraft/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 -'@soulcraftlabs/brainy'` is not rewritten to a vendored copy). The prototype +'@soulcraft/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 `@soulcraftlabs/brainy@7.22.0` but - `node_modules/@soulcraftlabs/brainy` is still 7.20.x. + upgraded Brainy. The package.json says `@soulcraft/brainy@7.22.0` but + `node_modules/@soulcraft/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 -`@soulcraftlabs/brainy` to ≥7.21. See docs/concepts/storage-adapters.md. +`@soulcraft/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": { - "@soulcraftlabs/brainy": "^7.21.0" }` accepts any compatible 7.x. Don't pin + "@soulcraft/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/@soulcraftlabs/brainy/dist/storage/baseStorage.d.ts` — the +- `node_modules/@soulcraft/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 616c8fc4..11d86ec8 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 '@soulcraftlabs/brainy' +import { Brainy, NounType } from '@soulcraft/brainy' const brain = new Brainy() await brain.init() diff --git a/docs/guides/framework-integration.md b/docs/guides/framework-integration.md index 8f85da00..984466c5 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 '@soulcraftlabs/brainy'` +- **Zero configuration**: Just `import { Brainy } from '@soulcraft/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 @soulcraftlabs/brainy +npm install @soulcraft/brainy ``` ### Basic Integration ```javascript -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' let brainPromise @@ -248,7 +248,7 @@ The matching backend endpoint uses Brainy directly (Node/Bun): ```typescript // server: api/search -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' let brainPromise @@ -432,7 +432,7 @@ import { defineConfig } from 'vite' export default defineConfig({ ssr: { - external: ['@soulcraftlabs/brainy'] + external: ['@soulcraft/brainy'] } }) ``` @@ -440,7 +440,7 @@ export default defineConfig({ ```javascript // rollup.config.js (server bundle) export default { - external: ['@soulcraftlabs/brainy', 'node:fs', 'node:path', 'node:crypto'] + external: ['@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy'` into a server-only module so it never reaches the browser bundle. +**Solution**: Move the `import { Brainy } from '@soulcraft/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 ffabe55c..b1bb15ef 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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 18c3cb9a..66f50713 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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 3bc26dae..7837d49e 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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' const brain = new Brainy() await brain.init() @@ -187,7 +187,7 @@ await brain.import(file, { ## Complete Example ```typescript -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' import * as fs from 'fs' async function importCatalog() { diff --git a/docs/guides/inspection.md b/docs/guides/inspection.md index 8560b543..240e81ae 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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 20d40ea2..0a36f632 100644 --- a/docs/guides/installation.md +++ b/docs/guides/installation.md @@ -21,21 +21,21 @@ next: ## Install ```bash -npm install @soulcraftlabs/brainy +npm install @soulcraft/brainy ``` Or with your preferred package manager: ```bash -bun add @soulcraftlabs/brainy -yarn add @soulcraftlabs/brainy -pnpm add @soulcraftlabs/brainy +bun add @soulcraft/brainy +yarn add @soulcraft/brainy +pnpm add @soulcraft/brainy ``` ## Verify ```typescript -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' const brain = new Brainy() await brain.init() @@ -52,7 +52,7 @@ npm install @soulcraft/cor ``` ```typescript -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy, NounType, VerbType } from '@soulcraft/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 5f00534a..8b1f239e 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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' const brain = new Brainy() await brain.init() @@ -112,7 +112,7 @@ Recommendations: ${stats.recommendations.join(', ')} ### Step 1: Update Package ```bash -npm install @soulcraftlabs/brainy@latest +npm install @soulcraft/brainy@latest ``` ### Step 2: Restart Your Application @@ -134,7 +134,7 @@ npm run start ### Check Adaptive Sizing is Working ```typescript -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 @soulcraftlabs/brainy@3.35.0 +npm install @soulcraft/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 @soulcraftlabs/brainy@latest` +1. ✅ **Upgrade:** `npm install @soulcraft/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 cc1b2b6a..e5b7b1d6 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 @soulcraftlabs/brainy +bun add @soulcraft/brainy bun run server.ts ``` diff --git a/docs/guides/namespace-migration.md b/docs/guides/namespace-migration.md deleted file mode 100644 index f7d2c7f7..00000000 --- a/docs/guides/namespace-migration.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -title: Migrating to 9.0 — your fields and system fields -slug: guides/namespace-migration -public: true -category: guides -template: guide -order: 1 -description: The simple story of the 9.0 field-addressing change and the mechanical checklist for updating your call sites — every miss fails loudly with the fix in the error. -next: - - concepts/field-addressing ---- - -# Migrating to 9.0 — your fields and system fields - -The one-sentence version: **your data's field names are now completely -yours, the engine's own fields all live behind one `system.` prefix, and -nothing in between can silently go wrong anymore.** - -## What changed, simply - -**1. Any field name just works.** Before 9.0 the engine quietly owned -certain names. A field called `level` could be shadowed by the engine's -internal index layer of the same name (sorts silently returned insertion -order); names like `confidence` or `subtype` were rejected inside -`metadata`; names like `content` or `id` were silently never indexed, so -filtering on them returned nothing. All of that is gone. Any name — -`level`, `confidence`, `type`, `id`, `content`, anything — is stored -exactly as written and works with every feature: filtering, sorting, -grouping, aggregation, search, and time-travel reads. - -**2. The engine's fields moved behind `system.`.** The engine still keeps -its own per-record bookkeeping — creation time, type, confidence, and so -on. Those are reached one way only now: spelled out, e.g. -`system.createdAt`, `system.type`. They are just as queryable and sortable -as before. `orderBy: 'createdAt'` means *your* field named `createdAt`; -`orderBy: 'system.createdAt'` means the engine's timestamp. No guessing, -no priority rules. - -**3. Storage keeps the two physically separate.** New records store your -metadata in its own nested compartment, so a user field named -`confidence` and the engine's confidence live side by side, both intact, -through restarts, index rebuilds, and `asOf()` history. Old records stay -readable forever; nothing rewrites your data. - -**4. Mistakes are loud.** An ambiguous or unknown field name is a typed -error naming the fix. Unimplemented options refuse instead of being -ignored. The only forbidden name in your metadata is one literally -starting with `system.`. - -## The mechanical checklist - -Every missed site fails **loudly** with the correction in the error -message — nothing silently changes meaning. Sweep these patterns: - -| Before (8.x) | After (9.0) | -|---|---| -| `orderBy: 'createdAt'` (meaning the engine timestamp) | `orderBy: 'system.createdAt'` | -| `where: { subtype: 'invoice' }` (the engine subtype) | `where: { 'system.subtype': 'invoice' }` | -| `where: { confidence: { greaterThan: 0.8 } }` (the engine scalar) | `where: { 'system.confidence': { greaterThan: 0.8 } }` | -| `groupBy: ['noun']` or `groupBy: ['type']` | `groupBy: ['system.type']` | -| `where: { visibility: 'internal' }` / `{ service: … }` (engine values) | `'system.visibility'` / `'system.service'` | -| `metadata: { confidence: 0.9 }` expecting a throw or a lift to the engine scalar | it is YOUR field now — set the engine scalar via the `confidence` param | -| `new Brainy({ reservedFieldPolicy: … })` | remove the option (it throws with this note) | -| `find({ cursor })` / `includeRelations` / `writeOnly` | refuse with `UnsupportedFindOptionError` — they were silently ignored before | - -If a bare name in a query was genuinely *your* field all along (`orderBy: -'score'`, `where: { status: 'active' }`), **change nothing** — bare names -mean your fields, always. - -## What happens at first open - -Each existing database rebuilds its derived indexes once, automatically, -at the first open on 9.0 (index epoch 3 — the index keys split the two -namespaces). One-time cost, observable via `getIndexStatus()`; no manual -step, and your stored data is not modified. - -## For tooling and raw-record readers - -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 '@soulcraftlabs/brainy' -const { reserved, custom } = splitNounMetadataRecord(rawRecord) -// reserved = engine fields · custom = the user's bag, ANY names -``` - -Feature detection (never version-sniff): - -```typescript -import * as brainy from '@soulcraftlabs/brainy' -const lawActive = 'FIELD_ADDRESSING_CAPABILITY' in brainy // 'field-addressing/v1' -``` - -## Where to go next - -- [Field addressing](../concepts/field-addressing.md) — the full contract: - the ten system scalars, the relation mirror, refusal semantics, and the - cross-engine ordering guarantees. diff --git a/docs/guides/nextjs-integration.md b/docs/guides/nextjs-integration.md index 25d6062d..ab55e51f 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 @soulcraftlabs/brainy +npm install @soulcraft/brainy ``` ### Basic Setup @@ -18,7 +18,7 @@ npm install @soulcraftlabs/brainy // app/components/BrainyProvider.jsx 'use client' import { createContext, useContext, useEffect, useState } from 'react' -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' const BrainyContext = createContext() @@ -271,7 +271,7 @@ export default function SearchPage() { ```javascript // app/api/search/route.js (App Router) -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' let brain = null @@ -332,7 +332,7 @@ export async function GET() { ```javascript // pages/api/search.js (Pages Router) -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' let brain = null @@ -374,7 +374,7 @@ export default async function handler(req, res) { ```javascript // app/api/data/route.js -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' let brain = null @@ -418,7 +418,7 @@ export async function POST(request) { ```jsx // app/actions/brainy.js 'use server' -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' let brain = null @@ -630,7 +630,7 @@ CMD ["npm", "start"] /** @type {import('next').NextConfig} */ const nextConfig = { experimental: { - serverComponentsExternalPackages: ['@soulcraftlabs/brainy'] + serverComponentsExternalPackages: ['@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' const BrainyContext = createContext() @@ -873,7 +873,7 @@ import { BrainyProvider } from '../app/components/BrainyProvider' import { Search } from '../app/components/Search' // Mock Brainy -jest.mock('@soulcraftlabs/brainy', () => ({ +jest.mock('@soulcraft/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 2984998b..268bc5fa 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 '@soulcraftlabs/brainy' +import { Brainy, RevisionConflictError } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { GenerationConflictError } from '@soulcraft/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 d9a4e896..097c55fe 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 @soulcraftlabs/brainy +npm install @soulcraft/brainy ``` ## 2. Initialize ```typescript -import { Brainy, NounType, VerbType } from '@soulcraftlabs/brainy' +import { Brainy, NounType, VerbType } from '@soulcraft/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 '@soulcraftlabs/brainy' +import type { Result } from '@soulcraft/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 27dabe75..9f2e2e5b 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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' const brain = await Brainy.create() @@ -78,7 +78,7 @@ interface ImportProgress { ```typescript import { useState } from 'react' -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 a4224bc8..06ec9f3a 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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { FileSystemStorage, MemoryStorage } from '@soulcraft/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 74311528..ff5de320 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 '@soulcraftlabs/brainy' +import { Brainy, NounType } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy, NounType } from '@soulcraft/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 53aa2a5c..a3c64fb9 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 `@soulcraftlabs/brainy@8.0.12` (or later) and open the store.** +- **Just upgrade to `@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 `@soulcraftlabs/brainy@7.x`. 8.0 does not keep the old +your own snapshot) and pin `@soulcraft/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 34d18ebf..7f7c6a06 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 @soulcraftlabs/brainy +npm install @soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' let brainPromise @@ -1201,7 +1201,7 @@ import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], ssr: { - external: ['@soulcraftlabs/brainy'] + external: ['@soulcraft/brainy'] } }) ``` diff --git a/docs/neural-extraction.md b/docs/neural-extraction.md index cfb6d764..989b1b60 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 '@soulcraftlabs/brainy' +import { Brainy, NounType } from '@soulcraft/brainy' const brain = new Brainy() await brain.init() @@ -62,9 +62,9 @@ const people = await brain.extractEntities('...', { import { SmartExtractor, SmartRelationshipExtractor -} from '@soulcraftlabs/brainy' +} from '@soulcraft/brainy' // Or use subpath imports: -import { SmartExtractor } from '@soulcraftlabs/brainy/neural/SmartExtractor' +import { SmartExtractor } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { SmartExtractor, FormatContext } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { SmartRelationshipExtractor } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { NeuralEntityExtractor } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { SmartExtractor } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { SmartRelationshipExtractor } from '@soulcraft/brainy' const relExtractor = new SmartRelationshipExtractor(brain) diff --git a/docs/path-registry.md b/docs/path-registry.md deleted file mode 100644 index 8a55004c..00000000 --- a/docs/path-registry.md +++ /dev/null @@ -1,86 +0,0 @@ -# The Path Registry — brainy's twin table - -The brainy half of the cross-engine Path Registry (the native accelerator -maintains the master list; IDs are shared and stable — `LC3`, `DP7`, … are -citable in commits, board rounds, release notes, and pins). Every row owes -five things: **service class** (INDEX-SERVED | BOUNDED-FALLBACK, announced | -TYPED REFUSAL), **latency budget** at 1k/10k/100k/1M (design bar: billions), -**lifecycle behavior**, **failure narration**, and a **test pin**. A path not -in this registry does not ship; an unregistered path is a red gate in the -scan audit. - -**The availability bar governing every row: user-visible downtime is -seconds, at restart only.** Migration, heal, compaction, embedding, and -retention run behind the doors — yielding, budget-capped, narrated. No path -may hold the doors while it does housekeeping. - -Status legend: ✅ contracted + pinned (test cited) · 🟡 partial (what holds -and what's missing, stated) · 🔴 owed (named, never silent). - -## LC — Lifecycle - -| ID | Brainy row | Status | -|----|-----------|--------| -| LC1 | Same-version reopen adopts everything: brain-format epoch match → zero rebuilds; aggregation state adopts by stamp; persisted indexes load. | ✅ `tests/unit/brainy/brain-format-handshake` + `migration-deference` (no-drift reopen never rebuilds) | -| LC2 | New empty brain: doors immediate. | ✅ exercised by every suite's setup | -| LC3 | Upgrade, same epoch: as LC1 — new code on unchanged formats owes nothing at open. | ✅ same pins as LC1 (epoch equality is the gate) | -| LC4 | Upgrade with epoch migration: TODAY brainy's epoch rebuild runs at open before doors. | 🔴 **owed — the sev's lockout row.** The doors-open-serving-old-structures design (yielding installments + atomic swap) lands measured-and-gated behind the service-class pair, per the lifecycle-sprint choreography. Acceptance case: the 9,184-row hours-lockout. | -| LC5 | Crash recovery: bounded, resumable, narrated. Aggregation leg ✅ (behind-stamp → incremental catch-up off the fact log + time-travel reconciliation, capped at 5,000 affected before an ANNOUNCED rescan). Vector/metadata legs ride epoch machinery (rebuild-from-canonical, narrated). | 🟡 aggregation pinned (`tests/integration/aggregation-lifecycle-catchup`); the rebuild legs are narrated but not yet installment-yielding (couples to LC4) | -| LC6 | Shutdown under load: close() drains the background flush flight, tears down cadence timers, runs ONE time-bounded compaction pass (~5s budget, resumable). | 🟡 pinned for flush/compaction (8.9.0 suites); SIGTERM drain budget not yet declared | -| LC7 | Rollback/downgrade: an N−1 build opening an N brain. | 🔴 owed — no declared read-compat window or typed refusal today (epoch mismatch triggers a rebuild, not a refusal; v2 nested-bag records read as a phantom user field on pre-law builds). Needs the declared-window contract. | -| LC8 | Relocatable brain directory: no absolute paths in artifacts; persist()/load() round-trips. | 🟡 persist/load pinned; byte-for-byte relocation depot cases are the pair gate's (shared corpora) | -| LC9 | Double-open: second writer gets a typed lock refusal (PID-liveness + heartbeat stale detection; `force` escape hatch logs loudly). | ✅ writer-lock suites (8.7.1) | - -## DP — Data plane - -| ID | Brainy row | Status | -|----|-----------|--------| -| DP1 | `get()` by id: direct storage read + hydrate. INDEX-SERVED (id-mapped). Milliseconds at every scale. | ✅ exercised everywhere; budget rides the pair speed table | -| DP2 | `find({query})`: embed + vector search. The embed dominates (native side owns the budget); JS HNSW serves the search leg. | 🟡 300ms-class p95 is the pair speed-table row; brainy-alone budget declared there | -| DP3 | Filtered/sorted list: column top-K when the field is columnized (INDEX-SERVED, zero canonical reads on the sorted page — value pairs come from ONE batched metadata-record pass); no-column fallback is BOUNDED-ANNOUNCED (one batch pass, announces once per field past 500 rows); unknown field → TYPED REFUSAL naming both candidate spellings. | ✅ `tests/unit/utils/metadataIndex-sort-callshape` (zero per-row reads, batch-only — latency-blind) + `metadataIndex-nested-orderby` (dotted keys serve-or-refuse) + `tests/integration/orderby-sort-bug` | -| DP4 | Aggregation/stats: ALWAYS answers. Write-time incremental; behind-stamp reconciles incrementally; genuine rebuilds go through the native parallel door or the paged JS walk; nothing ever latches off; before-image-less deletes flag a LOUD rescan, never a silent skip. | ✅ `tests/integration/aggregation-lifecycle-catchup` + `tests/unit/aggregation/aggregation-provider-rebuild` | -| DP5 | Graph traversal: `related()` paged via adjacency; whole-graph analytics carry declared cost. | 🟡 paged reads pinned; analytics cost-class declaration owed (rides VENUE-GRAPH-TRUST audit tool) | -| DP6 | Single write: ack at the canonical commit; visibility committed at ack (the atomic vector update kills the remove→add dark window); maintenance NEVER holds the ack (background flush cadence — THE ACK LAW pins: a hung flush cannot block a write, a hung EMBEDDER cannot block a write). | ✅ `tests/unit/brainy/persistence-policy` + `tests/unit/hnsw/update-item-atomic` + `tests/integration/deferred-embedding` | -| DP7 | Bulk ingest: sustained rate holds flat — per-write maintenance taxes must not grow with brain size (A4 removed caller-flush convoys; deferred embedding removes the per-write embed tax where opted). | 🟡 the decay-curve row is a pair speed-table RED GATE; brainy-alone sustained-rate run rides the same corpora | -| DP8 | Read under write pressure: no flicker window — a row that exists is never invisible to recall, even transiently (same-vector re-index is a no-op; changed-vector swaps in place, node never leaves the index; deferred updates serve the OLD vector until the atomic swap — stale-beats-absent). | ✅ brainy leg pinned (`tests/unit/hnsw/update-item-atomic` 9/9 + `deferred-embedding` stale-beats-absent); the symmetry property suite + runtime sentinels remain the B4 program | -| — | **As-of semantic recall** (time-travel vector search): `asOf(G).find()` serves the vectors AS THEY STOOD at G — byte-exact past vectors, tombstone masking, the deferred-embed cell honest on the vector leg, TYPED refusal beyond the head. Brainy-alone leg = ephemeral at-generation materialization (documented O(n log n at G) build, bounded); the at-scale leg rides the accelerated provider's as-of index. | ✅ `tests/integration/asof-semantic-recall` 4/4 (registry ID pending the master table's mint) | -| — | **The lazy-open gate honors EVERY provider's not-ready report** (a not-ready metadata provider can no longer latch the silent-empty state under `disableAutoRebuild`). | ✅ `tests/unit/brainy/lazy-notready-honor` | - -## MT — Maintenance (never in the door path) - -| ID | Brainy row | Status | -|----|-----------|--------| -| MT1 | Flush/checkpoint: ENGINE-OWNED cadence (write-count/interval/idle triggers, single-flight, background, loud on failure; callers never flush in hot paths; `flush()` stays as an awaitable barrier). | ✅ `tests/unit/brainy/persistence-policy` | -| MT2 | Compaction: never on flush (durability-only law, 8.9.0); close-time pass time-budgeted + resumable; explicit `compactHistory({timeBudgetMs})`. | ✅ 8.9.0 suites | -| MT3 | Index upkeep (mapper folds, delta promotion): native-side machinery; brainy's JS legs are small and synchronous-cheap. | 🟡 declared; yield audit rides the pair | -| MT4 | Heal/rebuild walks (`repairIndex`, backfill walks): paged; failure latches with cooldown; NOT yet yield-to-foreground installments. | 🔴 owed — the priority-isolation clause (couples to LC4; same choreography) | -| MT5 | Deferred embedding worker: ack at durability, durable pending markers (written BEFORE the commit — orphan-safe), crash-recovered at open via a bounded prefix listing, single-flight, 60s hang guard, `awaitPendingEmbeds()` barrier + `pendingEmbeds` gauge. VFS write paths adopt it end-to-end. | ✅ `tests/integration/deferred-embedding` 5/5 | -| MT6 | Retention/archival walks: retention `'all'` does nothing by design; bounded-retention reclaim is close-time/explicit only. | 🟡 8.9.0 behavior pinned; archival profile is the co-frozen D1+D3 unit | - -## FM — Failure modes - -| ID | Brainy row | Status | -|----|-----------|--------| -| FM1 | Disk full / IO error mid-op: transaction rollback + typed error; failed rollback → StoreInconsistentError quarantines writes until repairIndex(). | 🟡 rollback paths pinned; explicit disk-full depot case owed | -| FM2 | Memory pressure: query limits + reserved-memory config; unified cache eviction. | 🟡 declared budgets; cascade pin owed | -| FM3 | Torn/corrupt file on open: malformed brain-format marker → safe rebuild (never trusting a bad epoch); corrupt records surface loudly. | 🟡 marker pin ✅ (`brain-format-handshake`); broader quarantine is native-side | -| FM4 | Native module unavailable: plugin load failure is LOUD (version-coupling law throws on range mismatch — never silently version-drifted); JS engine serves with its own declared budgets, named as the active backend in op names. | ✅ `tests/unit/plugin-version-coupling` + op-name stamping | - -## FL — Fleet - -| ID | Brainy row | Status | -|----|-----------|--------| -| FL1 | Cold open on demand: LC1's adopt-everything open; warm() available for eager paths. | 🟡 open cost pinned at LC1; millisecond budget rides the speed table | -| FL2–FL4 | Boot storm / upgrade wave / isolation: fleet-layer policies over LC1/LC4 — engine leg = budgeted opens + LC4's behind-doors migration. | 🔴 owed with LC4 | -| FL5 | Brain as product object: create instant (LC2) · erase = `clear()` explicit + complete · export = portable-graph, canon-complete mode available. | ✅ clear-persistence + portable-graph + canonical-enumeration suites | - -## Status summary - -Contracted + pinned this train: **DP3, DP4, DP6, DP8(brainy leg), MT1, -MT5, LC5(aggregation), the lazy-open not-ready gate, LC1/LC3/LC9, FM4, -FL5** — each with the cited test. Owed, in production-risk order, all -coupled to the priority-isolation program the lifecycle sev opened: **LC4 -(doors-open migration), MT4 (yielding heals), LC7 (downgrade contract), -LC6 (SIGTERM budget), FL2–FL4, FM1/FM2 depot cases, B4 symmetry suite + -sentinels.** Rows move from owed to contracted only with a cited test — -none lands by prose. diff --git a/docs/transactions.md b/docs/transactions.md index cbea39c0..fce7d10e 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 '@soulcraftlabs/brainy' -import { NounType } from '@soulcraftlabs/brainy/types' +import { Brainy } from '@soulcraft/brainy' +import { NounType } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 464b91fb..da42874c 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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 f1319d5b..380862e1 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 '@soulcraftlabs/brainy/vfs/semantic' -import { Brainy } from '@soulcraftlabs/brainy' -import { VirtualFileSystem, VFSEntity } from '@soulcraftlabs/brainy/vfs' +import { BaseProjectionStrategy } from '@soulcraft/brainy/vfs/semantic' +import { Brainy } from '@soulcraft/brainy' +import { VirtualFileSystem, VFSEntity } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy'` +1. Import correct types: `import { Brainy, VirtualFileSystem } from '@soulcraft/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 4a1f83dc..8b0efce6 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 @soulcraftlabs/brainy +npm install @soulcraft/brainy ``` ```typescript -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 @soulcraftlabs/brainy # Check version -npm install @soulcraftlabs/brainy@latest # Update if needed +npm ls @soulcraft/brainy # Check version +npm install @soulcraft/brainy@latest # Update if needed ``` ### "VFS not initialized" errors diff --git a/docs/vfs/README.md b/docs/vfs/README.md index a94910c9..b95f0d7b 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 '@soulcraftlabs/brainy/vfs' +import { VirtualFileSystem } from '@soulcraft/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 @soulcraftlabs/brainy +npm install @soulcraft/brainy ``` ## Requirements diff --git a/docs/vfs/ROADMAP.md b/docs/vfs/ROADMAP.md index c8d15cd2..93c5b901 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 '@soulcraftlabs/brainy/vfs/fuse' +import { mountVFS } from '@soulcraft/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 '@soulcraftlabs/brainy/vfs/express' +import { createStaticMiddleware } from '@soulcraft/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 '@soulcraftlabs/brainy/vfs/vscode' +import { VFSProvider } from '@soulcraft/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 f34ee9ae..9298c822 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 '@soulcraftlabs/brainy/vfs/semantic' +import { BaseProjectionStrategy } from '@soulcraft/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 5dcaaeb8..e0c6a94c 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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { VFSError, VFSErrorCode } from '@soulcraft/brainy' try { await vfs.readFile('/nonexistent.txt') diff --git a/docs/vfs/VFS_CORE.md b/docs/vfs/VFS_CORE.md index c1d502c0..1eeaf9f8 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 '@soulcraftlabs/brainy' +import { GitBridge } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' async function vfsExample() { // Initialize diff --git a/docs/vfs/VFS_GRAPH_TYPES.md b/docs/vfs/VFS_GRAPH_TYPES.md index 478bef7f..3c1f30f0 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 '@soulcraftlabs/brainy' +import { NounType, VerbType } from '@soulcraft/brainy' ``` \ No newline at end of file diff --git a/docs/vfs/VFS_INITIALIZATION.md b/docs/vfs/VFS_INITIALIZATION.md index fd12fc71..97e6b0bf 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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' async function useVFS() { // Initialize Brainy @@ -100,7 +100,7 @@ useVFS().catch(console.error) ## TypeScript Usage ```typescript -import { Brainy, VirtualFileSystem } from '@soulcraftlabs/brainy' +import { Brainy, VirtualFileSystem } from '@soulcraft/brainy' class FileManager { private brain: Brainy diff --git a/docs/vfs/building-file-explorers.md b/docs/vfs/building-file-explorers.md index 7514c12e..6bb31871 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 '@soulcraftlabs/brainy' +import { Brainy, VirtualFileSystem } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { VirtualFileSystem } from '@soulcraft/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 '@soulcraftlabs/brainy/vfs' +import { VFSTreeUtils } from '@soulcraft/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 e3b33506..9e83cf25 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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' import { WebSocket } from 'ws' // ===================================================== diff --git a/examples/monitor-cache-performance.ts b/examples/monitor-cache-performance.ts index 87c965a2..9d50d476 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 '@soulcraftlabs/brainy' +import { Brainy, NounType } from '@soulcraft/brainy' // ANSI color codes for pretty output const colors = { diff --git a/integrations/README.md b/integrations/README.md index de156623..aa3d795b 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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' const app = express() const brain = new Brainy({ @@ -232,7 +232,7 @@ app.listen(3000, () => { ```typescript import { Hono } from 'hono' -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' const app = new Hono() diff --git a/integrations/google-sheets/README.md b/integrations/google-sheets/README.md index 8309a30a..b2b0af3a 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 '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' const brain = new Brainy({ integrations: true }) await brain.init() @@ -112,7 +112,7 @@ With Express: ```javascript import express from 'express' -import { Brainy } from '@soulcraftlabs/brainy' +import { Brainy } from '@soulcraft/brainy' const app = express() const brain = new Brainy({ integrations: true }) diff --git a/package-lock.json b/package-lock.json index c4757030..37aeb81d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@soulcraftlabs/brainy", - "version": "10.4.12", + "name": "@soulcraft/brainy", + "version": "8.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@soulcraftlabs/brainy", - "version": "10.4.12", + "name": "@soulcraft/brainy", + "version": "8.10.0", "license": "MIT", "dependencies": { "@msgpack/msgpack": "^3.1.2", diff --git a/package.json b/package.json index 649f2aaf..e4bc8144 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { - "name": "@soulcraftlabs/brainy", - "version": "10.4.12", - "brainyContract": 1, + "name": "@soulcraft/brainy", + "version": "8.10.0", "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", @@ -88,7 +87,7 @@ "test:watch": "NODE_OPTIONS='--max-old-space-size=8192' vitest --config tests/configs/vitest.unit.config.ts", "test:coverage": "NODE_OPTIONS='--max-old-space-size=8192' vitest run --config tests/configs/vitest.unit.config.ts --coverage", "test:unit": "NODE_OPTIONS='--max-old-space-size=8192' vitest run --config tests/configs/vitest.unit.config.ts", - "test:perf": "vitest run --config tests/configs/vitest.perf.config.ts", + "test:perf": "vitest run tests/unit/performance --reporter=basic", "test:integration": "NODE_OPTIONS='--max-old-space-size=8192' vitest run --config tests/configs/vitest.integration.config.ts", "test:semantic": "NODE_OPTIONS='--max-old-space-size=8192' vitest run --config tests/configs/vitest.semantic.config.ts", "test:all": "npm run test:unit && npm run test:integration", @@ -127,16 +126,15 @@ "license": "MIT", "private": false, "publishConfig": { - "access": "public", - "registry": "https://source.soulcraft.com/api/packages/soulcraftlabs/npm/" + "access": "public" }, - "homepage": "https://source.soulcraft.com/soulcraftlabs/open-brainy", + "homepage": "https://github.com/soulcraftlabs/brainy", "bugs": { - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/issues" + "url": "https://github.com/soulcraftlabs/brainy/issues" }, "repository": { "type": "git", - "url": "git+https://source.soulcraft.com/soulcraftlabs/open-brainy.git" + "url": "git+https://github.com/soulcraftlabs/brainy.git" }, "files": [ "dist/**/*.js", diff --git a/scripts/buildEmbeddedPatterns.ts b/scripts/buildEmbeddedPatterns.ts index c046df45..73e51224 100644 --- a/scripts/buildEmbeddedPatterns.ts +++ b/scripts/buildEmbeddedPatterns.ts @@ -10,7 +10,6 @@ import { TransformerEmbedding } from '../src/utils/embedding.js' import * as fs from 'fs/promises' import * as path from 'path' import { fileURLToPath } from 'url' -import { resolveDeterministicStamp } from './lib/deterministicStamp.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -98,22 +97,13 @@ async function buildEmbeddedPatterns() { // Convert to base64 for embedding in TypeScript const uint8 = new Uint8Array(buffer) const base64 = Buffer.from(uint8).toString('base64') - - // Deterministic stamp: derived from the git commit time of this - // generator's inputs, never from wall-clock time — two builds of the - // same source tree must produce byte-identical output. - const outputPath = path.join(__dirname, '..', 'src', 'neural', 'embeddedPatterns.ts') - const generatedStamp = resolveDeterministicStamp( - [path.join(__dirname, 'buildEmbeddedPatterns.ts'), libraryPath], - outputPath - ) - + // Generate TypeScript file with everything embedded const tsContent = `/** * 🧠 BRAINY EMBEDDED PATTERNS * * AUTO-GENERATED - DO NOT EDIT - * Generated: ${generatedStamp} + * Generated: ${new Date().toISOString()} * Patterns: ${libraryData.patterns.length} * Coverage: 94-98% of all queries * @@ -207,6 +197,7 @@ prodLog.info(\`🧠 Brainy Pattern Library loaded: \${EMBEDDED_PATTERNS.length} ` // Write the TypeScript file + const outputPath = path.join(__dirname, '..', 'src', 'neural', 'embeddedPatterns.ts') await fs.writeFile(outputPath, tsContent) // Report statistics diff --git a/scripts/buildTypeEmbeddings.ts b/scripts/buildTypeEmbeddings.ts index 688d6ac1..61bcf238 100644 --- a/scripts/buildTypeEmbeddings.ts +++ b/scripts/buildTypeEmbeddings.ts @@ -11,7 +11,6 @@ import * as fs from 'fs/promises' import * as path from 'path' import { fileURLToPath } from 'url' import { NounType, VerbType } from '../src/types/graphTypes.js' -import { resolveDeterministicStamp } from './lib/deterministicStamp.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -374,24 +373,12 @@ async function buildTypeEmbeddings() { const uint8 = new Uint8Array(buffer) const base64 = Buffer.from(uint8).toString('base64') - // Deterministic stamp: derived from the git commit time of this - // generator's inputs, never from wall-clock time — two builds of the - // same source tree must produce byte-identical output. - const outputPath = path.join(__dirname, '..', 'src', 'neural', 'embeddedTypeEmbeddings.ts') - const generatedStamp = resolveDeterministicStamp( - [ - path.join(__dirname, 'buildTypeEmbeddings.ts'), - path.join(__dirname, '..', 'src', 'types', 'graphTypes.ts') - ], - outputPath - ) - // Generate TypeScript file const tsContent = `/** * 🧠 BRAINY EMBEDDED TYPE EMBEDDINGS * * AUTO-GENERATED - DO NOT EDIT - * Generated: ${generatedStamp} + * Generated: ${new Date().toISOString()} * Noun Types: ${nounTypes.length} * Verb Types: ${verbTypes.length} * @@ -408,7 +395,7 @@ export const TYPE_METADATA = { verbTypes: ${verbTypes.length}, totalTypes: ${totalTypes}, embeddingDimensions: ${embeddingDim}, - generatedAt: "${generatedStamp}", + generatedAt: "${new Date().toISOString()}", sizeBytes: { embeddings: ${buffer.byteLength}, base64: ${base64.length} @@ -507,6 +494,7 @@ prodLog.info(\`🧠 Brainy Type Embeddings loaded: \${TYPE_METADATA.nounTypes} n ` // Write the TypeScript file + const outputPath = path.join(__dirname, '..', 'src', 'neural', 'embeddedTypeEmbeddings.ts') await fs.writeFile(outputPath, tsContent) // Report statistics diff --git a/scripts/emit-contract-manifest.mjs b/scripts/emit-contract-manifest.mjs deleted file mode 100644 index be73d4ca..00000000 --- a/scripts/emit-contract-manifest.mjs +++ /dev/null @@ -1,128 +0,0 @@ -#!/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/gate/README.md b/scripts/gate/README.md deleted file mode 100644 index 0a8afab0..00000000 --- a/scripts/gate/README.md +++ /dev/null @@ -1,85 +0,0 @@ -# Gate Guards - -Two standalone scripts that stand between a test/build gate and a false -verdict: one refuses to let the gate start on a noisy machine, the other -refuses to let a truncated or crashed vitest run be read as green. - -## Why these exist - -Both guards exist because of the 2026-08-13 lost-day ledger: a gate ran on -a machine under load, and separately a vitest worker pool died mid-suite -while still printing a plausible-looking summary line, and in both cases -the bad result was trusted and acted on for the better part of a day before -anyone noticed. Neither failure mode announces itself — a loaded machine -still finishes and reports numbers, and a truncated test run still prints a -`Test Files` / `Tests` line — so both guards check the evidence explicitly -rather than trusting that a gate finishing means the gate was valid. - -## gate-preflight.sh - -Run before any gate lane starts. Exits 1 the moment the machine isn't -gate-clean, with one `FATAL:` line per violation naming the exact offender -(the pid and command, the path, the measured value). Prints one `OK:` line -per check that passes. `WARNING:` lines mark checks that were skipped, not -failures. - -Checks: - -| # | Check | Default threshold | Override | -|---|-------|--------------------|----------| -| a | 1-minute load average | `nproc / 2` | `GATE_MAX_LOAD` | -| b | any non-allowlisted process over 50% of one core | 50% | `GATE_ALLOW_REGEX` (extra pattern matched against the process's args) | -| c | cpu0 scaling governor must be `performance` | — | none (warns and skips if the sysfs path is absent) | -| d | free space on `/` and `/tmp` | 10G each | `GATE_SKIP_DISK_CHECK=1` to skip entirely | - -The allowlist for check (b) is always: this script's own process tree -(its ancestors and its direct child processes), `sshd`, `systemd`, and -kernel threads (recognizable by args wrapped in brackets, e.g. -`[kworker/0:1]`). `GATE_ALLOW_REGEX` extends it — it does not replace it. - -## vitest-verdict-check.sh - -Run after every vitest lane, against that lane's captured log. Fails -loudly, quoting the exact line or string that tripped it, when the log's -own summary can't be trusted: - -- no `Test Files` (or, in `--count-tests` mode, `Tests`) summary line is - present at all -- the parenthesized total in that line doesn't match what was expected -- fewer files/tests are accounted for (passed + failed + skipped) than the - total claims — a truncated run -- the log contains `Unhandled Error` or `Timeout calling` anywhere — a dead - worker pool, regardless of what the summary line claims - -``` -vitest-verdict-check.sh -vitest-verdict-check.sh --count-tests -``` - -The first form checks `Test Files` for an exact match. The second checks -`Tests` for a minimum (a floor, not an exact count, since the total number -of individual tests moves more often than the number of test files). - -## Wiring into a CI lane - -```sh -# Before any lane that will report a verdict: -scripts/gate/gate-preflight.sh || exit 1 - -# Run the suite, capturing its output: -npx vitest run tests/unit 2>&1 | tee /tmp/unit.log - -# After every vitest lane, check the log against the actual file count: -EXPECTED_FILES=$(ls tests/unit/**/*.test.ts | wc -l) -scripts/gate/vitest-verdict-check.sh /tmp/unit.log "$EXPECTED_FILES" || exit 1 -``` - -## Exit-code contract - -| Script | Exit 0 | Exit 1 | -|--------|--------|--------| -| `gate-preflight.sh` | machine is gate-clean | one or more `FATAL:` violations printed | -| `vitest-verdict-check.sh` | log's summary is trustworthy and matches | usage error, missing/unreadable log, or one or more `FATAL:` violations printed | - -Non-zero from either script means: do not trust the gate that was about to -run, or the result of the one that just ran. diff --git a/scripts/gate/gate-preflight.sh b/scripts/gate/gate-preflight.sh deleted file mode 100755 index c6208f49..00000000 --- a/scripts/gate/gate-preflight.sh +++ /dev/null @@ -1,206 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Brainy Gate Preflight -# Refuses to let a test/build gate run on a machine that isn't clean enough -# to trust the numbers it produces. See scripts/gate/README.md for why (the -# 2026-08-13 lost-day ledger). -# -# Checks: 1-minute load average, any non-allowlisted process pinning a core, -# the cpu0 scaling governor, and free space on / and /tmp. -# -# Exit 0 and print one OK line per passing check when the machine is clean. -# Exit 1 and print one FATAL line per violation, naming the offender, when -# it is not. -# -# Known trap: a helper function whose last executed statement is a `while` -# (or any command whose own exit status happens to be nonzero) hands that -# status back as the function's return value. Called as a plain statement, -# that silently kills this script under `set -e`. Every helper below ends -# on an explicit `return 0` as its own statement, never on a loop or test. -# -# The same failure mode hides in plainer-looking lines too: `var=$(cmd)` is -# a bare assignment, so `set -e` DOES treat a nonzero `cmd` (or, under -# `pipefail`, a nonzero stage anywhere in `cmd`'s pipeline) as a failure of -# that statement and kills the script right there — even mid-loop, even -# when the "failure" is routine (a process that exited before a second -# lookup, a path that doesn't exist). Every such assignment below is paired -# with an explicit `|| var=""` fallback so a routine miss degrades to an -# empty value instead of an exit. - -VIOLATIONS=0 -ANCESTOR_PIDS="" - -fatal() { - echo "FATAL: $1" - VIOLATIONS=$((VIOLATIONS + 1)) -} - -ok() { - echo "OK: $1" -} - -# Walks this process's parent chain up to pid 1, then takes one snapshot of -# its direct children (the ps/read pipeline in check_processes), and -# records both in ANCESTOR_PIDS — so the process-scan below can recognize -# its own tree (the shell/terminal/session that launched it, plus its own -# helper commands) instead of flagging it. Children are captured once, up -# front, rather than re-queried per row later, so a helper command that has -# already exited by the time it's looked up can't be mistaken for a miss. -build_ancestor_pids() { - local pid="$$" - local ppid child - ANCESTOR_PIDS=" $pid " - while [ "$pid" != "1" ]; do - ppid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') || ppid="" - if [ -z "$ppid" ]; then - break - fi - ANCESTOR_PIDS="${ANCESTOR_PIDS}${ppid} " - pid="$ppid" - done - - while IFS= read -r child; do - [ -z "$child" ] && continue - ANCESTOR_PIDS="${ANCESTOR_PIDS}${child} " - done < <(ps --ppid "$$" -o pid= 2>/dev/null || true) - - return 0 -} - -# (a) 1-minute load average vs. threshold (default: nproc / 2). -check_load() { - local max_load="${GATE_MAX_LOAD:-}" - if [ -z "$max_load" ]; then - max_load=$(( $(nproc) / 2 )) - if [ "$max_load" -lt 1 ]; then - max_load=1 - fi - fi - - local load_1m - load_1m=$(cut -d' ' -f1 /proc/loadavg) - - if awk -v l="$load_1m" -v m="$max_load" 'BEGIN { exit !(l > m) }'; then - fatal "1-minute load average ${load_1m} exceeds threshold ${max_load} (GATE_MAX_LOAD=${max_load})" - else - ok "1-minute load average ${load_1m} is within threshold ${max_load}" - fi - return 0 -} - -# (b) any process outside the allowlist pinning more than half a core. -# Parsed with `read` into named fields, not an awk/cut chain — a fixed-column -# awk/cut split on `ps` output duplicated fields the first time this was -# tried, because process args vary in word count. `read` with a fixed list -# of variables dumps everything left over into the last one (args), which -# handles that correctly. -check_processes() { - local max_pcpu=50 - local extra_regex="${GATE_ALLOW_REGEX:-}" - local violation_found=0 - local line pcpu pid args pcpu_int - - while IFS= read -r line; do - [ -z "$line" ] && continue - read -r pcpu pid args <<< "$line" - - # Kernel threads report their comm in brackets, e.g. "[kworker/0:1]". - case "$args" in - \[*\]) continue ;; - esac - - # This script's own tree: its ancestors (shell, terminal, session) and - # its direct children, both captured once by build_ancestor_pids. - case " $ANCESTOR_PIDS " in - *" $pid "*) continue ;; - esac - - case "$args" in - *sshd*|*systemd*) continue ;; - esac - - if [ -n "$extra_regex" ] && [[ "$args" =~ $extra_regex ]]; then - continue - fi - - pcpu_int="${pcpu%.*}" - if [ -z "$pcpu_int" ]; then - pcpu_int=0 - fi - if [ "$pcpu_int" -gt "$max_pcpu" ]; then - fatal "pid ${pid} ('${args}') is using ${pcpu}% of one core" - violation_found=1 - fi - done < <(ps -eo pcpu,pid,args --sort=-pcpu | tail -n +2) - - if [ "$violation_found" -eq 0 ]; then - ok "no process outside the allowlist exceeds ${max_pcpu}% of one core" - fi - return 0 -} - -# (c) cpu0 scaling governor must be "performance". Skipped with a warning -# (not a violation) when the sysfs path doesn't exist on this machine. -check_governor() { - local gov_path="/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor" - if [ ! -r "$gov_path" ]; then - echo "WARNING: ${gov_path} not present; skipping governor check" - return 0 - fi - - local governor - governor=$(cat "$gov_path" 2>/dev/null) || governor="" - if [ "$governor" != "performance" ]; then - fatal "cpu0 governor is '${governor}', not 'performance'" - else - ok "cpu0 governor is 'performance'" - fi - return 0 -} - -# (d) free-space floors on / and /tmp (default 10G each). Skip entirely via -# GATE_SKIP_DISK_CHECK=1. -check_disk() { - if [ "${GATE_SKIP_DISK_CHECK:-0}" = "1" ]; then - echo "WARNING: disk free-space check skipped (GATE_SKIP_DISK_CHECK=1)" - return 0 - fi - - local floor_gb=10 - local floor_bytes=$((floor_gb * 1024 * 1024 * 1024)) - local path avail_bytes avail_gb - - for path in / /tmp; do - avail_bytes=$(df --output=avail -B1 "$path" 2>/dev/null | tail -n 1 | tr -d ' ') || avail_bytes="" - if [ -z "$avail_bytes" ]; then - echo "WARNING: could not determine free space on ${path}; skipping" - continue - fi - if [ "$avail_bytes" -lt "$floor_bytes" ]; then - avail_gb=$((avail_bytes / 1024 / 1024 / 1024)) - fatal "${path} has only ${avail_gb}G free, below the ${floor_gb}G floor" - else - ok "${path} has enough free space (floor ${floor_gb}G)" - fi - done - return 0 -} - -echo "Brainy gate preflight" -echo "----------------------" - -build_ancestor_pids -check_load -check_processes -check_governor -check_disk - -echo "----------------------" -if [ "$VIOLATIONS" -gt 0 ]; then - echo "FATAL: gate preflight failed with ${VIOLATIONS} violation(s) — machine is not gate-clean" - exit 1 -fi - -echo "gate preflight passed — machine is gate-clean" -exit 0 diff --git a/scripts/gate/vitest-verdict-check.sh b/scripts/gate/vitest-verdict-check.sh deleted file mode 100755 index 36243a1d..00000000 --- a/scripts/gate/vitest-verdict-check.sh +++ /dev/null @@ -1,158 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Brainy Vitest Verdict Check -# Confirms a vitest run's own summary line is trustworthy before anything -# downstream treats a green run as green. See scripts/gate/README.md for why -# (the 2026-08-13 lost-day ledger). -# -# Usage: -# vitest-verdict-check.sh -# vitest-verdict-check.sh --count-tests -# -# The first form checks the "Test Files" summary line's total against an -# exact expected count. The second checks the "Tests" summary line's total -# against a minimum. Both also fail on any sign the worker pool died -# mid-run, whether or not a summary line still made it into the log. -# -# Exit 0 and print one OK line per passing check when the log is clean. -# Exit 1 and print one FATAL line per violation, quoting the exact line or -# string that tripped it, when it is not. -# -# Known trap (shared with gate-preflight.sh): every helper below ends on an -# explicit `return 0` as its own statement, never on a loop or test, so a -# helper's last command can never hand its own exit status back as the -# function's under `set -e`. The same applies to `var=$(cmd)` assignments -# mid-helper: a bare assignment IS checked by `set -e`, so a `grep` that -# legitimately finds nothing (exit 1) would otherwise kill the script -# instead of just leaving the variable empty — every such assignment below -# is paired with an explicit `|| true` inside the substitution. - -usage() { - echo "Usage: $0 " - echo " $0 --count-tests " - exit 1 -} - -MODE="files" -if [ "${1:-}" = "--count-tests" ]; then - MODE="tests" - shift -fi - -LOG_FILE="${1:-}" -THRESHOLD="${2:-}" - -if [ -z "$LOG_FILE" ] || [ -z "$THRESHOLD" ]; then - usage -fi - -if [ ! -f "$LOG_FILE" ]; then - echo "FATAL: log file '${LOG_FILE}' does not exist" - exit 1 -fi - -if ! [[ "$THRESHOLD" =~ ^[0-9]+$ ]]; then - echo "FATAL: threshold '${THRESHOLD}' is not a non-negative integer" - exit 1 -fi - -VIOLATIONS=0 - -fatal() { - echo "FATAL: $1" - VIOLATIONS=$((VIOLATIONS + 1)) -} - -ok() { - echo "OK: $1" -} - -# Vitest colorizes its summary with ANSI escapes; strip them before parsing -# anything, or the color codes end up embedded in the fields we grep for. -CLEAN_LOG="$(sed 's/\x1b\[[0-9;]*m//g' "$LOG_FILE")" - -# Worker-pool death: if either string appears, the run's own summary line — -# even if present and even if its numbers look fine — cannot be trusted, -# because the process died mid-suite and vitest's own accounting is what -# died with it. -check_worker_death() { - if echo "$CLEAN_LOG" | grep -q "Unhandled Error"; then - fatal "log contains 'Unhandled Error' — worker pool died mid-run" - fi - if echo "$CLEAN_LOG" | grep -q "Timeout calling"; then - fatal "log contains 'Timeout calling' — worker pool died mid-run" - fi - return 0 -} - -# Shared shape between the "Test Files" and "Tests" summary lines: -#

--version --date \ - * --from-changelog - * Derives an entry from the CHANGELOG.md entry for (headline = the - * entry's first bullet, items = every bullet, trimmed of its trailing - * commit hash), then: - * - clones (or, if a cached clone already exists, fetches and resets) - * the releases repo into a local cache directory, - * - prepends the entry to /

.json, newest first — replacing - * any existing entry for the same version so a re-run is idempotent, - * - validates the file's shape before and after, - * - commits the change as "chore(wall):

" and pushes main. - * A failure at any step (clone, validation, commit, push, a - * non-fast-forward remote) exits non-zero naming the cure. Nothing is - * ever skipped — the wall either lands correctly or the release fails. - * - * 2. Dry run: - * node wall-entry.mjs --dry-run --product

--version \ - * --date --from-changelog - * Derives the entry exactly as above and prints it, along with the file - * it would be written to, but touches no clone and no remote — usable - * from a fresh checkout with no cache and no network. - * - * 3. Validate only (--check): - * node wall-entry.mjs --check --file - * Validates an arbitrary wall file's exact key set (top-level and - * per-entry), field types, and strict-descending semver ordering with - * no duplicates. Read-only; never writes. Exit 0 = clean, exit 1 = - * named violations printed to stderr. - * - * The remote and the local cache directory are each overridable - * (--remote / --cache-dir, or WALL_ENTRY_RELEASES_REMOTE / - * WALL_ENTRY_RELEASES_CACHE_DIR) so tests can point at a throwaway local - * bare repo and a throwaway cache directory — never the real remote or the - * real developer cache. - * - * No dependencies beyond the system `git` binary — CHANGELOG parsing, - * semver comparison, and JSON shape checking are all hand-rolled below. - */ - -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs' -import { execFileSync } from 'node:child_process' -import { homedir } from 'node:os' -import { dirname, join } from 'node:path' - -const DEFAULT_REMOTE = 'git@source.soulcraft.com:soulcraftlabs/releases.git' - -/** @returns {string} */ -function defaultCacheDir() { - const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache') - return join(base, 'soulcraft-releases') -} - -// Required on every entry; "thumb" is optional (may be absent, or present as -// string | null) — matching the HQ contract's {..., thumb?}. -const ENTRY_REQUIRED_KEYS = ['version', 'date', 'headline', 'items', 'url'] -const ENTRY_OPTIONAL_KEYS = ['thumb'] -const ENTRY_ALLOWED_KEYS = [...ENTRY_REQUIRED_KEYS, ...ENTRY_OPTIONAL_KEYS] -const FILE_KEYS = ['product', 'entries'] - -// The public permalink pattern, by product. Every entry MUST carry an https -// permalink: HQ's parser rejects a wall whose entries carry url: null (the -// whole feed became unreadable on 2026-09-02). A product whose forge repo is -// private links its PUBLIC package page on The Source instead of a release -// page that would 404 for HQ's readers. -const RELEASE_URL_PATTERNS = { - 'open-brainy': (version) => `https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v${version}`, - 'brainy': (version) => `https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/${version}`, -} - -/** - * Parse argv into a flag map. `--flag value` sets a string; `--flag` alone - * (end of argv, or followed by another `--flag`) sets boolean true. - * @param {string[]} argv - * @returns {Record} - */ -function parseArgs(argv) { - /** @type {Record} */ - const args = {} - for (let i = 0; i < argv.length; i++) { - const a = argv[i] - if (!a.startsWith('--')) continue - const key = a.slice(2) - const next = argv[i + 1] - if (next === undefined || next.startsWith('--')) { - args[key] = true - } else { - args[key] = next - i++ - } - } - return args -} - -/** - * Print a loud, named error and exit 1. Every refusal in this script goes - * through here so the failure mode is always the same shape: "wall-entry: ". - * @param {string} message - * @returns {never} - */ -function fail(message) { - console.error(`wall-entry: ${message}`) - process.exit(1) -} - -/** - * @param {string} version - * @returns {{major: number, minor: number, patch: number, pre: string | null} | null} - */ -function parseSemver(version) { - const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version) - if (!m) return null - return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ?? null } -} - -/** - * @param {string} a - * @param {string} b - * @returns {number} positive if a > b, negative if a < b, 0 if equal. - */ -function compareSemver(a, b) { - const pa = parseSemver(a) - const pb = parseSemver(b) - if (!pa || !pb) throw new Error(`cannot compare non-semver versions "${a}" vs "${b}"`) - if (pa.major !== pb.major) return pa.major - pb.major - if (pa.minor !== pb.minor) return pa.minor - pb.minor - if (pa.patch !== pb.patch) return pa.patch - pb.patch - if (pa.pre === pb.pre) return 0 - if (pa.pre === null) return 1 // a release outranks any prerelease of the same core version - if (pb.pre === null) return -1 - return pa.pre < pb.pre ? -1 : pa.pre > pb.pre ? 1 : 0 -} - -/** - * Validate a wall file's full shape: top-level keys ("product", "entries" — - * no more, no less), per-entry keys and field types ("thumb" optional), and - * strict-descending semver ordering with no duplicates. Collects every - * violation instead of failing on the first, so a caller reports the whole - * picture in one pass. - * @param {unknown} data - * @returns {string[]} Violation messages; empty means the file is clean. - */ -function validateShape(data) { - /** @type {string[]} */ - const errors = [] - - if (typeof data !== 'object' || data === null || Array.isArray(data)) { - return ['top level: expected a JSON object'] - } - const obj = /** @type {Record} */ (data) - - const topKeys = Object.keys(obj) - const missingTop = FILE_KEYS.filter((k) => !(k in obj)) - const extraTop = topKeys.filter((k) => !FILE_KEYS.includes(k)) - if (missingTop.length) errors.push(`top level: missing key(s) ${missingTop.join(', ')}`) - if (extraTop.length) errors.push(`top level: unexpected key(s) ${extraTop.join(', ')}`) - - if (typeof obj.product !== 'string' || obj.product.trim() === '') { - errors.push('top level: "product" must be a non-empty string') - } - if (!Array.isArray(obj.entries)) { - errors.push('top level: "entries" must be an array') - return errors // nothing further to check without an array - } - - const entries = /** @type {unknown[]} */ (obj.entries) - entries.forEach((rawEntry, i) => { - const label = `entries[${i}]` - if (typeof rawEntry !== 'object' || rawEntry === null || Array.isArray(rawEntry)) { - errors.push(`${label}: expected an object`) - return - } - const entry = /** @type {Record} */ (rawEntry) - const keys = Object.keys(entry) - const missing = ENTRY_REQUIRED_KEYS.filter((k) => !(k in entry)) - const extra = keys.filter((k) => !ENTRY_ALLOWED_KEYS.includes(k)) - if (missing.length) errors.push(`${label}: missing key(s) ${missing.join(', ')}`) - if (extra.length) errors.push(`${label}: unexpected key(s) ${extra.join(', ')}`) - - if (typeof entry.version !== 'string' || !parseSemver(entry.version)) { - errors.push(`${label}: "version" must be a semver string (got ${JSON.stringify(entry.version)})`) - } - if (typeof entry.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date) || Number.isNaN(Date.parse(entry.date))) { - errors.push(`${label}: "date" must be a YYYY-MM-DD string (got ${JSON.stringify(entry.date)})`) - } - if (typeof entry.headline !== 'string' || entry.headline.trim() === '') { - errors.push(`${label}: "headline" must be a non-empty string`) - } - if (!Array.isArray(entry.items) || entry.items.length === 0 || entry.items.some((it) => typeof it !== 'string' || it.trim() === '')) { - errors.push(`${label}: "items" must be a non-empty array of non-empty strings`) - } - if (typeof entry.url !== 'string' || !/^https:\/\/\S+$/.test(entry.url)) { - errors.push(`${label}: "url" must be an https permalink — never null; HQ's parser rejects the whole feed`) - } - if ('thumb' in entry && !(entry.thumb === null || typeof entry.thumb === 'string')) { - errors.push(`${label}: "thumb" must be a string or null when present`) - } - }) - - // Ordering: newest first, strictly descending, no duplicate versions — - // checked only over entries whose version parsed (a bad version is - // already reported above; comparing it too would just be noise). - const versioned = entries - .map((e, i) => ({ i, version: /** @type {any} */ (e)?.version })) - .filter((e) => typeof e.version === 'string' && parseSemver(e.version)) - for (let i = 0; i < versioned.length - 1; i++) { - const a = versioned[i] - const b = versioned[i + 1] - const cmp = compareSemver(a.version, b.version) - if (cmp === 0) { - errors.push(`entries[${a.i}] and entries[${b.i}]: duplicate version ${a.version}`) - } else if (cmp < 0) { - errors.push(`entries[${a.i}] (${a.version}) sits above entries[${b.i}] (${b.version}) — not newest-first`) - } - } - - return errors -} - -/** - * Extract one version's entry body from a standard-version-style CHANGELOG.md - * (headings `### [version](url) (date)`, followed by `- bullet (hash)` lines - * until the next heading or EOF). - * @param {string} changelog - * @param {string} version - * @returns {string[]} Bullet lines, trimmed of their leading "- " and - * trailing " (hash)". - */ -function extractChangelogBullets(changelog, version) { - const lines = changelog.split('\n') - const headingRe = /^### \[([^\]]+)\]\(.*\)\s*\(\d{4}-\d{2}-\d{2}\)\s*$/ - let start = -1 - for (let i = 0; i < lines.length; i++) { - const m = headingRe.exec(lines[i]) - if (m && m[1] === version) { - start = i + 1 - break - } - } - if (start === -1) { - fail( - `version ${version} has no CHANGELOG entry yet — run this after the CHANGELOG step composes "### [${version}]", not before`, - ) - } - /** @type {string[]} */ - const bullets = [] - for (let i = start; i < lines.length; i++) { - if (headingRe.test(lines[i])) break // next entry starts - const bulletMatch = /^- (.+?)(?:\s\(([0-9a-f]{6,40})\))?$/.exec(lines[i].trim()) - if (lines[i].trim().startsWith('- ') && bulletMatch) { - const text = bulletMatch[1].trim() - if (text) bullets.push(text) - } - } - if (bullets.length === 0) { - fail(`version ${version}'s CHANGELOG entry has no bullets to derive a headline/items from`) - } - return bullets -} - -/** - * Derive a wall entry from a CHANGELOG.md. - * @param {{product: string, version: string, date: string, changelogPath: string, url?: string, thumb?: string | null}} opts - * @returns {{version: string, date: string, headline: string, items: string[], url: string, thumb: string | null}} - */ -function deriveEntry({ product, version, date, changelogPath, url, thumb }) { - if (!parseSemver(version)) fail(`--version "${version}" is not a semver string`) - if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) { - fail(`--date "${date}" is not a YYYY-MM-DD date`) - } - if (!existsSync(changelogPath)) fail(`--from-changelog "${changelogPath}" does not exist`) - - const changelog = readFileSync(changelogPath, 'utf8') - const items = extractChangelogBullets(changelog, version) - const headline = items[0] - - const pattern = RELEASE_URL_PATTERNS[product] - if (url === undefined && pattern === undefined) { - throw new Error(`wall-entry: no permalink pattern for product "${product}" — add one to RELEASE_URL_PATTERNS or pass --url; entries never carry url: null`) - } - const resolvedUrl = url !== undefined ? url : pattern(version) - const resolvedThumb = thumb !== undefined ? thumb : null - - return { version, date, headline, items, url: resolvedUrl, thumb: resolvedThumb } -} - -/** - * Load and shape-validate a wall file. - * @param {string} filePath - * @returns {Record} - */ -function loadWallFile(filePath) { - if (!existsSync(filePath)) fail(`"${filePath}" does not exist`) - /** @type {unknown} */ - let data - try { - data = JSON.parse(readFileSync(filePath, 'utf8')) - } catch (err) { - fail(`"${filePath}" is not valid JSON: ${/** @type {Error} */ (err).message}`) - } - const errors = validateShape(data) - if (errors.length) { - fail(`"${filePath}" fails shape validation —\n ${errors.join('\n ')}`) - } - return /** @type {Record} */ (data) -} - -/** - * Run a git command, throwing an Error whose message is git's own stderr - * (trimmed) on failure — every caller wraps this to name the cure. - * @param {string[]} args - * @param {string} cwd - * @returns {string} stdout, trimmed. - */ -function git(args, cwd) { - try { - return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() - } catch (err) { - const stderr = /** @type {any} */ (err).stderr - const message = (typeof stderr === 'string' && stderr.trim()) || /** @type {Error} */ (err).message - throw new Error(message) - } -} - -/** - * Ensure a clean, up-to-date local clone of the releases repo at - * `cacheDir`, checked out on `main` — cloning fresh if `cacheDir` has no - * `.git`, otherwise fetching and hard-resetting onto `origin/main` (so a - * stray local commit or edit left by a previous failed run can never leak - * into the next one). - * @param {string} remote - * @param {string} cacheDir - */ -function ensureReleasesClone(remote, cacheDir) { - if (existsSync(join(cacheDir, '.git'))) { - try { - git(['remote', 'set-url', 'origin', remote], cacheDir) - git(['fetch', '--prune', 'origin'], cacheDir) - git(['checkout', 'main'], cacheDir) - git(['reset', '--hard', 'origin/main'], cacheDir) - git(['clean', '-fd'], cacheDir) - } catch (err) { - fail( - `cannot refresh the cached releases checkout at "${cacheDir}" from "${remote}" — ${/** @type {Error} */ (err).message}\n` + - ` cure: delete "${cacheDir}" and re-run so it re-clones from scratch, or confirm SSH access with "ssh -T git@source.soulcraft.com"`, - ) - } - return - } - - mkdirSync(dirname(cacheDir), { recursive: true }) - try { - git(['clone', remote, cacheDir], dirname(cacheDir)) - } catch (err) { - fail( - `cannot clone "${remote}" — ${/** @type {Error} */ (err).message}\n` + - ` cure: confirm SSH access with "ssh -T git@source.soulcraft.com" and that the soulcraftlabs/releases repo exists yet`, - ) - } - try { - git(['checkout', 'main'], cacheDir) - } catch (err) { - fail( - `cloned "${remote}" into "${cacheDir}" but could not check out "main" — ${/** @type {Error} */ (err).message}\n` + - ` cure: confirm the releases repo's default branch is named "main"`, - ) - } -} - -/** - * Prepend `entry` to the wall at `/.json`, replacing any - * existing entry for the same version (idempotent re-runs), validating - * before and after, committing, and pushing — or refusing loudly, naming - * the cure, at whichever step fails. - * @param {{version: string, date: string, headline: string, items: string[], url: string, thumb: string | null}} entry - * @param {string} product - * @param {string} remote - * @param {string} cacheDir - */ -function publishEntry(entry, product, remote, cacheDir) { - ensureReleasesClone(remote, cacheDir) - - const filePath = join(cacheDir, `${product}.json`) - if (!existsSync(filePath)) { - fail( - `"${filePath}" does not exist in the releases repo — cure: seed "${product}.json" at the repo root first (it must exist before any release rail can prepend to it)`, - ) - } - const wall = loadWallFile(filePath) - - if (wall.product !== product) { - fail(`"${filePath}" has product "${wall.product}", but --product "${product}" was given — refusing a cross-product write`) - } - - const replacing = wall.entries.some((e) => e.version === entry.version) - wall.entries = [entry, ...wall.entries.filter((e) => e.version !== entry.version)] - - const postErrors = validateShape(wall) - if (postErrors.length) { - fail(`the entry for ${entry.version} would leave "${filePath}" invalid —\n ${postErrors.join('\n ')}`) - } - - writeFileSync(filePath, JSON.stringify(wall, null, 2) + '\n', 'utf8') - - const status = git(['status', '--porcelain', '--', `${product}.json`], cacheDir) - if (status === '') { - console.log(`wall-entry: "${product}.json" already carries an identical entry for ${entry.version} — nothing to commit or push`) - return - } - - try { - git(['add', `${product}.json`], cacheDir) - git(['commit', '-m', `chore(wall): ${product} ${entry.version}`], cacheDir) - } catch (err) { - fail(`cannot commit the wall entry in "${cacheDir}" — ${/** @type {Error} */ (err).message}\n cure: inspect "${cacheDir}" by hand and re-run once its git state is clean`) - } - - try { - git(['push', 'origin', 'main'], cacheDir) - } catch (err) { - fail( - `push to "${remote}" failed (likely a non-fast-forward — another release landed on main first) — ${/** @type {Error} */ (err).message}\n` + - ` cure: re-run this release step; it re-fetches and resets onto the latest origin/main before retrying`, - ) - } - - const sha = git(['rev-parse', 'HEAD'], cacheDir) - console.log( - `wall-entry: ${replacing ? 'replaced' : 'wrote'} v${entry.version} in "${product}.json" (${wall.entries.length} entries, newest first) — pushed ${sha} to ${remote} main`, - ) -} - -function main() { - const args = parseArgs(process.argv.slice(2)) - - if (args.check) { - const filePath = /** @type {string | undefined} */ (args.file) - if (!filePath) fail('--check needs --file ') - const wall = loadWallFile(/** @type {string} */ (filePath)) - console.log(`wall-entry --check: "${filePath}" OK — product "${wall.product}", ${wall.entries.length} entries, newest-first, no duplicates`) - process.exit(0) - } - - // Generate mode (default, also covers --dry-run): --product, --version, - // --date, --from-changelog required. - const product = /** @type {string | undefined} */ (args.product) - const version = /** @type {string | undefined} */ (args.version) - const date = /** @type {string | undefined} */ (args.date) - const fromChangelog = /** @type {string | undefined} */ (args['from-changelog']) - - const missing = [] - if (!product) missing.push('--product') - if (!version) missing.push('--version') - if (!date) missing.push('--date') - if (!fromChangelog) missing.push('--from-changelog') - if (missing.length) { - fail( - `missing required flag(s): ${missing.join(', ')}\n` + - 'Usage:\n' + - ' wall-entry.mjs --product

--version --date --from-changelog [--dry-run]\n' + - ' wall-entry.mjs --check --file ', - ) - } - - const urlArg = args.url === true ? undefined : /** @type {string | undefined} */ (args.url) - const thumbArg = args.thumb === true ? undefined : /** @type {string | undefined} */ (args.thumb) - - const entry = deriveEntry({ - product: /** @type {string} */ (product), - version: /** @type {string} */ (version), - date: /** @type {string} */ (date), - changelogPath: /** @type {string} */ (fromChangelog), - url: urlArg, - thumb: thumbArg, - }) - - const remote = /** @type {string} */ (args.remote ?? process.env.WALL_ENTRY_RELEASES_REMOTE ?? DEFAULT_REMOTE) - const cacheDir = /** @type {string} */ (args['cache-dir'] ?? process.env.WALL_ENTRY_RELEASES_CACHE_DIR ?? defaultCacheDir()) - - if (args['dry-run']) { - console.log(`wall-entry --dry-run: would write to "${join(cacheDir, `${product}.json`)}" in ${remote} (main), pushed as "chore(wall): ${product} ${version}"`) - console.log(JSON.stringify(entry, null, 2)) - process.exit(0) - } - - publishEntry(entry, /** @type {string} */ (product), remote, cacheDir) -} - -main() diff --git a/src/aggregation/AggregationIndex.ts b/src/aggregation/AggregationIndex.ts index d3a1fd74..f9382218 100644 --- a/src/aggregation/AggregationIndex.ts +++ b/src/aggregation/AggregationIndex.ts @@ -14,22 +14,7 @@ */ import type { StorageAdapter, HNSWNounWithMetadata } from '../coreTypes.js' -import { parseFieldAddress, readEntityFieldAddress } from '../db/fieldAddressing.js' -import type { HNSWNounWithMetadata as AddressedEntity } from '../coreTypes.js' - -/** - * Read a user-supplied field name under the one addressing law (sealed - * 2026-08-03): bare / `metadata.` = the user's metadata field, `system.` = - * the ruled engine scalar, malformed = typed refusal. The aggregation engine - * NEVER resolves names any other way — the pre-law resolver made bare - * `subtype`/`confidence` read engine scalars, silently shadowing user fields. - */ -function readAddressed(e: unknown, name: string): unknown { - return readEntityFieldAddress( - e as AddressedEntity, - parseFieldAddress(name, 'entity') - ) -} +import { resolveEntityField } from '../coreTypes.js' import type { AggregateDefinition, AggregateGroupState, @@ -110,15 +95,11 @@ function matchesSource(entity: Record, source: AggregateDefinit // live in the custom bag, so those filters could never match anything. if (source.where && Object.keys(source.where).length > 0) { const e = entity as unknown as HNSWNounWithMetadata - for (const [key, condition] of Object.entries(source.where)) { - // Evaluate ONE field at a time under a neutral key: the address may be - // dotted ('system.subtype'), and the filter evaluator would otherwise - // walk dots as a nested path instead of treating the key as an address. - const value = readAddressed(e, key) - if (!matchesMetadataFilter({ v: value }, { v: condition } as Record)) { - return false - } + const resolved: Record = {} + for (const key of Object.keys(source.where)) { + resolved[key] = resolveEntityField(e, key) } + if (!matchesMetadataFilter(resolved, source.where)) return false } return true @@ -148,11 +129,11 @@ function computeGroupKeys( for (const dim of groupBy) { if (typeof dim === 'string') { - const val = readAddressed(e, dim) + const val = resolveEntityField(e, dim) const v = val !== undefined && val !== null ? String(val) : '__null__' for (const k of keys) k[dim] = v } else if ('unnest' in dim) { - const val = readAddressed(e, dim.field) + const val = resolveEntityField(e, dim.field) const raw = Array.isArray(val) ? val : val !== undefined && val !== null ? [val] : [] // Distinct elements: an entity with duplicate tags counts once per distinct tag. const elems = Array.from(new Set(raw.map(x => String(x)))) @@ -164,7 +145,7 @@ function computeGroupKeys( keys = next } else { // Time-windowed field - const val = readAddressed(e, dim.field) + const val = resolveEntityField(e, dim.field) const v = typeof val === 'number' ? bucketTimestamp(val, dim.window) : '__null__' for (const k of keys) k[dim.field] = v } @@ -193,7 +174,7 @@ function computeGroupKey( * in metadata are both handled in one place. */ function getNumericField(entity: Record, field: string): number | undefined { - const val = readAddressed(entity as unknown as HNSWNounWithMetadata, field) + const val = resolveEntityField(entity as unknown as HNSWNounWithMetadata, field) if (typeof val === 'number' && !isNaN(val)) return val if (typeof val === 'string') { const num = parseFloat(val) @@ -371,15 +352,6 @@ export class AggregationIndex { */ private pendingAdopt = new Set() - /** - * Aggregates adopted with a BEHIND stamp: name → the exact generation - * window `(from, to]` whose writes the adopted state has not seen. The - * owner (Brainy) drains this via {@link getPendingCatchUps} + - * {@link reconcileEntity} + {@link finishCatchUp} BEFORE serving queries — - * cost bounded by the window's affected entities, never store size. - */ - private pendingCatchUp = new Map() - /** * In-flight rescan targets. While a name has a staging map, ALL * contributions (the walk's and concurrent write hooks') land there instead @@ -446,47 +418,25 @@ export class AggregationIndex { } /** - * The adoption verdict for persisted state, against the store's committed - * watermark (SELF-ENGINE-LIFECYCLE-SPRINT ask (b) — behind-stamp is no - * longer a whole-store rescan): - * - * - `'adopt'` — stamp equals the watermark (clean), or the store has no - * watermark capability (hash-only adoption, the pre-stamp behavior). - * - `'catchup'` — stamp is BEHIND the watermark (an unclean exit after - * later writes, or a long-lived writer whose last flush predates recent - * writes). The state is exact AS OF its stamp, so it is adopted and the - * missing window `(stamp, committed]` is reconciled INCREMENTALLY per - * affected entity via time-travel reads — bounded by writes since the - * last flush, never by store size. The owner drains - * {@link getPendingCatchUps} before serving queries. - * - `'rescan'` — no stamp (pre-stamp state on a stamped store) or stamp - * AHEAD of the watermark (e.g. a fact-log truncation on a copied store - * pulled the watermark back): the state over-counts unverifiably; one - * exact rescan, said out loud. + * May this persisted state be ADOPTED? When the store exposes its committed + * watermark, the state's `sourceGeneration` must EQUAL it: behind means + * later writes are missing from the state (unclean shutdown); ahead means + * it counts writes that no longer exist (e.g. a fact-log truncation on a + * copied store pulled the watermark back). Either way: one exact rescan, + * said out loud — never a silent adopt. Stores without the capability (and + * pre-stamp state on them) fall back to hash-only adoption. */ - private stateAdoptionVerdict( - name: string, - stateData: unknown - ): 'adopt' | 'catchup' | 'rescan' { + private stateGenerationAdoptable(name: string, stateData: unknown): boolean { const committed = this.storage.committedGeneration?.() ?? null - if (committed === null) return 'adopt' + if (committed === null) return true const raw = (stateData as Record).sourceGeneration const stamped = typeof raw === 'number' ? raw : null - if (stamped === committed) return 'adopt' - if (stamped !== null && stamped < committed) { - this.pendingCatchUp.set(name, { from: stamped, to: committed }) - prodLog.info( - `[Aggregation] '${name}': persisted state is at generation ${stamped}, store is at ` + - `${committed} — adopting and reconciling the ${committed - stamped}-generation window ` + - `incrementally (no store rescan)` - ) - return 'catchup' - } + if (stamped === committed) return true prodLog.warn( `[Aggregation] '${name}': persisted state is at generation ${stamped ?? 'unstamped'} ` + `but the store's committed generation is ${committed} — rescanning instead of adopting` ) - return 'rescan' + return false } private async loadPersisted(): Promise { @@ -507,21 +457,20 @@ export class AggregationIndex { const appHash = this.definitionHashes.get(def.name) || '' if (appHash === savedHash && this.pendingAdopt.has(def.name)) { const stateData = await this.storage.getMetadata(`${STATE_KEY_PREFIX}${def.name}__`) - const verdict = - stateData && stateData.groups - ? this.stateAdoptionVerdict(def.name, stateData) - : 'rescan' - if (verdict !== 'rescan') { + if ( + stateData && + stateData.groups && + this.stateGenerationAdoptable(def.name, stateData) + ) { const groupMap = new Map() - for (const group of stateData!.groups as AggregateGroupState[]) { + for (const group of stateData.groups as AggregateGroupState[]) { groupMap.set(serializeGroupKey(group.groupKey), group) } this.states.set(def.name, groupMap) this.pendingAdopt.delete(def.name) this.needsBackfill.delete(def.name) prodLog.info( - `[Aggregation] '${def.name}': adopted persisted state (${groupMap.size} groups) — ` + - (verdict === 'catchup' ? 'incremental catch-up pending' : 'no rescan') + `[Aggregation] '${def.name}': adopted persisted state (${groupMap.size} groups) — no rescan` ) } // No/invalid persisted state: stays in pendingAdopt and resolves @@ -536,23 +485,22 @@ export class AggregationIndex { const currentHash = hashDefinition(def) const stateData = await this.storage.getMetadata(`${STATE_KEY_PREFIX}${def.name}__`) - const restoreVerdict = - stateData && stateData.groups && savedHash === currentHash - ? this.stateAdoptionVerdict(def.name, stateData) - : 'rescan' - if (restoreVerdict !== 'rescan') { - // Definition unchanged — load state (exact as of its stamp; a - // 'catchup' verdict reconciles the missing window incrementally). + if ( + stateData && + stateData.groups && + savedHash === currentHash && + this.stateGenerationAdoptable(def.name, stateData) + ) { + // Definition unchanged — load state const groupMap = new Map() - for (const group of stateData!.groups as AggregateGroupState[]) { + for (const group of stateData.groups as AggregateGroupState[]) { const serialized = serializeGroupKey(group.groupKey) groupMap.set(serialized, group) } this.states.set(def.name, groupMap) this.needsBackfill.delete(def.name) prodLog.info( - `[Aggregation] '${def.name}': restored definition + adopted persisted state (${groupMap.size} groups)` + - (restoreVerdict === 'catchup' ? ' — incremental catch-up pending' : '') + `[Aggregation] '${def.name}': restored definition + adopted persisted state (${groupMap.size} groups)` ) } else { // Definition changed or no saved state — start fresh and backfill from @@ -570,35 +518,15 @@ export class AggregationIndex { } } - // Restore native provider state from persistence — GATED by the same - // adoption verdict as caller-side state (the unconditional adopt was an - // asymmetry: a stale native blob restored over a moved store silently - // over/under-counted). 'adopt' restores; 'catchup' restores too (the - // incremental reconciliation drives the provider through - // incrementalUpdate over the exact missing window); 'rescan' SKIPS the - // blob — the flagged rebuild repopulates the provider from source. - // Legacy unstamped envelopes verdict as rescan, loudly, never silently. + // Restore native provider state from persistence if (this.nativeProvider?.restoreState) { const nativeState = await this.storage.getMetadata('__aggregation_native_state__') - const blob = - nativeState && typeof nativeState === 'string' - ? nativeState - : nativeState && typeof nativeState === 'object' && nativeState.data - ? (nativeState.data as string) - : null - if (blob !== null) { - const verdict = this.stateAdoptionVerdict( - '__native__', - nativeState && typeof nativeState === 'object' ? (nativeState as Record) : {} - ) - if (verdict === 'adopt' || verdict === 'catchup') { - this.nativeProvider.restoreState(blob) - } else { - prodLog.warn( - `[Aggregation] native provider state not adopted (verdict: ${verdict}) — ` + - `the flagged rescan repopulates the provider from source` - ) - } + if (nativeState && typeof nativeState === 'string') { + this.nativeProvider.restoreState(nativeState) + } else if (nativeState && typeof nativeState === 'object' && nativeState.data) { + // flush() persists `{ data: serializeState() }`, so `data` is the + // provider's serialized state string. + this.nativeProvider.restoreState(nativeState.data as string) } } } @@ -634,17 +562,12 @@ export class AggregationIndex { } } - // Persist native provider state — stamped. noteSourceGeneration lets the - // provider bake the committed watermark into its OWN envelope before - // serializing (so a native-side reopen can verify honesty without our - // wrapper); the wrapper carries the same stamp for OUR adoption verdict. + // Persist native provider state if (this.nativeProvider?.serializeState) { - const nativeGen = this.storage.committedGeneration?.() ?? null - if (nativeGen !== null) this.nativeProvider.noteSourceGeneration?.(nativeGen) const nativeState = this.nativeProvider.serializeState() await this.storage.saveMetadata( '__aggregation_native_state__', - nativeGen === null ? { data: nativeState } : { data: nativeState, sourceGeneration: nativeGen } + { data: nativeState } ) } @@ -805,119 +728,6 @@ export class AggregationIndex { this.dirty.add(name) } - // ============= Incremental Catch-Up (behind-stamp adoption) ============= - - /** The aggregates adopted behind the watermark, with their exact missing windows. */ - getPendingCatchUps(): Array<{ name: string; from: number; to: number }> { - return Array.from(this.pendingCatchUp, ([name, w]) => ({ name, ...w })) - } - - /** - * Reconcile ONE entity's contribution across a catch-up window using the - * same exact delta algebra the write-time hooks use: remove the - * contribution the adopted state counted (the entity AS OF the stamp), - * add the contribution it should count (AS OF the window's end). `null` - * on either side means the entity did not exist then. Composes exactly - * with live hooks because every application is a precise old/new pair — - * order between catch-up and post-window writes cannot drift the totals. - */ - reconcileEntity( - name: string, - id: string, - before: Record | null, - after: Record | null - ): void { - const def = this.definitions.get(name) - if (!def) return - if (before && after) { - if (isAggregateEntity(after)) return - const oldMatches = matchesSource(before, def.source) - const newMatches = matchesSource(after, def.source) - if (this.nativeProvider && (oldMatches || newMatches)) { - this.applyNativeResults( - name, - this.nativeProvider.incrementalUpdate(name, def, after, 'update', before) - ) - return - } - if (oldMatches) this.removeContribution(name, def, before) - if (newMatches) this.addContribution(name, def, after) - return - } - if (after) { - if (isAggregateEntity(after) || !matchesSource(after, def.source)) return - if (this.nativeProvider) { - this.applyNativeResults(name, this.nativeProvider.incrementalUpdate(name, def, after, 'add')) - } else { - this.addContribution(name, def, after) - } - return - } - if (before) { - if (isAggregateEntity(before) || !matchesSource(before, def.source)) return - if (this.nativeProvider) { - this.applyNativeResults(name, this.nativeProvider.incrementalUpdate(name, def, before, 'delete')) - } else { - this.removeContribution(name, def, before) - } - } - } - - /** Whether the native provider offers the parallel whole-rebuild path. */ - hasProviderRebuild(): boolean { - return typeof this.nativeProvider?.rebuildAggregate === 'function' - } - - /** The catch-up window for `name` is fully reconciled; state is current. */ - finishCatchUp(name: string): void { - this.pendingCatchUp.delete(name) - this.dirty.add(name) - } - - /** - * A catch-up could not complete (window unreadable, affected set over the - * bound, …): demote to an exact rescan, loudly — never serve un-reconciled. - */ - demoteCatchUpToBackfill(name: string, reason: string): void { - this.pendingCatchUp.delete(name) - this.needsBackfill.add(name) - prodLog.warn(`[Aggregation] '${name}': catch-up demoted to full rescan — ${reason}`) - } - - /** - * Rebuild an aggregate through the native provider's parallel path - * (SELF-ENGINE-LIFECYCLE-SPRINT ask (c) — `rebuildAggregate` existed on - * the provider contract but was never invoked; the JS walk fed - * per-entity FFI calls instead). Returns false when no provider rebuild - * exists — the caller streams the JS walk as before. - */ - rebuildWithProvider(name: string, entities: Array>): boolean { - const def = this.definitions.get(name) - if (!def || !this.nativeProvider?.rebuildAggregate) return false - const rebuilt = this.nativeProvider.rebuildAggregate( - def, - entities.filter(e => !isAggregateEntity(e) && matchesSource(e, def.source)) - ) - this.states.set(name, rebuilt) - this.backfillStaging.delete(name) - this.needsBackfill.delete(name) - this.dirty.add(name) - return true - } - - /** - * A write-path hook could not see the entity it needed (e.g. a delete - * whose before-image was unavailable): flag EVERY defined aggregate for - * an exact rescan, loudly — the counts must never silently drift - * (SELF-ENGINE-LIFECYCLE-SPRINT ask (d): the gated hook used to SKIP). - */ - flagAllForRescan(reason: string): void { - for (const name of this.definitions.keys()) this.needsBackfill.add(name) - prodLog.warn( - `[Aggregation] all ${this.definitions.size} aggregate(s) flagged for rescan — ${reason}` - ) - } - // ============= Write-Time Hooks ============= /** @@ -1180,7 +990,7 @@ export class AggregationIndex { // distinctCount tracks distinct values of ANY type (strings, numbers, booleans), // keyed by their string form — NOT numeric-coerced, since its primary use is // categorical (distinct categories / users / tags), not numeric columns. - const raw = readAddressed(entity as unknown as HNSWNounWithMetadata, metricDef.field!) + const raw = resolveEntityField(entity as unknown as HNSWNounWithMetadata, metricDef.field!) if (raw !== undefined && raw !== null) { if (!state.valueCounts) state.valueCounts = {} const key = String(raw) @@ -1224,7 +1034,7 @@ export class AggregationIndex { state.count = Math.max(0, state.count - 1) state.sum = Math.max(0, state.sum - 1) } else if (metricDef.op === 'distinctCount') { - const raw = readAddressed(entity as unknown as HNSWNounWithMetadata, metricDef.field!) + const raw = resolveEntityField(entity as unknown as HNSWNounWithMetadata, metricDef.field!) if (raw !== undefined && raw !== null && state.valueCounts) { const key = String(raw) const c = state.valueCounts[key] diff --git a/src/brainy.ts b/src/brainy.ts index b8eb7f56..37b6e491 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -15,7 +15,6 @@ import { JsHnswVectorIndex } from './hnsw/hnswIndex.js' import { createStorage, resolveFilesystemRoot } from './storage/storageFactory.js' import type { StorageOptions } from './storage/storageFactory.js' import { rebuildCounts } from './utils/rebuildCounts.js' -import { jsonSafeIndexMetadata } from './utils/jsonSafeIndexMetadata.js' import type { MetadataWriteBuffer } from './utils/metadataWriteBuffer.js' import { BaseStorage } from './storage/baseStorage.js' import { @@ -26,8 +25,7 @@ import { } from './storage/brainFormat.js' import type { BrainFormat } from './storage/brainFormat.js' import { StorageAdapter, Vector, DistanceFunction, EmbeddingFunction, GraphVerb, STANDARD_ENTITY_FIELDS } from './coreTypes.js' -import { isZeroNormVector } from './utils/distance.js' -import type { HNSWNoun, HNSWNounWithMetadata, HNSWVerbWithMetadata, EntityVisibility } from './coreTypes.js' +import type { HNSWNounWithMetadata, HNSWVerbWithMetadata, EntityVisibility } from './coreTypes.js' import { defaultEmbeddingFunction, cosineDistance, @@ -67,8 +65,7 @@ import type { OpaqueIdSet, AtGenerationVectors, VectorIndexProvider, - GraphIndexProvider, - ProviderMaintenanceDebt + GraphIndexProvider } from './plugin.js' import type { BrainyPlugin, @@ -99,7 +96,6 @@ import { SaveVerbOperation, AddToGraphIndexOperation, RemoveFromVectorIndexOperation, - ReplaceInVectorIndexOperation, RemoveFromMetadataIndexOperation, RemoveFromGraphIndexOperation, UpdateNounMetadataOperation, @@ -146,16 +142,12 @@ import { ScoreExplanation, FillSubtypeRule, FillSubtypeRules, - FillSubtypesResult, - RepairReport, - RepairFamilyReport + FillSubtypesResult } from './types/brainy.types.js' import { NounType, VerbType, TypeUtils } from './types/graphTypes.js' import { splitNounMetadataRecord, - splitVerbMetadataRecord, - buildNounMetadataRecord, - buildVerbMetadataRecord + splitVerbMetadataRecord } from './types/reservedFields.js' import { BrainyInterface } from './types/brainyInterface.js' import type { IntegrationHub, IntegrationHubConfig } from './integrations/core/IntegrationHub.js' @@ -165,8 +157,6 @@ import { AggregationIndex } from './aggregation/AggregationIndex.js' import { AggregateMaterializer } from './aggregation/materializer.js' import type { AggregateDefinition, AggregateQueryParams, AggregateResult } from './types/brainy.types.js' import type { MigrationProgress } from './types/brainy.types.js' -import type { IndexedProjectionPath, WaitForIndexedOptions } from './types/brainy.types.js' -import { WaitForIndexedTimeoutError } from './types/brainy.types.js' import { resolveJsHnswConfig, DEFAULT_RECALL } from './utils/recallPreset.js' import * as fs from 'node:fs' import * as os from 'node:os' @@ -182,7 +172,7 @@ import { type ImportResult } from './db/portableGraph.js' import { GenerationStore, type CommitBeforeImages } from './db/generationStore.js' -import type { FactScanHandle, FactMarkerRecord } from './db/factLog.js' +import type { FactScanHandle } from './db/factLog.js' import { ENTITY_TREE_STAMP_PATH, readFamilyStamp, @@ -199,25 +189,7 @@ 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, - assessProviderRebuild, - describeRebuildProgress -} from './utils/indexReadiness.js' -import { reconstructNounWrapper } from './db/factLog.js' -import { asBrainyFieldRefusal } from './db/fieldAddressing.js' -import { - readLogAuthority, - runLogCompletenessOracle, - flipToLogAuthority, - recordDigest, - nounEntityTruth, - LOG_AUTHORITY_PATH, - type LogAuthorityRecord, - type LogAuthorityStorage, - type OracleReport -} from './db/logAuthority.js' +import { assessIndexReadiness } from './utils/indexReadiness.js' import { MemoryStorage } from './storage/adapters/memoryStorage.js' import type { CompactHistoryOptions, @@ -297,7 +269,6 @@ type ResolvedBrainyConfig = Required< | 'eagerEmbeddings' | 'migrationWaitTimeoutMs' | 'transactionBudgetFloorMs' - | 'persistence' > > & Pick< @@ -311,7 +282,6 @@ type ResolvedBrainyConfig = Required< | 'eagerEmbeddings' | 'migrationWaitTimeoutMs' | 'transactionBudgetFloorMs' - | 'persistence' > /** @@ -395,22 +365,6 @@ interface PlannedTransact { * rejected batch (CAS conflict, failed apply) emits nothing. */ changeEvents: PendingChangeEvent[] - /** - * V2 marker records riding the batch's ONE commit fact (e.g. the - * deferred-embedding pending markers) — same generation, same atomic - * append as the batch itself. A rejected batch appends no fact, so no - * 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[] } /** @@ -470,30 +424,6 @@ export interface WarmReport { totalDurationMs: number } -/** - * @description Result of {@link Brainy.maintenanceDebt}: one outcome per - * index surface, mirroring {@link WarmReport}'s shape. - * - `'reported'` — the active provider for this surface implements - * `maintenanceDebt?()` and its {@link ProviderMaintenanceDebt} payload is - * attached verbatim under `debt`. - * - `'unavailable'` — the active provider does not implement the hook, so - * nothing is known; brainy never estimates or infers a payload on its - * behalf. - */ -export type MaintenanceDebtOutcome = 'reported' | 'unavailable' - -/** - * @description Per-surface result of {@link Brainy.maintenanceDebt}. Brainy - * performs no thresholding, polling, or estimation over this data — it is a - * pure passthrough of each active provider's own self-report (the provider - * owns the numbers; the operator owns the policy). - */ -export interface MaintenanceDebtReport { - vector: { outcome: MaintenanceDebtOutcome; debt?: ProviderMaintenanceDebt } - metadata: { outcome: MaintenanceDebtOutcome; debt?: ProviderMaintenanceDebt } - graph: { outcome: MaintenanceDebtOutcome; debt?: ProviderMaintenanceDebt } -} - /** * How long a failed aggregation-backfill walk suppresses fresh walk attempts. * Within the window, queries rethrow the recorded failure instantly (loud, @@ -531,19 +461,6 @@ export class Brainy implements BrainyInterface { private static sigintListener?: () => void private static beforeExitListener?: () => void - /** True while the `beforeExit` pass is running its flushes. Node re-emits - * 'beforeExit' after every loop drain and that pass schedules async work, so - * a second emit can arrive on top of the first; it returns instead of - * stacking a parallel pass. NOT a one-shot: every genuine drain still gets a - * flush. See {@link registerShutdownHooks}. */ - private static beforeExitFlushInFlight = false - - /** Whether the drained-event-loop notice has been printed for this - * registration cycle. Printed ONCE — `console.log` to a pipe is itself - * event-loop work, so narrating on every emit would keep the loop turning - * and narrate forever. Reset by {@link deregisterShutdownHooksIfIdle}. */ - private static beforeExitNarrated = false - /** Poll cadence (ms) for the migration LOCK when a provider exposes no * event-driven `whenMigrationComplete()` signal. See {@link awaitMigrationLock}. */ private static readonly MIGRATION_POLL_INTERVAL_MS = 250 @@ -575,24 +492,9 @@ export class Brainy implements BrainyInterface { * store has assigned the batch generation by then; for single-op writes it * reads the post-write watermark. The arrow body reads `generationStore` * lazily, so it is safe to define before `init()` assigns the store. - * Metadata/vector index writes use the bootstrap-honest twin - * {@link indexWriteGeneration} below. */ private readonly graphWriteGeneration = (): bigint => BigInt(this.generationStore.generation()) - /** - * The metadata/vector twin of {@link graphWriteGeneration}, honest about - * bootstrap: while generation stamping is inactive (init-time - * infrastructure writes, e.g. the VFS root, applied via - * `runWithoutGeneration`) there IS no commit generation — this resolves to - * `undefined` so a provider records "unstamped", never a fabricated 0. - * The graph thunk keeps its non-optional `bigint` contract (no graph - * writes occur during bootstrap). - */ - private readonly indexWriteGeneration = (): bigint | undefined => - this._generationStampingActive - ? BigInt(this.generationStore.generation()) - : undefined /** Lazily built host surface shared by every `Db` value of this brain. */ private _dbHost?: DbHost /** @@ -663,6 +565,8 @@ export class Brainy implements BrainyInterface { /** One-shot guard so the degraded-reads warning fires once per degraded window * (reset when the degraded state clears). See {@link warnIfReadsDegraded}. */ private _degradedReadWarned = false + /** One-shot guard so the metadata cold-open consistency probe runs once per brain. */ + private _metadataConsistencyProbed = false /** Graph-adjacency cold-load consistency: verified-live this session (one-shot). */ private _graphAdjacencyVerified = false /** Re-entrancy guard: a verify (rebuild → reads) is in flight. */ @@ -752,142 +656,6 @@ export class Brainy implements BrainyInterface { private _pendingMigrationRunner?: MigrationRunner // Deferred migration runner for large datasets private _aggregationIndex?: AggregationIndex // Incremental aggregation engine private _aggregationBackfillFlight: Promise | null = null // Single-flight backfill walk - private _aggregationCatchUpFlight: Promise | null = null // Single-flight behind-stamp catch-up - - // ENGINE-OWNED PERSISTENCE CADENCE (SELF-ENGINE-LIFECYCLE-SPRINT): - // write-count / interval / idle triggers → ONE background flush at a time. - // 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 - - /** - * FLUSH IS SINGLE-FLIGHT, AND THE QUEUE IS ONE DEEP. `_flushInFlight` is the - * flush body actually running; `_flushFollowUp` is the AT MOST ONE flush - * queued behind it. Every caller — the write cadence, the cross-process - * flush-request watcher, an application calling `flush()` directly — either - * runs (nothing in flight), or joins the single queued follow-up. - * - * WHY A FOLLOW-UP RATHER THAN JOINING THE RUNNING FLUSH: a caller flushes to - * make ITS writes durable, and those writes may have landed after the - * running flush read its state. Joining would return "flushed" over data - * that was never persisted. Chaining one follow-up costs nothing when there - * is nothing new (a clean brain's flush returns immediately — see - * `_dirtySinceLastFlush`) and is correct when there is. - * - * MEASURED, in the production shutdown this was written for: two - * "Flushing Brainy indexes and caches to disk..." runs overlapping 3s - * apart on one brain, their walls growing 295ms → 4.9s as they contended - * for the same providers. - * - * THE WAITER IS SETTLED BY THE MACHINE, NEVER BY A PROMISE CHAIN. The queue - * is a BARE DEFERRED (`_flushQueued` plus its `_flushQueuedSettle` handles), - * not `leader.then(() => this.flush())`. A chained follow-up is settled only - * by resolving the very promise the leader is being awaited through, so the - * moment anything inside a flush body awaits `flush()` the graph closes on - * itself and NOBODY resolves — an unbounded hang, not a slow flush. Here the - * leader never awaits the queue: its `finally` PROMOTES the waiter to a new - * leader and settles the deferred from that run, and the leader's own - * promise settles without waiting for it. Every exit — the leader - * resolving, the leader REJECTING, the promoted run rejecting — runs the - * same promotion, so a queued caller is always settled exactly once. - */ - private _flushInFlight: Promise | null = null - private _flushQueued: Promise | null = null - private _flushQueuedSettle: { - resolve: () => void - reject: (error: unknown) => void - } | null = null - /** Flush bodies that got past the single-flight gate (pinned by tests). */ - private _flushBodyRuns = 0 - /** Flush bodies running right now, and the high-water mark — which the - * single-flight law requires to stay at 1 (pinned by tests). */ - private _flushBodiesActive = 0 - private _flushConcurrencyPeak = 0 - - // DEFERRED EMBEDDING (MT5): pending markers are LOG RECORDS — an - // embed.pending record rides the deferred write's own commit fact and - // embed.landed rides the landing commit; this set is the in-memory - // fast-path index, rebuilt at open by folding the log's marker records. - // ONE background worker drains it. A crash can delay a vector, never - // lose one. - private _pendingEmbedIds = new Set() - private _embedWorkerFlight: Promise | null = null - - /** - * Ids cleared from {@link _pendingEmbedIds} with NO durable disarming record - * behind them — today exactly one case: a pending row that still EXISTS but - * carries no embeddable data, which the worker reaps in memory only. The log - * still says those ids are pending, so the pending-embed CHECKPOINT must - * carry them: the checkpoint's contract is "as of generation G the LOG's - * pending set was exactly this list", and a checkpoint that quietly dropped - * an id the log still arms would make the bounded fold disagree with a full - * fold from generation 1 — the one divergence that could lose a vector. - * Bounded by the number of such rows; an id leaves when it is re-enqueued or - * durably disarmed. - */ - private _pendingEmbedUndurableClears = new Set() - - /** - * Pending-set transitions (enqueue/clear) since the last checkpoint attempt — - * the checkpoint CADENCE. One mechanism, one hardcoded default, no knob and - * no timer (nothing to leave running after close). - */ - private _pendingEmbedCheckpointTransitions = 0 - - /** - * A checkpoint is OWED: the cadence came due (or the set drained) and no - * write has satisfied it yet. It stays armed across attempts the durability - * law refuses, so the next transition that CAN be checkpointed is. - */ - private _pendingEmbedCheckpointDue = false - - /** Single-flight guard for the fire-and-forget checkpoint write. */ - private _pendingEmbedCheckpointFlight: Promise | null = null - - /** - * What the last pending-embed recovery fold actually did — the bound it - * used, where it started, and how many facts it read. The narration's - * source, and the accounting a pin reads instead of a clock. - */ - private _pendingEmbedFoldReport: { - bound: 'checkpoint' | 'low-water' | 'genesis' - fromGeneration: number - factsScanned: number - seeded: number - pending: number - } | null = null - - // OPEN-PATH FIX: the background embedding-engine warm kicked off (never - // awaited) by `performInit()` when `eagerEmbeddings` resolves true. Stored - // for observability only — `embed()`/`embeddingManager.embed()` already - // await the engine's OWN singleton init promise internally, so nothing - // needs to explicitly await this field for correctness. Never rejects on - // its own: a `.catch` narrates the failure and swallows it so a failed - // warm never surfaces as an unhandled rejection. - private _embeddingWarmPromise: Promise | null = null - - /** The stored log-authority switch, read once at open (default: tree). */ - private _logAuthority: LogAuthorityRecord = { authority: 'tree' } // A failed walk latches its error: retries within the cooldown rethrow it // instantly instead of re-walking, so a tight caller-side retry loop costs // one loud error per query, never a full store walk per query. @@ -946,66 +714,13 @@ export class Brainy implements BrainyInterface { // applies only to instances that were never closed. private closed = false - /** - * THE ONE CLOSE. Set SYNCHRONOUSLY by the first `close()` call, before that - * call yields, and never cleared — close is terminal. Every later or - * concurrent caller receives this same promise, so a shutdown with two - * callers (a host's pool close and the engine's own signal handler) runs - * ONE teardown, not two. - * - * MEASURED, the day this was added: a host that owns shutdown called - * `close()` on every pooled store at SIGTERM while the engine's signal - * handler flushed the same instances in parallel and released their writer - * locks in its own `finally`. One store took 149s to close (148s of it - * silent) against 24s for its idle siblings, and the same race in a local - * reproduction printed `Writer fence lost … the lock file is gone` — the - * handler observing a lock the close it was racing had already released. - * Two owners of one shutdown; now there is one, whoever calls first. - */ - private _closeInFlight: Promise | null = null - - // Index-build-at-open state. `lazyRebuildCompleted` predates the health-gate - // law (it named a first-QUERY lazy rebuild) and stays for `getIndexStatus()` - // API compatibility, but its truth changed: a needed rebuild now runs - // unconditionally at open() (see `rebuildIndexesIfNeeded`), never deferred to - // a read, so this simply flips true once that open-time step has run. - // `lazyRebuildInProgress` / `lazyRebuildPromise` (the first-query rebuild's - // concurrency guard) are retired with the lazy-build path they served — - // `ensureIndexesLoaded()` is a read-time CHECK now, never a build. + // Lazy rebuild state (Production-scale lazy loading) + // Prevents race conditions when multiple queries trigger rebuild simultaneously + private lazyRebuildInProgress = false private lazyRebuildCompleted = false - - // 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. - /** - * 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() + private lazyRebuildPromise: Promise | null = null constructor(config?: BrainyConfig) { - // The reserved-field write policy died with the field-addressing law: - // every metadata name is the user's now (engine scalars write via their - // dedicated params and read at `system.*`), so there is nothing left for - // the policy to govern. A config still passing it refuses loudly rather - // than being silently ignored. - if (config && 'reservedFieldPolicy' in (config as Record)) { - throw new Error( - `reservedFieldPolicy was removed by the field-addressing law: metadata field ` + - `names are never reserved anymore — every name in the metadata bag is the ` + - `user's and works like any other field. Set engine scalars via their ` + - `dedicated params (confidence, weight, subtype, …) and query them as ` + - `system.. Remove the reservedFieldPolicy option.` - ) - } - // Normalize configuration with defaults this.config = this.normalizeConfig(config) @@ -1106,14 +821,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 `@soulcraftlabs/brainy` dynamically + * long as the plugin's own dist resolves `@soulcraft/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 - * `@soulcraftlabs/brainy ≤7.20.x`. + * `@soulcraft/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. @@ -1159,17 +874,6 @@ export class Brainy implements BrainyInterface { } } - /** - * Factory hook for the generation store, so an engine built on top of this - * reference implementation can substitute a `GenerationStore` that keeps - * the same behavioural contract (for example, one backed by a native - * implementation) — overriding it never changes this engine's own - * behaviour, since the default implementation is unchanged. - */ - protected createGenerationStore(storage: BaseStorage): GenerationStore { - return new GenerationStore(storage) - } - /** * Initialize Brainy. * @@ -1263,86 +967,6 @@ export class Brainy implements BrainyInterface { configureLogger({ level: LogLevel.DEBUG }) // Enable verbose logging } - // 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() - 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 { // Auto-detect and activate plugins BEFORE storage setup // so plugin-provided storage factories (e.g., filesystem override from cor) are available @@ -1403,7 +1027,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 ` + - `\`@soulcraftlabs/brainy\` to ≥7.21. See docs/concepts/storage-adapters.md.` + `\`@soulcraft/brainy\` to ≥7.21. See docs/concepts/storage-adapters.md.` ) } else { console.warn( @@ -1414,12 +1038,6 @@ export class Brainy implements BrainyInterface { } } - // PHASE 1 of 5 — "storage init": plugin/legacy-layout bootstrap, - // storage adapter construction+init, the OS-limit check, and the - // writer-lock claim, all folded into one bucket (everything above this - // line since performInit started). - markPhase('storage-init') - // 8.0 generational MVCC: open the record layer BEFORE any index is // created or loaded. Crash recovery may rewrite canonical entity files // (restoring before-images of an uncommitted transaction), and every @@ -1427,13 +1045,10 @@ export class Brainy implements BrainyInterface { // guarantees indexes never observe rolled-back state. Reader-mode // instances skip recovery (readers never write; the next writer // repairs). - this.generationStore = this.createGenerationStore(this.storage) - 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' }) - ) + this.generationStore = new GenerationStore(this.storage) + const generationOpenResult = await 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 @@ -1471,11 +1086,7 @@ 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 step( - 'verify-entity-tree-stamp', - 'comparing the entity tree\'s stamped generation and rollups against the store', - () => this.verifyEntityTreeStamp() - ) + await 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 — @@ -1487,11 +1098,7 @@ 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 step( - 'read-brain-format', - 'reading the on-disk format marker that decides whether the derived indexes are stale', - () => readBrainFormat(this.storage) - ) + this._brainFormat = await readBrainFormat(this.storage) this._indexEpochStale = this._brainFormat === null || this._brainFormat.indexEpoch !== EXPECTED_INDEX_EPOCH @@ -1502,19 +1109,9 @@ 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 step( - 'pre-upgrade-backup', - 'snapshotting the brain directory before a one-time format rebuild (migrationBackup)', - () => this.createMigrationBackupIfNeeded() - ) + await this.createMigrationBackupIfNeeded() } - // PHASE 2 of 5 — "generation-store open+fold": GenerationStore - // construction+open (crash-recovery replay/rollback fold), the - // derived-family registration, the fact-scan seam, the entity-tree - // stamp check, the brain-format handshake, and the pre-upgrade backup. - markPhase('generation-store-open-fold') - // Provider: embeddings (reassign embedder if plugin provides one) const embeddingProvider = this.pluginRegistry.getProvider('embeddings') if (embeddingProvider) { @@ -1589,42 +1186,6 @@ export class Brainy implements BrainyInterface { this.graphIndex = graphIndex } - // Fact-log v2 mint seam: after-image records carry minted dense ints, - // and the ONE authority for those assignments is the metadata index's - // id mapper (append-only getOrAssign — a rebuilt mapper reproduces - // them exactly). The generation store cannot know the mapper, so the - // mint thunk is injected here, immediately after the index is ready; - // installing it is what flips the fact log's LIVE writes to the v2 - // segment format. A configuration whose mapper is unavailable throws - // at mint time — an int of 0 is never written. - this.generationStore.setIntMinter((kind, id) => { - const mapper = this.metadataIndex?.getIdMapper?.() - if (!mapper || typeof mapper.getOrAssign !== 'function') { - throw new Error( - `fact log v2: cannot mint the ${kind} int for ${id} — the metadata index's ` + - `id mapper is unavailable on this configuration; refusing to write an ` + - `after-image without a reproducible int` - ) - } - const minted = mapper.getOrAssign(id, undefined) - const asBigint = typeof minted === 'bigint' ? minted : BigInt(minted) - // THE RESERVED-ROOT EXEMPTION: the VFS root (the all-zeros UUID) is - // minted int 0 BY CONSTRUCTION at genesis on existing brains — the - // one legitimate zero in the id space. Zero for ANY other id is a - // corrupt mint and refuses. (Without this, every existing brain's - // adoption oracle false-flagged its own root and refused the flip.) - const isReservedRoot = - asBigint === 0n && id === '00000000-0000-0000-0000-000000000000' - if (asBigint < 0n || (asBigint === 0n && !isReservedRoot)) { - throw new Error( - `fact log v2: the id mapper minted ${asBigint} for ${kind} ${id} — ` + - `minted ints are positive (int 0 is reserved for the VFS root alone); ` + - `refusing to write` - ) - } - return asBigint - }) - // Eager cold-load (readiness contract). A provider that persists its // derived state exposes init?(): trigger the load NOW — AFTER // metadataIndex.init() above (the id-mapper is hydrated first, so a @@ -1669,64 +1230,20 @@ 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([ - kick('metadata', this.metadataIndex), - kick('vector', this.index as unknown as { rebuild: () => Promise }), - kick('graph', this.graphIndex) + this.metadataIndex.rebuild(), + this.index.rebuild(), + this.graphIndex.rebuild() ]) } - // METADATA WATERMARK CATCHUP: the JS metadata index computed its - // three-way watermark verdict inside metadataIndex.init() above, - // against the generation store's now-FINAL committed generation (the - // crash-recovery fold above — the durable-at-ack replay of acked - // writes whose canonical bytes hadn't reached disk — has already run, - // and any rolled-back-transaction rebuild just above already brought - // every index current, so the verdict is consumed here whether or not - // that rebuild ran). Consumed BEFORE the rebuild gate below and BEFORE - // this open serves any read — the cure for the class of bug where - // canonical get()/counts recover a crash-window write but find() - // keeps serving the metadata index's pre-crash state (the index - // flushes only periodically, not per-commit). - await this.consumeMetadataWatermarkVerdict(generationOpenResult.rolledBackGenerations > 0) - // 8.0 versioned-provider replay-gap check: a provider whose persisted // index generation is behind the storage layer's committed generation // replays the gap itself (post-commit applier contract) — surface the // gap for observability. for (const provider of this.versionedIndexProviders()) { const providerGen = provider.generation() - // Defensive finite-integer guard: committedGeneration() is validated - // at the store's open (torn artifacts discard, narrated) — but a - // RangeError here would kill the whole open, so the consumer guards - // too. A non-finite value narrates and skips the gap check (the - // provider's own replay contract still governs). - const committedRaw = this.generationStore.committedGeneration() - if (!Number.isSafeInteger(committedRaw) || committedRaw < 0) { - prodLog.warn( - `[Brainy] committed generation is non-integer (${String(committedRaw)}) at ` + - `init — torn-artifact survivor; skipping the provider replay-gap check` - ) - continue - } - const committed = BigInt(committedRaw) + const committed = BigInt(this.generationStore.committedGeneration()) if (providerGen < committed) { prodLog.info( `[Brainy] Versioned index provider is at generation ${providerGen} ` + @@ -1773,38 +1290,12 @@ 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 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 + // Rebuild indexes if needed for existing data + await this.rebuildIndexesIfNeeded() // Check for pending data migrations await this.checkMigrations() - // PHASE 3 of 5 — "index init+gate": provider wiring (embeddings, - // 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, 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) if (!Brainy.shutdownHooksRegisteredGlobally) { this.registerShutdownHooks() @@ -1863,11 +1354,7 @@ 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 step( - 'vfs.init', - 'creating or adopting the VFS root and wiring the path resolver', - () => this._vfs!.init() - ) + await this._vfs.init() this._vfsInitialized = true // Mark VFS as fully initialized // 8.0 MVCC: infrastructure bootstrap (VFS root, etc.) is now the @@ -1877,145 +1364,15 @@ export class Brainy implements BrainyInterface { this._generationStampingActive = true } - // LOG-AUTHORITY SWITCH (checked at open only). A STORED artifact - // always wins: an already-flipped brain runs durable-at-ack; an - // explicitly-recorded tree posture is honored. With NO artifact, the - // 10.0.0 FLEET DEFAULT is ADOPT-AT-OPEN (config logAuthority: - // 'adopt'): the verification oracle gates the flip — curable - // divergences are baseline-backfilled, the brain flips ONLY on green, - // and a brain that cannot go green STAYS tree-authoritative LOUDLY - // with the refusal recorded (cheap subsequent opens; an operator - // re-runs adoptLogAuthority() after fixing the divergence). - // 'defer' is the documented opt-out: no automatic adoption. - if (!this.isReadOnly) { - const storedArtifact = await this.storage - .readRawObject(LOG_AUTHORITY_PATH) - .catch(() => null) - 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') - prodLog.info('[Brainy] storage authority: generation log (durable-at-ack enabled)') - } else if ( - storedArtifact === null && - this.config.logAuthority === 'adopt' && - this.generationStore.getFactLog() !== null - ) { - try { - 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)' - ) - } catch (err) { - // The guarded ruling: a brain that cannot verify STAYS tree, - // loudly, with the refusal recorded so subsequent opens are - // cheap. Never a silent half-state; never a failed open. - const reason = (err as Error).message - prodLog.warn( - `[Brainy] log-authority adoption REFUSED at open — this brain stays ` + - `tree-authoritative until an operator resolves the divergence and ` + - `re-runs adoptLogAuthority(). Reason: ${reason}` - ) - try { - const refusal: LogAuthorityRecord = { - authority: 'tree', - adoptRefusal: { at: Date.now(), reason: reason.slice(0, 500) } - } - await this.storage.writeRawObject(LOG_AUTHORITY_PATH, refusal) - this._logAuthority = refusal - } catch { - // Unrecordable refusal = the next open retries the oracle — - // the conservative outcome. - } - } - } - } - - // MT5 crash recovery — REPLAY, NOT LISTING: the pending-embed markers - // live IN the generation log (embed.pending rides the deferred write's - // own fact; embed.landed rides the landing commit), so recovery folds - // the log's marker records back into the in-memory set — after the - // one-time bridge migrates any sidecar files a pre-log build left - // behind — and resumes the worker in the background. A crash between - // a deferred write's ack and its background embed DELAYED a vector; - // this is where it lands. - if (!this.isReadOnly) { - // Foreground, as the crash-recovery contract pins it: a reopened brain - // has its markers re-armed when open() returns. The low-water mark - // bounds this to the log's tail on any brain that has ever drained — - // milliseconds — so the foreground cost is the unmarked first open - // only, once per upgraded brain. - try { - 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 (from the low-water mark) into the pending set', - () => this.recoverPendingEmbedsFromLog() - ) - if (this._pendingEmbedIds.size > 0) { - prodLog.info( - `[Brainy] ${this._pendingEmbedIds.size} deferred embed(s) pending from a previous ` + - `session — resuming in the background` - ) - const t = setTimeout(() => this.kickEmbedWorker(), 0) - ;(t as { unref?: () => void }).unref?.() - } - } catch (err) { - prodLog.warn( - `[Brainy] pending-embed recovery failed: ${(err as Error).message} — ` + - `the log's markers remain durable; recovery retries next open` - ) - } - } - - // PHASE 4 of 5 — "VFS bootstrap": shutdown-hook registration, blob - // storage init, the provider-summary log, flipping `initialized`, - // the migration-lock wait, VFS construction+init, flipping generation - // stamping active, the log-authority adopt/oracle check, and - // pending-embed crash recovery. - markPhase('vfs-bootstrap') - - // Eager embedding initialization — BACKGROUND WARM (open-path fix). + // Eager embedding initialization. // - // Adaptive default (8.0): the WASM embedding engine eagerly WARMS + // Adaptive default (8.0): the WASM embedding engine eagerly initializes // during init() WHENEVER it is the active embedder — i.e. no native // 'embeddings' provider has taken over — and the instance is a writer // (not reader-mode) outside of unit tests. The WASM module (≈93MB with - // the embedded model) takes 90-140s to compile on throttled CPUs. - // - // Historically this AWAITED `embeddingManager.init()` INLINE, so every - // writer's open() blocked on the compile — N concurrent opens all - // queued on the ONE process-global singleton (an ~80x contention - // multiplier measured in a production restart storm: 90,017ms busy vs - // 1,117ms quiet). The engine only needs to be ready before the FIRST - // REAL embed() call, not before init() returns, so this now only - // STARTS the warm and moves on — init() never waits for it. - // - // No double-await needed for correctness: `this.embed()` (~line 15420) - // delegates to `this.embedder`, which for the default engine is - // `embeddingManager.getEmbeddingFunction()` → `embeddingManager.embed()` - // (src/embeddings/EmbeddingManager.ts). That method calls `await - // this.init()` FIRST, and `init()` itself serializes every concurrent - // caller onto ONE shared `globalInitPromise` — so the first real - // embed() automatically waits for whichever finishes first: this - // background warm (if still running) or a fresh init() (if the warm - // hasn't reached this code yet, e.g. `eagerEmbeddings: false`). - // Verified by reading both call sites; `_embeddingWarmPromise` below - // is stored for observability only, never re-awaited by embed(). + // the embedded model) takes 90-140s to compile on throttled CPUs; paying + // that during boot rather than on the first embed()-driven call is the + // right default for the overwhelmingly common single-process server. // // Skipped automatically when: // - a native 'embeddings' provider is registered (it owns embeddings; @@ -2023,8 +1380,8 @@ export class Brainy implements BrainyInterface { // - reader-mode (readers don't embed — they query existing vectors), // - unit-test mode (tests must stay fast and use the mock embedder). // - // `eagerEmbeddings: false` keeps meaning "no warm at all" — fully lazy, - // the first embed() call pays the full cost inline, same as before. + // `eagerEmbeddings: false` is the explicit override to force lazy init + // (first-embed) even when this instance is the active embedder. const isUnitTestMode = isDeterministicEmbedMode() const eager = this.config.eagerEmbeddings ?? true if ( @@ -2033,45 +1390,9 @@ export class Brainy implements BrainyInterface { this.config.mode !== 'reader' && !isUnitTestMode ) { - const warmStart = Date.now() - console.log('Background embedding-engine warm started (init() does not wait for it)...') - this._embeddingWarmPromise = embeddingManager - .init() - .then(() => { - prodLog.info( - `[Brainy] background embedding-engine warm complete in ${Date.now() - warmStart}ms` - ) - }) - .catch((err) => { - // Loud, never silent: a warm that fails to compile must be - // heard NOW, not discovered as a mystery latency spike on - // whichever request happens to trigger the first real embed(). - // That first embed() call still retries init() itself (the - // singleton promise contract above) and surfaces its own typed - // error to its caller — this is the immediate, background echo. - prodLog.warn( - `[Brainy] background embedding-engine warm FAILED: ` + - `${(err as Error).message} — the first embed() call will retry ` + - `initialization and surface the error there` - ) - }) - } - - // PHASE 5 of 5 — "embedding-warm-started": just the synchronous cost - // of kicking off the background warm above (the warm's own compile - // time is NOT included — that's the whole point of backgrounding it). - markPhase('embedding-warm-started') - { - const totalOpenMs = Date.now() - initStart - if (totalOpenMs > 2000) { - const phaseList = Object.entries(phaseTimingsMs) - .map(([name, ms]) => `${name}=${ms}ms`) - .join(', ') - prodLog.narrate( - `[Brainy] slow open: ${totalOpenMs}ms total (${phaseList}) — see the ` + - `phase breakdown above to find which one to investigate first` - ) - } + console.log('Eager embedding initialization enabled...') + await embeddingManager.init() + console.log('Embedding engine ready') } // Integration Hub initialization @@ -2124,15 +1445,7 @@ export class Brainy implements BrainyInterface { if (error instanceof Error && (error as Error & { code?: string }).code === 'BRAINY_WRITER_LOCKED') { throw error } - // Wrap with the original as `cause` so the originating frame (a plugin's - // own file:line, e.g. a provider boot failure) survives to the caller's - // 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) + throw new Error(`Failed to initialize Brainy: ${error}`) } } @@ -2143,204 +1456,83 @@ export class Brainy implements BrainyInterface { * Critical for Cloud Run, Fargate, Lambda, and other containerized deployments. * * Handles: - * - SIGTERM: Graceful termination (Cloud Run, Fargate, Lambda) — CLOSES. - * - SIGINT: Ctrl+C (development/local testing) — CLOSES. - * - beforeExit: the event loop drained — FLUSHES, and closes NOTHING. A - * drained loop is not a shutdown; see {@link flushOnDrainedEventLoop}'s - * contract below. + * - SIGTERM: Graceful termination (Cloud Run, Fargate, Lambda) + * - SIGINT: Ctrl+C (development/local testing) + * - beforeExit: Node.js cleanup hook (fallback) * * NOTE: Registers globally (once for all instances) to avoid MaxListenersExceededWarning */ private registerShutdownHooks(): void { - /** - * The signal-path shutdown. ONE OWNER PER BRAIN, AND THE PATH IS `close()`. - * - * WHAT THIS REPLACED, and why. The handler used to run its own shutdown — - * a parallel per-component flush, the generation store's close, a second - * parallel round of component closes, and a `finally` that stopped the - * flush-request watcher and released the writer lock. That is a SECOND - * teardown of the same brain, and a host application with its own SIGTERM - * handler (the shape every pooled deployment has) ran the FIRST one at the - * same moment. MEASURED in production the day this changed: a host closing - * seven pooled stores at SIGTERM printed "Shutdown signal received - - * flushing pending data...", went silent for 148s, printed "Flushed - * successfully (1 instance)", and the host's own close of that same store - * returned 1s later — 149s, against 24s for the six stores with no engine - * work in flight. The same race reproduced locally as - * `Failed to flush one Brainy instance on shutdown: Writer fence lost … - * the lock file is gone`: this handler observing a lock that the close it - * was racing had already released. - * - * SO: defer one macrotask, then per instance either STEP ASIDE (a close - * has begun or finished — its owner owns the flush, the markers and the - * lock) or `await instance.close()` — the one durable path, identical to - * what any caller gets. The three laws the old block carried are all - * satisfied by `close()`, each verified against its code: - * - * 1. PER-INSTANCE ISOLATION — kept HERE, in the per-instance try/catch - * below: one brain's failed close never aborts the loop over the rest. - * (`close()` itself is per-instance by construction.) - * 2. THE MARKER IS PART OF SHUTDOWN — `close()` → `closeDurableSteps()` - * Phase 1 awaits `this.generationStore.close()`, which persists the - * counter, advances the fold checkpoint and stamps the clean-shutdown - * marker LAST. That is the step that decides adopt-vs-fold at the next - * open, and it is the same call the old block made. - * 3. THE LOCK IS ALWAYS GIVEN UP — `close()`'s terminal releases run - * whether the durable steps threw or not (its contract: "TWO PARTS, AND - * THE SECOND IS UNCONDITIONAL"): `stopFlushRequestWatcher()` then - * `releaseWriterLock()`, then the VFS shutdown and the terminal - * `closed` flag, and only then is the original failure rethrown. - * `close()` releases the lock in MORE cases than the old block did — it - * also drains the metadata write buffer first, so no pending write can - * land after a successor writer claims the lock. - */ - const closeOnShutdown = async () => { + const flushOnShutdown = async () => { console.log('Shutdown signal received - flushing pending data...') - // DEFER ONE MACROTASK. A host application registers its own listener on - // the same signal, and Node runs listeners in registration order — ours - // is usually first, because the brain was opened before the host wired - // its shutdown. Yielding once lets every other listener for this signal - // run its synchronous prologue, so a host that calls close() gets to be - // the owner. It is only a courtesy, never the safety: close()'s own - // single-flight gate is what makes a lost race harmless. - await new Promise((resolve) => setImmediate(resolve)) - - let closedCount = 0 - let deferredCount = 0 - let failedCount = 0 - // Snapshot: close() splices Brainy.instances while we iterate. - for (const instance of [...Brainy.instances]) { - if (!instance.initialized) continue - // SOMEONE ELSE OWNS THIS ONE. Not a flush, not a lock release, not a - // component close — nothing. Touching a brain whose close is running - // is the whole defect this handler was rewritten for. - if (instance.closed || instance._closeInFlight !== null) { - deferredCount++ - continue - } - try { - // Law 1: this try/catch is the isolation — the loop continues. - await instance.close() - closedCount++ - } catch (error) { - failedCount++ - console.error('Failed to close one Brainy instance on shutdown:', error) - } - } - if (closedCount > 0) { - console.log(`Flushed successfully (${closedCount} instance${closedCount > 1 ? 's' : ''})`) - } - if (deferredCount > 0) { - console.log( - `${deferredCount} Brainy instance${deferredCount > 1 ? 's are' : ' is'} already ` + - `closing — left to the caller that owns that close.` - ) - } - 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.` - ) - } - } - - /** - * THE DRAINED-EVENT-LOOP PATH. A DRAINED LOOP IS NOT A SHUTDOWN. - * - * Node emits `'beforeExit'` whenever the event loop has no REF'd work - * left — NOT when the process is ending, and with no signal involved. A - * perfectly healthy script reaches that state routinely: this engine - * unref's its idle and cadence timers ("an idle brain costs nothing"), so - * a script awaiting anything those timers drive is, for that instant, - * a process with no ref'd work and an open brain. - * - * MEASURED on the 11.1 rehearsal lane against a copy of a real store: the - * `beforeExit` listener was wired to the SIGNAL path, so after the heal - * phase the log printed `Shutdown signal received - flushing pending - * data...` and `Flushed successfully (1 instance)` with NO signal ever - * sent, and the script's very next `add()` threw `Brainy instance is not - * initialized: it was closed via close(). Create a new instance.` The - * engine had closed a live brain out from under a running script. - * - * SO, THE LAW: this path NEVER closes, deregisters, tears down or - * force-exits anything, and never releases a writer lock. It runs - * `flush()` — the engine's own non-closing durability door — on each live - * brain, and leaves every one of them open and usable. - * - * WHY flush() AND NOT NOTHING. Each claim checked against the code it - * names: - * 1. IT CANNOT CLOSE ANYTHING. `flush()` → `_flushSteps()` persists - * DERIVED state only: the count ledger, the metadata/graph/vector - * projections, the generation counter, aggregation state, the - * entity-tree stamp. It closes no component, deactivates no plugin, - * touches neither `initialized` nor `closed`, and never calls - * `releaseWriterLock()` — the clean-shutdown marker is written by - * `generationStore.close()` alone, reached only from `close()`. - * 2. IT CANNOT RACE A LATER WRITE INTO CORRUPTION. A background flush - * concurrent with live writes is the engine's ORDINARY steady state: - * `noteWriteForPersistence()` kicks exactly this call off an unref'd - * timer on every busy brain. `flush()` is single-flight with one queued - * follow-up, and a write landing mid-flush re-sets the dirty witness, - * so its work is never lost — it belongs to the next flush. - * 3. IT CANNOT SPIN. `flush()` on a clean brain returns without touching a - * provider or scheduling I/O, so the second emit does no event-loop - * work and the process exits. That is also why the listener is NOT - * self-deregistered any more: a one-shot listener spent on a spurious - * mid-script drain leaves the genuine end-of-script drain with nothing. - * 4. A FAILED FLUSH IS SURVIVABLE AND LOUD. The write path is durable at - * ack via the fact log; derived state is rebuildable. A throw is - * reported per instance and the loop continues — exactly how - * `kickBackgroundFlush()` already treats the same failure. - * - * The one thing lost against a closing handler is the clean-shutdown - * marker for a script that opens a brain and never closes it: its next - * open folds the log. That is the correct trade — a missing marker costs - * a recovery fold, closing a live brain costs the caller its brain — and - * the narration below names the cure. - */ - const flushOnDrainedEventLoop = async () => { - // A second emit can land on top of the first (this pass schedules async - // work, the loop turns, the loop drains again). One pass at a time. - if (Brainy.beforeExitFlushInFlight) return - - // Step aside for anyone whose close is running or done — the same - // ownership rule the signal path follows. - const live = [...Brainy.instances].filter( - (instance) => instance.initialized && !instance.closed && instance._closeInFlight === null - ) - if (live.length === 0) return - - // ONCE per registration cycle: a `console.log` to a pipe is itself - // event-loop work, so narrating on every emit would keep the loop - // turning and narrate forever. - if (!Brainy.beforeExitNarrated) { - Brainy.beforeExitNarrated = true - console.log( - `[Brainy] event loop drained with ${live.length} brain${live.length > 1 ? 's' : ''} ` + - `open — persisting derived state; NOTHING was closed. A drained loop is not a ` + - `shutdown: call close() (or send SIGTERM) when you mean one.` - ) - } - - Brainy.beforeExitFlushInFlight = true try { - for (const instance of live) { - try { - await instance.flush() - } catch (error) { - // Per-instance isolation, and never fatal: canonical data is - // durable at ack, so a failed derived-state flush costs the next - // open a rebuild — it must not cost this one its brain. - console.error( - '[Brainy] flush on a drained event loop failed for one open brain ' + - '(the brain stays open and usable; derived-state persistence retries at the ' + - 'next flush, and canonical data is unaffected):', - error - ) + 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++ } } - } finally { - Brainy.beforeExitFlushInFlight = false + if (flushedCount > 0) { + console.log(`Flushed successfully (${flushedCount} instance${flushedCount > 1 ? 's' : ''})`) + } + } catch (error) { + console.error('Failed to flush on shutdown:', error) } } @@ -2348,52 +1540,26 @@ 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. - * - * THE COUNT IS TAKEN WHEN THE SIGNAL ARRIVES, not after the shutdown ran. - * "Is anyone else handling this signal?" is a question about the moment - * the signal landed. Asking afterwards reads a process that has already - * torn itself down: the handler now CLOSES its instances, and closing the - * last brain deregisters Brainy's own listeners — so a host application's - * single remaining listener would look like `<= 1` and get force-exited - * out of its own graceful shutdown, precisely the failure above. - * - * SIGNALS ONLY — NEVER `beforeExit`. The reasoning above is entirely about - * a signal Brainy has suppressed Node's default terminate behaviour for. - * `beforeExit` suppresses nothing: Node exits by itself once the loop is - * genuinely done, and the script that is still running when it fires is - * not shutting down at all. Calling this from that path would end a live - * script at exit code 0 mid-work. It is called from the two signal - * listeners below and from nowhere else. - */ - const exitIfSoleShutdownOwner = (ownersWhenSignalled: number): void => { - if (ownersWhenSignalled <= 1) { - process.exit(0) - } - } Brainy.sigtermListener = async () => { - const owners = process.listenerCount('SIGTERM') - await closeOnShutdown() - exitIfSoleShutdownOwner(owners) + await flushOnShutdown() + process.exit(0) } Brainy.sigintListener = async () => { - const owners = process.listenerCount('SIGINT') - await closeOnShutdown() - exitIfSoleShutdownOwner(owners) + await flushOnShutdown() + process.exit(0) + } + Brainy.beforeExitListener = async () => { + // Self-deregister FIRST: Node re-emits 'beforeExit' after every event- + // loop drain, and this flush schedules new async work — with the + // listener still attached, a script that never calls close() would spin + // flush → drain → flush forever and never exit. One flush, then the + // next drain finds no listener and the process exits. + if (Brainy.beforeExitListener) { + process.off('beforeExit', Brainy.beforeExitListener) + Brainy.beforeExitListener = undefined + } + await flushOnShutdown() } - Brainy.beforeExitListener = flushOnDrainedEventLoop process.on('SIGTERM', Brainy.sigtermListener) process.on('SIGINT', Brainy.sigintListener) process.on('beforeExit', Brainy.beforeExitListener) @@ -2415,11 +1581,6 @@ export class Brainy implements BrainyInterface { Brainy.sigtermListener = undefined Brainy.sigintListener = undefined Brainy.beforeExitListener = undefined - // A later re-init is a fresh cycle: it may narrate its own drained-loop - // notice, and no pass of the previous cycle can still be running (the last - // close() drained the flush chain). - Brainy.beforeExitNarrated = false - Brainy.beforeExitFlushInFlight = false Brainy.shutdownHooksRegisteredGlobally = false } @@ -2470,33 +1631,6 @@ export class Brainy implements BrainyInterface { return this.initialized } - /** - * @description Whether `close()` has BEGUN on this instance — in flight or - * already finished. The question a shutdown owner asks: this brain's - * teardown belongs to whoever started it, and a second party must not flush - * its components or release its writer lock underneath it. - * - * True from the synchronous moment `close()` is entered, so a listener that - * yields a tick and comes back reads the truth, not a stale "not yet". - * @returns `true` once a close has started. - */ - get isClosing(): boolean { - return this._closeInFlight !== null - } - - /** - * @description Whether `close()` has FINISHED tearing this instance down — - * durable steps attempted, writer lock released, instance terminal. A - * closed brain never re-initializes; every operation on it throws. - * - * True after a close that FAILED partway, too: such a brain still holds no - * writer lock and still serves nothing (see {@link close}). - * @returns `true` once the teardown has completed. - */ - get isClosed(): boolean { - return this.closed - } - /** * Promise that resolves when Brainy is fully initialized and ready to use * @@ -2651,860 +1785,12 @@ export class Brainy implements BrainyInterface { * deletes — the before-image + per-id-chain set. * @param run - The single-op's existing operation batch builder (the * `tx => {…}` body previously passed straight to `executeTransaction`). - * @param precommit - Optional CAS precondition, run under the commit mutex. - * @param pendingEvents - Change-feed events to stamp and emit post-commit. - * @param records - Optional v2 marker records (e.g. the deferred-embedding - * lifecycle markers) riding this write's commit fact — same generation, - * one atomic append. Refused on generation-less bootstrap writes. */ - /** - * Storage-root-relative prefix of the RETIRED sidecar pending-embed marker - * files (pre-log builds persisted one raw object per pending embed here). - * The markers live IN the generation log now (`embed.pending` / - * `embed.landed` records); this prefix survives ONLY for the one-time - * migration bridge ({@link bridgeLegacyPendingEmbedSidecars}) — no other - * code path writes, lists, or deletes it. - */ - private static readonly PENDING_EMBED_PREFIX = '_system/pending_embeds/' - - /** - * Storage-root-relative path of the ADVISORY pending-embed low-water mark: - * `{ generation, writtenAt }`, written whenever the pending set drains to - * empty (and at clean close when empty). Every marker in facts at or below - * `generation` is consumed, so recovery scans from `generation + 1`. The - * mark is advisory and monotone-safe: stale-low costs a longer scan, never - * a lost marker; it is never required for correctness. - */ - private static readonly PENDING_EMBED_LOWWATER_PATH = '_system/pending_embeds_lowwater.json' - - /** - * Storage-root-relative path of the pending-embed CHECKPOINT: - * `{ generation, pending: string[], writtenAt }` — "as of durable generation - * G the pending set was exactly this list". Open seeds the set from `pending` - * and scans the log from `G + 1`, so the fold costs O(facts since G) - * REGARDLESS of whether the set ever drains. - * - * WHY IT REPLACES THE EMPTY-ONLY MARK AS THE BOUND. The low-water mark - * ({@link PENDING_EMBED_LOWWATER_PATH}) can only be written when the pending - * set is EMPTY, because it carries no set — it means "everything at or below - * G is consumed". A brain holding even ONE id that never lands (an embed that - * keeps failing; a row reaped in memory only and re-folded every open) never - * drains, so it never writes a mark, so the bound never engages on exactly - * the brains whose fold is expensive: every open re-reads the whole log. The - * checkpoint carries the set, so it needs no drain. - * - * The mark is still written and still read as the FALLBACK bound (a - * checkpoint that is absent, torn, or malformed degrades to it, and then to - * generation 1). Correctness over cost in every degradation: a stale or - * missing checkpoint only lengthens the scan. - */ - private static readonly PENDING_EMBED_CHECKPOINT_PATH = '_system/pending_embeds_checkpoint.json' - - /** - * Checkpoint CADENCE BASE: attempt a checkpoint every N pending-set - * transitions (enqueues + clears) while the brain is open, on top of the - * drain-to-empty and clean-close writes. Hardcoded 90th-percentile default, - * no knob, no timer: 64 transitions is far below the cost of the fold it - * bounds and far above the per-write noise floor. An attempt that cannot - * satisfy the durability law is SKIPPED, not forced — the next transition - * retries. - * - * The interval ADAPTS to the one signal that matters, the backlog's own - * size, because a checkpoint writes the WHOLE pending list: the interval is - * `max(64, ceil(|pending| / 64))`, which holds the amortized cost of the - * mechanism at ≤ 64 ids written per transition NO MATTER how large the - * backlog grows. A term that scales with the store rather than with the - * work is exactly the defect class this file is fixing; it must not be - * reintroduced by the cure. - */ - private static readonly PENDING_EMBED_CHECKPOINT_EVERY = 64 - - /** - * @description Mark a deferred embed pending (MT5): the id joins the - * in-memory fast-path set and the returned `embed.pending` record is - * threaded onto the deferred write's OWN commit fact — same generation, - * same atomic append, and (in at-ack log durability) the same covering - * fsync as the write itself. The marker can never be orphaned from its - * write nor the write from its marker: a failed commit appends no fact, - * so no durable marker exists either (the in-memory entry is harmless - * and reaped by the worker). Recovery folds the marker back out of the - * log at open ({@link recoverPendingEmbedsFromLog}). - */ - private enqueuePendingEmbed(id: string): FactMarkerRecord { - this._pendingEmbedIds.add(id) - // Re-armed for real: any earlier in-memory-only clear is superseded. - this._pendingEmbedUndurableClears.delete(id) - this.noteEmbedCheckpointCadence() - return { type: 'embed.pending', id, enqueuedAt: Date.now() } - } - - /** - * @description Clear a pending embed from the in-memory set. The DURABLE - * clear is the `embed.landed` record riding the landing commit's own fact - * (or, for a row deleted before its embed landed, the row's tombstone - * fact) — the recovery fold consumes those; nothing here touches storage. - * One honest residue: a pending row whose entity still exists but carries - * no data is reaped in memory only, so it re-folds at the next open and - * is re-reaped there — a bounded no-op, never a lost vector. That residue - * is the ONLY `durability: 'in-memory-only'` caller, and the checkpoint - * keeps carrying those ids so the bounded fold and a full fold from - * generation 1 agree exactly (see {@link _pendingEmbedUndurableClears}). - * - * @param id - The pending id to clear. - * @param durability - `'durable'` (default) when a record in the log at or - * below the current head disarms this id (an `embed.landed` riding the - * landing or unvector commit, or the row's tombstone — including the row - * simply not being there any more); `'in-memory-only'` when nothing in the - * log says so. - */ - private clearPendingEmbed( - id: string, - durability: 'durable' | 'in-memory-only' = 'durable' - ): void { - this._pendingEmbedIds.delete(id) - if (durability === 'in-memory-only') this._pendingEmbedUndurableClears.add(id) - else this._pendingEmbedUndurableClears.delete(id) - if (this._pendingEmbedIds.size === 0) this.maybeWriteEmbedLowWater() - this.noteEmbedCheckpointCadence() - } - - /** - * @description Advance the advisory low-water mark: called at drain-to-empty - * (and at clean close when empty), it records the fact log's CURRENT head — - * with the set empty, every marker at or below the head has been consumed, - * so the next open's recovery fold scans only what comes after. Fire-and- - * forget at the drain (close() awaits the core); loud on failure: a missed - * write costs the next open a longer scan, never a marker. No-op without a - * fact log (no durable markers exist there) and on read-only opens. - */ - private maybeWriteEmbedLowWater(): void { - void this.writeEmbedLowWater() - } - - /** The awaitable core of {@link maybeWriteEmbedLowWater} — close() awaits it. */ - private async writeEmbedLowWater(): Promise { - if (this.isReadOnly) return - const log = this.generationStore ? this.generationStore.getFactLog() : null - if (!log) return - const generation = log.headGeneration() - if (!(generation > 0)) return - try { - await this.storage.writeRawObject(Brainy.PENDING_EMBED_LOWWATER_PATH, { - generation, - writtenAt: Date.now() - }) - } catch (err) { - prodLog.warn( - `[Brainy] pending-embed low-water write failed at generation ${generation}: ` + - `${(err as Error).message} — the next open scans from the previous mark` - ) - } - } - - /** - * @description Capture a pending-embed checkpoint, or refuse. - * - * THE DURABILITY LAW, satisfied by construction. The checkpoint asserts "as - * of generation G the log's pending set was exactly this list", and the next - * open TRUSTS it: it seeds the set and never reads a fact at or below G - * again. So a checkpoint may only be taken at a G whose facts are DURABLE. - * A checkpoint taken at head H while the facts up to H are still buffered - * would be read back after a crash that truncated the tail — and an - * `embed.landed` in a truncated fact would be gone from the log while the - * checkpoint still recorded its id as landed. The row's landing vector went - * with the truncated fact, so nothing would ever re-arm it: A LOST VECTOR. - * - * The gate is therefore `0 < head ≤ committed`. `committed` is the - * generation manifest's watermark — the point the store's own recovery - * treats as truth, and the point below which `FactLog.open()` never - * truncates — and the group-commit flush fsyncs the log BEFORE advancing it - * (see `GenerationStore.flushPendingSingleOps`). So every fact at or below - * `head` is fsynced and survives the crash exactly as the checkpoint - * describes it. Anything else (a head above the manifest, no log, no - * generation yet, a read-only or closed brain) REFUSES: skipping a - * checkpoint costs a longer scan next open, never a marker. - * - * The snapshot is taken SYNCHRONOUSLY with reading the two generations — no - * `await` between them — so no commit and no worker step can slip between - * "the generation I am about to claim" and "the set I claim for it". - * - * The one asymmetry, deliberately in the safe direction: an id whose - * `embed.pending` record has not been appended yet (enqueued in memory, its - * commit still in flight) is captured as pending at G although its marker - * will land at G+1 or later. Over-stating pending costs one idempotent - * re-embed attempt; under-stating it is the shape that loses a vector, and - * cannot happen — every clear either rides a durable record at or below the - * head, or is carried in {@link _pendingEmbedUndurableClears}. - * - * @returns The checkpoint payload, or `null` when this instant cannot host - * one. - */ - private captureEmbedCheckpoint(): { generation: number; pending: string[] } | null { - if (this.isReadOnly || this.closed) return null - const store = this.generationStore - if (!store) return null - const log = store.getFactLog() - if (!log) return null - // --- ONE SYNCHRONOUS INSTANT: no await until the return. --- - const generation = log.headGeneration() - const committed = store.committedGeneration() - if (!(generation > 0) || generation > committed) return null - const pending = new Set(this._pendingEmbedIds) - for (const id of this._pendingEmbedUndurableClears) pending.add(id) - // --- end of the synchronous instant. --- - return { generation, pending: [...pending] } - } - - /** - * @description Fire-and-forget checkpoint write, single-flight: a burst of - * transitions never stacks writes, and because each attempt captures - * immediately before it writes, the file always ends up holding the most - * recently captured (generation, set) PAIR — and every such pair is - * independently true, so even an out-of-order landing is safe. - * {@link closeDurableSteps} awaits the flight before taking the final one. - */ - private maybeWriteEmbedCheckpoint(): void { - if (this._pendingEmbedCheckpointFlight) return - this._pendingEmbedCheckpointFlight = this.writeEmbedCheckpoint() - .then((wrote) => { - if (wrote) { - this._pendingEmbedCheckpointDue = false - this._pendingEmbedCheckpointTransitions = 0 - } - }) - .finally(() => { - this._pendingEmbedCheckpointFlight = null - }) - } - - /** - * The awaitable core of {@link maybeWriteEmbedCheckpoint}. - * @returns `true` when a checkpoint was actually written. - */ - private async writeEmbedCheckpoint(): Promise { - const snapshot = this.captureEmbedCheckpoint() - if (!snapshot) return false - try { - // Atomic on disk: the filesystem adapter's writeRawObject is tmp+rename - // (see BaseStorage.writeRawObject), so a crash mid-write leaves either - // the previous checkpoint or the new one — never a spliced file. And a - // file that IS unreadable (a torn gzip, invalid JSON) throws typed on - // read and degrades to the fallback bound; it can never parse into a - // partial `pending` list. - // - // The file is NOT separately fsynced, and does not need to be: losing - // the rename to a power cut leaves the PREVIOUS checkpoint (or none), - // which only lengthens the next scan. The invariant that matters is the - // other direction — a checkpoint that IS visible names a generation - // whose facts are durable — and that is established by the capture gate - // above, not by this write. - await this.storage.writeRawObject(Brainy.PENDING_EMBED_CHECKPOINT_PATH, { - generation: snapshot.generation, - pending: snapshot.pending, - writtenAt: Date.now() - }) - return true - } catch (err) { - prodLog.warn( - `[Brainy] pending-embed checkpoint write failed at generation ` + - `${snapshot.generation}: ${(err as Error).message} — the next open scans ` + - `from the previous checkpoint` - ) - return false - } - } - - /** - * @description The checkpoint cadence tick: count one pending-set transition - * and OWE a checkpoint every {@link PENDING_EMBED_CHECKPOINT_EVERY} - * transitions, plus on every drain to empty. The debt stays armed across - * attempts the durability law refuses — during a write burst the log head - * legitimately runs ahead of the manifest, so the first attempt often cannot - * be taken — and the next transition retries it. An active brain therefore - * checkpoints steadily without ever forcing a flush; an idle one relies on - * its clean close. No timer is involved, so nothing survives close(). - */ - private noteEmbedCheckpointCadence(): void { - if (this.isReadOnly || this.closed) return - this._pendingEmbedCheckpointTransitions++ - const listed = this._pendingEmbedIds.size + this._pendingEmbedUndurableClears.size - const every = Math.max( - Brainy.PENDING_EMBED_CHECKPOINT_EVERY, - Math.ceil(listed / Brainy.PENDING_EMBED_CHECKPOINT_EVERY) - ) - if ( - this._pendingEmbedIds.size === 0 || - this._pendingEmbedCheckpointTransitions >= every - ) { - this._pendingEmbedCheckpointDue = true - } - if (this._pendingEmbedCheckpointDue) this.maybeWriteEmbedCheckpoint() - } - - /** - * @description Resolve the pending-embed fold's BOUND: the checkpoint first - * (a set plus a generation), then the legacy low-water mark (a generation - * only), then genesis. Every degradation is loud and lengthens the scan - * rather than shortening it — a bound that could skip a marker is never - * derived from a value this method could not fully validate. - * @returns The bound's name, the first generation to scan, and the ids to - * seed the pending set with. - */ - private async readPendingEmbedBound(): Promise<{ - bound: 'checkpoint' | 'low-water' | 'genesis' - fromGeneration: number - seeded: string[] - }> { - let checkpointRejected: string | null = null - try { - const raw = await this.storage.readRawObject(Brainy.PENDING_EMBED_CHECKPOINT_PATH) - if (raw !== null && raw !== undefined) { - const parsed = Brainy.parsePendingEmbedCheckpoint(raw) - if (parsed) { - return { - bound: 'checkpoint', - fromGeneration: parsed.generation + 1, - seeded: parsed.pending - } - } - checkpointRejected = 'its shape is not { generation: number > 0, pending: string[] }' - } - } catch (err) { - // A real storage fault (EIO/EACCES/…). Corruption never lands here: the - // adapter maps a torn raw object to `null` AFTER logging it as a - // production error, so a torn checkpoint arrives as "absent" — loud at - // the adapter, and bounded here by the fallback below. - checkpointRejected = `reading it failed: ${(err as Error).message}` - } - if (checkpointRejected !== null) { - prodLog.warn( - `[Brainy] pending-embed checkpoint REFUSED (${checkpointRejected}) — falling back ` + - `to the low-water mark, else a full fold from generation 1` - ) - } - - try { - const mark = (await this.storage.readRawObject(Brainy.PENDING_EMBED_LOWWATER_PATH)) as { - generation?: number - } | null - if (mark && typeof mark.generation === 'number' && mark.generation > 0) { - return { bound: 'low-water', fromGeneration: mark.generation + 1, seeded: [] } - } - } catch { - // No mark (or unreadable): scan from 1 — correctness over cost. - } - return { bound: 'genesis', fromGeneration: 1, seeded: [] } - } - - /** - * @description Validate a raw checkpoint object STRICTLY. Anything that is - * not exactly `{ generation: integer > 0, pending: string[] }` is refused - * whole — a partially-usable checkpoint is the one shape that could seed a - * short pending set behind a high bound, which is how a vector is lost. - * @param raw - The object read back from storage. - * @returns The validated checkpoint, or `null`. - */ - private static parsePendingEmbedCheckpoint( - raw: unknown - ): { generation: number; pending: string[] } | null { - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null - const { generation, pending } = raw as { generation?: unknown; pending?: unknown } - if (typeof generation !== 'number' || !Number.isSafeInteger(generation) || generation <= 0) { - return null - } - if (!Array.isArray(pending) || pending.some((id) => typeof id !== 'string' || id === '')) { - return null - } - return { generation, pending: pending as string[] } - } - - /** - * @description Rebuild the pending-embed set by REPLAYING the generation - * log's marker records (recovery = replay, not listing): `embed.pending` - * arms an id, `embed.landed` disarms it, and a noun tombstone disarms it - * too (a row deleted before its embed landed owes no vector). What - * survives the fold is exactly the set of acknowledged deferred writes - * whose vectors have not landed. - * - * BOUND: the scan starts after the pending-embed CHECKPOINT - * ({@link Brainy.PENDING_EMBED_CHECKPOINT_PATH}) — "as of durable generation - * G the pending set was exactly this list" — so the fold seeds the set from - * that list and reads only the facts after G. O(delta) whether or not the - * set ever drains, which is the whole point: the previous bound, the - * empty-only low-water mark, could not be written at all by a brain holding - * one id that never lands, so those brains re-read their whole log at every - * open. The mark remains the FALLBACK bound (checkpoint absent, torn, or - * malformed), and generation 1 the fallback below that — a brain opened for - * the first time after this change has neither a checkpoint nor, if it never - * drained, a mark, so it pays one full fold and writes a checkpoint on the - * way out. A stale bound costs a longer scan, never a marker. The fold stays - * on the open's foreground — the crash-recovery contract pins that a - * reopened brain has its markers re-armed when open() returns — and the - * bound is what makes that cheap. What it did (bound, start, facts read) is - * narrated and kept in {@link _pendingEmbedFoldReport}. - * It is SKIPPED WHOLESALE when the log has never had a v2 tail - * ({@link FactLog.hasV2History} — v1 facts cannot carry marker records), - * so pre-cutover brains pay nothing; on a mixed log the scan still reads - * the v1 segments (a segment's format is only known from its bytes) but - * they fold to nothing, so the DECODE cost is bounded by v2 history. - * Storage without a fact log hosts no durable markers at all — the - * pending set is session-local there, matching that storage's overall - * durability posture. - */ - private async recoverPendingEmbedsFromLog(): Promise { - const log = this.generationStore.getFactLog() - if (!log || !log.hasV2History()) return - const { bound, fromGeneration, seeded } = await this.readPendingEmbedBound() - for (const id of seeded) this._pendingEmbedIds.add(id) - let factsScanned = 0 - const scan = log.scanFacts({ fromGeneration }) - for await (const batch of scan.batches()) { - for (const fact of batch.facts) { - factsScanned++ - for (const record of fact.records ?? []) { - if (record.type === 'embed.pending') { - this._pendingEmbedIds.add(record.id) - } else if (record.type === 'embed.landed') { - this._pendingEmbedIds.delete(record.id) - } - } - for (const op of fact.ops) { - if (op.kind === 'noun' && op.record === null) { - this._pendingEmbedIds.delete(op.id) - } - } - } - } - this._pendingEmbedFoldReport = { - bound, - fromGeneration, - factsScanned, - seeded: seeded.length, - pending: this._pendingEmbedIds.size - } - // The narration channel: an operator is entitled to hear which bound - // applied and what it cost, on every open — that is how a bound that - // silently stopped engaging (the defect this replaced) becomes visible. - prodLog.narrate( - `[Brainy] pending-embed fold: ${bound} bound → scanned ${factsScanned} fact(s) ` + - `from generation ${fromGeneration}, seeded ${seeded.length} id(s), ` + - `${this._pendingEmbedIds.size} pending` - ) - } - - /** - * @description ONE-TIME LEGACY BRIDGE: a brain that deferred embeds under - * a pre-log build persisted one sidecar marker file per pending embed - * under {@link PENDING_EMBED_PREFIX}. At open, fold those ids into the - * pending set AND migrate them: commit ONE fact carrying their - * `embed.pending` records (the log is the markers' durable home now), - * then delete the sidecar files — in that order, so a crash between the - * two re-runs the bridge instead of losing a marker (a re-migrated - * duplicate folds idempotently; at worst an already-landed embed re-runs - * once — idempotent, never lost). Narrated loudly. Storage without a - * fact log keeps its sidecars in place (there is no log to migrate into) - * and folds them into memory only, exactly as loud. - */ - private async bridgeLegacyPendingEmbedSidecars(): Promise { - const markerPaths = await this.storage.listRawObjects(Brainy.PENDING_EMBED_PREFIX) - if (markerPaths.length === 0) return - const ids: string[] = [] - for (const path of markerPaths) { - const id = path.slice(path.lastIndexOf('/') + 1) - if (id) ids.push(id) - } - if (ids.length === 0) return - for (const id of ids) this._pendingEmbedIds.add(id) - if (!this.generationStore.getFactLog()) { - prodLog.warn( - `[Brainy] ${ids.length} legacy pending-embed sidecar marker(s) found, but this ` + - `storage hosts no fact log to migrate them into — folded into memory; the ` + - `sidecar files remain the durable recovery source on this configuration` - ) - return - } - const enqueuedAt = Date.now() - const markers: FactMarkerRecord[] = ids.map((id) => ({ - type: 'embed.pending', - id, - enqueuedAt - })) - // One migration commit: a zero-op fact carrying every legacy marker - // (empty-ops facts are legal; the records leg makes this one visible). - await this.generationStore.commitSingleOp({ - touched: {}, - records: markers, - execute: async () => {} - }) - for (const id of ids) { - await this.storage.deleteRawObject(`${Brainy.PENDING_EMBED_PREFIX}${id}`).catch(() => {}) - } - prodLog.info( - `[Brainy] migrated ${ids.length} legacy pending-embed sidecar marker(s) into the ` + - `generation log and removed the sidecar files (one-time bridge)` - ) - } - - /** - * @description Start (or skip into) the ONE deferred-embedding worker. - * Never awaited by write paths; failures are LOUD and markers survive for - * the next kick (next deferred write, or the next open's recovery). - */ - private kickEmbedWorker(): void { - if (this._embedWorkerFlight || this._pendingEmbedIds.size === 0 || this.isReadOnly) return - this._embedWorkerFlight = this.runEmbedWorker() - .catch((err) => { - prodLog.error( - `[Brainy] deferred-embed worker failed: ${(err as Error).message} — ` + - `markers retained; retries at the next deferred write or open` - ) - }) - .finally(() => { - this._embedWorkerFlight = null - if (this._pendingEmbedIds.size > 0) { - // New arrivals during the run: schedule (never recurse) the next pass. - const t = setTimeout(() => this.kickEmbedWorker(), 0) - ;(t as { unref?: () => void }).unref?.() - } - }) - } - - /** - * @description Drain the pending-embed set: embed each row's CURRENT data - * (a row updated again before its turn embeds the latest content — the - * marker set is idempotent per id) and swap the vector in ATOMICALLY - * (ReplaceInVectorIndex → the in-place update; the row is never absent - * from search). Orphans (row deleted, or no data) reap their markers. - */ - private async runEmbedWorker(): Promise { - const batch = Array.from(this._pendingEmbedIds) - for (const id of batch) { - try { - const entity = await this.get(id, { includeVectors: true }) - if (!entity) { - // The row is GONE. Either it was deleted — its tombstone fact - // durably disarms the marker, at or below the head, exactly as the - // fold reads it — or its create never became durable, in which case - // the log carries no `embed.pending` for it either. Both are durable - // clears: a full fold from generation 1 reaches the same answer. - this.clearPendingEmbed(id, 'durable') - continue - } - if (entity.data === undefined || entity.data === null) { - // Orphan reap, IN MEMORY ONLY: a data-less-but-present row (edge - // case) has nothing to embed, but no record in the log says so, so - // the fold would re-arm it. Cleared here and carried in the - // checkpoint (see clearPendingEmbed) — it re-folds and re-reaps at - // the next open exactly as before: bounded, never a lost vector, - // and never a checkpoint that disagrees with the log. - this.clearPendingEmbed(id, 'in-memory-only') - continue - } - // Hang guard: a wedged embedder must not block every later pending - // embed forever — time out LOUDLY, keep the marker, move on. (A - // failure is retryable; an unbounded silent wait is the outlawed - // shape.) - const newVector = await Promise.race([ - this.embed(entity.data), - new Promise((_, reject) => { - const t = setTimeout( - () => reject(new Error('deferred embed timed out after 60s')), - 60_000 - ) - ;(t as { unref?: () => void }).unref?.() - }) - ]) - if (!this.dimensions) { - this.dimensions = newVector.length - } else if (newVector.length !== this.dimensions) { - throw new Error( - `deferred embed produced ${newVector.length} dimensions, store expects ${this.dimensions}` - ) - } - const oldVector = (entity.vector as number[] | undefined) ?? [] - // The landing commit's fact carries the embed.landed record (vector - // inline, per the v2 format) alongside the row's after-image — the - // durable "this pending is consumed" that recovery's fold reads. - await this.persistSingleOp( - { nouns: [id] }, - async (tx) => { - tx.addOperation( - new SaveNounOperation(this.storage, { - id, - vector: newVector, - connections: new Map(), - level: 0 - }) - ) - tx.addOperation( - new ReplaceInVectorIndexOperation(this.index, id, oldVector, newVector, this.indexWriteGeneration) - ) - }, - undefined, - undefined, - [{ type: 'embed.landed', id, vector: newVector }], - 'system:embed-landing' - ) - // Vectored-noun ledger: the landing commit above carries a vector - // write with NO accompanying metadata operation, so the - // saveNounMetadata(..., hasVector) seam never fires for it — the - // narrow storage hook is the only seam left. `oldVector.length===0` - // (already known for free from the pre-embed read above) proves this - // is a GENUINE first landing, not a re-embed of an already-vectored - // row (e.g. a deferred update() on a row that already had a real - // vector) — the latter must never double-count. - if (oldVector.length === 0) { - await this.storage.noteVectorLanded?.(id) - } - this.clearPendingEmbed(id) - } catch (err) { - prodLog.warn( - `[Brainy] deferred embed for ${id} failed: ${(err as Error).message} — marker retained for retry` - ) - } - } - } - - /** - * @description The deferred-embedding BARRIER: resolves when every pending - * embed has landed (vector searchable) or been reaped. The eventual- - * vector-index contract's awaitable edge — tests and "must be searchable - * before I proceed" callers use this; nothing else ever needs to wait. - */ - public async awaitPendingEmbeds(): Promise { - while (this._pendingEmbedIds.size > 0 || this._embedWorkerFlight) { - this.kickEmbedWorker() - await (this._embedWorkerFlight ?? Promise.resolve()) - } - } - - /** The deferred-embedding backlog size (also on getIndexStatus().pendingEmbeds). */ - public pendingEmbedCount(): number { - return this._pendingEmbedIds.size - } - - /** - * THE READ BARRIER: wait until a projection — or every projection — has - * caught up to the CURRENT committed head, so a write-then-recall caller - * has ONE honest await instead of a sleep-and-hope. - * - * Legs: - * - `'semantic'` — waits for the deferred-embedding backlog to drain - * (delegates to {@link awaitPendingEmbeds}, which keeps working - * unchanged as this leg's engine). After it resolves, every previously - * acknowledged write is vector-searchable. - * - `'metadata'` / `'graph'` / `'aggregation'` — resolve IMMEDIATELY by - * design today: these projections are updated inside the write path, so - * by the time a write's promise resolves they already reflect it. Their - * asynchrony arrives with the log-authority read path; the door's shape - * freezes now so callers written against it keep working unchanged when - * those legs become real waits. - * - no argument — every projection at the head; today that reduces to the - * semantic drain (the only asynchronous projection in the current - * architecture). - * - * `opts.generation`: resolve as soon as the projection's watermark has - * reached that committed generation. The pending-embed set carries no - * generation stamps today, so the refinement is conservative — an empty - * backlog resolves immediately (the watermark is at the head, hence ≥ any - * committed generation); a non-empty backlog waits for the full drain, a - * SUPERSET of the requested wait, never a partial one. - * - * `opts.timeoutMs`: on expiry the promise REJECTS with - * {@link WaitForIndexedTimeoutError} — typed, carrying the leg and the - * still-pending embed count, and naming the gauge to check - * (`getIndexStatus().projections.semantic.pendingEmbeds`). Never a silent - * partial wait: a timeout means the projection has NOT caught up. - * - * @example Write, then semantically recall — no polling, no sleeps - * ```typescript - * const id = await brain.add({ - * data: 'quarterly revenue narrative', - * type: NounType.Document, - * deferEmbedding: true, - * metadata: { kind: 'report' } - * }) - * await brain.waitForIndexed('semantic') // the barrier: vector landed + indexed - * const hits = await brain.find({ query: 'revenue report', searchMode: 'semantic' }) - * // `id` is eligible to appear in `hits` — the recall is honest, not lucky. - * ``` - * - * @param path - The projection to wait on; omit to wait on all of them. - * @param opts - Optional `generation` watermark target and `timeoutMs` bound. - * @throws {WaitForIndexedTimeoutError} When `timeoutMs` expires before the - * projection catches up. - */ - public async waitForIndexed( - path?: IndexedProjectionPath, - opts?: WaitForIndexedOptions - ): Promise { - await this.ensureInitialized() - - // Synchronous projections: updated inside the write path today, so an - // acknowledged write is already reflected — resolve immediately BY - // DESIGN (honest, not a stub). When the log-authority read path makes - // these legs asynchronous, only this body changes; the door's shape is - // frozen now. - if (path === 'metadata' || path === 'graph' || path === 'aggregation') { - return - } - - // 'semantic' — or no-arg, which today reduces to it: the deferred-embed - // backlog is the only asynchronous projection in the current - // architecture. - - // Generation refinement (conservative — see JSDoc): an empty backlog - // means the semantic watermark is at the head, hence ≥ any committed G. - if (opts?.generation !== undefined && this._pendingEmbedIds.size === 0) { - return - } - - const timeoutMs = opts?.timeoutMs - const drained = this.awaitPendingEmbeds() - if (timeoutMs === undefined) { - return drained - } - - // Typed timeout: reject LOUDLY with the leg + the live backlog gauge. - // (`drained` never rejects — the worker catches its own failures — so - // abandoning it on timeout cannot leak an unhandled rejection; the - // backlog keeps draining in the background.) - let timer: ReturnType | undefined - try { - await Promise.race([ - drained, - new Promise((_, reject) => { - timer = setTimeout( - () => - reject( - new WaitForIndexedTimeoutError( - path ?? 'all', - timeoutMs, - this._pendingEmbedIds.size - ) - ), - timeoutMs - ) - ;(timer as { unref?: () => void }).unref?.() - }) - ]) - } finally { - if (timer !== undefined) clearTimeout(timer) - } - } - - /** - * @description The write-side persistence trigger (policy `'auto'`): count - * the committed write, kick a single-flight BACKGROUND flush when the - * write-count or interval threshold is crossed, and (re)arm the idle - * timer. Never awaited by the write path — the ack is already durable at - * the canonical layer; this schedules DERIVED-state persistence on the - * 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++ - const every = cfg?.flushEveryWrites ?? 512 - const intervalMs = cfg?.flushIntervalMs ?? 30_000 - const idleMs = cfg?.flushOnIdleMs ?? 2_000 - - if ( - this._persistDirtyWrites >= every || - Date.now() - this._persistLastFlushAt >= intervalMs - ) { - this.kickBackgroundFlush('threshold') - } - - if (this._persistIdleTimer) clearTimeout(this._persistIdleTimer) - this.armIdleFlushTimer(idleMs, intervalMs) - } - - /** - * @description Arm the idle-flush timer — DEBOUNCED UNDER LOAD. The idle - * trigger exists to make a QUIET system durable fast; it must never add - * flush pressure to a BUSY one. When individual writes are slower than - * the idle window (a contended disk), every inter-write gap looks like - * "idle" and would fire a full flush per write — a measured 15-flush - * amplifier during 100 contended adds on a production-shaped box. The - * law: an idle fire landing within `intervalMs` of the last flush DEFERS - * (re-arms for the remaining interval) rather than flushing — deferred, - * never dropped, so a lone write on a then-quiet system still persists at - * the interval boundary without any further write arriving; a genuinely - * quiet system (last flush long past) flushes on idle exactly as before. - */ - private armIdleFlushTimer(idleMs: number, intervalMs: number, delayMs = idleMs): void { - // The idle-fire spacing floor: 10× the CONFIGURED idle window, capped by - // the interval — always derived from idleMs, never from a deferred - // re-arm delay (recomputing from the delay compounds into runaway - // deferral). Scales with intent — a caller configuring a tiny idle - // window gets fast idle-driven durability (small floor); default config - // (2s idle / 30s interval) gets a 20s floor, capping the contended-disk - // shape at ~1 idle flush per 20s instead of one per inter-write gap. - const floorMs = Math.min(intervalMs, idleMs * 10) - const timer = setTimeout(() => { - this._persistIdleTimer = null - if (this._persistDirtyWrites === 0) return - const sinceFlush = Date.now() - this._persistLastFlushAt - if (sinceFlush >= floorMs) { - this.kickBackgroundFlush('idle') - } else { - // Deferred, never dropped: land exactly at the floor boundary. - this.armIdleFlushTimer(idleMs, intervalMs, Math.max(idleMs, floorMs - sinceFlush)) - } - }, delayMs) - // Never hold the process open for a cadence timer. - ;(timer as { unref?: () => void }).unref?.() - this._persistIdleTimer = timer - } - - /** - * @description Start (or join) the ONE background flush. The dirty counter - * resets at kick time so writes landing during the flush re-accumulate - * toward the next trigger. A failure is LOUD and leaves the writes counted - * again — silence is not an option, and neither is a retry storm (the next - * trigger re-attempts). - * - * COALESCING LIVES IN {@link flush}, NOT HERE. A kick that arrives while a - * flush is running used to return without doing anything — the writes it - * counted waited for some LATER trigger, and this method's guard also could - * not coalesce the flushes it does not start (the cross-process - * flush-request watcher and application `flush()` calls both go straight to - * `flush()`; two of those overlapping is exactly what production showed). - * The gate in `flush()` covers every caller: this kick now either runs the - * flush or joins the single queued follow-up, so the writes it counted are - * always someone's work, and there is still never a second concurrent run. - */ - private kickBackgroundFlush(reason: 'threshold' | 'idle'): void { - const counted = this._persistDirtyWrites - this._persistDirtyWrites = 0 - this._persistLastFlushAt = Date.now() - this._persistBackgroundFlight = this.flush() - .catch((err) => { - this._persistDirtyWrites += counted // re-arm the trigger honestly - prodLog.error( - `[Brainy] background flush (${reason}) FAILED: ${(err as Error).message} — ` + - `derived-state persistence retries at the next trigger; canonical data is unaffected` - ) - }) - .finally(() => { - this._persistBackgroundFlight = null - }) - } - private async persistSingleOp( touched: { nouns?: string[]; verbs?: string[] }, run: TransactionFunction, precommit?: (before: CommitBeforeImages) => void, - pendingEvents?: PendingChangeEvent[], - records?: FactMarkerRecord[], - origin?: string + pendingEvents?: PendingChangeEvent[] ): Promise<{ generation?: number; timestamp: number; degraded?: string[] }> { // Change-feed capture: when this write will emit, hold a reference to the // commit's before-images so `remove` events can carry the record's last @@ -3519,15 +1805,6 @@ export class Brainy implements BrainyInterface { : precommit if (!this._generationStampingActive) { - // Marker records ride a commit FACT — a generation-less bootstrap - // write has none to ride. No bootstrap path defers embeds today; - // refuse loudly rather than silently dropping a durable marker. - if (records && records.length > 0) { - throw new Error( - 'persistSingleOp: marker records require a generation-stamped commit — ' + - 'a bootstrap (generation-0) write cannot carry them' - ) - } // Init-time / infrastructure baseline write (e.g. the VFS root): apply // WITHOUT creating a generation. Generation 0 is the freshly-materialized // brain (bootstrap included); the first USER write is generation 1. @@ -3566,8 +1843,6 @@ export class Brainy implements BrainyInterface { receipt = await this.generationStore.commitSingleOp({ touched, precommit: captureAndCheck, - ...(records && records.length > 0 ? { records } : {}), - ...(origin ? { origin } : {}), execute: () => this.transactionManager.executeTransaction(run, { timeout: transactTimeoutBudget( @@ -3603,7 +1878,6 @@ export class Brainy implements BrainyInterface { ) } } - this.noteWriteForPersistence() return receipt } @@ -3719,6 +1993,12 @@ export class Brainy implements BrainyInterface { // Zero-config validation (static import for performance) validateAddParams(params) + // Reserved fields arriving via the metadata bag (untyped callers — the + // compile-time guard stops TypeScript callers) are normalized to their + // canonical top-level location BEFORE any enforcement runs, so a + // remapped subtype participates in subtype-pairing enforcement and the + // indexed metadata bag carries only custom fields. + params = this.remapReservedAddMetadata(params) // Tracked-field vocabulary enforcement (Layer 2). Walks both bags so a // tracked field declared at top level (e.g. 'subtype') and one declared in @@ -3779,98 +2059,50 @@ export class Brainy implements BrainyInterface { } // Get or compute vector - // MT5 deferred embedding: ack at durability with a stub vector and a - // pending marker riding the insert's OWN commit fact (same generation, - // one atomic append — a marker-less committed row, the silently-missing- - // vector shape, is structurally impossible). The background worker - // embeds + inserts. - const deferringEmbed = params.deferEmbedding === true && !params.vector - let vector = deferringEmbed - ? [] - : params.vector || (await this.embed(params.data)) + const vector = 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.` + // Ensure dimensions are set + if (!this.dimensions) { + this.dimensions = vector.length + } else if (vector.length !== this.dimensions) { + throw new Error( + `Vector dimension mismatch: expected ${this.dimensions}, got ${vector.length}` ) - 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 - // vector is the "unvectored" empty-array shape carries no dimension - // information, deferred or not — an explicit `vector: []` (e.g. the VFS - // root's zero-norm fix, see VirtualFileSystem.doInitializeRoot()) must - // never pin `this.dimensions` to 0, which would poison every subsequent - // real embed's dimension check for the life of the store. - if (!deferringEmbed && vector.length > 0) { - if (!this.dimensions) { - this.dimensions = vector.length - } else if (vector.length !== this.dimensions) { - throw new Error( - `Vector dimension mismatch: expected ${this.dimensions}, got ${vector.length}` - ) + // Prepare metadata for storage + // data is stored opaquely in the 'data' field - NOT spread into top-level metadata. + // Only metadata fields are queryable via find({ where }). + const storageMetadata = { + ...params.metadata, + // Preserve the caller's original (non-UUID) id when normalized, so reads + // can surface it. A real UUID passes through with no _originalId. + ...(originalId !== undefined && { [ORIGINAL_ID_KEY]: originalId }), + data: params.data, + noun: params.type, + ...(params.subtype !== undefined && { subtype: params.subtype }), + // visibility: stored only when not 'public' (absent === public, keeps records lean) + ...(params.visibility !== undefined && + params.visibility !== 'public' && { visibility: params.visibility }), + service: params.service, + createdAt: Date.now(), + updatedAt: Date.now(), + _rev: 1, + ...(params.confidence !== undefined && { confidence: params.confidence }), + ...(params.weight !== undefined && { weight: params.weight }), + ...(params.createdBy && { createdBy: params.createdBy }) } - } - - // Prepare metadata for storage: a v2 nested-bag record — engine fields - // top-level, the user's bag nested VERBATIM (any name, including engine - // spellings like `confidence` or `type`, is the user's and survives - // faithfully; the field-addressing law). - const storageMetadata = buildNounMetadataRecord( - { - data: params.data, - noun: params.type, - ...(params.subtype !== undefined && { subtype: params.subtype }), - // visibility: stored only when not 'public' (absent === public, keeps records lean) - ...(params.visibility !== undefined && - params.visibility !== 'public' && { visibility: params.visibility }), - service: params.service, - createdAt: Date.now(), - updatedAt: Date.now(), - _rev: 1, - ...(params.confidence !== undefined && { confidence: params.confidence }), - ...(params.weight !== undefined && { weight: params.weight }), - ...(params.createdBy && { createdBy: params.createdBy }) - }, - { - ...params.metadata, - // Preserve the caller's original (non-UUID) id when normalized, so reads - // can surface it. A real UUID passes through with no _originalId. - ...(originalId !== undefined && { [ORIGINAL_ID_KEY]: originalId }) - } - ) // Build entity structure for indexing (NEW - with top-level fields) // Optional fields must use conditional spreading to match storageMetadata exactly. // If undefined values are included as explicit keys, extractIndexableFields indexes // them as '__NULL__' entries that removeFromIndex can never clean up (storageMetadata // omits those keys entirely via conditional spreading, so the fields don't match). - // No `level` here: engine plumbing never enters the indexing view — a - // hardcoded level:0 landed in the SAME flattened index column as user - // metadata named `level`, poisoning it multi-valued ([0, real]). const entityForIndexing = { id, vector, connections: new Map(), + level: 0, type: params.type, ...(params.subtype !== undefined && { subtype: params.subtype }), ...(params.visibility !== undefined && @@ -3908,22 +2140,11 @@ export class Brainy implements BrainyInterface { } : undefined - // MT5: the pending marker RIDES the insert's own commit fact (same - // generation, one atomic append) — threaded to persistSingleOp below. - // A failed commit appends nothing, so no orphaned durable marker can - // exist; the in-memory entry is harmless and reaped by the worker. - const embedMarkers: FactMarkerRecord[] | undefined = deferringEmbed - ? [this.enqueuePendingEmbed(id)] - : undefined - const runInsert: TransactionFunction = async (tx) => { // Operation 1: Save metadata FIRST (TypeAwareStorage caching) // isNew=true: skip pre-read for rollback (entity doesn't exist yet) - // hasVector: the vectored-noun ledger counts this insert iff its - // vector is real/non-empty (never true for a deferred embed, whose - // stub `vector` is `[]` — it counts later, at landing). tx.addOperation( - new SaveNounMetadataOperation(this.storage, id, storageMetadata, true, vector.length > 0) + new SaveNounMetadataOperation(this.storage, id, storageMetadata, true) ) // Operation 2: Save vector data @@ -3937,23 +2158,14 @@ export class Brainy implements BrainyInterface { }, true) ) - // Operation 3: Add to HNSW index (after entity saved). Gated on - // `vector.length > 0`, not `!deferringEmbed`: a deferred embed has - // nothing to index yet (the worker's atomic update inserts the real - // vector later), and an explicit `vector: []` insert (the VFS root's - // zero-norm fix — permanently unvectored plumbing, never embedded) - // is exactly the same "nothing to index yet" shape. The zero-norm - // BELT (a real all-zero vector, non-empty) is enforced inside - // AddToVectorIndexOperation itself — see its JSDoc. - if (vector.length > 0) { - tx.addOperation( - new AddToVectorIndexOperation(this.index, id, vector, this.indexWriteGeneration) - ) - } + // Operation 3: Add to HNSW index (after entity saved) + tx.addOperation( + new AddToVectorIndexOperation(this.index, id, vector) + ) // Operation 4: Add to metadata index tx.addOperation( - new AddToMetadataIndexOperation(this.metadataIndex, id, entityForIndexing, this.indexWriteGeneration) + new AddToMetadataIndexOperation(this.metadataIndex, id, entityForIndexing) ) } @@ -3983,7 +2195,7 @@ export class Brainy implements BrainyInterface { const MAX_UPSERT_ATTEMPTS = 10 for (let attempt = 0; ; attempt++) { try { - await this.persistSingleOp({ nouns: [id] }, runInsert, insertPrecommit, addEvents, embedMarkers) + await this.persistSingleOp({ nouns: [id] }, runInsert, insertPrecommit, addEvents) break } catch (err) { if (!(err instanceof InsertPreconditionExistsSignal)) { @@ -4017,7 +2229,6 @@ export class Brainy implements BrainyInterface { this._aggregationIndex.onEntityAdded(id, entityForIndexing) } - if (deferringEmbed) this.kickEmbedWorker() return id } @@ -4193,16 +2404,6 @@ export class Brainy implements BrainyInterface { } // Route to metadata-only or full entity based on options - // A PROJECTED get goes through the same seam every list page uses, so a - // detail read of two scalars costs an index read rather than a record read. - // It is checked before `includeVectors` because the two are incompatible by - // construction: a projection returns the named fields, and a vector is not - // one of them unless it was named. - if (options?.fields !== undefined && options.fields.length > 0) { - const page = await this.#hydratePage([id], options.fields) - return page.get(id) ?? null - } - const includeVectors = options?.includeVectors ?? false // Default: metadata-only (fast) if (includeVectors) { @@ -4249,170 +2450,6 @@ export class Brainy implements BrainyInterface { * const children = childIds.map(id => childrenMap.get(id)).filter(Boolean) * ``` */ - /** - * **The projection seam** — hydrate a page of ids under an optional `fields` - * projection, opening the canonical record only when the index cannot serve - * what was asked for. - * - * Without a projection this is exactly `batchGet`, byte for byte: the whole - * point is that `fields` absent changes nothing. - * - * With one, the order is: ask the index for the named scalars in a single - * batched door; see which requested fields it actually served; and read - * records ONLY if something is still missing — and only to fill those fields. - * A page whose every requested field is index-served performs zero canonical - * reads, which is the whole reason the door exists. - * - * `guardFields` are fetched ALONGSIDE the projection and trimmed off before - * the caller sees them. find()'s index-integrity guard re-validates every row - * against its own predicate, and it reads the entity to do so — so a row - * projected down to `title` would fail a `where: { kind }` it genuinely - * matches, and the whole page would vanish. The fields a filter names are - * fields the index can serve by definition, so carrying them costs nothing - * and keeps the guard honest. - * - * A field nothing can supply is simply absent from the row. That is the - * permissive law: a projection asks "these, if you have them", and an - * optional field must not turn a list into an exception. It deliberately does - * NOT route through the strict address resolver, which throws - * `UnresolvableFieldError` for an unknown key — that strictness is right for - * `orderBy`, where a typo silently changes the order, and wrong here, where - * the honest answer is "this row does not have that". - * - * @param ids - Canonical ids for the page. - * @param fields - The projection, or undefined for the full record. - * @returns `id → entity`, projected when `fields` was given. - */ - /** - * The index keys find()'s integrity guard reads when it re-validates a row. - * - * The guard calls `entityMatchesFind(entity, params)`, so a projected entity - * must still carry whatever the params constrain — otherwise a row that - * genuinely matches is dropped for lacking the evidence. These are fetched - * with the projection and trimmed off before the caller sees them. - * - * @param params - The find params. - * @returns Index keys to carry through hydration. - */ - #guardFieldsFor(params: FindParams): string[] { - const keys: string[] = [] - if (params.where && typeof params.where === 'object') { - // Top-level where keys only: nested `anyOf`/`allOf` branches are carried - // by their own keys when the guard walks them, and a filter whose - // evidence is missing keeps the row (the guard's own catch) rather than - // dropping it. - for (const key of Object.keys(params.where as Record)) { - if (key === 'anyOf' || key === 'allOf' || key === 'not') continue - keys.push(key) - } - } - if (params.type !== undefined) keys.push('system.type') - if (params.subtype !== undefined) keys.push('system.subtype') - if (params.service !== undefined) keys.push('system.service') - if (params.excludeVFS === true) keys.push('vfsType', 'isVFSEntity') - return keys - } - - async #hydratePage( - ids: string[], - fields?: readonly string[], - guardFields: readonly string[] = [] - ): Promise>> { - if (fields === undefined || fields.length === 0) return this.batchGet(ids) - - const wanted = [...new Set([...fields, ...guardFields])] - const provider = this.metadataIndex as unknown as MetadataIndexProvider - let served = new Map>() - if (typeof provider.getScalarsForIds === 'function') { - served = await provider.getScalarsForIds(ids, wanted) - } - - // Which ids still owe a field? Only those cost a record read, and a page - // that owes nothing costs none at all. - const owing: string[] = [] - for (const id of ids) { - const row = served.get(id) - if (row === undefined || wanted.some((f) => !(f in row))) owing.push(id) - } - - // The records are read for the OWED fields only; everything the index - // already served is used as-is, so a body field pulls its own record and - // no more than that. - const records = owing.length > 0 ? await this.batchGet(owing) : new Map>() - - const out = new Map>() - for (const id of ids) { - const fromIndex = served.get(id) - const record = records.get(id) - // An id neither the index nor storage knows is not a row. - if (fromIndex === undefined && record === undefined) continue - out.set(id, this.#projectEntity(id, wanted, fromIndex, record)) - } - return out - } - - /** - * Build one projected entity: `id`, plus exactly the requested fields that - * something could supply. - * - * Values come from the index first and the record second, and they must agree - * — the index only reports what it can serve exactly, so a field it served is - * the record's value. A field neither has is omitted rather than set to - * `undefined`: absent and present-and-undefined are different answers, and a - * caller checking `'slug' in row.metadata` deserves the true one. - * - * @param id - The entity id, always present on the result. - * @param fields - The requested index keys. - * @param fromIndex - What the index served for this id, if anything. - * @param record - The canonical entity, if one had to be read. - * @returns The projected entity. - */ - #projectEntity( - id: string, - fields: readonly string[], - fromIndex: Record | undefined, - record: Entity | undefined - ): Entity { - const projected: Record = { id } - const metadata: Record = {} - let sawMetadata = false - - for (const field of fields) { - let value: unknown - let found = false - if (fromIndex !== undefined && field in fromIndex) { - value = fromIndex[field] - found = true - } else if (record !== undefined) { - if (field.startsWith('system.')) { - const inner = field.slice('system.'.length) - const bag = record as unknown as Record - if (inner in bag && bag[inner] !== undefined) { - value = bag[inner] - found = true - } - } else { - const bag = (record.metadata ?? {}) as Record - if (field in bag) { - value = bag[field] - found = true - } - } - } - if (!found) continue - - if (field.startsWith('system.')) { - projected[field.slice('system.'.length)] = value - } else { - metadata[field] = value - sawMetadata = true - } - } - - if (sawMetadata) projected.metadata = metadata - return projected as unknown as Entity - } - async batchGet(ids: string[], options?: GetOptions): Promise>> { // Canonical read (see get): resolves by id from storage, no derived index. await this.ensureInitialized({ needs: [] }) @@ -4563,6 +2600,320 @@ export class Brainy implements BrainyInterface { return entity } + /** One-shot registry for reserved-field warnings (per process, per method+field). */ + private static warnedReservedFields = new Set() + + /** + * @description Resolve the human-readable "correct write path" guidance for a + * reserved field on a given write method. Single source of truth shared by the + * `'throw'` (Error message) and `'warn'` (one-shot warning) paths so the two + * never drift. The trio `confidence` / `weight` / `subtype` and the + * add()/relate()-time fields `service` / `createdBy` / `visibility` map to a + * dedicated param; everything else is system-managed. + * @param method - The public write method the bag arrived through. + * @param field - The reserved field name found in the metadata bag. + * @returns Guidance naming the correct way to set the field. + */ + private reservedWritePath( + method: 'add' | 'update' | 'relate' | 'updateRelation', + field: string + ): string { + const typeParam = "the top-level 'type' param" + switch (field) { + case 'noun': + case 'verb': + return typeParam + case 'data': + return "the top-level 'data' param" + case 'confidence': + return "the 'confidence' param" + case 'weight': + return "the 'weight' param" + case 'subtype': + return "the 'subtype' param" + case 'visibility': + return "the 'visibility' param ('public' | 'internal')" + case 'service': + return method === 'add' + ? "the 'service' param of add()" + : method === 'relate' + ? "the 'service' param of relate()" + : 'nothing — service is fixed at create time' + case 'createdBy': + return method === 'add' + ? "the 'createdBy' param of add()" + : 'nothing — createdBy is system-managed' + case 'createdAt': + return 'nothing — creation time is set automatically' + case 'updatedAt': + return 'nothing — set automatically on every write' + case '_rev': + return method === 'update' + ? "the 'ifRev' param for optimistic concurrency" + : 'nothing — revisions are system-managed' + default: + return 'a dedicated top-level param' + } + } + + /** + * @description Enforce {@link BrainyConfig.reservedFieldPolicy} for reserved + * fields found inside a metadata bag. Called by every write-path remap once + * the bag has been split and at least one reserved key is present. + * + * - `'throw'` (default): throw a clear Error naming every offending key and + * its correct write path. The caller never reaches the remap. + * - `'warn'`: emit a ONE-SHOT (per method+field, per process) warning for + * EVERY reserved key found — both the user-mutable fields that are about to + * be remapped and the system-managed fields that are about to be dropped — + * then fall through to the legacy remap. + * - `'remap'`: silent legacy remap, no warning. + * + * @param method - The public write method the bag arrived through. + * @param reserved - The reserved half of the split metadata bag (non-empty). + * @param reservedListName - `'RESERVED_ENTITY_FIELDS'` or + * `'RESERVED_RELATION_FIELDS'` — named in the thrown Error for discoverability. + * @returns `true` when the caller should proceed with the legacy remap + * (`'warn'` / `'remap'`); `'throw'` never returns (it throws first). + * @throws {Error} When the policy is `'throw'` and any reserved key is present. + */ + private enforceReservedPolicy( + method: 'add' | 'update' | 'relate' | 'updateRelation', + reserved: Partial>, + reservedListName: 'RESERVED_ENTITY_FIELDS' | 'RESERVED_RELATION_FIELDS' + ): boolean { + const policy = this.config.reservedFieldPolicy ?? 'throw' + const keys = Object.keys(reserved) + if (keys.length === 0) return true + + if (policy === 'throw') { + const detail = keys + .map((k) => { + const path = this.reservedWritePath(method, k) + // System-managed fields resolve to a "nothing — …" sentinel; phrase + // those as "is system-managed" rather than "pass it as the nothing". + return path.startsWith('nothing') + ? `metadata.${k} is a reserved field (${path.replace(/^nothing\s*—\s*/, '')}) and cannot be set through ${method}()` + : `metadata.${k} is a reserved field — pass it as ${path} to ${method}()` + }) + .join('; ') + throw new Error( + `${detail} (reserved: see ${reservedListName}). ` + + `Set reservedFieldPolicy:'remap' to opt into legacy remapping, ` + + `or reservedFieldPolicy:'warn' to remap with a warning.` + ) + } + + if (policy === 'warn') { + // One-shot warning for EVERY reserved key (today only system-managed ones + // warn — this closes that gap so user-mutable remaps are visible too). + for (const k of keys) { + this.warnReservedRemapped(method, k, this.reservedWritePath(method, k)) + } + } + + // 'warn' and 'remap' both fall through to the legacy remap. + return true + } + + /** + * @description One-shot (per method+field, per process) warning that a + * reserved field arrived inside a metadata bag under the `'warn'` policy. The + * wording is neutral on "remapped vs dropped" — `reservedWritePath()` already + * tells the caller where the value goes (a dedicated param, or "nothing"). + * @param method - The public write method the bag arrived through. + * @param field - The reserved field name found in the bag. + * @param rightPath - Guidance naming the correct write path. + */ + private warnReservedRemapped(method: string, field: string, rightPath: string): void { + const key = `${method}:${field}` + if (Brainy.warnedReservedFields.has(key)) return + Brainy.warnedReservedFields.add(key) + // System-managed fields resolve to a "nothing — …" sentinel; phrase the + // guidance so it reads cleanly in both the remapped and dropped cases. + const guidance = rightPath.startsWith('nothing') + ? `it is ${rightPath.replace(/^nothing\s*—\s*/, '')} and was dropped` + : `set it via ${rightPath} instead` + prodLog.warn( + `[brainy] ${method}(): '${field}' is a reserved field and was found inside the ` + + `metadata bag — ${guidance}. (Legacy remap applied because ` + + `reservedFieldPolicy is 'warn'. This warning is shown once per field per process.)` + ) + } + + /** + * @description Normalize an `add()` params object with respect to + * Brainy-reserved fields arriving inside `metadata` (untyped callers only — + * the compile-time guard on `AddParams.metadata` stops TypeScript callers). + * Governed by {@link BrainyConfig.reservedFieldPolicy} (default `'throw'`): + * `'throw'` rejects the write naming the offending key(s); `'warn'`/`'remap'` + * fall through to the legacy remap, where fields with a dedicated `add()` + * param (`confidence`, `weight`, `subtype`, `visibility`, `service`, + * `createdBy`) are remapped to that param unless the caller also passed it + * explicitly (top-level wins) and system-managed fields (`noun`, `data`, + * `createdAt`, `updatedAt`, `_rev`) are dropped. A remapped `subtype` flows + * through subtype-pairing enforcement exactly like a top-level one. + * @param params - The caller's add params (not mutated). + * @returns Params with reserved fields normalized out of `metadata`. + * @throws {Error} When `reservedFieldPolicy` is `'throw'` and the bag carries a reserved key. + */ + private remapReservedAddMetadata(params: AddParams): AddParams { + const bag = params.metadata as Record | undefined + if (!bag || typeof bag !== 'object') return params + const { reserved, custom } = splitNounMetadataRecord(bag) + if (Object.keys(reserved).length === 0) return params + + // Policy gate: 'throw' (default) throws here; 'warn' warns once per key then + // remaps; 'remap' silently remaps. (Throw never returns.) + this.enforceReservedPolicy('add', reserved, 'RESERVED_ENTITY_FIELDS') + + const createdBy = reserved.createdBy as { augmentation?: unknown; version?: unknown } | undefined + const createdByValid = + typeof createdBy === 'object' && + createdBy !== null && + typeof createdBy.augmentation === 'string' && + typeof createdBy.version === 'string' + + return { + ...params, + metadata: custom as AddParams['metadata'], + ...(params.confidence === undefined && + typeof reserved.confidence === 'number' && { confidence: reserved.confidence }), + ...(params.weight === undefined && + typeof reserved.weight === 'number' && { weight: reserved.weight }), + ...(params.subtype === undefined && + typeof reserved.subtype === 'string' && { subtype: reserved.subtype }), + ...(params.visibility === undefined && + (reserved.visibility === 'public' || reserved.visibility === 'internal') && { + visibility: reserved.visibility as 'public' | 'internal' + }), + ...(params.service === undefined && + typeof reserved.service === 'string' && { service: reserved.service }), + ...(params.createdBy === undefined && + createdByValid && { createdBy: createdBy as { augmentation: string; version: string } }) + } + } + + /** + * @description Normalize an `update()` params object with respect to + * Brainy-reserved fields arriving inside the metadata patch — the `update()` + * mirror of {@link remapReservedAddMetadata}, closing the historical trap + * where `add({metadata:{confidence}})` lifted the field but + * `update({metadata:{confidence}})` silently dropped it (the patch value + * survived the merge and was then clobbered by the preserve-existing + * spread; a production consumer's confidence-evolution writes no-oped until + * read back). Governed by {@link BrainyConfig.reservedFieldPolicy} (default + * `'throw'`): `'throw'` rejects the write; `'warn'`/`'remap'` remap + * user-mutable fields (`confidence`, `weight`, `subtype`) to their dedicated + * param unless the caller also passed it (top-level wins) and drop everything + * else (`noun`, `data`, `createdAt`, `updatedAt`, `service`, `createdBy`, + * `_rev`) as system-managed or fixed at `add()` time. + * @param params - The caller's update params (not mutated). + * @returns Params with reserved fields normalized out of `metadata`. + * @throws {Error} When `reservedFieldPolicy` is `'throw'` and the bag carries a reserved key. + */ + private remapReservedUpdateMetadata(params: UpdateParams): UpdateParams { + const bag = params.metadata as Record | undefined + if (!bag || typeof bag !== 'object') return params + const { reserved, custom } = splitNounMetadataRecord(bag) + if (Object.keys(reserved).length === 0) return params + + // Policy gate: 'throw' (default) throws; 'warn' warns once per key then + // remaps; 'remap' silently remaps. + this.enforceReservedPolicy('update', reserved, 'RESERVED_ENTITY_FIELDS') + + return { + ...params, + metadata: custom as UpdateParams['metadata'], + ...(params.confidence === undefined && + typeof reserved.confidence === 'number' && { confidence: reserved.confidence }), + ...(params.weight === undefined && + typeof reserved.weight === 'number' && { weight: reserved.weight }), + ...(params.subtype === undefined && + typeof reserved.subtype === 'string' && { subtype: reserved.subtype }) + } + } + + /** + * @description Normalize a `relate()` params object with respect to + * Brainy-reserved fields arriving inside `metadata` — the relationship + * mirror of {@link remapReservedAddMetadata}. Governed by + * {@link BrainyConfig.reservedFieldPolicy} (default `'throw'`): `'throw'` + * rejects the write; `'warn'`/`'remap'` remap fields with a dedicated + * `relate()` param (`confidence`, `weight`, `subtype`, `visibility`, + * `service`) to that param (top-level wins) and drop system-managed fields + * (`verb`, `data`, `createdAt`, `updatedAt`, `createdBy`, `_rev`). + * @param params - The caller's relate params (not mutated). + * @returns Params with reserved fields normalized out of `metadata`. + * @throws {Error} When `reservedFieldPolicy` is `'throw'` and the bag carries a reserved key. + */ + private remapReservedRelateMetadata(params: RelateParams): RelateParams { + const bag = params.metadata as Record | undefined + if (!bag || typeof bag !== 'object') return params + const { reserved, custom } = splitVerbMetadataRecord(bag) + if (Object.keys(reserved).length === 0) return params + + // Policy gate: 'throw' (default) throws; 'warn' warns once per key then + // remaps; 'remap' silently remaps. + this.enforceReservedPolicy('relate', reserved, 'RESERVED_RELATION_FIELDS') + + return { + ...params, + metadata: custom as RelateParams['metadata'], + ...(params.confidence === undefined && + typeof reserved.confidence === 'number' && { confidence: reserved.confidence }), + ...(params.weight === undefined && + typeof reserved.weight === 'number' && { weight: reserved.weight }), + ...(params.subtype === undefined && + typeof reserved.subtype === 'string' && { subtype: reserved.subtype }), + ...(params.visibility === undefined && + (reserved.visibility === 'public' || reserved.visibility === 'internal') && { + visibility: reserved.visibility as 'public' | 'internal' + }), + ...(params.service === undefined && + typeof reserved.service === 'string' && { service: reserved.service }) + } + } + + /** + * @description Normalize an `updateRelation()` params object with respect + * to Brainy-reserved fields arriving inside the metadata patch — the + * relationship mirror of {@link remapReservedUpdateMetadata}. Governed by + * {@link BrainyConfig.reservedFieldPolicy} (default `'throw'`): `'throw'` + * rejects the write; `'warn'`/`'remap'` remap user-mutable fields + * (`confidence`, `weight`, `subtype`, `visibility`) to their dedicated param + * (top-level wins) and drop everything else. + * @param params - The caller's update-relation params (not mutated). + * @returns Params with reserved fields normalized out of `metadata`. + * @throws {Error} When `reservedFieldPolicy` is `'throw'` and the bag carries a reserved key. + */ + private remapReservedUpdateRelationMetadata( + params: UpdateRelationParams + ): UpdateRelationParams { + const bag = params.metadata as Record | undefined + if (!bag || typeof bag !== 'object') return params + const { reserved, custom } = splitVerbMetadataRecord(bag) + if (Object.keys(reserved).length === 0) return params + + // Policy gate: 'throw' (default) throws; 'warn' warns once per key then + // remaps; 'remap' silently remaps. + this.enforceReservedPolicy('updateRelation', reserved, 'RESERVED_RELATION_FIELDS') + + return { + ...params, + metadata: custom as UpdateRelationParams['metadata'], + ...(params.confidence === undefined && + typeof reserved.confidence === 'number' && { confidence: reserved.confidence }), + ...(params.weight === undefined && + typeof reserved.weight === 'number' && { weight: reserved.weight }), + ...(params.subtype === undefined && + typeof reserved.subtype === 'string' && { subtype: reserved.subtype }), + ...(params.visibility === undefined && + (reserved.visibility === 'public' || reserved.visibility === 'internal') && { + visibility: reserved.visibility as 'public' | 'internal' + }) + } + } /** * Update an existing entity @@ -4628,6 +2979,12 @@ export class Brainy implements BrainyInterface { // Reserved fields arriving via the metadata patch are remapped to their // canonical top-level location, mirroring add()'s lift. Without this the // patch value survived the merge but was then clobbered by the + // preserve-existing spreads below — a silent no-op consumers could only + // detect by reading values back. User-mutable fields (confidence, + // weight, subtype) remap unless the same field was also passed top-level + // (top-level wins); system-managed fields are dropped with a one-shot + // warning naming the right path. + params = this.remapReservedUpdateMetadata(params) // Tracked-field vocabulary enforcement (Layer 2). Same as add() — the // metadata bag carries fields registered via trackField(), and subtype is @@ -4677,112 +3034,55 @@ export class Brainy implements BrainyInterface { // new `data`); otherwise new `data` re-embeds; otherwise the existing // vector is kept. Any vector change re-indexes HNSW below. let vector = existing.vector - // 'data' is a real new value whenever it's not null/undefined — an - // empty string ('') is legitimate content (e.g. truncating a file to - // empty via overwrite), matching validateUpdateParams's absent-vs-empty - // distinction. Using `Boolean(params.data)` here would treat '' as "no - // new data", silently skipping BOTH the deferred marker and the eager - // re-embed below — a stale vector left behind with no path to ever - // correct itself (a quiet loss, not the deferred-but-eventually- - // correct flicker the deferEmbedding contract promises). - const rawHasNewData = params.data !== undefined && params.data !== null - // NO RE-EMBED ON UNCHANGED DATA: a write carrying the row's CURRENT data - // is not a data change — no re-embed, no deferred landing, no vector - // rewrite. A host heartbeat re-writing an unchanged row every few - // seconds fed a live index-row loop on a production store (each - // "change" landed a vector); the amplifier dies here regardless of how - // 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 && !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) { + if (params.vector) { + if (this.dimensions && params.vector.length !== this.dimensions) { throw new Error( - `Vector dimension mismatch: expected ${this.dimensions}, got ${explicitVector.length}` + `Vector dimension mismatch: expected ${this.dimensions}, got ${params.vector.length}` ) } - vector = explicitVector - } else if (hasNewData && !deferringEmbed) { + vector = params.vector + } else if (params.data) { 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 || explicitVector - ) + const needsReindexing = Boolean(params.data || params.type || params.vector) // Always update the noun with new metadata const newMetadata = params.merge !== false ? { ...existing.metadata, ...params.metadata } : params.metadata || existing.metadata - // Prepare the updated v2 nested-bag record: engine fields top-level, - // the merged user bag nested verbatim (collider names stay the user's). - const updatedMetadata = buildNounMetadataRecord( - { - data: params.data !== undefined ? params.data : existing.data, - noun: params.type || existing.type, - service: existing.service, - createdAt: existing.createdAt, - updatedAt: Date.now(), - _rev: currentRev + 1, - // Update confidence and weight if provided, otherwise preserve existing - ...(params.confidence !== undefined && { confidence: params.confidence }), - ...(params.weight !== undefined && { weight: params.weight }), - ...(params.confidence === undefined && existing.confidence !== undefined && { confidence: existing.confidence }), - ...(params.weight === undefined && existing.weight !== undefined && { weight: existing.weight }), - // Update subtype if provided, otherwise preserve existing - ...(params.subtype !== undefined && { subtype: params.subtype }), - ...(params.subtype === undefined && existing.subtype !== undefined && { subtype: existing.subtype }), - // Visibility: take the new value if provided, else preserve existing. Stored only - // when the effective value is not 'public' (absent === public, keeps records lean). - // A change to 'public' therefore drops the field entirely. - ...(((params.visibility ?? existing.visibility) ?? 'public') !== 'public' && { - visibility: params.visibility ?? existing.visibility - }) - }, - newMetadata as Record - ) + // Prepare updated metadata object + // data is stored opaquely in the 'data' field - NOT spread into top-level metadata. + const updatedMetadata = { + ...newMetadata, + data: params.data !== undefined ? params.data : existing.data, + noun: params.type || existing.type, + service: existing.service, + createdAt: existing.createdAt, + updatedAt: Date.now(), + _rev: currentRev + 1, + // Update confidence and weight if provided, otherwise preserve existing + ...(params.confidence !== undefined && { confidence: params.confidence }), + ...(params.weight !== undefined && { weight: params.weight }), + ...(params.confidence === undefined && existing.confidence !== undefined && { confidence: existing.confidence }), + ...(params.weight === undefined && existing.weight !== undefined && { weight: existing.weight }), + // Update subtype if provided, otherwise preserve existing + ...(params.subtype !== undefined && { subtype: params.subtype }), + ...(params.subtype === undefined && existing.subtype !== undefined && { subtype: existing.subtype }), + // Visibility: take the new value if provided, else preserve existing. Stored only + // when the effective value is not 'public' (absent === public, keeps records lean). + // A change to 'public' therefore drops the field entirely. + ...(((params.visibility ?? existing.visibility) ?? 'public') !== 'public' && { + visibility: params.visibility ?? existing.visibility + }) + } - // Build entity structure for metadata index (with top-level fields). - // No `level`: engine plumbing never enters the indexing view (it - // poisoned the flattened user `level` column — VENUE-BRAINY-ORDERBY-NOOP). + // Build entity structure for metadata index (with top-level fields) const entityForIndexing = { id: params.id, vector, connections: new Map(), + level: 0, type: params.type || existing.type, subtype: params.subtype !== undefined ? params.subtype : existing.subtype, ...(((params.visibility ?? existing.visibility) ?? 'public') !== 'public' && { @@ -4828,28 +3128,6 @@ export class Brainy implements BrainyInterface { updatedMetadata._rev = authoritativeRev + 1 } - // MT5: the pending marker rides the update's own commit fact (same - // generation, one atomic append) — threaded to persistSingleOp below. - const embedMarkers: FactMarkerRecord[] | undefined = deferringEmbed - ? [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) => { @@ -4858,33 +3136,23 @@ export class Brainy implements BrainyInterface { new UpdateNounMetadataOperation(this.storage, params.id, updatedMetadata) ) - // Operations 2-4: vector-record write + HNSW reindex — ONLY when the - // vector side actually changed (new data/vector/type). A metadata-only - // update must never rewrite the noun record: the record carries the - // full vector, so an unconditional save turned every metadata touch - // into a whole-vector rewrite + fsync — under a read-heavy consumer - // sweep that bumps per-entity stats, this amplified into disk - // saturation on a production deployment (SELF-ENGINE-RESTART-GRIND, - // 2026-07-29: 5.8GB written in 40min from ~50 recalls/min). + // Operation 2: Update vector data (will use updated type cache) + tx.addOperation( + new SaveNounOperation(this.storage, { + id: params.id, + vector, + connections: new Map(), + level: 0 + }) + ) + + // Operation 3-4: Update HNSW index (remove and re-add if reindexing needed) if (needsReindexing) { tx.addOperation( - new SaveNounOperation(this.storage, { - id: params.id, - vector, - connections: new Map(), - level: 0 - }) + new RemoveFromVectorIndexOperation(this.index, params.id, existing.vector) ) - // ONE atomic vector-index leg: the historical Remove→Add pair was - // two separately-awaited operations — between them the row was in - // NEITHER index (dark to semantic recall, visible to metadata - // reads). ReplaceInVectorIndexOperation goes through the provider's - // in-place updateItem when available (row never absent; an - // element-wise UNCHANGED vector — the type-only-update shape that - // flickered in production — is a pure no-op), else remove+add - // adjacent within the single op. tx.addOperation( - new ReplaceInVectorIndexOperation(this.index, params.id, existing.vector, vector, this.indexWriteGeneration) + new AddToVectorIndexOperation(this.index, params.id, vector) ) } @@ -4914,10 +3182,10 @@ export class Brainy implements BrainyInterface { metadata: existing.metadata // CRITICAL: keep as nested 'metadata' property! } tx.addOperation( - new RemoveFromMetadataIndexOperation(this.metadataIndex, params.id, removalMetadata, this.indexWriteGeneration) + new RemoveFromMetadataIndexOperation(this.metadataIndex, params.id, removalMetadata) ) tx.addOperation( - new AddToMetadataIndexOperation(this.metadataIndex, params.id, entityForIndexing, this.indexWriteGeneration) + new AddToMetadataIndexOperation(this.metadataIndex, params.id, entityForIndexing) ) }, casPrecommit, this._changeFeed.hasListeners ? [ @@ -4938,33 +3206,7 @@ export class Brainy implements BrainyInterface { } } ] - : 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) - } + : undefined) // Aggregation hook (outside transaction — derived data). `existing` is // the full get() view — every reserved field top-level — and must be @@ -4977,106 +3219,6 @@ export class Brainy implements BrainyInterface { existing as unknown as Record ) } - - if (deferringEmbed) this.kickEmbedWorker() - } - - /** - * @description Build the metadata-index retraction operation for one id - * (noun or verb) — the null-metadata-safe closure shared by every removal - * leg that reaches the metadata index with a possibly-missed pre-read: - * `remove()`'s own noun leg, its verb-cascade retractions, `unrelate()`, - * and their `transact()`/`planTx*` mirrors (both callers add the returned - * operation to their own batch — `tx.addOperation()` for a single-op - * transaction, `plan.operations.push()` for a planned `transact()` batch). - * THE NULL-METADATA SKIP IS CLOSED (a posting-leak class): - * - metadata present → the ordinary, provider-agnostic - * `RemoveFromMetadataIndexOperation` (exact per-field retraction). - * - metadata absent (a torn pre-read, or the row was already gone) → - * a provider exposing `removeEntityById` (the id-keyed contract) gets - * exact per-entity retraction via its reverse record; the JS index - * gets `removeFromIndex(id)` — safe id-keyed cleanup (deleted bitmap + - * id mapper; field statistics reconcile at the next rebuild/repairIndex), - * narrated; a native provider WITHOUT the contract is never called - * metadata-omitted (that path walks its value space) — the skip is - * tracked in the degraded set instead, narrated, so `repairIndex()` - * reconciles it (and this method returns `null` — no operation to add). - * Silence is the only thing outlawed. - * @param id - The noun/verb id being retracted. - * @param metadata - The pre-read metadata/entity structure, or falsy when - * the read missed. - * @param context - Narration prefix identifying the caller/id, e.g. - * `remove(${id})` or `remove(${entityId}) cascade unrelate ${verbId}`. - * @returns The operation to add to the caller's batch, or `null` when - * nothing could be done (already narrated + tracked as degraded). - */ - /** - * @description A JSON-safe view of a record bound for the metadata-index - * crossing — delegates to the shared {@link jsonSafeIndexMetadata} leaf, - * which the metadata-index transaction operations ALSO apply at execute - * and rollback time. This plan-time wrap alone proved insufficient: it - * returns the same reference when the record is clean, and `transact()`'s - * delete legs share that reference with a graph-retraction op whose - * execute-time endpoint resolution mirrors BigInt ints onto it (the full - * aliasing story lives on the leaf module's doc). - * @param metadata - The candidate index-metadata record. - * @returns The same object when already JSON-safe, else a shallow copy - * without the BigInt-valued keys. - */ - private static jsonSafeIndexMetadata(metadata: unknown): unknown { - return jsonSafeIndexMetadata(metadata) - } - - private metadataIndexRetractionOp( - id: string, - metadata: unknown, - context: string - ): Operation | null { - if (metadata) { - return new RemoveFromMetadataIndexOperation( - this.metadataIndex, id, Brainy.jsonSafeIndexMetadata(metadata), this.indexWriteGeneration - ) - } - const prov = this.metadataIndex as unknown as { - removeEntityById?: (id: string) => Promise - removeFromIndex?: (id: string, metadata?: unknown, generation?: bigint) => Promise - } - if (typeof prov.removeEntityById === 'function') { - const g = this.indexWriteGeneration - return { - name: 'RemoveEntityByIdTombstone', - execute: async () => { - await prov.removeEntityById!(id) - return async () => { - // Undo of an id-keyed tombstone on an absent row: nothing to - // restore (the row had no readable metadata to re-post). - void g - } - } - } - } else if (this.metadataIndex instanceof MetadataIndexManager) { - const gv = this.indexWriteGeneration - prodLog.warn( - `[Brainy] ${context}: no metadata at delete — id-keyed index cleanup ran ` + - `(deleted bitmap + id mapper); field statistics reconcile at the next rebuild/repairIndex.` - ) - return { - name: 'IdKeyedIndexCleanup', - execute: async () => { - await prov.removeFromIndex!(id, undefined, typeof gv === 'function' ? gv() : gv) - return async () => {} - } - } - } else { - this._indexDegradedIds.add(id) - prodLog.warn( - `[Brainy] ${context}: no metadata at delete and this provider has no id-keyed ` + - `removal — its postings for this id are NOT tombstoned yet (tracked as degraded; ` + - `repairIndex() reconciles). Never calling a metadata-omitted native removal: that ` + - `path walks the store's value space.` - ) - return null - } } /** @@ -5110,22 +3252,9 @@ export class Brainy implements BrainyInterface { // stored, so remove() deletes the same entity. A real UUID passes through. id = resolveEntityId(id) - // Get entity metadata and related verbs before deletion. TORN-TOLERANT: - // a torn record must still be deletable (the delete IS the cure) — a - // torn pre-read reads as null and the null-path below handles it loudly. - let metadata: any = null - let noun: any = null - try { - metadata = await this.storage.getNounMetadata(id) - } catch (err) { - if ((err as { code?: string }).code !== 'TORN_RECORD') throw err - prodLog.warn(`[Brainy] remove(${id}): metadata pre-read is TORN — deleting anyway; index legs run id-keyed`) - } - try { - noun = await this.storage.getNoun(id) - } catch (err) { - if ((err as { code?: string }).code !== 'TORN_RECORD') throw err - } + // Get entity metadata and related verbs before deletion + const metadata = await this.storage.getNounMetadata(id) + const noun = await this.storage.getNoun(id) const verbs = await this.storage.getVerbsBySource(id) const targetVerbs = await this.storage.getVerbsByTarget(id) const allVerbs = [...verbs, ...targetVerbs] @@ -5139,15 +3268,15 @@ export class Brainy implements BrainyInterface { // Operation 1: Remove from vector index if (noun) { tx.addOperation( - new RemoveFromVectorIndexOperation(this.index, id, noun.vector, this.indexWriteGeneration) + new RemoveFromVectorIndexOperation(this.index, id, noun.vector) ) } - // Operation 2: Remove from metadata index (null-metadata-safe — see - // metadataIndexRetractionOp's JSDoc for the full closure). - { - const retractionOp = this.metadataIndexRetractionOp(id, metadata, `remove(${id})`) - if (retractionOp) tx.addOperation(retractionOp) + // Operation 2: Remove from metadata index + if (metadata) { + tx.addOperation( + new RemoveFromMetadataIndexOperation(this.metadataIndex, id, metadata) + ) } // Operation 3: Delete noun (full removal). The pre-read metadata rides @@ -5165,21 +3294,6 @@ export class Brainy implements BrainyInterface { tx.addOperation( new RemoveFromGraphIndexOperation(this.graphIndex, verb, { sourceInt, targetInt }, this.graphWriteGeneration) ) - // Retract the cascaded relation's metadata-index row too — the - // live mirror of what a rebuild would derive for this (now-gone) - // edge (mirrors the noun leg above). The whole hydrated verb - // (system fields top-level + the custom bag under `metadata`, - // same shape `extractIndexableFields` reads for any entity-record - // frame) is the before-image — every entry in `allVerbs` was - // already successfully hydrated by the reads above, so this is - // never metadata-omitted in practice, but the closure stays - // defensive rather than assuming. - { - const cascadeRetractionOp = this.metadataIndexRetractionOp( - verb.id, verb, `remove(${id}) cascade unrelate ${verb.id}` - ) - if (cascadeRetractionOp) tx.addOperation(cascadeRetractionOp) - } // Delete verb metadata tx.addOperation( new DeleteVerbMetadataOperation(this.storage, verb.id) @@ -5218,20 +3332,12 @@ export class Brainy implements BrainyInterface { // Aggregation hook (outside transaction — derived data). The view must // carry EVERY reserved field top-level (not a subset): a groupBy on // subtype/visibility/etc. otherwise decrements a nonexistent group and - // the real count never comes down. A delete whose before-image is - // unavailable can no longer SKIP the hook silently (the gated skip let - // counts drift upward forever) — it flags an exact rescan, loudly. - if (this._aggregationIndex) { - if (metadata) { - this._aggregationIndex.onEntityDeleted( - id, - this.entityForAggFromRawRecord(metadata as Record) - ) - } else { - this._aggregationIndex.flagAllForRescan( - `delete of ${id} carried no before-image metadata — contribution unknowable` - ) - } + // the real count never comes down. + if (this._aggregationIndex && metadata) { + this._aggregationIndex.onEntityDeleted( + id, + this.entityForAggFromRawRecord(metadata as Record) + ) } } @@ -5265,14 +3371,8 @@ export class Brainy implements BrainyInterface { verb: Pick & { sourceInt?: bigint; targetInt?: bigint } ): { sourceInt: bigint; targetInt: bigint } { const idMapper = this.metadataIndex.getIdMapper() - // Thread the write generation into any mint: a native mapper stamps the - // assignment record with the real watermark instead of a literal 0. - // Evaluated HERE (mint time) — at execute time inside a batch this is the - // in-flight commit generation; at plan time it is the pre-batch watermark - // (truthful: the mint happened before the batch committed). - const generation = this.indexWriteGeneration() - const sourceInt = BigInt(idMapper.getOrAssign(verb.sourceId, generation)) - const targetInt = BigInt(idMapper.getOrAssign(verb.targetId, generation)) + const sourceInt = BigInt(idMapper.getOrAssign(verb.sourceId)) + const targetInt = BigInt(idMapper.getOrAssign(verb.targetId)) verb.sourceInt = sourceInt verb.targetInt = targetInt return { sourceInt, targetInt } @@ -5355,13 +3455,6 @@ export class Brainy implements BrainyInterface { uuid: string, options?: { direction?: 'in' | 'out' | 'both'; limit?: number; offset?: number } ): Promise { - // READ-SURFACE READINESS GATE (the 4.2.4 blackout's brainy half): every - // index read funnels through this helper, so the gate here makes - // serve-while-not-ready UNREPRESENTABLE — a production store once acked - // writes while every non-find() read served empty from a not-ready - // provider for 15 minutes. A CHECK only — it never builds; throws a typed - // NotReady error if a provider's health report says it isn't serving. - this.ensureIndexesLoaded(['graph']) const entityInt = this.graphEntityInt(uuid) if (entityInt === undefined) return [] const neighborInts = await this.graphIndex.getNeighbors(entityInt, options) @@ -5389,72 +3482,72 @@ export class Brainy implements BrainyInterface { /** * @description Verify that the graph adjacency is actually LIVE before a graph read trusts * its result. A native graph index can load its relationship COUNT (manifest) on a cold open - * but NOT its source→target adjacency, so `getNeighbors()` returns `[]` for EVERY source even - * though edges are persisted — and `find({ connected })` / `neighbors()` / `related()` would - * serve that `[]` as if it were truth. + * of a LARGE brain (≥10k nouns, which skips the eager index rebuild) but NOT its + * source→target adjacency, so `getNeighbors()` returns `[]` for EVERY source even though + * edges are persisted — and `find({ connected })` / `neighbors()` / `related()` would serve + * that `[]` as if it were truth. * - * NEVER REBUILDS, NEVER WALKS THE STORE — a read-path rebuild is exactly the dark-rebuild - * failure mode this contract retires (open() alone owns building; see - * {@link rebuildIndexesIfNeeded}). Two detection strategies, in order of honesty: - * - **Preferred:** {@link assessProviderHealth} — the provider's named `healthReport()` when - * exposed, else its sync `isReady()`. Not serving → THROW {@link GraphIndexNotReadyError} - * naming the reasons, immediately — no rebuild attempt. - * - **Fallback (providers with neither signal):** a READ-ONLY GLOBAL known-edge sample (a real + * Two detection strategies, in order of honesty: + * - **Preferred (8.0 contract):** the provider exposes a sync `isReady()` that is true ONLY + * when the edges are loaded. `false` → hydrate the id-mapper (a native int adjacency + * resolves endpoints through it), rebuild from storage, and re-check `isReady()`; if it is + * still `false`, throw {@link GraphIndexNotReadyError} rather than returning `[]`. + * - **Fallback (providers without `isReady()`):** a GLOBAL known-edge sample (a real * persisted verb's `sourceId`, which by definition HAS an outgoing edge) — NOT any queried * anchor, because brainy cannot cheaply tell "adjacency unloaded" from "this node is - * genuinely edgeless" per-anchor. If that known-edge source resolves to no neighbors, THROW — - * the probe refuses loudly; it does not self-heal. + * genuinely edgeless" per-anchor. If that known-edge source resolves to no neighbors, the + * adjacency did not load: rebuild and re-probe; if even that fails, throw. * - * @returns `'live'` when the adjacency is already trustworthy (or there is genuinely nothing to - * verify). - * @throws {GraphIndexNotReadyError} when the index is not serving, or claims edges but cannot - * serve a known persisted edge. + * @returns `'live'` when the adjacency is already trustworthy (or there is genuinely nothing + * to verify), or `'rebuilt'` when a cold-unloaded adjacency was just healed from storage — + * in which case callers that observed an empty result must RE-RUN their collection. + * @throws {GraphIndexNotReadyError} when the index claims edges but cannot serve a known + * persisted edge (or stays not-ready) even after a rebuild. */ - private async verifyGraphAdjacencyLive(): Promise<'live'> { + private async verifyGraphAdjacencyLive(): Promise<'live' | 'rebuilt'> { if (this._graphAdjacencyVerified) return 'live' // Coordinated migration LOCK (#18): while the graph provider owns a locked - // rebuild-from-canonical, brainy must NOT judge it here — the provider owns - // its index until it verifies-and-swaps. The data-plane lock - // (awaitMigrationLock in ensureInitialized) already makes callers wait, so - // this is normally unreachable mid-migration; the guard is defensive. It + // rebuild-from-canonical, brainy must NOT fire its own graphIndex.rebuild() + // on a read — that would race the provider's in-place rebuild. The data-plane + // lock (awaitMigrationLock in ensureInitialized) already makes callers wait, + // so this is normally unreachable mid-migration; the guard is defensive. It // deliberately does NOT set `_graphAdjacencyVerified`, so the real verify runs // once the migration clears. if (this.providerIsMigrating(this.graphIndex)) return 'live' - // Re-entrancy: a fallback probe below calls getNeighbors(), which does not - // re-enter this guard, but the short-circuit is kept defensively cheap. + // Re-entrancy: rebuild() can trigger reads (neighbors/related) that call back into this + // guard. While a verify is in flight, short-circuit so we cannot recurse into rebuild(). if (this._graphAdjacencyVerifying) return 'live' this._graphAdjacencyVerifying = true try { - // ── Strategy 1: the health-report/isReady() authority — never rebuilds ── - const assessment = assessProviderHealth(this.graphIndex) - if (assessment.via === 'health-report' || assessment.via === 'is-ready') { - if (assessment.readiness === 'ready') { + const gi = this.graphIndex as GraphAdjacencyIndex & { isReady?: () => boolean } + + // ── Strategy 1: honest isReady() signal (cortex >= 2.7.8 / 3.0) ────────── + if (typeof gi.isReady === 'function') { + if (gi.isReady()) { 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.` + // Not ready: the edges did not load on open. Hydrate the id-mapper, then rebuild. + if (!this.config.silent) { + console.warn( + `[Brainy] Graph adjacency reports not-ready (isReady() === false) — the persisted ` + + `adjacency did not load on open. Rebuilding from storage…` ) } + await this.hydrateIdMapperForGraphRebuild() + await this.graphIndex.rebuild() + if (gi.isReady()) { + this._graphAdjacencyVerified = true + return 'rebuilt' + } throw new GraphIndexNotReadyError( - `Graph adjacency index is not serving (via ${assessment.via}): ` + - `${assessment.reasons.join('; ') || 'not ready'}. find({ connected }), neighbors() and ` + - `related() refuse rather than serve an empty result — rebuild via ` + - `repairIndex({ rebuild: ['graph'] }) or reopen the brain.` + `Graph adjacency index reports not-ready even after a rebuild — the persisted ` + + `adjacency could not be loaded. find({ connected }), neighbors() and related() ` + + `cannot be served reliably for this brain.` ) } - // ── Strategy 2: known-edge-sample probe (providers with neither signal) ─ - // READ-ONLY — refuses loudly on failure; never calls rebuild(). + // ── Strategy 2: known-edge-sample probe (providers without isReady()) ──── const claimed = await this.graphIndex.size() if (!claimed || claimed <= 0) return 'live' // no edges claimed — nothing to verify @@ -5470,9 +3563,10 @@ export class Brainy implements BrainyInterface { // the sample is not one of this brain's own edges — e.g. a shared on-disk store reused // across instances surfaces a foreign verb whose UUID this brain's resident mapper never // interned. We cannot prove a cold-unloaded adjacency from such a sample, so treat it as - // INCONCLUSIVE: mark verified and return 'live' rather than throwing. (The honest cold-load - // signal for native providers is Strategy 1, checked above; the JS baseline keeps its mapper - // resident, so its OWN edges always resolve.) + // INCONCLUSIVE: mark verified and return 'live' rather than rebuilding/throwing. (The honest + // cold-load signal for native providers is isReady(), checked above; the JS baseline keeps + // its mapper resident, so its OWN edges always resolve — the targeted 7.x failure mode, + // "mapper loaded but adjacency empty", still resolves the source and is detected below.) const sourceInt = this.graphEntityInt(verb.sourceId) if (sourceInt === undefined) { this._graphAdjacencyVerified = true @@ -5480,23 +3574,37 @@ export class Brainy implements BrainyInterface { } // Ask the adjacency for ONE neighbor of the (mapped) known-edge source. - const hasNeighbor = (await this.graphIndex.getNeighbors(sourceInt, { limit: 1 })).length > 0 - if (hasNeighbor) { + const probeKnownSource = async (): Promise => + (await this.graphIndex.getNeighbors(sourceInt, { limit: 1 })).length > 0 + + if (await probeKnownSource()) { this._graphAdjacencyVerified = true return 'live' // adjacency is live — the common case } // INCONSISTENT: the index reports edges but a KNOWN-mapped persisted edge's source has none → - // the adjacency did not load. Refuse loudly — never rebuild from a read. + // the adjacency did not load on open. Hydrate the mapper and rebuild from storage. + if (!this.config.silent) { + console.warn( + `[Brainy] Graph adjacency reports ${claimed} relationship(s) but a persisted edge ` + + `resolves to none — the persisted adjacency did not load on open. Rebuilding from storage…` + ) + } + await this.hydrateIdMapperForGraphRebuild() + await this.graphIndex.rebuild() + + if (await probeKnownSource()) { + this._graphAdjacencyVerified = true + return 'rebuilt' + } throw new GraphIndexNotReadyError( - `Graph adjacency index reports ${claimed} relationship(s) but a persisted edge's source ` + - `resolves to none — the persisted adjacency did not load. find({ connected }), ` + - `neighbors() and related() refuse rather than serve an empty result — rebuild via ` + - `repairIndex({ rebuild: ['graph'] }) or reopen the brain.` + `Graph adjacency index reports ${claimed} relationship(s) but returns no edges even ` + + `after a rebuild — the persisted adjacency could not be loaded. find({ connected }), ` + + `neighbors() and related() cannot be served reliably for this brain.` ) } catch (err) { if (err instanceof GraphIndexNotReadyError) throw err - // A transient probe failure must not break the actual query NOR be + // A transient probe/rebuild failure must not break the actual query NOR be // masked as "no data". Allow a re-check on the next graph read and fall through. this._graphAdjacencyVerified = false if (!this.config.silent) { @@ -5513,60 +3621,27 @@ export class Brainy implements BrainyInterface { * On a cold open a native metadata provider can report data yet not serve its * `where` postings, so `find({ where })` silently returns `[]` — the exact * failure a downstream deployment reported (cold reads blanking filtered pages - * after every restart). - * - * NEVER REBUILDS, NEVER WALKS THE STORE — a read-path rebuild is exactly the - * dark-rebuild failure mode this contract retires (open() alone owns - * building; see {@link rebuildIndexesIfNeeded}). Two detection strategies: - * - **Preferred:** {@link assessProviderHealth} — the provider's named - * `healthReport()` when exposed, else its sync `isReady()`. Not serving → - * THROW {@link MetadataIndexNotReadyError} naming the reasons, immediately. - * - **Fallback (providers with neither signal):** a READ-ONLY known-value - * probe, run on the first FILTERED `find()`: take a KNOWN persisted entity - * + one of its plain field values and ask the index to resolve it. If the - * index does not return the known id, THROW — the probe refuses loudly; - * it does not self-heal. Inconclusive cases (empty store, no plain field - * to probe, a shared store surfacing a foreign entity) are treated as - * live — never a false throw. A migrating provider is skipped (it owns - * its locked rebuild). - * @returns `'live'` when the index serves. + * after every restart). This one-shot guard, run on the first FILTERED `find()`, + * closes that: it takes a KNOWN persisted entity + one of its plain field values + * and asks the index to resolve it. If the index returns the known id the field + * postings are live (the common case, and the ONLY cost on a warm brain — one + * O(1) probe). If it does not, the postings did not load: brainy rebuilds the + * index from the canonical records and re-probes; if it STILL cannot serve the + * known value it throws a loud {@link MetadataIndexNotReadyError} rather than + * let a silent `[]` stand. Inconclusive cases (empty store, no plain field to + * probe, a shared store surfacing a foreign entity) are treated as live — never + * a false rebuild. A migrating provider is skipped (it owns its locked rebuild). + * @returns `'live'` when the index serves, `'rebuilt'` when a rebuild restored it. */ - private async verifyMetadataLive(): Promise<'live'> { + private async verifyMetadataLive(): Promise<'live' | 'rebuilt'> { if (this._metadataVerified) return 'live' // Migration LOCK (#18): a migrating provider owns its in-place rebuild — do // not race it. Defensive; the data-plane lock already gates callers upstream. if (this.providerIsMigrating(this.metadataIndex)) return 'live' - // Re-entrancy: the fallback probe below calls filterIdsBelted(), which - // re-enters ensureIndexesLoaded() (a cheap CHECK) but not this guard. + // Re-entrancy: rebuild() can trigger reads that call back into this guard. if (this._metadataVerifying) return 'live' this._metadataVerifying = true try { - // ── Strategy 1: the health-report/isReady() authority — never rebuilds ── - const assessment = assessProviderHealth(this.metadataIndex) - if (assessment.via === 'health-report' || assessment.via === 'is-ready') { - if (assessment.readiness === 'ready') { - 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 ` + - `reads refuse rather than serve an empty result — rebuild via ` + - `repairIndex({ rebuild: ['metadata'] }) or reopen the brain.` - ) - } - - // ── Strategy 2: known-value probe (providers with neither signal) ────── - // READ-ONLY — refuses loudly on failure; never calls rebuild(). // A KNOWN persisted entity + one plain field to probe. Sample a few so a // system-only entity (e.g. the VFS root) doesn't make every open inconclusive. const sample = await this.storage.getNouns({ pagination: { limit: 5, offset: 0 } }) @@ -5584,11 +3659,11 @@ export class Brainy implements BrainyInterface { const probeServes = async (): Promise => { try { - const ids = await this.filterIdsBelted({ [p.field]: p.value }) + const ids = await this.metadataIndex.getIdsForFilter({ [p.field]: p.value }) return ids.includes(p.id) } catch { // FIELD_NOT_INDEXED for a field a persisted entity actually holds is - // itself the cold/broken signal — treat as not-serving. + // itself the cold/broken signal — treat as not-serving (→ rebuild). return false } } @@ -5598,15 +3673,26 @@ export class Brainy implements BrainyInterface { return 'live' // field postings are live — the common case } + if (!this.config.silent) { + console.warn( + `[Brainy] Metadata field index returns no match for a known persisted value of ` + + `'${p.field}' — the field postings did not load on open. Rebuilding from storage…` + ) + } + await this.metadataIndex.rebuild() + + if (await probeServes()) { + this._metadataVerified = true + return 'rebuilt' + } throw new MetadataIndexNotReadyError( - `Metadata field index cannot serve a known persisted value of '${p.field}' — the field ` + - `postings did not load. find({ where }) and other filtered reads refuse rather than ` + - `serve an empty result — rebuild via repairIndex({ rebuild: ['metadata'] }) or reopen ` + - `the brain.` + `Metadata field index cannot serve a known persisted value of '${p.field}' even after ` + + `a rebuild — find({ where }) and other filtered reads cannot be served reliably for ` + + `this brain (a silent empty result would misrepresent existing data).` ) } catch (err) { if (err instanceof MetadataIndexNotReadyError) throw err - // A transient probe failure must not break the query NOR mask as + // A transient probe/rebuild failure must not break the query NOR mask as // "no data". Allow a re-check on the next filtered read and fall through. this._metadataVerified = false if (!this.config.silent) { @@ -5652,61 +3738,57 @@ export class Brainy implements BrainyInterface { * report a non-zero `size()` (its persisted COUNT loaded) yet not have loaded * its serving structure (the mmap/DiskANN graph) — so a pure semantic * `find({ query })` silently returns `[]`. A pure semantic query has - * `hasFilterCriteria === false`, so the metadata guard never fires; this - * guard closes that gap. Run one-shot on the first vector/proximity search. - * - * NEVER REBUILDS, NEVER WALKS THE STORE — a read-path rebuild is exactly the - * dark-rebuild failure mode this contract retires (open() alone owns - * building; see {@link rebuildIndexesIfNeeded}). Two detection strategies: - * - **Preferred:** {@link assessProviderHealth} — the provider's named - * `healthReport()` when exposed, else its sync `isReady()`. Not serving → - * THROW {@link VectorIndexNotReadyError} naming the reasons, immediately. - * - **Fallback (providers with neither signal):** a READ-ONLY KNOWN - * persisted vector (sampled + hydrated) is searched against the index; if - * it does not self-match, THROW — the probe refuses loudly; it does not - * self-heal. + * `hasFilterCriteria === false`, so the metadata guard never fires; this guard + * closes that gap. Run one-shot on the first vector/proximity search: + * - **Preferred (honest signal):** the provider exposes `isReady()`. `false` + * → rebuild from storage, re-check; if still `false`, throw + * {@link VectorIndexNotReadyError} rather than serving `[]`. + * - **Fallback (no `isReady()`):** a KNOWN persisted vector (sampled + + * hydrated) is searched against the index; if it does not self-match, the + * serving structure did not load — rebuild + re-probe, else throw. * Inconclusive cases (empty store, no probeable vector, `size()===0` — where - * the JS baseline is built at open) are treated as live: never a false - * throw. A migrating provider is skipped (it owns its locked rebuild). - * @returns `'live'` when the index serves. + * the JS baseline's cold load is `ensureIndexesLoaded`'s job) are treated as + * live: never a false rebuild. A migrating provider is skipped (it owns its + * locked rebuild). + * @returns `'live'` when the index serves, `'rebuilt'` when a rebuild restored it. */ - private async verifyVectorLive(): Promise<'live'> { + private async verifyVectorLive(): Promise<'live' | 'rebuilt'> { if (this._vectorVerified) return 'live' // Migration LOCK (#18): a migrating provider owns its in-place rebuild. if (this.providerIsMigrating(this.index)) return 'live' - // Re-entrancy: the fallback probe below calls index.search(), which does - // not re-enter this guard, but the short-circuit is kept defensively cheap. + // Re-entrancy: rebuild() can trigger reads that call back into this guard. if (this._vectorVerifying) return 'live' this._vectorVerifying = true try { - // ── Strategy 1: the health-report/isReady() authority — never rebuilds ── - const assessment = assessProviderHealth(this.index) - if (assessment.via === 'health-report' || assessment.via === 'is-ready') { - if (assessment.readiness === 'ready') { + // ── Strategy 1: honest isReady() signal (native provider) ────────────── + const readiness = assessIndexReadiness(this.index) + if (readiness !== 'unknown') { + if (readiness === 'ready') { 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.` + // Not ready: the serving structure did not load on open. Rebuild. + if (!this.config.silent) { + console.warn( + `[Brainy] Vector index reports not-ready (isReady() === false) — the persisted ` + + `vector index did not load on open. Rebuilding from storage…` ) } + await this.index.rebuild() + if (assessIndexReadiness(this.index) === 'ready') { + this._vectorVerified = true + return 'rebuilt' + } throw new VectorIndexNotReadyError( - `Vector index is not serving (via ${assessment.via}): ` + - `${assessment.reasons.join('; ') || 'not ready'}. Semantic find({ query }) and ` + - `proximity search refuse rather than serve an empty result — rebuild via ` + - `repairIndex({ rebuild: ['vector'] }) or reopen the brain.` + `Vector index reports not-ready even after a rebuild — semantic find({ query }) and ` + + `proximity search cannot be served reliably for this brain (a silent empty result ` + + `would misrepresent existing data).` ) } - // ── Strategy 2: known-vector probe (providers with neither signal) ───── - // READ-ONLY — refuses loudly on failure; never calls rebuild(). + // ── Strategy 2: known-vector probe (providers without isReady()) ─────── const claimed = this.index.size() - if (!claimed || claimed <= 0) return 'live' // JS cold path is built at open + if (!claimed || claimed <= 0) return 'live' // JS cold path is ensureIndexesLoaded's job const probe = await this.pickVectorProbe() if (!probe) { @@ -5716,30 +3798,44 @@ export class Brainy implements BrainyInterface { } const p = probe - // The failure mode we guard is the SILENT EMPTY result: a cold index that - // loaded its COUNT but not its serving structure returns `[]` for a - // known-present vector, while a warm index returns at least one hit. We - // check for a NON-EMPTY result, NOT an exact self-match — HNSW is - // approximate and `get()` may return a re-hydrated/normalized vector, so - // demanding the exact self as top-1 would false-positive on a perfectly - // healthy index (and wrongly throw). - const hits = await this.index.search(p.vector, 1) + const probeServes = async (): Promise => { + // The failure mode we guard is the SILENT EMPTY result: a cold index that + // loaded its COUNT but not its serving structure returns `[]` for a + // known-present vector, while a warm index returns at least one hit. We + // check for a NON-EMPTY result, NOT an exact self-match — HNSW is + // approximate and `get()` may return a re-hydrated/normalized vector, so + // demanding the exact self as top-1 would false-positive on a perfectly + // healthy index (and wrongly rebuild → throw). + const hits = await this.index.search(p.vector, 1) + return hits.length > 0 + } void p.id // probe keyed on the vector; id retained for diagnostics only - if (hits.length > 0) { + if (await probeServes()) { this._vectorVerified = true return 'live' // serving structure is live — the common case } + if (!this.config.silent) { + console.warn( + `[Brainy] Vector index reports ${claimed} vector(s) but a known persisted vector ` + + `returns no results — the serving structure did not load on open. Rebuilding…` + ) + } + await this.index.rebuild() + + if (await probeServes()) { + this._vectorVerified = true + return 'rebuilt' + } throw new VectorIndexNotReadyError( `Vector index reports ${claimed} vector(s) but a known persisted vector returns no ` + - `results — the serving structure did not load. Semantic find({ query }) refuses rather ` + - `than serve an empty result — rebuild via repairIndex({ rebuild: ['vector'] }) or ` + - `reopen the brain.` + `results even after a rebuild — semantic find({ query }) cannot be served reliably ` + + `for this brain (a silent empty result would misrepresent existing data).` ) } catch (err) { if (err instanceof VectorIndexNotReadyError) throw err - // A transient probe failure must not break the query NOR mask as + // A transient probe/rebuild failure must not break the query NOR mask as // "no data". Allow a re-check on the next vector read and fall through. this._vectorVerified = false if (!this.config.silent) { @@ -5914,6 +4010,9 @@ export class Brainy implements BrainyInterface { // engine-minted UUID — relation ids are never caller-supplied here.) params = { ...params, from: resolveEntityId(params.from), to: resolveEntityId(params.to) } + // Reserved fields arriving via the metadata bag are normalized to their + // canonical top-level params before enforcement — mirror of add()'s lift. + params = this.remapReservedRelateMetadata(params) // Subtype pairing enforcement (Layer 3 — 7.30.0). Per-type rules registered // via brain.requireSubtype() compose with the brain-wide strict-mode flag. @@ -5965,28 +4064,25 @@ export class Brainy implements BrainyInterface { (v, i) => (v + toEntity.vector[i]) / 2 ) - // Prepare verb metadata: a v2 nested-bag record — engine fields - // top-level, the user's edge bag nested verbatim (any name is the - // user's; the field-addressing law). + // Prepare verb metadata + // User metadata spread FIRST, then system fields ALWAYS win (prevents collision) // One timestamp for both createdAt and updatedAt so a never-updated edge reports a // stable updatedAt (=== createdAt) instead of a fresh Date.now() fabricated per read. const relateTs = Date.now() - const verbMetadata = buildVerbMetadataRecord( - { - verb: params.type, - ...(params.subtype !== undefined && { subtype: params.subtype }), - // visibility: stored only when not 'public' (absent === public, keeps records lean) - ...(params.visibility !== undefined && - params.visibility !== 'public' && { visibility: params.visibility }), - weight: params.weight ?? 1.0, - ...(params.confidence !== undefined && { confidence: params.confidence }), - ...(params.service !== undefined && { service: params.service }), - createdAt: relateTs, - updatedAt: relateTs, - ...(params.data !== undefined && { data: params.data }) - }, - (params.metadata as Record) || {} - ) + const verbMetadata = { + ...(params.metadata || {}), + verb: params.type, + ...(params.subtype !== undefined && { subtype: params.subtype }), + // visibility: stored only when not 'public' (absent === public, keeps records lean) + ...(params.visibility !== undefined && + params.visibility !== 'public' && { visibility: params.visibility }), + weight: params.weight ?? 1.0, + ...(params.confidence !== undefined && { confidence: params.confidence }), + ...(params.service !== undefined && { service: params.service }), + createdAt: relateTs, + updatedAt: relateTs, + ...(params.data !== undefined && { data: params.data }) + } // Save to storage (vector and metadata separately) const verb: GraphVerb = { @@ -6044,16 +4140,6 @@ export class Brainy implements BrainyInterface { ) ) - // Operation 3b: Add the verb's metadata-index row, in the SAME - // commit as the graph leg — the live mirror of what rebuild()'s - // verb walk already derives (ADR-007 A4: one mechanism, never a - // second hand-rolled shape). `verbMetadata` is the exact raw stored - // record `SaveVerbMetadataOperation` above just persisted — the same - // shape `storage.getVerbMetadata()`/rebuild() read back. - tx.addOperation( - new AddToMetadataIndexOperation(this.metadataIndex, id, verbMetadata, this.indexWriteGeneration) - ) - // Create bidirectional if requested if (params.bidirectional && reverseId) { const reverseVerb: GraphVerb = { @@ -6091,13 +4177,6 @@ export class Brainy implements BrainyInterface { (verbInt) => this.cacheVerbInt(verbInt, reverseId) ) ) - - // Operation 6b: Add the reverse edge's metadata-index row (same - // stored shape as the primary edge — SaveVerbMetadataOperation - // above persists the same `verbMetadata` object for both). - tx.addOperation( - new AddToMetadataIndexOperation(this.metadataIndex, reverseId, verbMetadata, this.indexWriteGeneration) - ) } }, undefined, @@ -6180,15 +4259,6 @@ export class Brainy implements BrainyInterface { ) } - // Operation 1b: Retract the verb's metadata-index row — the live - // mirror of remove()'s cascade leg (null-metadata-safe; see - // metadataIndexRetractionOp's JSDoc). Nothing to retract when the - // pre-read found no verb (already gone / never existed). - if (verb) { - const retractionOp = this.metadataIndexRetractionOp(id, verb, `unrelate(${id})`) - if (retractionOp) tx.addOperation(retractionOp) - } - // Operation 2: Delete verb metadata (which also deletes vector) tx.addOperation( new DeleteVerbMetadataOperation(this.storage, id) @@ -6244,6 +4314,9 @@ export class Brainy implements BrainyInterface { validateUpdateRelationParams(params) + // Reserved fields arriving via the metadata patch are remapped to their + // canonical top-level params — mirror of update()'s normalization. + params = this.remapReservedUpdateRelationMetadata(params) const existing = await this.storage.getVerb(params.id) if (!existing) { @@ -6272,36 +4345,32 @@ export class Brainy implements BrainyInterface { ? { ...(existingRec.metadata || {}), ...(params.metadata || {}) } : params.metadata || existingRec.metadata - // Build the updated stored record: v2 nested-bag — engine fields - // top-level, the merged user bag nested verbatim (mirror of update()). - const updatedWeight = params.weight ?? existingRec.weight ?? 1.0 - const updatedData = - params.data !== undefined ? params.data : existingRec.data - const updatedMetadata = buildVerbMetadataRecord( - { - verb: newVerbType, - ...(params.subtype !== undefined - ? { subtype: params.subtype } - : existingRec.subtype !== undefined && { subtype: existingRec.subtype }), - // Visibility: new value if provided, else preserve existing; stored only when the - // effective value is not 'public' (a change to 'public' drops the field). - ...(((params.visibility ?? existingRec.visibility) ?? 'public') !== 'public' && { - visibility: params.visibility ?? existingRec.visibility - }), - weight: updatedWeight, - ...(params.confidence !== undefined - ? { confidence: params.confidence } - : existingRec.confidence !== undefined && { confidence: existingRec.confidence }), - // service/createdBy are fixed at relate() time — always carried forward - // (omitting them here silently erased them on every updateRelation()). - ...(existingRec.service !== undefined && { service: existingRec.service }), - ...(existingRec.createdBy !== undefined && { createdBy: existingRec.createdBy }), - createdAt: existingRec.createdAt, - updatedAt: Date.now(), - ...(updatedData !== undefined && { data: updatedData }) - }, - newMetadata as Record - ) + // Build updated stored metadata. System fields ALWAYS win — same shape as relate(). + const updatedMetadata = { + ...newMetadata, + verb: newVerbType, + ...(params.subtype !== undefined + ? { subtype: params.subtype } + : existingRec.subtype !== undefined && { subtype: existingRec.subtype }), + // Visibility: new value if provided, else preserve existing; stored only when the + // effective value is not 'public' (a change to 'public' drops the field). + ...(((params.visibility ?? existingRec.visibility) ?? 'public') !== 'public' && { + visibility: params.visibility ?? existingRec.visibility + }), + weight: params.weight ?? existingRec.weight ?? 1.0, + ...(params.confidence !== undefined + ? { confidence: params.confidence } + : existingRec.confidence !== undefined && { confidence: existingRec.confidence }), + // service/createdBy are fixed at relate() time — always carried forward + // (omitting them here silently erased them on every updateRelation()). + ...(existingRec.service !== undefined && { service: existingRec.service }), + ...(existingRec.createdBy !== undefined && { createdBy: existingRec.createdBy }), + createdAt: existingRec.createdAt, + updatedAt: Date.now(), + ...(params.data !== undefined + ? { data: params.data } + : existingRec.data !== undefined && { data: existingRec.data }) + } // Build the verb view used by the graph index — top-level fields mirror relate()'s. const verbForIndex: GraphVerb = { @@ -6317,9 +4386,9 @@ export class Brainy implements BrainyInterface { ...(((params.visibility ?? existingRec.visibility) ?? 'public') !== 'public' && { visibility: params.visibility ?? existingRec.visibility }), - weight: updatedWeight, + weight: updatedMetadata.weight, metadata: newMetadata, - data: updatedData, + data: updatedMetadata.data, createdAt: existingRec.createdAt } @@ -6332,23 +4401,6 @@ export class Brainy implements BrainyInterface { new UpdateVerbMetadataOperation(this.storage, params.id, updatedMetadata) ) - // Re-post the verb's metadata-index row — remove the old shape, add - // the new one, same commit (the plain pair; there is no update-op - // capability for the metadata leg yet — see the GRAPH leg's - // typeChanged branch just below for the capability this ISN'T: - // that's the graph adjacency's own remove+add, keyed on the verb - // TYPE changing; the metadata row updates on EVERY updateRelation() - // call, since metadata/subtype/weight/etc. can all change without a - // type change). `existing` is the pre-update hydrated verb (already - // read above); `updatedMetadata` is the raw stored record just - // persisted — the same shape relate()/rebuild() use to add. - tx.addOperation( - new RemoveFromMetadataIndexOperation(this.metadataIndex, params.id, Brainy.jsonSafeIndexMetadata(existing), this.indexWriteGeneration) - ) - tx.addOperation( - new AddToMetadataIndexOperation(this.metadataIndex, params.id, updatedMetadata, this.indexWriteGeneration) - ) - // If the verb type changed, re-index in graph adjacency so traversal-by-type // stays consistent. The id is preserved across the swap. if (typeChanged && reindexInts) { @@ -7534,7 +5586,7 @@ export class Brainy implements BrainyInterface { this._aggregationIndex!.defineAggregate({ name: aggregateName, source: {}, - groupBy: perType ? [name, 'system.type'] : [name], + groupBy: perType ? [name, 'noun'] : [name], metrics: { count: { op: 'count' } } }) } @@ -7545,12 +5597,7 @@ export class Brainy implements BrainyInterface { * and `counts.byField()` agree on the convention. */ private fieldCountsAggregateName(name: string): string { - // v2 suffix: the per-type dimension moved from the legacy 'noun' alias to - // 'system.type' under the addressing law — a NEW name makes the ensure - // block re-define and BACKFILL from canonical instead of silently serving - // the old-dim definition (whose 'noun' key now reads user metadata and - // would drift). The v1 rows are derived state, superseded not lost. - return `__fieldCounts_v2__${name}` + return `__fieldCounts__${name}` } /** @@ -7942,12 +5989,8 @@ export class Brainy implements BrainyInterface { ): Promise> { const excluded = this.excludedVisibilityTiers(params) if (!excluded) return new Set() - // 'system.visibility' — the engine scalar's frozen address. A bare - // 'visibility' key would address the USER's metadata bag under the - // field-addressing law and silently hide nothing (VFS/system entities - // would leak into every default read). - const ids = await this.filterIdsBelted({ - 'system.visibility': excluded.length === 1 ? excluded[0] : { oneOf: excluded } + const ids = await this.metadataIndex.getIdsForFilter({ + visibility: excluded.length === 1 ? excluded[0] : { oneOf: excluded } }) return new Set(ids) } @@ -7983,10 +6026,14 @@ export class Brainy implements BrainyInterface { // loader and cold-read probes below already defer to a migrating provider. await this.ensureInitialized({ needs: [] }) - // READ-SURFACE READINESS GATE (see filterIdsBelted): a CHECK only — it - // never builds. open() already brought every provider to serving before - // init() returned; this throws a typed NotReady error if one isn't. - this.ensureIndexesLoaded(['metadata']) + // Ensure indexes are loaded (lazy loading when disableAutoRebuild: true) + // This is a production-safe, concurrency-controlled lazy load + await this.ensureIndexesLoaded() + + // One-shot cold-open self-heal: an O(1) probe of the metadata index (when the + // provider offers one) repairs an already-poisoned index on first read — the + // metadata counterpart of the graph cold-load guard. No-op for the JS index. + await this.ensureMetadataConsistencyProbed() // Loudly flag a degraded derived index (failed init rebuild, or an // adopt-forward degraded commit) so a partial result is never mistaken for @@ -7998,13 +6045,6 @@ export class Brainy implements BrainyInterface { let params: FindParams = typeof query === 'string' ? await this.parseNaturalQuery(query) : query - // The vector and graph legs gate only the finds that consult them. - const consultsVector = Boolean( - (params.query && params.query.trim() !== '') || params.vector || params.near - ) - if (consultsVector) this.ensureIndexesLoaded(['vector']) - if (params.connected) this.ensureIndexesLoaded(['graph']) - // Id normalization (8.0): resolve the graph-traversal anchor id(s) so a // caller may constrain by natural key. Each maps to the canonical UUID // add() stored; real UUIDs pass through. Done once here so every downstream @@ -8020,24 +6060,6 @@ export class Brainy implements BrainyInterface { } } - // MATCH-ALL NORMALIZATION (served-or-refused law): an empty `where: {}` - // carries zero predicates, so it MUST route exactly like an absent `where`. - // Left in place it reads as "filter criteria present" below, builds an - // empty index filter, and `getIdsForFilter({})` answers `[]` by contract — - // a silent empty on a query that semantically matches everything (worst on - // a freshly reopened brain, where it masquerades as data loss; on the - // vector path it short-circuits `find({ query, where: {} })` to `[]`). - // Dropped here, ONCE, before branch selection: the query takes the - // unfiltered match-all branch below, which serves from truth-complete - // sources — a storage page bounded to the offset+limit window (never a - // full walk), or the column store's top-K sort when orderBy is present. - // Every delegating surface (Db pins via host.find, pagination.find, - // streaming.search, subgraph query seeding) inherits this routing. - if (params.where !== undefined && !whereConstrains(params.where)) { - const { where: _emptyWhere, ...rest } = params - params = rest as FindParams - } - // Zero-config validation (static import for performance) validateFindParams(params) @@ -8088,64 +6110,25 @@ export class Brainy implements BrainyInterface { await this.verifyMetadataLive() } - // PLANNED FIND (optional provider door, `MetadataIndexProvider.planFindPage`). - // - // The stage doors below each serve one stage, so a find that consults - // three of them crosses into the index three times and marshals a result - // set at every crossing — a filter matching a hundred thousand rows - // builds a hundred thousand id strings to return a page of twenty-five. - // An index that can decide the stage order itself answers the page in one - // call and materializes ids only for the page. - // - // The hook sits ABOVE the branch selection because the branches are what - // decide stage order per call site; an index that plans has to be asked - // before that choice is made, not inside one of its arms. - // - // Optional and additive: a provider without the door, and any shape the - // door hands back, take exactly the path they always took. `null` is a - // routing decision the door must make BEFORE doing any work — never a - // partial answer. Every guard above still ran (readiness, the migration - // gate, the where-clause validation, the metadata cold-read guard), and - // the serving law is applied here on the way out: an empty answer is - // re-verified against the index that produced it before it is believed. - const planningIndex = this.metadataIndex as unknown as MetadataIndexProvider - if (typeof planningIndex.planFindPage === 'function') { - const planned = await planningIndex.planFindPage(params, [...hiddenIds], this.graphIndex) - if (planned !== null && planned !== undefined) { - if (planned.ids.length === 0) { - // A cold adjacency can report a size yet hold no edges, so an empty - // graph answer is not truth until the adjacency verifies live. A - // genuinely edgeless anchor verifies and the empty result stands. - if (planned.emptyAt === 'graph') await this.verifyGraphAdjacencyLive() - return [] - } - const plannedEntities = await this.batchGet(planned.ids) - const plannedResults: Result[] = [] - for (const id of planned.ids) { - const entity = plannedEntities.get(id) - if (entity) plannedResults.push(this.createResult(id, 1.0, entity)) - } - return plannedResults - } - } - // Handle metadata-only queries (no vector search needed) if (!hasVectorSearchCriteria && !hasGraphCriteria && hasFilterCriteria) { // Build filter for metadata index let filter: any = {} if (params.where) { - // Where keys pass through UNTOUCHED — the addressing law parses - // them at the index boundary. The old where.type→noun alias is - // dead: bare 'type' is the user's own field now. Object.assign(filter, params.where) + // Alias: where.type → where.noun (storage field name for entity type) + if ('type' in filter && !('noun' in filter)) { + filter.noun = filter.type + delete filter.type + } } - if (params.service) filter['system.service'] = params.service + if (params.service) filter.service = params.service // Subtype (top-level standard field — fast path, not metadata fallback). // Must be assigned BEFORE the type-array expansion below so the spread // into each anyOf branch carries it through. if (params.subtype !== undefined) { - filter['system.subtype'] = Array.isArray(params.subtype) + filter.subtype = Array.isArray(params.subtype) ? { oneOf: params.subtype } : params.subtype } @@ -8153,11 +6136,11 @@ export class Brainy implements BrainyInterface { if (params.type) { const types = Array.isArray(params.type) ? params.type : [params.type] if (types.length === 1) { - filter['system.type'] = types[0] + filter.noun = types[0] } else { filter = { anyOf: types.map(type => ({ - 'system.type': type, + noun: type, ...filter })) } @@ -8198,7 +6181,7 @@ export class Brainy implements BrainyInterface { // offset stays 0 because the visibility filter + slice happen here. The JS // index ignores the bound and returns all matches (behaviour unchanged). const pageEnd = (params.offset || 0) + (params.limit || 10) + hiddenIds.size - filteredIds = await this.filterIdsBelted(filter, { limit: pageEnd, offset: 0 }) + filteredIds = await this.metadataIndex.getIdsForFilter(filter, { limit: pageEnd, offset: 0 }) } // Visibility hard filter — drop hidden ids BEFORE pagination so limit is exact. @@ -8211,7 +6194,7 @@ export class Brainy implements BrainyInterface { // Batch-load entities for 10x faster cloud storage performance // GCS: 10 entities = 1×50ms vs 10×50ms = 500ms (10x faster) - const entitiesMap = await this.#hydratePage(pageIds, params.fields, this.#guardFieldsFor(params)) + const entitiesMap = await this.batchGet(pageIds) for (const id of pageIds) { const entity = entitiesMap.get(id) if (entity) { @@ -8248,7 +6231,7 @@ export class Brainy implements BrainyInterface { if (hiddenIds.size > 0) allUuids = allUuids.filter((id) => !hiddenIds.has(id)) const pageIds = allUuids.slice(offset, offset + limit) - const entitiesMap = await this.#hydratePage(pageIds, params.fields, this.#guardFieldsFor(params)) + const entitiesMap = await this.batchGet(pageIds) for (const id of pageIds) { const entity = entitiesMap.get(id) if (entity) { @@ -8270,13 +6253,13 @@ export class Brainy implements BrainyInterface { // filter returns nothing from getIdsForFilter, so the unfiltered case below uses // getNouns instead (it returns all nouns, including their visibility). if (Object.keys(filter).length > 0) { - let filteredIds = await this.filterIdsBelted(filter) + let filteredIds = await this.metadataIndex.getIdsForFilter(filter) // Visibility hard filter — drop hidden ids BEFORE pagination. if (hiddenIds.size > 0) filteredIds = filteredIds.filter((id) => !hiddenIds.has(id)) const pageIds = filteredIds.slice(offset, offset + limit) // Batch-load entities for 10x faster cloud storage performance - const entitiesMap = await this.#hydratePage(pageIds, params.fields, this.#guardFieldsFor(params)) + const entitiesMap = await this.batchGet(pageIds) for (const id of pageIds) { const entity = entitiesMap.get(id) if (entity) { @@ -8319,39 +6302,9 @@ export class Brainy implements BrainyInterface { // JS path — there the materialized `candidateIds` restricts the walk instead. let preResolvedAllowedIds: OpaqueIdSet | undefined - // Graph-first law (10.4.8, BRAINY-PROD-LATENCY-TRIAD rounds 44/45): with - // `connected` present the NEIGHBOUR SET is the candidate universe. It is - // resolved first from the adjacency (O(neighbours)), the metadata filter - // is evaluated over those ids only, and paging happens LAST. The earlier - // order materialized the whole-store filtered id list, paged it, hydrated - // the page, and only then intersected with the neighbours — O(store) per - // call, and a neighbour outside the first page was silently dropped. - let graphFirstIds: string[] | null = null - if (hasGraphCriteria) { - graphFirstIds = await this.resolveConnectedIds(params) - if (hiddenIds.size > 0) { - graphFirstIds = graphFirstIds.filter((id) => !hiddenIds.has(id)) - } - if ( - graphFirstIds.length > 0 && - (params.where || params.type || params.subtype || params.service || params.excludeVFS) - ) { - preResolvedFilter = this.buildMetadataFilter(params) - graphFirstIds = await this.filterIdsWithinBelted(preResolvedFilter, graphFirstIds) - } - if (graphFirstIds.length === 0) { - return [] - } - if (!hasVectorSearchCriteria) { - return await this.pageConnectedIds(params, graphFirstIds) - } - // The vector leg walks ONLY the neighbours (its candidate walk). The - // filter is already applied above, so no opaque universe is produced — - // it would describe the whole store, not the neighbour set. - preResolvedMetadataIds = graphFirstIds - } else if (params.where || params.type || params.subtype || params.service || params.excludeVFS) { + if (params.where || params.type || params.subtype || params.service || params.excludeVFS) { preResolvedFilter = this.buildMetadataFilter(params) - preResolvedMetadataIds = await this.filterIdsBelted(preResolvedFilter) + preResolvedMetadataIds = await this.metadataIndex.getIdsForFilter(preResolvedFilter) // Visibility hard filter — restrict the HNSW candidate set to non-hidden ids. if (hiddenIds.size > 0) { @@ -8382,18 +6335,6 @@ export class Brainy implements BrainyInterface { const searchMode = params.searchMode || 'auto' const limit = params.limit || 10 - // HYDRATE LAST (the hybrid path): its legs and its fusion rank IDS, and - // canonical is read at the two page exits below — never for a row the - // metadata filter is about to discard. This closure re-applies a hybrid - // row's match visibility once its entity is in hand; it is set only by - // the hybrid branch, so every other path hydrates unchanged. - let finishHybridRow: ((row: Result, pending: Result) => void) | undefined - - // Set once the metadata block below has already ranked and CUT the page. - // The tail must not cut it a second time: `offset` has been consumed, and - // re-slicing a `limit`-long page by `offset` returns nothing at all. - let pagedEarly = false - // Handle text-only query (user explicitly wants text search) if (searchMode === 'text' && params.query && params.query.trim() !== '') { results = await this.executeTextSearch(params.query, limit * 2) @@ -8404,32 +6345,20 @@ export class Brainy implements BrainyInterface { } // Handle explicit hybrid or auto mode with query else if ((searchMode === 'auto' || searchMode === 'hybrid') && params.query && params.query.trim() !== '' && !params.vector) { - // Zero-config hybrid: combine text + semantic search with RRF fusion. - // BOTH legs are held to the metadata filter's universe: the vector leg - // walks it as its candidate set, and the text leg ranks inside it - // instead of ranking the whole store and discarding what the filter - // would drop. Neither leg reads canonical — the page does, once. - const [textScored, semanticScored] = await Promise.all([ - this.executeTextSearchScored(params.query, limit * 2, preResolvedMetadataIds ?? undefined), - this.executeVectorSearchScored(params, preResolvedMetadataIds ?? undefined, preResolvedAllowedIds) + // Zero-config hybrid: combine text + semantic search with RRF fusion + const [textResults, semanticResults] = await Promise.all([ + this.executeTextSearch(params.query, limit * 2), + this.executeVectorSearch(params, preResolvedMetadataIds ?? undefined, preResolvedAllowedIds) ]) // Use user-specified alpha or auto-detect based on query length const alpha = params.hybridAlpha ?? this.autoAlpha(params.query) - // Tokenize query for match visibility. The word list needs the entity, - // so it is computed on the page, at hydration. + // Tokenize query for match visibility const queryWords = this.metadataIndex.tokenize(params.query) - const textResultIds = new Set(textScored.map((r) => r.id)) - finishHybridRow = (row, pending) => { - row.textMatches = this.findMatchingWords(row.entity, queryWords, textResultIds) - row.textScore = pending.textScore - row.semanticScore = pending.semanticScore - row.matchSource = pending.matchSource - } - // RRF fusion combines both ranked id sets with match visibility - results = this.rrfFusion(textScored, semanticScored, alpha) + // RRF fusion combines both result sets with match visibility + results = await this.rrfFusion(textResults, semanticResults, alpha, queryWords) } // Handle direct vector search (no query text) - no hybrid needed else if (params.vector && !params.query) { @@ -8486,33 +6415,24 @@ export class Brainy implements BrainyInterface { // Rank by score (top offset+limit), then drop the offset — identical ordering // to a full `sort((a, b) => b.score - a.score)` + slice, but the native // `sort:topK` provider can compute only the page instead of the full sort. - // - // ONLY when score IS the requested order. An explicit `orderBy` names a - // different ordering key, and this block cannot serve it: it ranks by - // score and CUTS the page, so the tail's `orderBy` sort below either - // never runs at all (the early return, when there is no `connected` / - // `fusion` work left) or runs over a page that score already chose — - // ordering eight rows relevance picked instead of the eight the field - // ordering asks for. Both readings were silent: `find({ query, where, - // orderBy })` answered in score order while `find({ where, orderBy })` - // answered in field order, and nothing said the request had been dropped. - // - // With `orderBy` present the candidate set falls through UNCUT to the - // tail, which orders it in full and pages that ordering — "page last", - // the graph-first law applied to ordering rather than to filtering. The - // set is bounded by the legs (the text matches inside the universe plus - // the beam walk's `limit * 2`), not by the store. - if (!params.orderBy && results.length >= offset + limit) { + if (results.length >= offset + limit) { const k = offset + limit const order = rankIndicesByScore(results.map(r => r.score), k, true) results = reorderByIndices(results, order).slice(offset, k) - pagedEarly = true - // Batch-load entities only for the paginated results (10x faster on GCS). - // This is the hydrate-last seam for the deferring paths: a row that - // arrives as a ranked shell is rebuilt in full here — flattened - // fields, entity and match visibility — never `entity` alone. - results = await this.hydrateResultPage(results, finishHybridRow) + // Batch-load entities only for the paginated results (10x faster on GCS) + const idsToLoad = results.filter(r => !r.entity).map(r => r.id) + if (idsToLoad.length > 0) { + const entitiesMap = await this.batchGet(idsToLoad) + for (const result of results) { + if (!result.entity) { + const entity = entitiesMap.get(result.id) + if (entity) { + result.entity = entity + } + } + } + } // Early return if no other processing needed if (!params.connected && !params.fusion) { @@ -8527,7 +6447,7 @@ export class Brainy implements BrainyInterface { // Batch-load entities for current page - O(page_size) instead of O(total_results) // GCS: 10 entities = 1×50ms vs 10×50ms = 500ms (10x faster) - const entitiesMap = await this.#hydratePage(pageIds, params.fields, this.#guardFieldsFor(params)) + const entitiesMap = await this.batchGet(pageIds) for (const id of pageIds) { const entity = entitiesMap.get(id) if (entity) { @@ -8555,7 +6475,7 @@ export class Brainy implements BrainyInterface { // Batch-load entities for paginated results (10x faster on GCS) const sortedResults: Result[] = [] - const entitiesMap = await this.#hydratePage(pageIds, params.fields, this.#guardFieldsFor(params)) + const entitiesMap = await this.batchGet(pageIds) for (const id of pageIds) { const entity = entitiesMap.get(id) if (entity) { @@ -8571,11 +6491,9 @@ export class Brainy implements BrainyInterface { } } - // The text leg of a hybrid find has no candidate door, so its hits are - // held to the neighbour set here; the vector leg walked only the neighbours. - if (graphFirstIds !== null && results.length > 0) { - const neighbourSet = new Set(graphFirstIds) - results = results.filter((r) => neighbourSet.has(r.id)) + // Graph search component with O(1) traversal + if (params.connected) { + results = await this.executeGraphSearch(params, results) } // Apply fusion scoring if requested @@ -8618,20 +6536,8 @@ export class Brainy implements BrainyInterface { const finalOffset = params.offset || 0 - // Efficient pagination - only slice what we need (limit already defined - // above), THEN read canonical for the page. Rows that arrived hydrated - // pass straight through; a deferred path reads exactly these rows. - // - // A page the metadata block already cut is NOT cut again: it holds the - // rows at [offset, offset+limit) of the ranking, so slicing it by - // `offset` a second time drops the whole page. That is how - // `find({ query, connected, where, offset })` — the shapes that reach - // here after early paging, `connected` and `fusion` — answered [] for - // every page but the first. - return await this.hydrateResultPage( - pagedEarly ? results : results.slice(finalOffset, finalOffset + limit), - finishHybridRow - ) + // Efficient pagination - only slice what we need (limit already defined above) + return results.slice(finalOffset, finalOffset + limit) })() // Index-integrity guard — applied ONCE here so every find() path (metadata, @@ -8660,28 +6566,6 @@ export class Brainy implements BrainyInterface { }) } - // PROJECTION TRIM — applied once, here, AFTER the integrity guard, so every - // find() path is trimmed uniformly and the guard still saw the evidence it - // needs. Hydration carried the guard's fields alongside the projection; - // this removes them, leaving exactly what the caller named. - // - // Rows that reached here from a path the seam does not hydrate (a vector or - // text leg builds its own entities) are trimmed from what they already - // hold, so the ANSWER is the same everywhere — only the cost differs, and - // only on the paths that still read a record. - if (params.fields !== undefined && params.fields.length > 0 && result.length > 0) { - const named = [...new Set(params.fields)] - result = result.map((r) => { - const projected = this.#projectEntity( - r.id, - named, - undefined, - r.entity as unknown as Entity - ) - return { ...r, entity: projected } as typeof r - }) - } - // includeVectors — opt-in vector hydration. Default (false) keeps the perf // contract: every result path above builds entities via the metadata-only // fast path, so `entity.vector` is the empty stub. When requested, fetch the @@ -9098,18 +6982,6 @@ export class Brainy implements BrainyInterface { `An empty selector would silently delete nothing — refusing.` ) } - // An empty `where: {}` carries zero predicates. find() serves it as - // MATCH-ALL (the served-or-refused law), which on this destructive path - // would silently become "delete up to `limit` arbitrary rows". A bulk - // delete of everything must be asked for explicitly (type selector, real - // predicates, or ids) — refuse the ambiguous shape loudly. - if (params.where && !params.ids && !params.type && !whereConstrains(params.where)) { - throw new Error( - `removeMany() received where: {} — an empty filter matches EVERYTHING, ` + - `and a match-all bulk delete must be explicit. Pass real predicates, ` + - `a { type }, or { ids }; to clear the store use clear().` - ) - } if (params.ids && params.ids.length === 0) { throw new Error( `removeMany() received ids: [] — an empty id list deletes nothing. ` + @@ -9204,13 +7076,13 @@ export class Brainy implements BrainyInterface { // Add delete operations to transaction if (noun) { tx.addOperation( - new RemoveFromVectorIndexOperation(this.index, id, noun.vector, this.indexWriteGeneration) + new RemoveFromVectorIndexOperation(this.index, id, noun.vector) ) } if (metadata) { tx.addOperation( - new RemoveFromMetadataIndexOperation(this.metadataIndex, id, metadata, this.indexWriteGeneration) + new RemoveFromMetadataIndexOperation(this.metadataIndex, id, metadata) ) } @@ -9493,11 +7365,6 @@ 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() @@ -9833,393 +7700,6 @@ export class Brainy implements BrainyInterface { return this.generationStore?.getFactLog()?.segmentPaths(options) ?? [] } - /** - * @description This brain's storage authority as read at open: `'tree'` - * (the canonical record tree is authoritative; the generation log is a - * complete dual-written journal — the default) or `'log'` (the log is - * authoritative; single-op acks are durable-at-ack). See - * {@link adoptLogAuthority} for the guarded flip. - */ - logAuthority(): LogAuthorityRecord { - return { ...this._logAuthority } - } - - /** - * @description Run the log-completeness VERIFICATION ORACLE (read-only): - * replay the generation log and diff the resulting per-id state against - * the canonical tree. Green = the log exactly reproduces canonical truth. - * Red NAMES every divergence class — `pre-log-record` rows (canonical - * history the log never saw) need a baseline backfill before this brain - * can ever flip. Safe at any time; walks are paged and memory-bounded - * (digests, never bodies). - */ - async verifyLogAuthority(): Promise { - return this.runOracle() - } - - /** - * The oracle run behind {@link Brainy.verifyLogAuthority}; the adoption - * backfill calls it with `listAll` so one scan yields the ENTIRE curable - * mismatch set instead of the wire-capped first 200. - */ - private async runOracle(options?: { listAll?: boolean }): Promise { - await this.ensureInitialized() - return runLogCompletenessOracle({ - storage: this.storage as unknown as LogAuthorityStorage, - scanFacts: () => this.scanFacts(), - ...(options?.listAll ? { mismatchListCap: Number.POSITIVE_INFINITY } : {}), - // Both sides normalize to ENTITY TRUTH before digesting: canonical - // wrappers denormalize HNSW residue (connections/level) the log never - // carries — digesting it would fake state-differs on any nonzero-level - // node (the residue has its own rebuild path; it is not entity state). - canonicalNounDigest: async (id: string) => { - const raw = await this.storage.readNounRaw(id) - if (raw.metadata === null && raw.vector === null) return null - return recordDigest(nounEntityTruth({ metadata: raw.metadata, vector: raw.vector })) - }, - factRecordDigest: (record: unknown) => - recordDigest(nounEntityTruth(record as { metadata: unknown; vector: unknown })) - }) - } - - /** - * @description THE GUARDED FLIP: run the oracle; on GREEN, persist the - * authority switch and enable durable-at-ack immediately (the rest of - * log-authoritative behavior engages at the next open — the switch is - * checked-at-open by law). On RED the flip REFUSES, naming the first - * divergence and the cure. One-directional unless an operator reverts - * the stored artifact explicitly. - * @returns The oracle report (green) — callers surface it as the flip receipt. - * @throws When the oracle is red; nothing is written. - */ - async adoptLogAuthority(): Promise { - await this.ensureInitialized() - this.assertWritable('adoptLogAuthority') - // Fold-checkpoint chain, phase 1: a FRESH brain (no committed - // generations) arms the chain now so the backfill's re-commits below - // feed the canonical-sync accumulator — its first stamp is then total. - // A non-fresh flip skips (the store refuses the arm); its chain starts - // at the first recovery fold instead. Disarmed on any failure below. - this.generationStore.beginFoldCheckpointBootstrap() - try { - return await this.adoptLogAuthorityInner() - } catch (err) { - this.generationStore.abandonFoldCheckpointBootstrap() - throw err - } - } - - /** The adoption body — see {@link Brainy.adoptLogAuthority} (which owns the - * fold-checkpoint bootstrap arm/disarm around it). */ - private async adoptLogAuthorityInner(): Promise { - let report = await this.runOracle({ listAll: true }) - - // BASELINE BACKFILL: curable divergences are rows whose CANONICAL truth - // simply never reached the log — pre-log records (e.g. the generation-0 - // VFS root, or a brain older than its log) and witness drift from - // maintenance that rewrote canonical outside a generation. The cure is - // an identity re-commit: any generational touch of the row makes the - // commit fact capture the CURRENT canonical bytes (the fact reads - // canonical back after execute), so the log converges on witness truth. - // Log-AHEAD divergences (log-live-canonical-absent / - // log-tombstone-canonical-present) are NOT curable by backfill — the - // log claims things the witness denies — and refuse loudly below. - // - // RUNS TO COMPLETION. Each pass sees the ENTIRE curable set (the oracle - // is run uncapped here) and cures all of it, so a pre-log baseline of - // any size adopts in ONE call — the only stop is the no-progress guard. - // A production brain with a 12.7k-row baseline once advanced exactly - // 800 rows per call (a five-pass ceiling × the 200-row wire cap) and sat - // tree-authoritative for hours; the bound was sized for drift, never - // for a baseline. Pace rides the write path now: one full-brain scan - // per pass amortizes over thousands of cures, not two hundred. - let passes = 0 - for (;;) { - if (report.verdict !== 'red') break - passes++ - const curable = report.mismatches.filter( - (m) => m.reason === 'pre-log-record' || m.reason === 'state-differs' - ) - const incurable = report.mismatches.filter( - (m) => m.reason !== 'pre-log-record' && m.reason !== 'state-differs' - ) - if (incurable.length > 0) { - throw new Error( - `adoptLogAuthority(): the log claims state the canonical witness denies ` + - `(${incurable.length} divergence(s); first: ${incurable[0].reason} on ` + - `${incurable[0].id}) — backfill cannot cure a log-ahead divergence. ` + - `Investigate before flipping; the witness remains authoritative.` - ) - } - if (curable.length === 0) break - prodLog.info( - `[Brainy] adoptLogAuthority: baseline backfill pass ${passes} — re-committing ` + - `${curable.length} row(s) whose canonical truth never reached the log` - ) - // Progress narration for a live operator: a large baseline is minutes - // of visible motion, never a silent wait. - const narrateEvery = curable.length >= 2000 ? 1000 : curable.length >= 400 ? 200 : 0 - let cured = 0 - for (const m of curable) { - const raw = await this.storage.readNounRaw(m.id) - if (raw.metadata === null && raw.vector === null) continue // vanished since the scan - cured++ - if (narrateEvery > 0 && cured % narrateEvery === 0) { - prodLog.info( - `[Brainy] adoptLogAuthority: backfill pass ${passes} — ${cured}/${curable.length} rows re-committed` - ) - } - // LAW-SHAPE RE-COMMIT: rewrite canonical as EXACTLY the wrapper the - // log's reconstruction produces (the hydration law: denormalized - // enumeration fields derived from the metadata leg + the embedding - // floats). This is what makes the backfill actually CURE - // state-differs drift: rows written before the hydration law carry - // denormalized copies that disagree with their own metadata leg, and - // an as-is identity re-commit preserves that drift forever — the - // oracle re-flags it every pass and existing brains never flip. The - // metadata leg is the authority (denormalized fields are its - // projections, per the field-addressing law); nothing degrades: the - // floats ride through, adjacency residue has its own rebuild path. - const wrapper = - raw.vector !== null && typeof raw.vector === 'object' && !Array.isArray(raw.vector) - ? (raw.vector as Record) - : null - const vector = Array.isArray(raw.vector) - ? (raw.vector as number[]) - : Array.isArray(wrapper?.vector) - ? (wrapper!.vector as number[]) - : [] - const lawWrapper = reconstructNounWrapper(m.id, raw.metadata, vector) - const priorRaw = { metadata: raw.metadata, vector: raw.vector } - await this.persistSingleOp({ nouns: [m.id] }, async (tx) => { - tx.addOperation({ - name: 'BaselineLawShapeRewrite', - execute: async () => { - await this.storage.writeNounRaw(m.id, { - metadata: raw.metadata, - vector: lawWrapper - }) - return async () => { - await this.storage.writeNounRaw(m.id, priorRaw) - } - } - }) - }, undefined, undefined, undefined, 'system:adoption-backfill') - } - const next = await this.runOracle({ listAll: true }) - // THE ONLY STOP: no progress. With uncapped listings both counts are - // exact, so "not fewer mismatches than before" means the cure could - // not express this divergence — refuse to spin, name it. - if (next.verdict === 'red' && next.mismatches.length >= report.mismatches.length) { - throw new Error( - `adoptLogAuthority(): baseline backfill made no progress ` + - `(${report.mismatches.length} → ${next.mismatches.length} mismatches; first: ` + - `${next.mismatches[0]?.reason} on ${next.mismatches[0]?.id}) — refusing to loop. ` + - `This is a divergence class the backfill cannot express; investigate.` - ) - } - report = next - } - if (passes > 0) { - prodLog.info( - `[Brainy] adoptLogAuthority: baseline backfill complete in ${passes} pass(es) — ` + - `oracle ${report.verdict}, ${report.nounsChecked} noun(s) checked` - ) - } - - this._logAuthority = await flipToLogAuthority( - this.storage as unknown as LogAuthorityStorage, - report - ) - this.generationStore.setLogDurability('at-ack') - // Fold-checkpoint chain, phase 2: the flip is recorded — open the stamp - // gate so the next flush/close barrier writes the first checkpoint. - this.generationStore.completeFoldCheckpointBootstrap() - // ARM-AT-FLIP for the NON-FRESH brain (the chain refused the fresh-brain - // arm because committed > 0): run one paged FULL canonical barrier now — - // every live row's canonical bytes fsynced, bounded memory — then stamp - // the first checkpoint. Without this, the chain could only arm at the - // brain's first crash, and that crash paid a WHOLE-LOG fold: a production - // brain hit exactly that on its first post-flip boot (a full-log - // materializing fold, restarted three times mid-flight). Adoption already - // pays O(N) oracle work; one more O(N) barrier founds bounded recovery - // from minute zero. - if (!this.generationStore.foldCheckpointChainArmed()) { - const PAGE = 500 - let synced = 0 - prodLog.info( - `[Brainy] adoptLogAuthority: founding the fold checkpoint — syncing every ` + - `row's canonical bytes (paged; progress every 2000 rows)` - ) - let offset = 0 - let cursor: string | undefined - for (;;) { - const page = await this.storage.getNouns({ - pagination: cursor ? { limit: PAGE, cursor } : { limit: PAGE, offset } - }) - const ids = page.items.map((i) => (i as { id: string }).id) - if (ids.length > 0) { - await this.storage.syncEntityCanonical?.(ids, []) - synced += ids.length - if (synced % 2000 < PAGE && synced >= 2000) { - prodLog.info(`[Brainy] adoptLogAuthority: checkpoint founding — ${synced} rows synced`) - } - } - if (page.hasMore && page.nextCursor) { cursor = page.nextCursor; offset += ids.length; continue } - if (page.hasMore && !page.nextCursor) { offset += PAGE; continue } - break - } - let vOffset = 0 - let vCursor: string | undefined - for (;;) { - const page = await this.storage.getVerbs({ - pagination: vCursor ? { limit: PAGE, cursor: vCursor } : { limit: PAGE, offset: vOffset } - }) - const ids = page.items.map((i) => (i as { id: string }).id) - if (ids.length > 0) { - await this.storage.syncEntityCanonical?.([], ids) - synced += ids.length - } - if (page.hasMore && page.nextCursor) { vCursor = page.nextCursor; vOffset += ids.length; continue } - if (page.hasMore && !page.nextCursor) { vOffset += PAGE; continue } - break - } - await this.generationStore.stampFoldCheckpointAfterFullBarrier() - } - return report - } - - /** - * @description THE ATTESTED PER-ID RECONCILE DOOR for the one divergence - * class the adoption backfill refuses BY DESIGN: `log-live-canonical-absent` - * — the log holds a live record for a row the canonical tree says does not - * exist. The engine cannot tell a legitimate pre-log deletion (the log - * missed the tombstone — the deferred-durability-era ack-window class) from - * canonical LOSS (the log holds the only surviving copy); auto-curing would - * silently destroy data in one of the two readings. A HUMAN attests which: - * - * - `attest: 'deleted'` — the row was legitimately deleted; mint the - * tombstone fact the log always lacked (canonical stays absent). The - * log's history keeps the old live record — as-of reads before the - * tombstone still see it. - * - `attest: 'restore'` — canonical lost the row; fold the log's latest - * after-image back into canonical (both sides now agree it lives). - * - * Loud, narrated, single-row, and stamped `origin: 'system:reconcile'` on - * both the tx-log entry and the commit fact. Refuses (typed) when the id's - * log and canonical already agree, when `restore` is attested but the log - * holds no record, and when canonical is PRESENT-but-different (that is - * `state-differs` — `adoptLogAuthority()`'s backfill owns it). - * - * @param id - The single entity id to reconcile. - * @param options.attest - The human's word on which reading is true. - * @returns What was done and the generation that recorded it. - * @throws When the divergence is not the attested class (nothing is written). - */ - async reconcileLogDivergence( - id: string, - options: { attest: 'deleted' | 'restore' } - ): Promise<{ reconciled: 'tombstoned' | 'restored'; id: string; generation: number }> { - await this.ensureInitialized() - this.assertWritable('reconcileLogDivergence') - - // Fold the log for THIS id (one scan; a rare operator door). - const scan = this.scanFacts() - if (!scan) { - throw new Error('reconcileLogDivergence: this store has no fact log — nothing to reconcile against') - } - let logLatest: { tombstoned: boolean; record: { metadata: unknown; vector: unknown } | null } | null = null - for await (const batch of scan.batches()) { - for (const fact of batch.facts) { - for (const op of fact.ops) { - if (op.kind === 'noun' && op.id === id) { - logLatest = - op.record === null - ? { tombstoned: true, record: null } - : { tombstoned: false, record: { metadata: op.record.metadata, vector: op.record.vector } } - } - } - } - } - const canonical = await this.storage.readNounRaw(id) - const canonicalAbsent = canonical.metadata === null && canonical.vector === null - - // Only the log-live + canonical-absent shape passes; everything else - // names its actual state and the door that owns it. - if (!logLatest || logLatest.tombstoned) { - throw new Error( - `reconcileLogDivergence(${id}): the log's latest state is ` + - `${logLatest ? 'a tombstone' : 'no record at all'} — there is no ` + - `log-live-canonical-absent divergence here. If the oracle reports this id, ` + - `re-run verifyLogAuthority() for the current class.` - ) - } - if (!canonicalAbsent) { - throw new Error( - `reconcileLogDivergence(${id}): canonical is PRESENT — this is not the ` + - `log-live-canonical-absent class. If canonical differs from the log ` + - `(state-differs), adoptLogAuthority()'s backfill cures it; nothing was written.` - ) - } - - if (options.attest === 'deleted') { - // Mint the tombstone fact the log always lacked. writeNounRaw with null - // parts is an idempotent delete; the commit fact reads canonical back - // after execute (absent) and records the tombstone. - const receipt = await this.persistSingleOp( - { nouns: [id] }, - async (tx) => { - tx.addOperation({ - name: 'ReconcileTombstone', - execute: async () => { - await this.storage.writeNounRaw(id, { metadata: null, vector: null }) - return async () => { - // Undo of an idempotent delete of an absent row: nothing. - } - } - }) - }, - undefined, - undefined, - undefined, - 'system:reconcile' - ) - prodLog.warn( - `[Brainy] reconcileLogDivergence: ${id} attested DELETED — tombstone fact minted ` + - `at generation ${receipt.generation}; the log now agrees the row is gone ` + - `(its history keeps the earlier live record).` - ) - return { reconciled: 'tombstoned', id, generation: receipt.generation! } - } - - // attest: 'restore' — the log's copy is the survivor; fold it back. - const record = logLatest.record! - const receipt = await this.persistSingleOp( - { nouns: [id] }, - async (tx) => { - tx.addOperation({ - name: 'ReconcileRestore', - execute: async () => { - await this.storage.writeNounRaw(id, record) - return async () => { - await this.storage.writeNounRaw(id, { metadata: null, vector: null }) - } - } - }) - }, - undefined, - undefined, - undefined, - 'system:reconcile' - ) - prodLog.warn( - `[Brainy] reconcileLogDivergence: ${id} attested RESTORE — the log's latest ` + - `after-image was folded back into canonical at generation ${receipt.generation}. ` + - `Derived indexes reconcile at next open/repairIndex; the row serves from canonical now.` - ) - return { reconciled: 'restored', id, generation: receipt.generation! } - } - /** * @description Read the reified transaction log — one entry per committed * generation, carrying the committed generation, the commit timestamp, and @@ -10449,7 +7929,6 @@ export class Brainy implements BrainyInterface { meta: options?.meta, ifAtGeneration: options?.ifAtGeneration, precommit: casPrecommit, - ...(plan.markerRecords.length > 0 ? { records: plan.markerRecords } : {}), execute: async () => { await this.transactionManager.executeTransaction( async (tx) => { @@ -10483,20 +7962,10 @@ 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) - this.noteWriteForPersistence() const receipt: TransactReceipt = { generation, timestamp, ids: plan.ids } return this.createPinnedDb({ generation, timestamp, receipt }) } @@ -10853,27 +8322,6 @@ export class Brainy implements BrainyInterface { return this.generationStore.compact(options) } - /** - * @description Repack cold generation history into sealed segments — - * re-representation, never deletion: every record and delta stays readable - * (`asOf()` unchanged); the physical file count drops by orders of - * magnitude. Runs automatically (time-bounded) at `close()`; call this for - * explicit maintenance windows on long-lived writers. The ONLY history - * transform permitted under the archival profile (`retention: 'all'`). - * @param options - `timeBudgetMs` bounds the pass (early stop = consistent - * prefix, next pass resumes); `batchGenerations` sizes each fold. - * @returns Folded generation count and segments created. - */ - async repackHistory(options?: { - timeBudgetMs?: number - batchGenerations?: number - }): Promise<{ foldedGenerations: number; segmentsCreated: number }> { - this.assertWritable('repackHistory') - await this.ensureInitialized() - await this.generationStore.flushPendingSingleOps() - return this.generationStore.repackHistory(options) - } - /** * @description Read-only generational-history footprint for fleet audits: * generation count, total on-disk bytes, generation/timestamp range, the @@ -10901,24 +8349,6 @@ export class Brainy implements BrainyInterface { } } - /** - * @description A deterministic content digest of the generation log through - * `g` (D8 — gate-to-generation provenance): identical history produces the - * identical digest on any machine; divergence produces a different one. - * Release gates and suite verdicts pin `{generation, digest}` and verify - * both at execution time instead of pinning a git commit. O(segments + - * live-tier window), never O(all generations). Throws `RangeError` out of - * range and `GenerationCompactedError` below the horizon — a gate can - * never silently pin reclaimed history. - * @example - * const gate = { generation: brain.generation(), digest: await brain.generationDigest(brain.generation()) } - */ - async generationDigest(g: number): Promise { - await this.ensureInitialized() - await this.generationStore.flushPendingSingleOps() - return this.generationStore.generationDigest(g) - } - /** * @description Drive the adaptive retention byte budget at runtime — the * settable input a machine-level coordinator (e.g. cor's `ResourceManager`, @@ -11101,13 +8531,7 @@ export class Brainy implements BrainyInterface { } const floorGeneration = this.generationStore.generation() - // The swap runs inside the generation store's exclusive section: pending - // flush timers are disarmed and buffers discarded BEFORE any directory is - // removed, so a background flush can never write into `_system/` mid-swap - // (the ENOTEMPTY race a checkpoint stamp once hit). - await this.generationStore.runStateReplacement(() => - this.storage.restoreFromDirectory(path) - ) + await this.storage.restoreFromDirectory(path) await this.generationStore.reopenAfterRestore(floorGeneration) // If the entity-id mapper is a NATIVE provider with a `rebuild()`, reload it @@ -11574,18 +8998,6 @@ export class Brainy implements BrainyInterface { for (const id of nounIds) { const noun = await snapshotStorage.getNoun(id) if (noun && Array.isArray(noun.vector) && noun.vector.length > 0) { - // THE ZERO-NORM LAW: a direct provider-write seam (this materializer - // inserts one-by-one, bypassing AddToVectorIndexOperation's own - // belt) — apply the same refusal here rather than handing a false - // attractor to the ephemeral reader's index. - if (isZeroNormVector(noun.vector)) { - prodLog.warn( - `[Brainy] materializeAtGeneration: refusing to index a zero-norm vector for ` + - `entity ${noun.id} — a zero-norm vector is not a vector and never crosses an ` + - `engine boundary (the materialized record is unaffected)` - ) - continue - } await reader.index.addItem({ id: noun.id, vector: noun.vector }) } } @@ -11694,9 +9106,7 @@ export class Brainy implements BrainyInterface { } // 'absent' / vectorless / wrong-dim → skip (not vector-rankable at this gen). if (Array.isArray(vec) && vec.length === dim) { - // Mint-now fallback stamps the CURRENT committed watermark (the mint - // happens now, regardless of the historical G being materialized). - ints.push(BigInt(idMapper.getInt(id) ?? idMapper.getOrAssign(id, this.indexWriteGeneration()))) + ints.push(BigInt(idMapper.getInt(id) ?? idMapper.getOrAssign(id))) rows.push(vec) } } @@ -11734,9 +9144,7 @@ export class Brainy implements BrainyInterface { postCommit: [], casUpdates: [], createdNouns: new Set(), - changeEvents: [], - markerRecords: [], - vectorUnlands: [] + changeEvents: [] } for (const op of ops) { @@ -11799,7 +9207,10 @@ export class Brainy implements BrainyInterface { ): Promise { const { op: _discriminator, ...rawParams } = op validateAddParams(rawParams as AddParams) - const params = rawParams as AddParams + // Same reserved-field normalization as add() — the metadata bag is + // cleaned BEFORE enforcement so a remapped subtype participates in + // subtype-pairing enforcement and only custom fields reach the index. + const params = this.remapReservedAddMetadata(rawParams as AddParams) this.enforceTrackedFieldValues(params.metadata as Record | undefined, 'metadata') this.enforceTrackedFieldValues({ subtype: params.subtype } as Record, 'top-level') this.enforceSubtypeOnAdd('add', params.type, params.subtype, params.metadata) @@ -11852,39 +9263,13 @@ export class Brainy implements BrainyInterface { } } - // MT5 deferred embedding: ack at durability with a stub vector and a - // DURABLE pending marker (written BEFORE the commit — an orphaned marker - // from a failed commit is harmless and reaped by the worker; a - // 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 - 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.` + const vector = params.vector || (await this.embed(params.data)) + if (!this.dimensions) { + this.dimensions = vector.length + } else if (vector.length !== this.dimensions) { + throw new Error( + `Vector dimension mismatch: expected ${this.dimensions}, got ${vector.length}` ) - 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. - if (!deferringEmbed && vector.length > 0) { - if (!this.dimensions) { - this.dimensions = vector.length - } else if (vector.length !== this.dimensions) { - throw new Error( - `Vector dimension mismatch: expected ${this.dimensions}, got ${vector.length}` - ) - } } // isNew controls the operation's rollback strategy: a custom id may @@ -11900,36 +9285,30 @@ export class Brainy implements BrainyInterface { plan.createdNouns.add(id) const now = Date.now() - // v2 nested-bag record — mirror of add(): engine fields top-level, the - // user's bag nested verbatim (collider names stay the user's). - const storageMetadata = buildNounMetadataRecord( - { - data: params.data, - noun: params.type, - ...(params.subtype !== undefined && { subtype: params.subtype }), - // visibility: stored only when not 'public' (absent === public, keeps records lean) - ...(params.visibility !== undefined && - params.visibility !== 'public' && { visibility: params.visibility }), - service: params.service, - createdAt: now, - updatedAt: now, - _rev: 1, - ...(params.confidence !== undefined && { confidence: params.confidence }), - ...(params.weight !== undefined && { weight: params.weight }), - ...(params.createdBy && { createdBy: params.createdBy }) - }, - { - ...params.metadata, - // Preserve the caller's original (non-UUID) id when normalized — mirror - // of add(). A real UUID passes through with no _originalId. - ...(originalId !== undefined && { [ORIGINAL_ID_KEY]: originalId }) - } - ) + const storageMetadata = { + ...params.metadata, + // Preserve the caller's original (non-UUID) id when normalized — mirror + // of add(). A real UUID passes through with no _originalId. + ...(originalId !== undefined && { [ORIGINAL_ID_KEY]: originalId }), + data: params.data, + noun: params.type, + ...(params.subtype !== undefined && { subtype: params.subtype }), + // visibility: stored only when not 'public' (absent === public, keeps records lean) + ...(params.visibility !== undefined && + params.visibility !== 'public' && { visibility: params.visibility }), + service: params.service, + createdAt: now, + updatedAt: now, + _rev: 1, + ...(params.confidence !== undefined && { confidence: params.confidence }), + ...(params.weight !== undefined && { weight: params.weight }), + ...(params.createdBy && { createdBy: params.createdBy }) + } const entityForIndexing = { id, vector, connections: new Map(), - // no `level` — plumbing never enters the indexing view + level: 0, type: params.type, ...(params.subtype !== undefined && { subtype: params.subtype }), ...(params.visibility !== undefined && @@ -11947,25 +9326,11 @@ export class Brainy implements BrainyInterface { } } - if (deferringEmbed) { - // The pending marker rides the batch's ONE commit fact (same - // generation, one atomic append); the worker kicks post-commit via - // the plan hook. - plan.markerRecords.push(this.enqueuePendingEmbed(id)) - plan.postCommit.push(() => this.kickEmbedWorker()) - } plan.operations.push( - // hasVector: see the single-add() insert path's comment — never true - // for a deferred embed (stub vector `[]`; counted later at landing). - new SaveNounMetadataOperation(this.storage, id, storageMetadata, isNew, vector.length > 0), + new SaveNounMetadataOperation(this.storage, id, storageMetadata, isNew), new SaveNounOperation(this.storage, { id, vector, connections: new Map(), level: 0 }, isNew), - // Gated on `vector.length > 0` — see the single-add() insert path's - // matching comment: an explicit `vector: []` has nothing to index - // either, deferred or not. - ...(vector.length > 0 - ? [new AddToVectorIndexOperation(this.index, id, vector, this.indexWriteGeneration)] - : []), - new AddToMetadataIndexOperation(this.metadataIndex, id, entityForIndexing, this.indexWriteGeneration) + new AddToVectorIndexOperation(this.index, id, vector), + new AddToMetadataIndexOperation(this.metadataIndex, id, entityForIndexing) ) plan.touchedNouns.push(id) plan.postCommit.push(() => { @@ -12001,7 +9366,10 @@ export class Brainy implements BrainyInterface { ): Promise { const { op: _discriminator, ...rawParams } = op validateUpdateParams(rawParams as UpdateParams) - const params = rawParams as UpdateParams + // Same reserved-field normalization as update() — user-mutable fields + // remap to their dedicated param (top-level wins), system-managed fields + // drop with a one-shot warning. + const params = this.remapReservedUpdateMetadata(rawParams as UpdateParams) // Id normalization (8.0) — mirror of update(): a natural key resolves to the // canonical UUID add() stored. A real UUID passes through. params.id = resolveEntityId(params.id) @@ -12035,104 +9403,47 @@ export class Brainy implements BrainyInterface { // Resolve the updated vector — mirror of update(): an explicit `vector` // always wins, new `data` re-embeds, otherwise the existing vector is // kept. Any vector change re-indexes HNSW below. - // 'data' is present whenever it's not null/undefined — '' is real - // content (see the identical hasNewData in update()); a plain truthy - // check would silently skip re-embedding an emptied value and leave a - // stale vector with no path to ever correct itself. - const rawHasNewData = params.data !== undefined && params.data !== null - // No re-embed on unchanged data — the transact() mirror of update()'s rule. - const dataUnchanged = rawHasNewData && Brainy.sameEntityData(params.data, existing.data) - const hasNewData = rawHasNewData && !dataUnchanged let vector = existing.vector - - // 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) { + if (params.vector) { + if (this.dimensions && params.vector.length !== this.dimensions) { throw new Error( - `Vector dimension mismatch: expected ${this.dimensions}, got ${explicitVector.length}` + `Vector dimension mismatch: expected ${this.dimensions}, got ${params.vector.length}` ) } - vector = explicitVector - } else if (hasNewData) { + vector = params.vector + } else if (params.data) { vector = await this.embed(params.data) } - 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 needsReindexing = Boolean(params.data || params.type || params.vector) const newMetadata = params.merge !== false ? { ...existing.metadata, ...params.metadata } : params.metadata || existing.metadata const now = Date.now() - // v2 nested-bag record — mirror of update(): engine fields top-level, - // the merged user bag nested verbatim. - const updatedMetadata = buildNounMetadataRecord( - { - data: params.data !== undefined ? params.data : existing.data, - noun: params.type || existing.type, - service: existing.service, - createdAt: existing.createdAt, - updatedAt: now, - _rev: currentRev + 1, - ...(params.confidence !== undefined && { confidence: params.confidence }), - ...(params.weight !== undefined && { weight: params.weight }), - ...(params.confidence === undefined && - existing.confidence !== undefined && { confidence: existing.confidence }), - ...(params.weight === undefined && - existing.weight !== undefined && { weight: existing.weight }), - ...(params.subtype !== undefined && { subtype: params.subtype }), - ...(params.subtype === undefined && - existing.subtype !== undefined && { subtype: existing.subtype }), - // Visibility: new value if provided, else preserve existing; stored only when the - // effective value is not 'public' (a change to 'public' drops the field). - ...(((params.visibility ?? existing.visibility) ?? 'public') !== 'public' && { - visibility: params.visibility ?? existing.visibility - }) - }, - newMetadata as Record - ) + const updatedMetadata = { + ...newMetadata, + data: params.data !== undefined ? params.data : existing.data, + noun: params.type || existing.type, + service: existing.service, + createdAt: existing.createdAt, + updatedAt: now, + _rev: currentRev + 1, + ...(params.confidence !== undefined && { confidence: params.confidence }), + ...(params.weight !== undefined && { weight: params.weight }), + ...(params.confidence === undefined && + existing.confidence !== undefined && { confidence: existing.confidence }), + ...(params.weight === undefined && + existing.weight !== undefined && { weight: existing.weight }), + ...(params.subtype !== undefined && { subtype: params.subtype }), + ...(params.subtype === undefined && + existing.subtype !== undefined && { subtype: existing.subtype }), + // Visibility: new value if provided, else preserve existing; stored only when the + // effective value is not 'public' (a change to 'public' drops the field). + ...(((params.visibility ?? existing.visibility) ?? 'public') !== 'public' && { + visibility: params.visibility ?? existing.visibility + }) + } // Register for the authoritative under-mutex CAS re-verify + rev re-stamp // (see PlannedTransact.casUpdates). The staged UpdateNounMetadataOperation @@ -12148,7 +9459,7 @@ export class Brainy implements BrainyInterface { id: params.id, vector, connections: new Map(), - // no `level` — plumbing never enters the indexing view + level: 0, type: params.type || existing.type, subtype: params.subtype !== undefined ? params.subtype : existing.subtype, ...(((params.visibility ?? existing.visibility) ?? 'public') !== 'public' && { @@ -12176,31 +9487,23 @@ export class Brainy implements BrainyInterface { } plan.operations.push( - new UpdateNounMetadataOperation(this.storage, params.id, updatedMetadata) + new UpdateNounMetadataOperation(this.storage, params.id, updatedMetadata), + new SaveNounOperation(this.storage, { + id: params.id, + vector, + connections: new Map(), + level: 0 + }) ) - // Noun-record write + HNSW reindex ONLY when the vector side actually - // changed — the same write-granularity law as update(): a metadata-only - // patch must never rewrite the whole vector record. This plan path is the - // one transact() updates ride, so an unconditional save here would - // re-open the read-sweep disk-saturation amplifier for exactly the - // consumers batching their stat touches through transact(). if (needsReindexing) { plan.operations.push( - new SaveNounOperation(this.storage, { - id: params.id, - vector, - connections: new Map(), - level: 0 - }), - // ONE atomic vector-index leg — same law as update(): the row must - // never be absent from vector search during an update (see - // ReplaceInVectorIndexOperation). - new ReplaceInVectorIndexOperation(this.index, params.id, existing.vector, vector, this.indexWriteGeneration) + new RemoveFromVectorIndexOperation(this.index, params.id, existing.vector), + new AddToVectorIndexOperation(this.index, params.id, vector) ) } plan.operations.push( - new RemoveFromMetadataIndexOperation(this.metadataIndex, params.id, removalMetadata, this.indexWriteGeneration), - new AddToMetadataIndexOperation(this.metadataIndex, params.id, entityForIndexing, this.indexWriteGeneration) + new RemoveFromMetadataIndexOperation(this.metadataIndex, params.id, removalMetadata), + new AddToMetadataIndexOperation(this.metadataIndex, params.id, entityForIndexing) ) plan.touchedNouns.push(params.id) @@ -12280,10 +9583,10 @@ export class Brainy implements BrainyInterface { } if (noun) { - plan.operations.push(new RemoveFromVectorIndexOperation(this.index, id, noun.vector, this.indexWriteGeneration)) + plan.operations.push(new RemoveFromVectorIndexOperation(this.index, id, noun.vector)) } if (metadata) { - plan.operations.push(new RemoveFromMetadataIndexOperation(this.metadataIndex, id, metadata, this.indexWriteGeneration)) + plan.operations.push(new RemoveFromMetadataIndexOperation(this.metadataIndex, id, metadata)) } // Pre-read metadata rides along: the count decrement must not depend on // re-reading the record being removed (see remove()). @@ -12297,15 +9600,6 @@ export class Brainy implements BrainyInterface { new RemoveFromGraphIndexOperation(this.graphIndex, verb, () => this.resolveVerbEndpointInts(verb), this.graphWriteGeneration), new DeleteVerbMetadataOperation(this.storage, verb.id) ) - // Retract the cascaded relation's metadata-index row too — the - // transact() mirror of remove()'s single-op cascade leg - // (null-metadata-safe; see metadataIndexRetractionOp's JSDoc). - { - const cascadeRetractionOp = this.metadataIndexRetractionOp( - verb.id, verb, `transact remove(${id}) cascade unrelate ${verb.id}` - ) - if (cascadeRetractionOp) plan.operations.push(cascadeRetractionOp) - } plan.touchedVerbs.push(verb.id) state.verbs.delete(verb.id) state.removedVerbs.add(verb.id) @@ -12344,14 +9638,6 @@ export class Brainy implements BrainyInterface { this._aggregationIndex.onEntityDeleted(id, entityForAgg) } }) - } else { - // Un-gated (mirror of remove()): a before-image-less delete flags an - // exact rescan instead of silently skipping the decrement. - plan.postCommit.push(() => { - this._aggregationIndex?.flagAllForRescan( - `transact delete of ${id} carried no before-image metadata — contribution unknowable` - ) - }) } state.nouns.delete(id) @@ -12372,7 +9658,8 @@ export class Brainy implements BrainyInterface { ): Promise { const { op: _discriminator, ...rawParams } = op validateRelateParams(rawParams as RelateParams) - const params = rawParams as RelateParams + // Same reserved-field normalization as relate(). + const params = this.remapReservedRelateMetadata(rawParams as RelateParams) // Id normalization (8.0) — mirror of relate(): resolve BOTH endpoints to the // canonical UUID add() stored, so a relate op may reference either side by // natural key. Real UUIDs pass through. (Relationship ids are engine-minted.) @@ -12422,23 +9709,19 @@ export class Brainy implements BrainyInterface { const id = uuidv4() const relationVector = fromEntity.vector.map((v, i) => (v + toEntity.vector[i]) / 2) const now = Date.now() - // v2 nested-bag record — mirror of relate(): engine fields top-level, - // the user's edge bag nested verbatim. - const verbMetadata = buildVerbMetadataRecord( - { - verb: params.type, - ...(params.subtype !== undefined && { subtype: params.subtype }), - // visibility: stored only when not 'public' (absent === public, keeps records lean) - ...(params.visibility !== undefined && - params.visibility !== 'public' && { visibility: params.visibility }), - weight: params.weight ?? 1.0, - ...(params.confidence !== undefined && { confidence: params.confidence }), - ...(params.service !== undefined && { service: params.service }), - createdAt: now, - ...(params.data !== undefined && { data: params.data }) - }, - (params.metadata as Record) || {} - ) + const verbMetadata = { + ...(params.metadata || {}), + verb: params.type, + ...(params.subtype !== undefined && { subtype: params.subtype }), + // visibility: stored only when not 'public' (absent === public, keeps records lean) + ...(params.visibility !== undefined && + params.visibility !== 'public' && { visibility: params.visibility }), + weight: params.weight ?? 1.0, + ...(params.confidence !== undefined && { confidence: params.confidence }), + ...(params.service !== undefined && { service: params.service }), + createdAt: now, + ...(params.data !== undefined && { data: params.data }) + } const verb: GraphVerb = { id, vector: relationVector, @@ -12472,10 +9755,7 @@ export class Brainy implements BrainyInterface { // id mapper to assign an int for an entity that did not exist yet. new AddToGraphIndexOperation(this.graphIndex, verb, () => this.resolveVerbEndpointInts(verb), this.graphWriteGeneration, (verbInt) => this.cacheVerbInt(verbInt, id) - ), - // The transact() mirror of relate()'s metadata-index leg — same - // commit as the graph leg, same raw stored shape. - new AddToMetadataIndexOperation(this.metadataIndex, id, verbMetadata, this.indexWriteGeneration) + ) ) plan.touchedVerbs.push(id) state.verbs.set(id, verb) @@ -12517,8 +9797,7 @@ export class Brainy implements BrainyInterface { new SaveVerbMetadataOperation(this.storage, reverseId, verbMetadata), new AddToGraphIndexOperation(this.graphIndex, reverseVerb, () => this.resolveVerbEndpointInts(reverseVerb), this.graphWriteGeneration, (verbInt) => this.cacheVerbInt(verbInt, reverseId) - ), - new AddToMetadataIndexOperation(this.metadataIndex, reverseId, verbMetadata, this.indexWriteGeneration) + ) ) plan.touchedVerbs.push(reverseId) state.verbs.set(reverseId, reverseVerb) @@ -12563,12 +9842,6 @@ export class Brainy implements BrainyInterface { // may have been created earlier in this same batch (forward refs). new RemoveFromGraphIndexOperation(this.graphIndex, verb, () => this.resolveVerbEndpointInts(verb), this.graphWriteGeneration) ) - // The transact() mirror of unrelate()'s metadata-index leg - // (null-metadata-safe; see metadataIndexRetractionOp's JSDoc — a - // present `verb` here is never metadata-omitted, but the closure - // stays defensive rather than assuming). - const retractionOp = this.metadataIndexRetractionOp(id, verb, `transact unrelate(${id})`) - if (retractionOp) plan.operations.push(retractionOp) } plan.operations.push(new DeleteVerbMetadataOperation(this.storage, id)) plan.touchedVerbs.push(id) @@ -13277,31 +10550,6 @@ export class Brainy implements BrainyInterface { } } - /** - * @description Stamp every projection's watermark with the store's - * current committed generation — the door BOTH {@link flush} and {@link - * close} open right before persisting, so EITHER path leaves a stamped, - * `'adopt'`-verdicting artifact on disk (stamp-after-data still holds - * inside each owner: this only hands the generation over — the owner's - * OWN flush is what durably writes the stamp, LAST). Before this method - * existed, `close()` had its own separate flush fan-out that never - * stamped, so a `close()` without a preceding explicit `flush()` left - * every projection unstamped — a real, closed store that legitimately - * verdicts `'rescan'` on its very next open (not a bug in the verdict, - * a gap in `close()`'s persistence completeness that this closes). - * No `committedGeneration` capability, or a replacement provider that - * doesn't carry the stamp method (a native pair swaps these managers) = - * no stamp = the owner's verdict machinery treats the artifact as - * legacy — never a flush/close crash either way. - */ - private stampProjectionWatermarks(): void { - const wmGen = this.storage?.committedGeneration?.() ?? null - if (wmGen === null) return - ;(this.metadataIndex as { stampWatermark?: (g: number) => void }).stampWatermark?.(wmGen) - ;(this.index as { stampWatermark?: (g: number) => void }).stampWatermark?.(wmGen) - ;(this.graphIndex as { stampWatermark?: (g: number) => void }).stampWatermark?.(wmGen) - } - /** * Flush all indexes and caches to persistent storage * CRITICAL FIX: Ensures data survives server restarts @@ -13323,100 +10571,7 @@ export class Brainy implements BrainyInterface { * process.exit(0) * }) */ - flush(): Promise { - // ---- THE SINGLE-FLIGHT GATE ---- - // One flush body runs at a time, with at most ONE queued behind it. See - // `_flushInFlight` / `_flushFollowUp` for the measurement that required - // this. NOT `async`: the gate hands back the very promise the work is on, - // so joining callers share identity, not just an outcome. The gate is - // crossed BEFORE any await, so two callers in the same tick cannot both - // find the field empty. - if (this._flushInFlight) { - if (!this._flushQueued) { - // A BARE DEFERRED, not a chain off the leader — see the field's doc. - // Nothing here awaits the leader, so no waiter can ever be reachable - // only through the promise it is itself blocking. - this._flushQueued = new Promise((resolve, reject) => { - this._flushQueuedSettle = { resolve, reject } - }) - } - return this._flushQueued - } - return this.#startFlushLeader() - } - - /** - * @description Run one flush body as the leader and install it as - * `_flushInFlight`. On settle — resolved OR rejected — the gate opens and - * the ONE queued waiter (if any) is promoted. The `finally` callback returns - * nothing on purpose: a callback that returned the promoted run's promise - * would make the leader await its own follower. - * - * ECMAScript-private (`#`), not TypeScript `private`: `private` is erased at - * compile time, so the method would still land on the prototype — and the - * contract manifest reads the surface the BUILD exposes, so it would emit - * this as a contract door. A door is a promise every engine implementing the - * contract must keep; this is the flush gate's own bookkeeping. `#` keeps it - * off the prototype, where the emitter cannot see it. - * @returns The leader's own promise, settling on its own body alone. - */ - #startFlushLeader(): Promise { - const run = this._runFlush() - // `finally` and not `then`: a failed flush must still open the gate, or - // one rejection would wedge every later flush behind a promise nobody - // will ever settle. - const gated: Promise = run.finally(() => { - if (this._flushInFlight === gated) this._flushInFlight = null - this.#promoteQueuedFlush() - }) - this._flushInFlight = gated - return gated - } - - /** - * @description Promote the single queued waiter (if one is waiting) to - * leader and settle its deferred from that run. Never throws into the - * leader's `finally`: a synchronous failure starting the promoted run is - * reported to the waiter, which must be settled on every path. - * - * ECMAScript-private for the same reason as the leader starter above: - * internals are not doors. - * @returns Nothing. - */ - #promoteQueuedFlush(): void { - const settle = this._flushQueuedSettle - if (!settle) return - // Clear BEFORE starting, so the promoted run's own joiners queue afresh - // rather than joining a deferred that is already being settled. - this._flushQueued = null - this._flushQueuedSettle = null - try { - this.#startFlushLeader().then(settle.resolve, settle.reject) - } catch (error) { - settle.reject(error) - } - } - - /** - * @description The flush body — everything {@link flush} promises, run - * exactly once at a time by that method's single-flight gate. Private - * because non-overlap is part of the contract: there is no supported way to - * run two of these at once, and the counters here witness that. - * @returns Nothing. - */ - private async _runFlush(): Promise { - this._flushBodyRuns++ - this._flushBodiesActive++ - this._flushConcurrencyPeak = Math.max(this._flushConcurrencyPeak, this._flushBodiesActive) - try { - await this._flushSteps() - } finally { - this._flushBodiesActive-- - } - } - - /** @description The flush steps themselves. See {@link flush}. */ - private async _flushSteps(): Promise { + async flush(): Promise { await this.ensureInitialized() // Read-only instances have no buffered writes to flush. close() may call @@ -13425,27 +10580,6 @@ 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() @@ -13455,8 +10589,6 @@ export class Brainy implements BrainyInterface { await this.generationStore.flushPendingSingleOps() // Flush all components in parallel for performance - // Watermark stamps ride every flush fan-out — see stampProjectionWatermarks(). - this.stampProjectionWatermarks() await Promise.all([ // 1. Flush storage adapter counts (entity/verb counts by type) (async () => { @@ -13480,22 +10612,7 @@ export class Brainy implements BrainyInterface { // 5. Persist the generation counter (8.0 MVCC — coalesced single-op // bumps become durable on every explicit flush) - this.generationStore.persistCounterNow(), - - // 6. Persist aggregation state, stamped at the committed generation - // (BRAINY-PROD-LATENCY-TRIAD / SELF-ENGINE-LIFECYCLE-SPRINT ask (a)): - // aggregation used to persist ONLY at close(), so a long-lived - // writer that flushes but never closes — the primary production - // shape — left its stamp behind after every write window, and any - // unclean exit forced a WHOLE-STORE backfill walk on the next - // first stats call (measured >60s and door-starving on a 9k-row - // production brain). Flushing here keeps the stamp current, so a - // reopen adopts (or incrementally catches up) instead of rescanning. - (async () => { - if (this._aggregationIndex) { - await this._aggregationIndex.flush() - } - })() + this.generationStore.persistCounterNow() ]) // NOTE (8.9.0): flush() no longer compacts history. Flush is DURABILITY @@ -13526,18 +10643,6 @@ 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 @@ -13548,7 +10653,7 @@ export class Brainy implements BrainyInterface { ]) await writeFamilyStamp(this.storage, ENTITY_TREE_STAMP_PATH, { family: 'entity-tree', - sourceGeneration: this.generationStore.committedGeneration(), + sourceGeneration: this.generationStore.generation(), members: { mode: 'rollup', invariants: { nounCount, verbCount } } }) } catch (error) { @@ -13561,24 +10666,16 @@ export class Brainy implements BrainyInterface { /** * @description Open-time coherence check for the entity tree's family stamp: - * compare `sourceGeneration` against the store's COMMITTED generation and - * the stamped rollup invariants against the live counters. Verdicts: + * compare `sourceGeneration` against the log head 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 @@ -13595,16 +10692,11 @@ export class Brainy implements BrainyInterface { this.storage.getNounCount(), this.storage.getVerbCount() ]) - const verdict = verifyFamilyStamp(stamp, this.generationStore.committedGeneration(), { + const verdict = verifyFamilyStamp(stamp, this.generationStore.generation(), { nounCount, verbCount }) - if (verdict.state === 'torn') { - await this.demoteTornEntityTreeStamp(stamp as FamilyStamp, verdict.stampSource, verdict.head, { - nounCount, - verbCount - }) - } else if (verdict.state === 'incoherent') { + 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 ` + @@ -13618,92 +10710,6 @@ 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. @@ -13748,36 +10754,10 @@ export class Brainy implements BrainyInterface { } /** - * @description The per-projection catch-up gauges served on - * `getIndexStatus().projections` (both the initialized and the - * pre-init snapshot — the numbers are safe to read at any lifecycle - * stage). Semantic reports the live deferred-embed backlog; metadata and - * graph are synchronous today (updated inside the write path); - * aggregation reports its rescan/catch-up backlogs (zero when the - * aggregation engine was never engaged). - */ - private projectionGauges(): { - semantic: { pendingEmbeds: number } - metadata: { synchronous: true } - graph: { synchronous: true } - aggregation: { pendingBackfills: number; pendingCatchUps: number } - } { - return { - semantic: { pendingEmbeds: this._pendingEmbedIds.size }, - metadata: { synchronous: true }, - graph: { synchronous: true }, - aggregation: { - pendingBackfills: this._aggregationIndex?.getPendingBackfills().length ?? 0, - pendingCatchUps: this._aggregationIndex?.getPendingCatchUps().length ?? 0 - } - } - } - - /** - * Get index loading status (diagnostic) + * Get index loading status (Diagnostic for lazy loading) * - * Returns detailed information about index population state. Useful for - * debugging empty query results or performance troubleshooting. + * Returns detailed information about index population and lazy loading state. + * Useful for debugging empty query results or performance troubleshooting. * * @example * ```typescript @@ -13785,115 +10765,12 @@ export class Brainy implements BrainyInterface { * console.log(`HNSW Index: ${status.hnswIndex.size} entities`) * console.log(`Metadata Index: ${status.metadataIndex.entries} entries`) * console.log(`Graph Index: ${status.graphIndex.relationships} relationships`) - * console.log(`Pending embeds: ${status.projections.semantic.pendingEmbeds}`) - * console.log(`Index build completed at open: ${status.lazyRebuildCompleted}`) + * console.log(`Lazy rebuild completed: ${status.lazyRebuildCompleted}`) * ``` */ - - /** - * The provider-seam belt for filter reads: whatever manager serves - * getIdsForFilter (the JS twin or a native replacement), a field refusal - * crossing this seam is normalized to BRAINY'S UnresolvableFieldError — - * one class identity for consumers, never a foreign twin that fails - * instanceof. All other errors pass through untouched. - */ - private async filterIdsBelted( - filter: unknown, - opts?: { limit?: number; offset?: number } - ): Promise { - // READ-SURFACE READINESS GATE (the 4.2.4 blackout's brainy half): every - // index read funnels through this helper, so the gate here makes - // serve-while-not-ready UNREPRESENTABLE — a production store once acked - // writes while every non-find() read served empty from a not-ready - // provider for 15 minutes. A CHECK only — it never builds; throws a typed - // NotReady error if a provider's health report says it isn't serving. - this.ensureIndexesLoaded(['metadata']) - try { - return await this.metadataIndex.getIdsForFilter(filter, opts) - } catch (err) { - const normalized = asBrainyFieldRefusal(err) - if (normalized) throw normalized - throw err - } - } - - /** - * The id-scoped twin of {@link filterIdsBelted}: evaluate `filter` over `ids` - * only, through the provider's own evaluation so the answer can never drift - * from `getIdsForFilter`'s. A provider without the door is served by its - * whole-store answer intersected here (the reference index implements the - * door itself). Same belt: field refusals cross as `BrainyFieldRefusal`. - */ - private async filterIdsWithinBelted(filter: unknown, ids: readonly string[]): Promise { - this.ensureIndexesLoaded(['metadata']) - const mip = this.metadataIndex as unknown as MetadataIndexProvider - try { - if (typeof mip.filterIdsWithin === 'function') { - return await mip.filterIdsWithin(filter, ids) - } - const matched = new Set(await this.metadataIndex.getIdsForFilter(filter)) - return ids.filter((id) => matched.has(id)) - } catch (err) { - const normalized = asBrainyFieldRefusal(err) - if (normalized) throw normalized - throw err - } - } - - /** - * The text-leg twin of {@link filterIdsWithinBelted}: rank `query` INSIDE the - * candidate universe, through the provider's own posting-list merge so the - * answer can never drift from `getIdsForTextQuery`'s. A provider without the - * door is served by its whole-store answer intersected here — the same rows - * in the same order, but it pays the whole-store marshal. - * - * @param query - The text query. - * @param ids - The candidate universe (the metadata filter's ids). - * @returns `{ id, matchCount }` rows inside `ids`, ranked by match count. - */ - private async textIdsWithinBelted( - query: string, - ids: readonly string[] - ): Promise> { - this.ensureIndexesLoaded(['metadata']) - const mip = this.metadataIndex as unknown as MetadataIndexProvider - if (typeof mip.getIdsForTextQueryWithin === 'function') { - return await mip.getIdsForTextQueryWithin(query, ids) - } - const within = new Set(ids) - const all = await this.metadataIndex.getIdsForTextQuery(query) - return all.filter((m) => within.has(m.id)) - } - async getIndexStatus(): Promise<{ initialized: boolean - /** `true` once open()'s index-build-if-needed step has run. Named for API - * compatibility with the retired first-query lazy-build path; a needed - * rebuild now always runs at open, never deferred to a read, so this is - * simply `initialized`'s index-build counterpart. */ lazyRebuildCompleted: boolean - /** Deferred embeds not yet landed (MT5) — the eventual-vector-index backlog. */ - pendingEmbeds: number - /** Per-projection catch-up gauges — the honest numbers behind - * {@link waitForIndexed}. `synchronous: true` marks projections updated - * inside the write path today: their barrier leg resolves immediately by - * design, and the flag becomes a real backlog gauge when the - * log-authority read path makes them asynchronous. */ - projections: { - /** The deferred-embedding backlog (same number as the top-level - * `pendingEmbeds`, which stays for compat). */ - semantic: { pendingEmbeds: number } - metadata: { synchronous: true } - graph: { synchronous: true } - aggregation: { - /** Aggregates flagged for a full rescan of existing entities - * (drained on the next aggregate query). */ - pendingBackfills: number - /** Aggregates adopted behind the watermark, with exact missing - * windows still to reconcile. */ - pendingCatchUps: number - } - } disableAutoRebuild: boolean /** `true` while a native provider runs the one-time 7.x → 8.0 rebuild LOCK. * A readiness probe should map this to HTTP 503 + Retry-After (transiently @@ -13939,8 +10816,6 @@ export class Brainy implements BrainyInterface { return { initialized: false, lazyRebuildCompleted: this.lazyRebuildCompleted, - pendingEmbeds: this._pendingEmbedIds.size, - projections: this.projectionGauges(), disableAutoRebuild: this.config.disableAutoRebuild || false, migrating: false, rebuildFailed: this._indexRebuildFailed != null, @@ -13983,8 +10858,6 @@ export class Brainy implements BrainyInterface { return { initialized: this.initialized, lazyRebuildCompleted: this.lazyRebuildCompleted, - pendingEmbeds: this._pendingEmbedIds.size, - projections: this.projectionGauges(), disableAutoRebuild: this.config.disableAutoRebuild || false, // A non-fatal index-rebuild failure recorded at init(), or adopt-forward // degraded ids, are degraded states (queries may be incomplete) — surface @@ -14076,49 +10949,21 @@ export class Brainy implements BrainyInterface { const metadataStats = await this.metadataIndex.getStats() const graphSize = await this.graphIndex.size() - // 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) { + // 1. Index size parity. HNSW must hold at least one node per indexed entity. + if (hnswSize === metadataStats.totalEntries) { checks.push({ name: 'index-parity', status: 'pass', - message: `HNSW (${hnswSize}) and the vectored-noun ledger (${vectorParityTarget}) agree.`, - details: { - hnswSize, - vectoredNouns: vectorParityTarget, - metadataEntries: metadataStats.totalEntries, - graphRelationships: graphSize - } + message: `HNSW (${hnswSize}) and metadata index (${metadataStats.totalEntries}) agree.`, + details: { hnswSize, metadataEntries: metadataStats.totalEntries, graphRelationships: graphSize } }) } else { - const drift = Math.abs(hnswSize - vectorParityTarget) + const drift = Math.abs(hnswSize - metadataStats.totalEntries) checks.push({ name: 'index-parity', - 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 - } + 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 } }) } @@ -14455,46 +11300,42 @@ export class Brainy implements BrainyInterface { // Get total count for pagination UI (O(1) when possible) count: async (params: Omit, 'limit' | 'offset'>) => { - // Match-all normalization (shared with find()): an empty `where: {}` - // carries no predicates. Counting it as a filter would route through - // getIdsForFilter({}) → [] → a silent count of 0 while rows exist. - const constrainingWhere = whereConstrains(params.where) ? params.where : undefined - // For simple type queries, use O(1) index counting - if (params.type && !params.subtype && !params.query && !constrainingWhere && !params.connected) { + if (params.type && !params.subtype && !params.query && !params.where && !params.connected) { const types = Array.isArray(params.type) ? params.type : [params.type] return types.reduce((sum, type) => sum + this.metadataIndex.getEntityCountByType(type), 0) } // For complex queries, use metadata index for efficient counting - if (constrainingWhere || params.subtype || params.service) { + if (params.where || params.subtype || params.service) { let filter: any = {} - if (constrainingWhere) { - // Where keys pass through UNTOUCHED — the one addressing law - // parses them at the index boundary (bare = user metadata, - // system.* = engine scalars). The old where.type→noun alias is - // dead: a bare 'type' is the user's own field now. - Object.assign(filter, constrainingWhere) + if (params.where) { + Object.assign(filter, params.where) + // Alias: where.type → where.noun (storage field name for entity type) + if ('type' in filter && !('noun' in filter)) { + filter.noun = filter.type + delete filter.type + } } - if (params.service) filter['system.service'] = params.service + if (params.service) filter.service = params.service if (params.subtype !== undefined) { - filter['system.subtype'] = Array.isArray(params.subtype) + filter.subtype = Array.isArray(params.subtype) ? { oneOf: params.subtype } : params.subtype } if (params.type) { const types = Array.isArray(params.type) ? params.type : [params.type] if (types.length === 1) { - filter['system.type'] = types[0] + filter.noun = types[0] } else { const baseFilter = { ...filter } filter = { - anyOf: types.map(type => ({ 'system.type': type, ...baseFilter })) + anyOf: types.map(type => ({ noun: type, ...baseFilter })) } } } - const filteredIds = await this.filterIdsBelted(filter) + const filteredIds = await this.metadataIndex.getIdsForFilter(filter) return filteredIds.length } @@ -14535,38 +11376,36 @@ export class Brainy implements BrainyInterface { return { // Stream all entities with optional filtering entities: async function* (this: Brainy, filter?: Partial>) { - // Match-all normalization (shared with find()): an empty `where: {}` - // carries no predicates — routing it through getIdsForFilter({}) - // would stream NOTHING while storage holds rows. Treat it as absent - // so it falls to the unfiltered storage-paginated walk below. - const constrainingWhere = whereConstrains(filter?.where) ? filter!.where : undefined - if (filter && (filter.type || filter.subtype || constrainingWhere || filter.service)) { + if (filter?.type || filter?.subtype || filter?.where || filter?.service) { // Use MetadataIndexManager for efficient filtered streaming let filterObj: any = {} - if (constrainingWhere) { - // Where keys pass through — the addressing law parses them at - // the index boundary; the type→noun alias is dead. - Object.assign(filterObj, constrainingWhere) + if (filter.where) { + Object.assign(filterObj, filter.where) + // Alias: where.type → where.noun (storage field name for entity type) + if ('type' in filterObj && !('noun' in filterObj)) { + filterObj.noun = filterObj.type + delete filterObj.type + } } - if (filter.service) filterObj['system.service'] = filter.service + if (filter.service) filterObj.service = filter.service if (filter.subtype !== undefined) { - filterObj['system.subtype'] = Array.isArray(filter.subtype) + filterObj.subtype = Array.isArray(filter.subtype) ? { oneOf: filter.subtype } : filter.subtype } if (filter.type) { const types = Array.isArray(filter.type) ? filter.type : [filter.type] if (types.length === 1) { - filterObj['system.type'] = types[0] + filterObj.noun = types[0] } else { const baseFilterObj = { ...filterObj } filterObj = { - anyOf: types.map(type => ({ 'system.type': type, ...baseFilterObj })) + anyOf: types.map(type => ({ noun: type, ...baseFilterObj })) } } } - const filteredIds = await this.filterIdsBelted(filterObj) + const filteredIds = await this.metadataIndex.getIdsForFilter(filterObj) // Stream filtered entities in batches for memory efficiency const batchSize = 100 @@ -14941,7 +11780,7 @@ export class Brainy implements BrainyInterface { // don't have the tracked field at all (e.g. the VFS root) bucket under // '__null__' and would otherwise pollute the count map. if (value === undefined || value === null || value === '__null__') continue - if (options?.type !== undefined && row.groupKey?.['system.type'] !== options.type) continue + if (options?.type !== undefined && row.groupKey?.['noun'] !== options.type) continue const key = String(value) result[key] = (result[key] || 0) + (typeof row.metrics?.count === 'number' ? row.metrics.count : row.count) } @@ -16225,29 +13064,16 @@ export class Brainy implements BrainyInterface { const report = await fn.call(provider) if (report && Array.isArray(report.invariants)) reports.push(report) } catch (err) { - // ONE CONTRACT FOR A THROWING PROBE, both engines: a probe that throws - // is `heal: 'none'` with the error in `detail` — flakiness can never - // buy a rebuild, and a thrown check never changes `serving` (the - // provider's serving verdict is composed by the provider, not inferred - // from a probe that failed to run). This catch used to synthesize - // `heal: 'rebuild'` — the read-triggered dark-rebuild lever one - // transient exception away — while the native composer said 'none'; - // two components disagreeing on what a throw means is how a flaky - // probe became an outage. `healthy: false` stays: an unrunnable probe - // is a named, loud, unverified state, never a clean bill. - const name = typeof (provider as { name?: string })?.name === 'string' - ? (provider as { name: string }).name - : 'unknown' reports.push({ - provider: name, + provider: 'unknown', healthy: false, - serving: true, + serving: false, invariants: [ { name: 'validate-invariants-threw', holds: false, detail: `validateInvariants() threw (contract violation — it must never throw): ${(err as Error).message}`, - heal: 'none' + heal: 'rebuild' } ], checkedAt: Date.now(), @@ -16373,13 +13199,6 @@ export class Brainy implements BrainyInterface { verbTypes?: Set, limit?: number ): Promise { - // READ-SURFACE READINESS GATE (the 4.2.4 blackout's brainy half): every - // index read funnels through this helper, so the gate here makes - // serve-while-not-ready UNREPRESENTABLE — a production store once acked - // writes while every non-find() read served empty from a not-ready - // provider for 15 minutes. A CHECK only — it never builds; throws a typed - // NotReady error if a provider's health report says it isn't serving. - this.ensureIndexesLoaded(['graph']) // 8.0 BigInt boundary: unmapped node → no relations. const nodeInt = this.graphEntityInt(nodeId) if (nodeInt === undefined) return [] @@ -16703,22 +13522,19 @@ export class Brainy implements BrainyInterface { service?: string excludeVFS?: boolean }): any | null { - // An empty `where: {}` carries no predicates — it is NOT structured - // criteria (see whereConstrains). Counting it would produce an empty - // filter object, and getIdsForFilter({}) / getIdSetForFilter({}) answer - // the empty set by contract — silently emptying a match-all query. - const constrainingWhere = whereConstrains(params.where) ? params.where : undefined - if (!(constrainingWhere || params.type || params.subtype || params.service || params.excludeVFS)) { + if (!(params.where || params.type || params.subtype || params.service || params.excludeVFS)) { return null } let filter: any = {} - if (constrainingWhere) { - // Where keys pass through UNTOUCHED — the one addressing law parses - // them at the index boundary (bare = user metadata, system.* = engine - // scalars, typed refusal otherwise). The old type→noun alias is dead. - Object.assign(filter, constrainingWhere) + if (params.where) { + Object.assign(filter, params.where) + // Alias: where.type → where.noun (storage field name for entity type) + if ('type' in filter && !('noun' in filter)) { + filter.noun = filter.type + delete filter.type + } } - if (params.service) filter['system.service'] = params.service + if (params.service) filter.service = params.service if (params.excludeVFS === true) { filter.vfsType = { exists: false } filter.isVFSEntity = { ne: true } @@ -16726,14 +13542,14 @@ export class Brainy implements BrainyInterface { // Subtype (top-level standard field — fast path). Assigned BEFORE the type-array // expansion below so the spread into each anyOf branch carries it through. if (params.subtype !== undefined) { - filter['system.subtype'] = Array.isArray(params.subtype) ? { oneOf: params.subtype } : params.subtype + filter.subtype = Array.isArray(params.subtype) ? { oneOf: params.subtype } : params.subtype } if (params.type) { const types = Array.isArray(params.type) ? params.type : [params.type] if (types.length === 1) { - filter['system.type'] = types[0] + filter.noun = types[0] } else { - filter = { anyOf: types.map((type) => ({ 'system.type': type, ...filter })) } + filter = { anyOf: types.map((type) => ({ noun: type, ...filter })) } } } return filter @@ -16757,44 +13573,6 @@ export class Brainy implements BrainyInterface { candidateIds?: string[], allowedIds?: OpaqueIdSet ): Promise[]> { - const scored = await this.executeVectorSearchScored(params, candidateIds, allowedIds) - - // Batch-load entities for 10-50x faster cloud storage performance - // GCS: 10 results = 1×50ms vs 10×50ms = 500ms (10x faster) - const entitiesMap = await this.batchGet(scored.map((s) => s.id)) - - const results: Result[] = [] - for (const { id, score } of scored) { - const entity = entitiesMap.get(id) - if (entity) { - results.push(this.createResult(id, score, entity)) - } - } - - return results - } - - /** - * The semantic leg WITHOUT hydration — ranked ids and their scores. - * - * The beam walk is already restricted to the candidate universe (that is what - * `candidateIds` / `allowedIds` are for), so the leg's cost is the walk. Its - * ROWS, though, are candidates for a fusion that will keep one page of them — - * so the hybrid path takes them unhydrated and reads exactly the page it - * returns. {@link executeVectorSearch} is the eager form, for the search modes - * whose leg output IS the answer. - * - * @param params - Find parameters (supplies the query/vector and the limit). - * @param candidateIds - Optional pre-resolved metadata universe (see - * {@link executeVectorSearch}). - * @param allowedIds - Optional opaque predicate-pushdown universe. - * @returns Ranked `{ id, score }` rows — no entity reads. - */ - private async executeVectorSearchScored( - params: FindParams, - candidateIds?: string[], - allowedIds?: OpaqueIdSet - ): Promise> { // Vector cold-read guard: before trusting a semantic/vector result, verify the // vector index actually SERVES a known persisted vector (one-shot per brain). // A pure semantic find({ query }) has no filter, so verifyMetadataLive never @@ -16820,10 +13598,21 @@ export class Brainy implements BrainyInterface { // HNSW search with optional metadata-first candidate filtering const searchResults: [string, number][] = await this.index.search(vector, limit * 2, undefined, searchOptions) - return searchResults.map(([id, distance]) => ({ - id, - score: Math.max(0, Math.min(1, 1 / (1 + distance))) - })) + // Batch-load entities for 10-50x faster cloud storage performance + // GCS: 10 results = 1×50ms vs 10×50ms = 500ms (10x faster) + const ids = searchResults.map(([id]) => id) + const entitiesMap = await this.batchGet(ids) + + const results: Result[] = [] + for (const [id, distance] of searchResults) { + const entity = entitiesMap.get(id) + if (entity) { + const score = Math.max(0, Math.min(1, 1 / (1 + distance))) + results.push(this.createResult(id, score, entity)) + } + } + + return results } /** @@ -16846,18 +13635,8 @@ export class Brainy implements BrainyInterface { ) } - // The anchor's VECTOR is the query; get() omits vectors by default, which - // fed a zero-length vector to the index and refused every near() with a - // dimension mismatch. Ask for it, and refuse by name when the anchor has - // none — a proximity search around an unvectored row has no meaning. - const nearEntity = await this.get(params.near.id, { includeVectors: true }) + const nearEntity = await this.get(params.near.id) if (!nearEntity) return [] - if (!nearEntity.vector || nearEntity.vector.length === 0) { - throw new Error( - `find({ near }): entity '${params.near.id}' has no vector to search around — ` + - `it was never embedded (or was unvectored). Embed it, or search with a query instead.` - ) - } const nearResults: [string, number][] = await this.index.search(nearEntity.vector, params.limit || 10) @@ -16885,16 +13664,16 @@ export class Brainy implements BrainyInterface { } /** - * Resolve `params.connected` to the neighbour id set — the graph-first - * find's candidate universe (deterministic traversal order, anchors excluded). + * Execute graph search component. * * Honors the full `GraphConstraints` contract: multi-hop `depth` (breadth-first via - * `neighbors()`), `via`/`type` verb-type filtering, and `direction`. An empty set - * is re-verified against the adjacency before it is believed — a not-serving - * adjacency throws rather than answering `[]` as truth. + * `neighbors()`), `via`/`type` verb-type filtering, and `direction`. Previously this read + * only `from`/`to`/`direction` and did a single 1-hop `getNeighbors()`, so `depth` and `via` + * were silently ignored — `find({ connected: { from, depth: 3 } })` returned only the + * immediate neighbour at every depth. */ - private async resolveConnectedIds(params: FindParams): Promise { - if (!params.connected) return [] + private async executeGraphSearch(params: FindParams, existingResults: Result[]): Promise[]> { + if (!params.connected) return existingResults const { from, to, depth, direction = 'both' } = params.connected const via = params.connected.via ?? params.connected.type @@ -16948,8 +13727,8 @@ export class Brainy implements BrainyInterface { if (anchorInt === undefined) return new Set() // unmapped → no relations const verbTypeIndex = TypeUtils.getVerbIndex(via as VerbType) - // No limit: match the JS BFS exactly — the page is cut downstream, - // after the metadata filter, by pageConnectedIds / the candidate walk. + // No limit: match the JS BFS exactly — overall result limiting happens + // downstream against existingResults. const reachedInts = await provider.findConnectedSubtype( anchorInt, verbTypeIndex, subtypeArr[0], effectiveDepth, null ) @@ -17025,53 +13804,34 @@ export class Brainy implements BrainyInterface { // Cold-load guard: an empty connected set is suspicious. The native adjacency can report // size()>0 (or isReady()===false) on a cold open yet have loaded NO source→target edges — so - // traversal would silently return [] as if it were truth. Re-verify against the health-report/ - // isReady() authority (or, for older providers, a READ-ONLY GLOBAL known-edge sample — NOT the - // queried anchor, which may be genuinely edgeless): a dead adjacency throws - // GraphIndexNotReadyError here rather than serving the empty set as fact — verifyGraphAdjacencyLive - // never rebuilds, so a genuinely edgeless anchor simply verifies 'live' and the empty result stands. + // traversal silently returns []. Re-verify against the honest isReady() signal (or, for older + // providers, a GLOBAL known-edge sample — NOT the queried anchor, which may be genuinely + // edgeless). If the adjacency was dead and a rebuild healed it, re-collect; if it stays dead, + // verifyGraphAdjacencyLive() throws GraphIndexNotReadyError. A genuinely edgeless anchor + // verifies 'live' and the empty result stands — no spurious rebuild/throw. if (connectedIds.size === 0) { - await this.verifyGraphAdjacencyLive() + const verdict = await this.verifyGraphAdjacencyLive() + if (verdict === 'rebuilt') { + await populate() + } } - return [...connectedIds] - } - - /** - * Page and hydrate an already-filtered neighbour set — the pure graph (and - * graph + metadata) find's tail. `orderBy` sorts the WHOLE set by field value - * before the page is cut (never the page after), null values last on `asc` - * and first on `desc`; without `orderBy` the traversal order stands. - */ - private async pageConnectedIds(params: FindParams, ids: string[]): Promise[]> { - const limit = params.limit || 10 - const offset = params.offset || 0 - let ordered = ids - if (params.orderBy) { - const field = params.orderBy - const asc = (params.order || 'asc') === 'asc' - const valued = await Promise.all( - ids.map(async (id) => ({ id, value: await this.metadataIndex.getFieldValueForEntity(id, field) })) - ) - valued.sort((a, b) => { - if (a.value == null && b.value == null) return 0 - if (a.value == null) return asc ? 1 : -1 - if (b.value == null) return asc ? -1 : 1 - if (a.value === b.value) return 0 - const comparison = a.value < b.value ? -1 : 1 - return asc ? comparison : -comparison - }) - ordered = valued.map((v) => v.id) + // Filter existing results to only connected entities + if (existingResults.length > 0) { + return existingResults.filter(r => connectedIds.has(r.id)) } - const pageIds = ordered.slice(offset, offset + limit) - const entitiesMap = await this.#hydratePage(pageIds, params.fields, this.#guardFieldsFor(params)) + + // Batch-load connected entities for fast cloud-storage performance const results: Result[] = [] - for (const id of pageIds) { + const ids = [...connectedIds] + const entitiesMap = await this.batchGet(ids) + for (const id of ids) { const entity = entitiesMap.get(id) if (entity) { results.push(this.createResult(id, 1.0, entity)) } } + return results } @@ -17123,64 +13883,30 @@ export class Brainy implements BrainyInterface { * @returns Array of Results with scores based on match count */ private async executeTextSearch(query: string, limit: number): Promise[]> { - const scored = await this.executeTextSearchScored(query, limit) - if (scored.length === 0) return [] + const textMatches = await this.metadataIndex.getIdsForTextQuery(query) + if (textMatches.length === 0) return [] - // Batch-load entities for the whole leg — this is the eager form, kept for - // the text-only search mode whose results ARE the answer. - const entitiesMap = await this.batchGet(scored.map((s) => s.id)) + // Take top matches and load entities + const topMatches = textMatches.slice(0, limit * 2) // Get more for filtering + const ids = topMatches.map(m => m.id) + const entitiesMap = await this.batchGet(ids) + // Create results with scores based on match count + const maxMatches = topMatches[0]?.matchCount || 1 const results: Result[] = [] - for (const { id, score } of scored) { - const entity = entitiesMap.get(id) + + for (const match of topMatches) { + const entity = entitiesMap.get(match.id) if (entity) { - results.push(this.createResult(id, score, entity)) + // Normalize score to 0-1 range based on match count + const score = match.matchCount / maxMatches + results.push(this.createResult(match.id, score, entity)) } } return results } - /** - * The text leg WITHOUT hydration — ranked ids and their scores. - * - * FILTER BEFORE HYDRATE: when the caller already knows the candidate - * universe (the metadata filter's ids in a hybrid `find({ query, where })`), - * it is passed here and the word index ranks INSIDE that universe. The - * earlier order ranked the whole store, took the top `limit * 2`, hydrated - * every one of them, and only then intersected with the filter — so a - * filtered hybrid find on a large store hydrated hundreds of rows to return - * a handful, and a matching row outside the store-wide text prefix was - * silently dropped (the same defect `find({ connected })` had before the - * graph-first law). - * - * The score is the match count normalized against the top row's, so a - * restricted call normalizes against the top row IN THE UNIVERSE — the same - * rule applied to the set actually being ranked. - * - * @param query - Text query to search for. - * @param limit - Result budget; the leg keeps `limit * 2` for the fusion. - * @param candidateIds - Optional candidate universe to rank inside. - * @returns Ranked `{ id, score }` rows — no entity reads. - */ - private async executeTextSearchScored( - query: string, - limit: number, - candidateIds?: readonly string[] - ): Promise> { - const textMatches = candidateIds - ? await this.textIdsWithinBelted(query, candidateIds) - : await this.metadataIndex.getIdsForTextQuery(query) - if (textMatches.length === 0) return [] - - // Take top matches (more than the page, for the fusion to rank) - const topMatches = textMatches.slice(0, limit * 2) - - // Normalize score to 0-1 range based on match count - const maxMatches = topMatches[0]?.matchCount || 1 - return topMatches.map((m) => ({ id: m.id, score: m.matchCount / maxMatches })) - } - /** * Auto-detect optimal alpha for hybrid search * @@ -17207,56 +13933,55 @@ export class Brainy implements BrainyInterface { * * Formula: score(d) = sum(1 / (k + rank(d))) for each list * - * Now includes match visibility (textScore, semanticScore, matchSource; the - * `textMatches` word list needs the entity and is filled at hydration). + * Now includes match visibility (textMatches, textScore, semanticScore, matchSource) * - * HYDRATE LAST: both legs arrive as ranked ids + scores, and the fusion ranks - * ids — no entity is read here. The rows it returns are ranked SHELLS; the - * page is cut from them and only that page is read from canonical (see - * {@link hydrateResultPage}). The earlier order hydrated both legs in full — - * hundreds of rows — to return one page of them. - * - * @param textResults - Ranked ids + scores from text search - * @param semanticResults - Ranked ids + scores from semantic search + * @param textResults - Results from text search + * @param semanticResults - Results from semantic search * @param alpha - Weight for semantic (0=text only, 1=semantic only) + * @param queryWords - Original query words for match tracking * @param k - RRF constant (default: 60, standard in literature) - * @returns Fused result shells sorted by combined score with match visibility + * @returns Fused results sorted by combined score with match visibility */ - private rrfFusion( - textResults: ReadonlyArray<{ id: string; score: number }>, - semanticResults: ReadonlyArray<{ id: string; score: number }>, + private async rrfFusion( + textResults: Result[], + semanticResults: Result[], alpha: number, + queryWords: string[], k: number = 60 - ): Result[] { + ): Promise[]> { // Track scores and match details per entity interface MatchData { rrf: number textScore?: number semanticScore?: number + textMatches: string[] hasText: boolean hasSemantic: boolean } const matchData = new Map() + const entityMap = new Map>() // Text contribution (1 - alpha weight) const textWeight = 1 - alpha textResults.forEach((r, rank) => { const rrfScore = textWeight * (1 / (k + rank + 1)) - const existing = matchData.get(r.id) || { rrf: 0, hasText: false, hasSemantic: false } + const existing = matchData.get(r.id) || { rrf: 0, textMatches: [], hasText: false, hasSemantic: false } existing.rrf += rrfScore existing.textScore = r.score // Original text search score (0-1) existing.hasText = true matchData.set(r.id, existing) + if (r.entity) entityMap.set(r.id, r.entity) }) // Semantic contribution (alpha weight) semanticResults.forEach((r, rank) => { const rrfScore = alpha * (1 / (k + rank + 1)) - const existing = matchData.get(r.id) || { rrf: 0, hasText: false, hasSemantic: false } + const existing = matchData.get(r.id) || { rrf: 0, textMatches: [], hasText: false, hasSemantic: false } existing.rrf += rrfScore existing.semanticScore = r.score // Original semantic search score (0-1) existing.hasSemantic = true matchData.set(r.id, existing) + if (r.entity) entityMap.set(r.id, r.entity) }) // Sort by fused score @@ -17264,93 +13989,51 @@ export class Brainy implements BrainyInterface { .sort((a, b) => b[1].rrf - a[1].rrf) .map(([id, data]) => ({ id, data })) - // Create ranked shells with match visibility + // Build results - need to load any missing entities + const missingIds = sortedIds.filter(s => !entityMap.has(s.id)).map(s => s.id) + if (missingIds.length > 0) { + const loaded = await this.batchGet(missingIds) + for (const [id, entity] of loaded) { + entityMap.set(id, entity) + } + } + + // Performance: Build set of text result IDs for O(1) lookup + // This avoids re-extracting text for entities that weren't in text results + const textResultIds = new Set(textResults.map(r => r.id)) + + // Create final results with match visibility const results: Result[] = [] for (const { id, data } of sortedIds) { - // Determine match source - let matchSource: 'text' | 'semantic' | 'both' - if (data.hasText && data.hasSemantic) { - matchSource = 'both' - } else if (data.hasText) { - matchSource = 'text' - } else { - matchSource = 'semantic' + const entity = entityMap.get(id) + if (entity) { + // Find which query words matched - uses fast path if entity wasn't in text results + const textMatches = this.findMatchingWords(entity, queryWords, textResultIds) + + // Determine match source + let matchSource: 'text' | 'semantic' | 'both' + if (data.hasText && data.hasSemantic) { + matchSource = 'both' + } else if (data.hasText) { + matchSource = 'text' + } else { + matchSource = 'semantic' + } + + // Create result with match visibility + const result = this.createResult(id, data.rrf, entity) + result.textMatches = textMatches + result.textScore = data.textScore + result.semanticScore = data.semanticScore + result.matchSource = matchSource + + results.push(result) } - - const result = this.pendingResult(id, data.rrf) - result.textScore = data.textScore - result.semanticScore = data.semanticScore - result.matchSource = matchSource - - results.push(result) } return results } - /** - * A ranked candidate whose entity has NOT been read yet. - * - * The shell carries everything the ranking tail needs — the id, the score, - * and the match-visibility fields — and nothing that requires canonical. It - * is typed `Result` so it flows through the shared dedupe / visibility / - * filter / rank / page tail unchanged; {@link hydrateResultPage} turns the - * survivors into real results before any caller sees them, and find()'s - * index-integrity guard drops any row that never gained an entity. - * - * @param id - The candidate's canonical id. - * @param score - Its rank score. - */ - private pendingResult(id: string, score: number): Result { - return { id, score } as Result - } - - /** - * Read canonical for exactly the rows that need it — the hydrate-last seam. - * - * Rows that already carry an entity (the eager legs: metadata, text-only, - * semantic-only, proximity, graph) pass through untouched, so this is a no-op - * for every path that has not deferred. Rows that are shells are read in ONE - * batch and rebuilt through {@link createResult}, so a hydrated row is - * indistinguishable from an eagerly-built one — same flattened fields, same - * `entity`, same key order — with `finish` re-applying the fields only the - * deferring path knows about (a hybrid row's match visibility). - * - * A shell whose id has no canonical row is dropped, exactly as the eager legs - * dropped it; find()'s index-integrity guard makes the same judgement on the - * page it returns. - * - * @param rows - The page's rows, ranked and paged already. - * @param finish - Applied to each rebuilt row, with its shell, after the - * flattened fields are set. - * @returns The page with every surviving row hydrated. - */ - private async hydrateResultPage( - rows: Result[], - finish?: (row: Result, pending: Result) => void - ): Promise[]> { - const pendingIds: string[] = [] - for (const row of rows) { - if (!row.entity) pendingIds.push(row.id) - } - if (pendingIds.length === 0) return rows - - const entitiesMap = await this.batchGet(pendingIds) - const hydrated: Result[] = [] - for (const row of rows) { - if (row.entity) { - hydrated.push(row) - continue - } - const entity = entitiesMap.get(row.id) - if (!entity) continue - const filled = this.createResult(row.id, row.score, entity, row.explanation) - finish?.(filled, row) - hydrated.push(filled) - } - return hydrated - } - /** * Find which query words match in an entity's text content * @@ -17630,16 +14313,9 @@ export class Brainy implements BrainyInterface { * *some* backing storage as a side effect but is reported honestly as * `'probed'`, never `'warmed'`. An empty index or unknown dimension has * nothing to probe (`'unavailable'`). - * - **Metadata**: calls the provider's own `warm?()` when the active - * `'metadataIndex'` provider implements it (`'warmed'`) — the seam a - * native metadata provider lights up so it is not duck-typed against the - * JS manager's method. Otherwise falls back to full hydration on the - * built-in JS manager — every persisted field's sparse index is loaded - * from storage (`MetadataIndexManager.hydrateAll()`), not just the - * heuristic common-fields subset `init()` warms — and reports `'warmed'`. - * Neither seam present → `'unavailable'` (honest: `init()` is never used - * as a substitute here, since a native provider's `init()` may be a - * cheap verify rather than a real warm). + * - **Metadata**: full hydration — every persisted field's sparse index is + * loaded from storage (`MetadataIndexManager.hydrateAll()`), not just the + * heuristic common-fields subset `init()` warms. * - **Graph**: calls the provider's own `warm?()` when the active graph * provider implements it; otherwise re-runs its existing eager cold-load * `init()` seam (idempotent — the JS adjacency index's `init()` already @@ -17695,23 +14371,12 @@ export class Brainy implements BrainyInterface { // --- Metadata -------------------------------------------------------- const metadataStart = Date.now() let metadataOutcome: WarmOutcome - const metadataProvider = this.metadataIndex as unknown as MetadataIndexProvider const metadataWithHydrate = this.metadataIndex as unknown as { hydrateAll?: () => Promise } - if (typeof metadataProvider.warm === 'function') { - // Active provider (e.g. a native metadata index) declares its own warm - // seam — route through it FIRST so a native provider's warmth is - // reported honestly instead of being duck-typed against the JS - // manager's hydrateAll(), which a native provider does not implement. - await metadataProvider.warm() - metadataOutcome = 'warmed' - } else if (typeof metadataWithHydrate.hydrateAll === 'function') { - // Built-in JS manager path — full sparse-index hydration. + if (typeof metadataWithHydrate.hydrateAll === 'function') { await metadataWithHydrate.hydrateAll() metadataOutcome = 'warmed' } else { - // No hydration seam on this metadata provider — nothing to run. (No - // init() fallback here: init() on a native provider may be a cheap - // verify, and reporting that as warmth would lie.) + // No hydration seam on this metadata provider — nothing to run. metadataOutcome = 'unavailable' } const metadataDurationMs = Date.now() - metadataStart @@ -17745,63 +14410,6 @@ export class Brainy implements BrainyInterface { } } - /** - * Read each index surface's self-reported outstanding maintenance work — - * the observability seam so an operator sees a grind coming (rising - * pending bytes/items, a stalled background pass) instead of discovering - * it as a CPU storm or a transaction blowing its budget mid-flight (see - * {@link TransactionTimeoutError}). - * - * PURE PASSTHROUGH: for each of vector/metadata/graph, this calls ONLY the - * ACTIVE provider's own `maintenanceDebt?()` hook (the same per-surface - * provider resolution {@link Brainy.warm} uses) and reports its - * {@link ProviderMaintenanceDebt} payload verbatim. There is no JS-side - * fallback computation, no threshold evaluation, and no polling — brainy - * surfaces the truth the provider measured; the provider owns the numbers - * and the operator owns the policy (what threshold matters, what action to - * take). A surface whose active provider does not implement the hook - * reports `'unavailable'` — never a guessed or zeroed payload. - * - * @returns A {@link MaintenanceDebtReport}: per-surface outcome + payload. - * @example - * ```typescript - * const debt = await brain.maintenanceDebt() - * if (debt.metadata.outcome === 'reported' && debt.metadata.debt?.pendingBytes) { - * console.log('metadata pending bytes:', debt.metadata.debt.pendingBytes) - * } - * ``` - */ - async maintenanceDebt(): Promise { - await this.ensureInitialized({ needs: ['vector', 'metadata', 'graph'] }) - - // --- Vector --------------------------------------------------------- - const vectorProvider = this.index as VectorIndexProvider & { - maintenanceDebt?: () => Promise - } - const vector = - typeof vectorProvider.maintenanceDebt === 'function' - ? { outcome: 'reported' as const, debt: await vectorProvider.maintenanceDebt() } - : { outcome: 'unavailable' as const } - - // --- Metadata -------------------------------------------------------- - const metadataProvider = this.metadataIndex as unknown as MetadataIndexProvider - const metadata = - typeof metadataProvider.maintenanceDebt === 'function' - ? { outcome: 'reported' as const, debt: await metadataProvider.maintenanceDebt() } - : { outcome: 'unavailable' as const } - - // --- Graph ------------------------------------------------------------- - const graphProvider = this.graphIndex as GraphIndexProvider & { - maintenanceDebt?: () => Promise - } - const graph = - typeof graphProvider.maintenanceDebt === 'function' - ? { outcome: 'reported' as const, debt: await graphProvider.maintenanceDebt() } - : { outcome: 'unavailable' as const } - - return { vector, metadata, graph } - } - /** * Explicitly warm up the embedding engine * @@ -17856,160 +14464,6 @@ export class Brainy implements BrainyInterface { return embeddingManager.isInitialized() } - /** - * Whether the process-global WASM embedding engine (all-MiniLM-L6-v2, - * fixed 384-dim output, ≈93MB with the bundled model, 90-140s cold compile - * on throttled CPUs) is this instance's active embedder — `false` when a - * plugin has replaced it via the `'embeddings'` provider key. A native - * provider has no such cold-start cost and may use a different output - * dimension, so it is never worth avoiding. - * - * Used by init-path bootstrap writes (the VFS root — see - * `VirtualFileSystem.doInitializeRoot()`) to decide whether embedding a - * value during `init()` risks paying the WASM engine's cold compile. - * - * @returns true when the default WASM engine is active (no native - * `'embeddings'` provider registered). - */ - usesDefaultWasmEmbedder(): boolean { - 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" - * empty-array shape: the vector record is rewritten to `[]`, the row is - * removed from the vector index (if present), and the vectored-noun - * ledger (`getCanonicalCounts().vectors.all`) is decremented through the - * sanctioned {@link StorageAdapter.noteVectorUnlanded} hook — so the - * coverage ledger never silently drifts. - * - * 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()`. - * - * @param id - The canonical noun id to migrate. - * @returns `true` if a migration write happened, `false` if the noun was - * already unvectored (or absent) — a no-op. - */ - async unvectorNounForRootMigration(id: string): Promise { - const noun = await this.storage.getNoun(id) - if (!noun || !Array.isArray(noun.vector) || noun.vector.length === 0) return false - - await this.persistSingleOp({ nouns: [id] }, async (tx) => { - // Rewrite the vector leg to the unvectored shape. Placeholder adjacency - // (mirrors update()'s own SaveNounOperation staging) — the op preserves - // stored graph state when `connections.size === 0`. - tx.addOperation( - new SaveNounOperation(this.storage, { - id, - vector: [], - connections: new Map(), - level: 0 - }) - ) - // Remove from the vector index — safe even if the row was never - // actually indexed (RemoveFromVectorIndexOperation's removeItem is a - // no-op when the id is absent). - tx.addOperation( - new RemoveFromVectorIndexOperation(this.index, id, noun.vector, this.indexWriteGeneration) - ) - }) - - // Vectored-noun ledger: this migration carries a vector write with no - // accompanying metadata operation (metadata is untouched), so the - // saveNounMetadata(..., hasVector) seam never fires for it — mirrors the - // deferred-embed LANDING path's use of the narrow storage hook, in - // reverse. - await this.storage.noteVectorUnlanded?.(id) - return true - } - /** * Setup embedder */ @@ -18090,19 +14544,8 @@ 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. - // "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) { + const rootEntities = await probe.listRawObjects('entities') + if (rootEntities.length > 0) { await probe.writeRawObject('_system/migration-layout.json', { layout: 'flat-v8', version: 8, @@ -18523,120 +14966,96 @@ export class Brainy implements BrainyInterface { // Multi-process safety mode: config?.mode ?? 'writer', force: config?.force ?? false, - // Engine-owned persistence cadence — defaults resolve at the trigger - // site (policy 'auto': 512 writes / 30s interval / 2s idle). - persistence: config?.persistence, - logAuthority: config?.logAuthority ?? 'adopt' + // Reserved-field-in-metadata-bag policy (8.0 — no silent failures). + // Default 'throw': an untyped caller that smuggles a reserved key past + // the compile guard gets a loud Error naming the correct write path. + // 'warn' = remap + one-shot warning per key; 'remap' = legacy silent remap. + reservedFieldPolicy: config?.reservedFieldPolicy ?? 'throw' } } /** - * @description THE READ GATE. Every read choke point (getNeighborUuids, - * find, filterIdsBelted, getTypedNeighbors) calls this before touching a - * derived index. It is a CHECK, never a build: it asks each of the three - * providers (vector, metadata, graph) for its named health verdict via - * {@link assessProviderHealth} — the provider's own sync, O(1) - * `healthReport()` when exposed, else the `isReady()` / size-heuristic - * fallback — and either lets the read proceed or throws the matching typed - * `*NotReadyError` naming the provider and its failing reasons. It NEVER - * triggers a rebuild and NEVER walks the store: a needed rebuild is - * entirely open()'s job (see {@link rebuildIndexesIfNeeded}), which runs to - * completion before `init()` returns — so by the time any read reaches - * this gate, a healthy provider is already built. A migrating provider is - * deferred to exactly as before (it owns its own in-place rebuild). + * Ensure indexes are loaded (Production-scale lazy loading) * - * A report with something worth telling an operator (a failing invariant, - * whether serving or not, or a named `unledgered` family) narrates via - * `prodLog.warn` ONCE per (provider, `report.generation`) — never once per - * read — before any throw decision is made. + * Called by query methods (find, search, get, etc.) when disableAutoRebuild is true. + * Handles concurrent queries safely - multiple calls wait for same rebuild. + * + * Performance: + * - First query: Triggers rebuild (~50-200ms for 1K-10K entities) + * - Concurrent queries: Wait for same rebuild (no duplicate work) + * - Subsequent queries: Instant (0ms check, indexes already loaded) + * + * Production scale: + * - 1K entities: ~50ms + * - 10K entities: ~200ms + * - 100K entities: ~2s (streaming pagination) + * - 1M+ entities: Uses chunked lazy loading (per-type on demand) */ - /** - * @description Whether two entity `data` payloads are the same content — - * the "no re-embed on unchanged data" comparison. Primitives compare by - * value; objects compare structurally with key order normalized. - * @param a - The incoming data. - * @param b - The stored data. - * @returns `true` when the content is identical. - */ - private static sameEntityData(a: unknown, b: unknown): boolean { - if (a === b) return true - if (a === null || b === null || typeof a !== typeof b) return false - if (typeof a !== 'object') return false - const stable = (v: unknown): string => - JSON.stringify(v, (_k, val) => - val && typeof val === 'object' && !Array.isArray(val) - ? Object.keys(val as Record).sort().reduce((o, k) => { - ;(o as Record)[k] = (val as Record)[k] - return o - }, {} as Record) - : val - ) - try { return stable(a) === stable(b) } catch { return false } - } - - private ensureIndexesLoaded( - families: ReadonlyArray<'vector' | 'metadata' | 'graph'> = ['vector', 'metadata', 'graph'] - ): void { - // PER-FAMILY SCOPE. This gate used to refuse on ANY provider's not-ready - // verdict at every read choke point — so a pure metadata find({where}) - // was refused because the VECTOR leg was not serving; a production - // deployment's badge reads returned 500s for exactly that reason on the - // pair's first adoption. A read may only be refused by the family it - // actually consults: metadata reads by the metadata leg (+ graph for a - // `connected` filter), vector search by the vector leg, traversal by the - // graph leg. Callers name what they need. - const all: ReadonlyArray BrainyError]> = [ - ['vector', this.index, VectorIndexNotReadyError], - ['metadata', this.metadataIndex, MetadataIndexNotReadyError], - ['graph', this.graphIndex, GraphIndexNotReadyError] - ] - const providers = all.filter(([name]) => families.includes(name)) - - for (const [name, provider, ErrorClass] of providers) { - // Migration LOCK (#18) deference: a migrating provider owns its own - // in-place rebuild — brainy must not judge (or race) it here. - if (this.providerIsMigrating(provider)) continue - - const assessment = assessProviderHealth(provider) - - if (assessment.reasons.length > 0 && assessment.report != null) { - const generation = assessment.report.generation - // 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 ` + - `empty result — open() builds the derived indexes; a read never does. Rebuild via ` + - `repairIndex({ rebuild: ['${name}'] }) or reopen the brain.` - ) - } + private async ensureIndexesLoaded(): Promise { + // Fast path: If rebuild already completed, return immediately (0ms) + if (this.lazyRebuildCompleted) { + return } + + // If indexes already populated AND honestly serving, mark complete and skip. + // Honest gate: when the provider exposes isReady(), that REPLACES the size()>0 + // proxy (a native index can report a non-zero size while its serving structure + // is not loaded — the silent-empty cold-load class). A not-ready provider falls + // through so the rebuild path can load it; verifyVectorLive() is the query-time + // backstop either way. Providers without isReady() keep the size() heuristic + // (the JS index's size()>0 genuinely means loaded). + const vectorReadiness = assessIndexReadiness(this.index) + if (vectorReadiness === 'ready' || (vectorReadiness === 'unknown' && this.index.size() > 0)) { + this.lazyRebuildCompleted = true + return + } + + // Migration LOCK (#18) deference: while the vector provider runs its one-time + // 7.x → 8.0 rebuild-from-canonical, a first query must NOT trigger brainy's + // force-rebuild — the provider owns that index. Normally unreachable here: the + // data-plane lock (awaitMigrationLock) makes the caller wait upstream, so a + // query only reaches this point once the migration has cleared. Defensive + // (no `lazyRebuildCompleted` latch) so the check re-runs: once the provider + // clears the lock, `index.size() > 0` above ends the lazy path normally. + if (this.providerIsMigrating(this.index)) { + return + } + + // Concurrency control: If rebuild is in progress, wait for it + if (this.lazyRebuildInProgress && this.lazyRebuildPromise) { + await this.lazyRebuildPromise + return + } + + // Check if lazy rebuild is needed + // Only needed if: disableAutoRebuild=true AND indexes are empty AND storage has data + if (!this.config.disableAutoRebuild) { + // Auto-rebuild is enabled, indexes should already be loaded + return + } + + // Check if storage has data (fast check with limit=1) + const entities = await this.storage.getNouns({ pagination: { limit: 1 } }) + const hasData = (entities.totalCount && entities.totalCount > 0) || entities.items.length > 0 + + if (!hasData) { + // Storage is empty, no rebuild needed + this.lazyRebuildCompleted = true + return + } + + // Start lazy rebuild (with mutex to prevent concurrent rebuilds) + this.lazyRebuildInProgress = true + this.lazyRebuildPromise = this.rebuildIndexesIfNeeded(true) + .then(() => { + this.lazyRebuildCompleted = true + }) + .finally(() => { + this.lazyRebuildInProgress = false + this.lazyRebuildPromise = null + }) + + await this.lazyRebuildPromise } /** @@ -18696,186 +15115,7 @@ export class Brainy implements BrainyInterface { } /** - * @description Consume the JS metadata index's watermark verdict (see - * {@link MetadataIndexManager.watermarkVerdict}) at open — the coordinator - * half of the catchup wiring; {@link MetadataIndexManager.applyWatermarkCatchup} - * is the mechanism half. Feature-detected to the JS manager only: a native - * metadata-index provider consumes the same verdict door in its own train - * (this method never touches the native-provider wrapper contract). - * - * Ordering: called from `performInit()` immediately after - * `metadataIndex.init()` has computed the verdict against the generation - * store's now-FINAL committed generation, and BEFORE `rebuildIndexesIfNeeded()` - * (the open-time rebuild gate) or any read serves — so a caller can never - * observe the pre-catchup state. - * - * @param alreadyRebuilt - `true` when crash recovery just rebuilt every - * index from canonical (rolled-back uncommitted transactions) — the - * verdict's prescribed action is redundant with what already ran (a - * fresh canonical walk supersedes any catchup fold or rescan), so it is - * skipped, narrated, rather than duplicating the work. - */ - private async consumeMetadataWatermarkVerdict(alreadyRebuilt: boolean): Promise { - if (!(this.metadataIndex instanceof MetadataIndexManager)) return - const verdict = this.metadataIndex.watermarkVerdict() - if (verdict === null || verdict === 'adopt') return - - if (alreadyRebuilt) { - prodLog.info( - `[Brainy] metadata index watermark verdict '${verdict}' at open — skipped: crash ` + - `recovery already rebuilt every index from canonical this open.` - ) - return - } - - const window = this.metadataIndex.watermarkGap() - // A genuine first boot (no persisted artifact at all) verdicts 'rescan' - // too — same as a real unverifiable artifact — but it is routine, not - // alarming: narrate it at info level instead of warn (mirrors the - // manager's own internal distinction in loadWatermarkVerdict()). - const firstBoot = verdict === 'rescan' && !this.metadataIndex.watermarkArtifactPresent() - const preNarrate = firstBoot ? prodLog.info.bind(prodLog) : prodLog.warn.bind(prodLog) - preNarrate( - verdict === 'catchup' && window - ? `[Brainy] metadata index watermark verdict: CATCHUP — folding generations ` + - `(${window.from}, ${window.to}] from the fact log before this open serves reads.` - : firstBoot - ? `[Brainy] metadata index watermark verdict: rescan (no persisted artifact — first ` + - `boot; the rebuild below is a trivial no-op walk).` - : `[Brainy] metadata index watermark verdict: RESCAN — the persisted artifact is ` + - `unverifiable (unstamped, or ahead of the store's committed generation); ` + - `forcing a full rebuild from canonical at open.` - ) - - const scan = window - ? this.scanFacts({ fromGeneration: window.from + 1, toGeneration: window.to }) - : null - const result = await this.metadataIndex.applyWatermarkCatchup(scan) - - if (result.action === 'rescan') { - const postNarrate = firstBoot ? prodLog.debug.bind(prodLog) : prodLog.warn.bind(prodLog) - postNarrate( - `[Brainy] metadata index catchup demoted to a full rebuild` + - `${result.reason ? ` — ${result.reason}` : ''}.` - ) - } else if (result.action === 'caught-up') { - prodLog.warn( - `[Brainy] metadata index catchup complete: ${result.factsApplied} fact(s) folded ` + - `(${result.nounsApplied} noun op(s), ${result.verbsApplied} verb op(s)) — index now ` + - `reflects generation ${result.window?.to}.` - ) - } - } - - /** - * @description B3 Deliverable 3 — THE ONLINE METADATA REBUILD. - * `repairIndex()`'s ceremony door for the `'metadata'` family routes here - * instead of calling `MetadataIndexManager.rebuild()` directly: build a - * FRESH replacement manager BESIDE the live one (same storage, same - * idMapper — identity is shared, never a second mapper), walk canonical - * into it while every live write during the build ALSO mirrors there - * (`MetadataIndexManager.beginShadow`), fold the generation window the - * walk may have read stale, then atomically swap this brain's reference — - * `this.metadataIndex` points at the OLD manager for the ENTIRE build, so - * every read in progress (and every read that starts before the swap - * line executes) keeps serving its full, unbuilt-adjacent population; - * nothing ever observes a half-built index. - * - * PERSISTENCE CHOICE (named per the B3 brief): the JS manager's persisted - * keys (field-index chunks, column-store segments, the watermark stamp, - * the id-mapper record) are GLOBAL per storage — not namespaced per - * manager instance — so two managers cannot safely persist independently - * mid-build (a segment-number race, a stamp race, an id-mapper reload - * that would discard the live manager's not-yet-flushed assignments — - * see `MetadataIndexManager.initForShadowBuild`'s JSDoc for the id-mapper - * hazard specifically). This build therefore PERSISTS ONLY AT SWAP: the - * shadow builds entirely in memory (`rebuild({ inMemoryOnly: true })` + - * a fact-log fold — neither touches storage) and flushes exactly once, - * after the swap, as the sole owner of the shared keys. - * - * FALLBACK: a store with no fact log (or a non-JS/native metadata - * provider — its own train owns its online-rebuild strategy) cannot - * safely bound "what landed during the walk"; this method falls back to - * the ORIGINAL blocking clear-then-walk `rebuild()`, narrated. - */ - private async rebuildMetadataIndexOnline(): Promise { - if (!(this.metadataIndex instanceof MetadataIndexManager)) { - // A registered provider (e.g. a native accelerator) may replace - // `this.metadataIndex` with a non-MetadataIndexManager object at - // runtime even though the field's declared type is the JS class — - // the cast mirrors the same reach-in used elsewhere in this file - // (e.g. checkHealth()'s `metadataProvider` locals) for exactly this. - const provider = this.metadataIndex as unknown as MetadataIndexProvider - await provider.rebuild() - return - } - - const committedAtStart = this.storage.committedGeneration?.() ?? null - const factLogAvailable = committedAtStart !== null && this.scanFacts() !== null - if (!factLogAvailable) { - prodLog.warn( - `[Brainy] repairIndex(): metadata rebuild — no fact log on this store, build-beside ` + - `is unavailable; falling back to the blocking rebuild (reads may serve a ` + - `partially-built index for its duration).` - ) - await this.metadataIndex.rebuild() - return - } - - prodLog.warn( - `[Brainy] repairIndex(): metadata rebuild — building a fresh replacement index BESIDE ` + - `the live one (reads keep serving the current index throughout); swapping in ` + - `atomically once it is caught up.` - ) - const startedAt = Date.now() - const oldManager = this.metadataIndex - const shadow = new MetadataIndexManager(this.storage, {}, { - entityIdMapper: oldManager.getIdMapper() - }) - - oldManager.beginShadow(shadow) - let committedAtSwap: number - try { - await shadow.buildBeside(committedAtStart!) - // Capture the true final generation right before the swap — a - // synchronous read, no `await` between here and the reference - // assignment below, so nothing can land ungoverned in the gap: the - // shadow has been live-mirroring every write since beginShadow() - // above, and this generation is the floor a FUTURE open's watermark - // verdict will trust once stamped. - committedAtSwap = this.storage.committedGeneration?.() ?? committedAtStart! - } catch (err) { - oldManager.endShadow() - prodLog.error( - `[Brainy] repairIndex(): online metadata rebuild FAILED during the walk/fold — the ` + - `live index is UNCHANGED (never swapped); reads keep serving the current ` + - `(pre-rebuild) metadata index. Error: ${(err as Error).message}` - ) - throw err - } - - oldManager.endShadow() - this.metadataIndex = shadow - - // NOW persist — the shadow is the SOLE owner of the shared storage keys - // (nothing references `oldManager` any more; it never flushes again). - shadow.stampWatermark(committedAtSwap) - await shadow.flush() - - prodLog.warn( - `[Brainy] repairIndex(): online metadata rebuild complete in ${Date.now() - startedAt}ms — ` + - `swapped in a fresh index reflecting generation ${committedAtSwap}, zero read downtime.` - ) - } - - /** - * @description Rebuild indexes from persisted data if needed — THE OPEN-TIME - * BUILD. Called once per open (init calls it; `repairIndex()`'s - * write-quarantine lift calls it forced). Runs to completion BEFORE `init()` - * returns: a needed rebuild is NEVER deferred to a read (there is no more - * first-query lazy path — see {@link ensureIndexesLoaded}, which is a - * read-time CHECK only). `disableAutoRebuild` no longer defers index - * construction to the first query; see its JSDoc in `brainy.types.ts`. + * Rebuild indexes from persisted data if needed (LAZY LOADING) * * FIXES FOR CRITICAL BUGS: * - Bug #1: GraphAdjacencyIndex rebuild never called ✅ FIXED @@ -18885,24 +15125,34 @@ export class Brainy implements BrainyInterface { * * Production-grade rebuild with: * - Handles BILLIONS of entities via streaming pagination - * - A provider's named {@link HealthReport} (when it exposes one) decides - * per-leg need; `isReady()` / a size heuristic decides otherwise — no - * dataset-size threshold gates whether the rebuild runs at open. + * - Smart threshold-based decisions (auto-rebuild < 1000 items) + * - Lazy loading on first query (when disableAutoRebuild: true) * - Progress reporting for large datasets * - Parallel index rebuilds for performance * - Robust error recovery (continues on partial failures) + * - Concurrency-safe (multiple queries wait for same rebuild) * - * @param force - Force the rebuild path to run even when no leg reports a need (used by `repairIndex()`'s write-quarantine lift). + * @param force - Force rebuild even if disableAutoRebuild is true (for lazy loading) */ private async rebuildIndexesIfNeeded(force = false): Promise { try { + // Check if auto-rebuild is explicitly disabled (ONLY during init, not for lazy loading) + // force=true means this is a lazy rebuild triggered by first query + if (this.config.disableAutoRebuild === true && !force) { + if (!this.config.silent) { + console.log('⚡ Auto-rebuild explicitly disabled via config') + console.log('💡 Indexes will build automatically on first query (lazy loading)') + } + return + } + // No instant fast-path here: the honest per-leg readiness checks below - // are all O(1) (one bounded storage sample + each provider's health - // report / size()/isReady()), and this method runs exactly once per - // open. The removed shortcut keyed off `this.index.size() > 0`, a - // dishonest proxy — it skipped the metadata and graph checks whenever - // the vector happened to be warm, and it never fired on a real cold - // process (the JS vector size is 0 until it loads). + // are all O(1) (one bounded storage sample + each provider's size()/ + // isReady()), and this method runs exactly once per open (init calls it; + // the lazy path passes force=true). The removed shortcut keyed off + // `this.index.size() > 0`, a dishonest proxy — it skipped the metadata + // and graph checks whenever the vector happened to be warm, and it never + // fired on a real cold process (the JS vector size is 0 until it loads). // BUG #2 FIX: Don't trust counts - check actual storage instead // Counts can be lost/corrupted in container restarts @@ -18921,23 +15171,30 @@ export class Brainy implements BrainyInterface { return } + // Intelligent decision: Auto-rebuild based on dataset size + // Production scale: Handles billions via streaming pagination + const AUTO_REBUILD_THRESHOLD = 10000 // Auto-rebuild if < 10K items (increased from 1K) + // Check if indexes need rebuilding const metadataStats = await this.metadataIndex.getStats() const hnswIndexSize = this.index.size() - // Readiness contract: a provider's named {@link HealthReport} (when - // exposed) is the authority — `serving === false` needs the rebuild, - // full stop. Absent a health report, fall back to `isReady()` (an - // mmap/disk-native index legitimately reports 0 resident entries while - // fully durable on disk, so rebuilding it from canonical on every boot - // would be the 48-seconds-per-restart class a production deployment - // hit); absent BOTH, keep the per-leg empty-heuristic passed in. - const legNeedsRebuild = (provider: unknown, emptyFallback: boolean): boolean => { - const assessment = assessProviderHealth(provider) - if (assessment.via === 'health-report') return assessment.readiness !== 'ready' - if (assessment.via === 'is-ready') return assessment.readiness === 'not-ready' - return emptyFallback + // Readiness contract: when a provider exposes isReady(), that honest + // signal REPLACES the size/count heuristic below — an mmap/disk-native + // index legitimately reports 0 resident entries while fully durable on + // disk, and rebuilding it from canonical re-reads every entity file on + // every boot (the 48-seconds-per-restart class a production deployment + // hit). The signal is honest in BOTH directions: a provider whose + // durable state failed to load returns false and gets its rebuild even + // when size() > 0 (the silent-empty cold-load failure). Providers + // without isReady() keep the exact prior empty-heuristics. + const providerReady = (leg: unknown): boolean | undefined => { + const candidate = leg as { isReady?: () => boolean } + return typeof candidate.isReady === 'function' ? candidate.isReady() : undefined } + const metadataReady = providerReady(this.metadataIndex) + const vectorReady = providerReady(this.index) + const graphReady = providerReady(this.graphIndex) // Epoch-drift trigger: a format-version change makes EVERY derived index // suspect even when each is non-empty, so it forces a rebuild of all @@ -18951,43 +15208,15 @@ 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. - // 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 metadataMigrating = this.providerIsMigrating(this.metadataIndex) + const vectorMigrating = this.providerIsMigrating(this.index) + const graphMigrating = this.providerIsMigrating(this.graphIndex) const anyMigrating = metadataMigrating || vectorMigrating || graphMigrating // Per-leg decision, in precedence order: a migrating provider owns its - // index (skip) → epoch drift forces a rebuild → the health-report/ - // isReady() authority decides → otherwise a per-leg fallback. The - // fallbacks differ by leg because "empty" means different things: + // index (skip) → epoch drift forces a rebuild → an exposed isReady() + // decides → otherwise a per-leg fallback. The fallbacks differ by leg + // because "empty" means different things: // - METADATA: past the empty-store early-return, entities exist, so the // id-mapper SHOULD have loaded entries — totalEntries===0 is a real // load-failure signal, so rebuild (self-heal from canonical). @@ -18998,132 +15227,62 @@ export class Brainy implements BrainyInterface { // against canonical) inside storage.getGraphIndex() BEFORE this gate, // so it is already authoritative here; re-deriving would be spurious // (a full O(E) verb scan on every open of an edgeless brain). It - // therefore rebuilds only on epoch drift or a native !isReady()/ - // not-serving report. (verifyGraphAdjacencyLive is the query-time - // backstop — it refuses loudly, it never rebuilds.) + // therefore rebuilds only on epoch drift or a native !isReady(). + // (verifyGraphAdjacencyLive is the query-time backstop.) const shouldRebuildMetadata = !metadataMigrating && - (epochStale || legNeedsRebuild(this.metadataIndex, metadataStats.totalEntries === 0)) - - // VECTOR LEG — the two-engine gate's last red: a migrated 7.x-era store - // can hold canonical vectored nouns with NO derived vector index built. - // `legNeedsRebuild`'s size-heuristic fallback (below) only fires off - // `hnswIndexSize === 0`, and its health-report branch trusts a - // provider's own `serving` verdict verbatim — but a provider's health - // report can legitimately say `serving: true` while vector coverage is - // honestly UNLEDGERED on ITS side too (an unledgered invariant never - // flips serving), so neither signal alone can tell "genuinely empty" - // apart from "never built". The canonical vectored-noun ledger - // (`getCanonicalCounts().vectors.all` — Deliverable 1) is the - // denominator that CAN tell them apart, and is compared here: - // - a CONFIDENT (non-suspect) ledger `> 0` while the reported node - // count is 0 is a proven coverage gap — force the build regardless - // of what a health report claims; - // - a CONFIDENT ledger `=== 0` while the node count is 0 proves there - // is nothing to load (e.g. every noun's embed is still deferred) — - // skip the size-heuristic fallback's blunt "always rebuild when - // empty" trigger, which otherwise wastes a full canonical walk for - // zero benefit on every cold open of such a store; - // - an unavailable/suspect ledger changes nothing — loud errors never - // quiet losses, so a doubtful ledger must never suppress a rebuild - // the old heuristic would have run. - // The bare `isReady()` boolean (no report, no `unledgered` concept) is - // NOT overridden — that signal is what fixed the 48-seconds-per-restart - // regression pinned in tests/unit/cold-open-rebuild-gate.test.ts (a - // disk-native provider legitimately reporting 0 resident while durable - // on disk), and re-deriving it from a denominator the provider itself - // has no way to consult would reopen exactly that regression. - const vectorAssessment = assessProviderHealth(this.index) - const vectorLedger = await this.storage.getCanonicalCounts?.() - const vectorLedgerAll = vectorLedger?.vectors.all - const vectorLedgerConfident = vectorLedger !== undefined && !vectorLedger.suspect - const vectorHasCoverageProof = vectorLedgerConfident && (vectorLedgerAll as number) > 0 - const vectorConfirmedEmpty = vectorLedgerConfident && vectorLedgerAll === 0 - - let vectorNeedsRebuild: boolean - if (vectorAssessment.via === 'is-ready') { - // Bare isReady() stays authoritative and UNMODIFIED — see above. - vectorNeedsRebuild = vectorAssessment.readiness === 'not-ready' - } else if (vectorAssessment.via === 'health-report') { - vectorNeedsRebuild = - vectorAssessment.readiness !== 'ready' || - (hnswIndexSize === 0 && vectorHasCoverageProof) - } else { - // size-heuristic / no provider (the built-in JS engine's own posture). - vectorNeedsRebuild = hnswIndexSize === 0 && !vectorConfirmedEmpty - } - - const shouldRebuildVector = !vectorMigrating && (epochStale || vectorNeedsRebuild) - - // Narration (and the FAIL-TYPED backstop below) are scoped EXACTLY to - // the defect this gate closes: a provider whose OWN health report - // claims `serving: true` — an affirmative "I am ready" a caller would - // otherwise trust outright — while the canonical ledger proves vector - // coverage is missing. This is deliberately NARROWER than "any branch - // where the ledger contributed to the decision": - // - the bare isReady() branch is untouched, as above (never in scope); - // - the health-report branch's OWN `readiness !== 'ready'` case is - // already an ordinary, PRE-EXISTING rebuild trigger (the provider - // admits not-ready) — not a ledger override, so not a "gap"; - // - the size-heuristic/no-provider branch's rebuild-when-empty is the - // SAME blunt trigger the code always had (`hnswIndexSize === 0`) - // — the ledger only ever SUPPRESSES a rebuild there (the confirmed- - // empty case), it never forces one the old heuristic wouldn't - // already have run. Marking that branch a "gap" too made the - // FAIL-TYPED backstop fire on ordinary white-box tests that stub - // rebuild() as a no-op and pin `size()` at 0 to drive OTHER - // assertions (e.g. migration-deference's isMigrating() coverage) — - // those are not silent-empty defects, so they must open exactly as - // before (tests/unit/brainy/migration-deference.test.ts). - const vectorCoverageGap = + (epochStale || + (metadataReady !== undefined ? !metadataReady : metadataStats.totalEntries === 0)) + const shouldRebuildVector = !vectorMigrating && - vectorAssessment.via === 'health-report' && - vectorAssessment.readiness === 'ready' && - hnswIndexSize === 0 && - vectorHasCoverageProof - if (vectorCoverageGap) { - prodLog.warn( - `[Brainy] open(): vector index reports ${hnswIndexSize} node(s) but the canonical ` + - `ledger holds ${vectorLedgerAll} vectored noun(s) — the derived vector index is ` + - `missing or unbuilt on this store. Forcing the vector rebuild rather than serving ` + - `silent-empty search results.` - ) - } - + (epochStale || (vectorReady !== undefined ? !vectorReady : hnswIndexSize === 0)) const shouldRebuildGraph = !graphMigrating && - (epochStale || legNeedsRebuild(this.graphIndex, false)) + (epochStale || (graphReady !== undefined ? !graphReady : false)) const needsRebuild = shouldRebuildMetadata || shouldRebuildVector || shouldRebuildGraph if (!needsRebuild && !force) { - // All indexes report current — durably loaded (health-report/isReady/ - // size), or owned by a background migration. No rebuild needed. + // All indexes report current — durably loaded (isReady/size), or owned + // by a background migration. No rebuild needed. return } - // Name exactly which legs rebuild — "all indexes" was a lie whenever - // the durable legs were skipped (e.g. only the JS vector index loads - // here on a warm reopen), and it misread as a whole-brain rebuild in - // consumer boot logs. - const rebuildingLegs = [ - shouldRebuildMetadata && 'metadata', - shouldRebuildVector && 'vector', - shouldRebuildGraph && 'graph' - ] - .filter(Boolean) - .join(' + ') + // Determine rebuild strategy + const isLazyRebuild = force && this.config.disableAutoRebuild === true + const isSmallDataset = totalCount < AUTO_REBUILD_THRESHOLD + const shouldRebuild = isLazyRebuild || isSmallDataset || this.config.disableAutoRebuild === false - // ALWAYS narrated (prodLog, never the silent-suppressible console): there - // is no more first-query lazy path — a rebuild that runs here BLOCKS - // open() regardless of dataset size or `disableAutoRebuild`, so an - // operator must see it in the boot log, not discover it as an - // unexplained slow open. - prodLog.warn( - `[Brainy] open() is building/rebuilding the ${rebuildingLegs || 'no'} index(es) from ` + - `${totalCount.toLocaleString()} stored entities — open blocks until the derived ` + - `indexes serve; reads never build.` - ) + if (!shouldRebuild) { + // Large dataset with auto-rebuild disabled: Wait for lazy loading + if (!this.config.silent) { + console.log(`⚡ Large dataset (${totalCount.toLocaleString()} items) - using lazy loading for optimal startup`) + console.log('💡 Indexes will build automatically on first query') + } + return + } + + // REBUILD: Either small dataset, forced rebuild, or explicit enable + const rebuildReason = isLazyRebuild + ? '🔄 Lazy loading triggered by first query' + : isSmallDataset + ? `🔄 Small dataset (${totalCount.toLocaleString()} items)` + : '🔄 Auto-rebuild explicitly enabled' + + if (!this.config.silent) { + // Name exactly which legs rebuild — "all indexes" was a lie whenever + // the durable legs were skipped (e.g. only the JS vector index loads + // here on a warm reopen), and it misread as a whole-brain rebuild in + // consumer boot logs. + const rebuildingLegs = [ + shouldRebuildMetadata && 'metadata', + shouldRebuildVector && 'vector', + shouldRebuildGraph && 'graph' + ] + .filter(Boolean) + .join(' + ') + console.log(`${rebuildReason} - loading/rebuilding ${rebuildingLegs || 'no'} index(es) from persisted data...`) + } // Before the graph rebuild, hydrate the entity id-mapper from the persisted // snapshot. A native int-keyed adjacency resolves every verb endpoint through @@ -19142,49 +15301,21 @@ export class Brainy implements BrainyInterface { // provider running its own background migration is skipped here (it owns // its index until it verifies-and-swaps). const rebuildStartTime = Date.now() - // The vector leg's build door, by contract with the native provider: a - // provider exposing fillFromCanonical() gets THAT call — idempotent, the - // provider's own init runs it first so this is the backstop — never a - // full rebuild() for a coverage gap. A PARTIAL shortfall deliberately - // triggers nothing here: that is repair()'s operator door. The JS index - // has no fill door and keeps its rebuild. - const vectorBuild = (): Promise => { - const fillDoor = (this.index as unknown as { fillFromCanonical?: () => Promise }) - .fillFromCanonical - if (vectorCoverageGap && typeof fillDoor === 'function') { - prodLog.warn( - `[Brainy] open(): vector coverage gap routes through the provider's ` + - `fillFromCanonical() (idempotent canonical fill), not a full rebuild.` - ) - return fillDoor.call(this.index) - } - return this.index.rebuild() - } await Promise.all([ shouldRebuildMetadata ? this.metadataIndex.rebuild() : Promise.resolve(), - shouldRebuildVector ? vectorBuild() : Promise.resolve(), + shouldRebuildVector ? this.index.rebuild() : Promise.resolve(), shouldRebuildGraph ? this.graphIndex.rebuild() : Promise.resolve() ]) const rebuildDuration = Date.now() - rebuildStartTime const metadataCountAfter = (await this.metadataIndex.getStats()).totalEntries - const graphSizeAfter = await this.graphIndex.size() - - // Completion narration — ALWAYS via prodLog (see the pre-rebuild narration - // above for why): the operator who saw "open() is building…" needs the - // matching "…and it's done" line, with the numbers to confirm it worked. - prodLog.warn( - `[Brainy] open() finished building derived indexes in ${rebuildDuration}ms: ` + - `metadata=${metadataCountAfter} entries, vector=${this.index.size()} nodes, ` + - `graph=${graphSizeAfter} relationships.` - ) if (!this.config.silent) { console.log( `All indexes rebuilt in ${rebuildDuration}ms:\n` + ` - Metadata: ${metadataCountAfter} entries\n` + ` - HNSW Vector: ${this.index.size()} nodes\n` + - ` - Graph Adjacency: ${graphSizeAfter} relationships` + ` - Graph Adjacency: ${await this.graphIndex.size()} relationships` ) } @@ -19193,15 +15324,6 @@ 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. ` + @@ -19212,24 +15334,6 @@ export class Brainy implements BrainyInterface { console.log(`[Brainy] Second rebuild result: ${secondAttempt} entries`) } - // Vector coverage verification: the coverage-gap rebuild above (see - // `vectorCoverageGap`) MUST have actually restored the ledger's - // vectored nouns. A provider that STILL reports 0 nodes after its own - // rebuild() ran — no JS (or provider) fallback could build from what's - // on disk — cannot silently complete open(): search would then serve - // empty results with no signal, exactly the defect this gate closes. - // FAIL TYPED, pre-serve, rather than let a broken vector leg pass as a - // successful open. - if (vectorCoverageGap && this.index.size() === 0) { - throw new VectorIndexNotReadyError( - `open(): the canonical ledger holds ${vectorLedgerAll} vectored noun(s) but the vector ` + - `index still reports 0 node(s) after rebuild() — the derived vector index could not ` + - `be restored from canonical. Refusing to serve silent-empty search results; ` + - `investigate the vector provider/storage, or repairIndex({ rebuild: ['vector'] }) ` + - `after restoring the underlying data.` - ) - } - // 8.0 ⇄ native-provider handshake (NON-DESTRUCTIVE): the derived indexes // have now rebuilt and verified, so they match this build's epoch — // re-stamp the marker LAST, only here. A crash anywhere above leaves the @@ -19617,6 +15721,49 @@ export class Brainy implements BrainyInterface { return result } + /** + * Run the optional metadata cold-open consistency probe at most once per brain. + * When the active provider exposes `probeConsistency()` (the native cross-bucket + * O(1) sampler), a `false` result triggers `detectAndRepairCorruption()` so an + * already-poisoned index self-heals on first read — the metadata counterpart of + * the 7.33.2 graph cold-load guard. Best-effort: a probe failure never breaks the + * read (the guard is reset so a transient failure retries). No-op for the JS index + * (it exposes no probe), and the full-scan `validateConsistency` stays the explicit + * deep diagnostic via `validateIndexConsistency()`. + */ + private async ensureMetadataConsistencyProbed(): Promise { + if (this._metadataConsistencyProbed) return + // Defer while the metadata provider runs its one-time in-place migration: + // probing (and self-healing via rebuild) an index the provider is mid-rebuild + // would collide with the provider that owns it. Mirrors the vector deference + // in ensureIndexesLoaded. Do NOT latch — once the migration clears, the next + // read runs the probe. (The family-scoped find() gate waits on the metadata + // family separately before any actual filter read.) + if (this.providerIsMigrating(this.metadataIndex)) return + this._metadataConsistencyProbed = true + const provider = this.metadataIndex as { + probeConsistency?: () => Promise + detectAndRepairCorruption?: () => Promise + } + if (typeof provider.probeConsistency !== 'function') return + try { + const healthy = await provider.probeConsistency() + if (!healthy && typeof provider.detectAndRepairCorruption === 'function') { + if (!this.config.silent) { + console.warn('[Brainy] metadata index failed the cold-open consistency probe — self-healing via rebuild.') + } + await provider.detectAndRepairCorruption() + } + } catch (error) { + // The self-heal is best-effort and must never break a read. Reset the guard + // so a transient probe failure is retried on the next read. + this._metadataConsistencyProbed = false + if (!this.config.silent) { + console.warn('[Brainy] metadata cold-open consistency probe failed (continuing):', error) + } + } + } + /** * Detect and repair corrupted metadata indexes. * @@ -19742,110 +15889,8 @@ export class Brainy implements BrainyInterface { ) } - /** - * @description The ceremony door for index repair. Bare `repairIndex()` is - * REPORT-DRIVEN, exactly as before: it prunes orphans, recomputes count - * rollups, reconciles VFS containment, and — for the three derived-index - * providers — consults each one's `validateInvariants()` and rebuilds only - * a family whose failing invariant asks for it (`heal: 'rebuild'`). - * - * `options.rebuild` is the EXPLICIT operator override: name one or more - * families (or `'all'`) to rebuild them UNCONDITIONALLY — no invariant is - * consulted, JS or native provider alike. Use it when an operator has - * independent reason to believe a family needs reconciling regardless of - * what its own self-report says (a report can only be as honest as the - * provider that produced it). A family named here is recorded as its own - * `provider:` row with `rebuilt: true` and - * `reason: 'explicit rebuild requested'`, and is SKIPPED by the normal - * 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 as it goes. - */ - async repairIndex(options?: { rebuild?: Array<'metadata' | 'graph' | 'vector'> | 'all' }): Promise { + async repairIndex(): 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[] = [] - - // 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 @@ -19860,28 +15905,14 @@ 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', { - checked: true, - healed: pruned, - ...(pruned > 0 - ? { detail: `${orphans.nouns.length} noun + ${orphans.verbs.length} verb container(s) pruned` } - : {}) - }) - if (pruned > 0) { - prodLog.narrate( + if (orphans.nouns.length + orphans.verbs.length > 0) { + prodLog.warn( `[Brainy] repairIndex() pruned ${orphans.nouns.length} orphaned noun + ` + `${orphans.verbs.length} orphaned verb container(s) left by a pre-8.3.1 ` + `partial delete.` ) } - } else { - record('orphaned-containers', { checked: false, healed: 0, skipped: 'storage has no container model' }) } // SANCTIONED RECOUNT — unconditional, not gated on orphans found: the // persisted counters can be inflated over perfectly clean shelves (deletes @@ -19890,20 +15921,8 @@ 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', { - checked: typeof pruner.rebuildTypeCounts === 'function', - healed: 0, - detail: typeof pruner.rebuildTypeCounts === 'function' - ? 'recomputed from one canonical walk (unconditional)' - : undefined, - ...(typeof pruner.rebuildTypeCounts !== 'function' ? { skipped: 'storage has no count rollups' } : {}) - }) // The recount changed the rollup truth — re-stamp the entity tree so the // stamp's invariants match the healed counters (repair leaves a coherent @@ -19916,189 +15935,52 @@ 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, - healed: containment.removed + containment.restored, - ...(containment.removed + containment.restored > 0 - ? { detail: `${containment.removed} stale edge(s) removed, ${containment.restored} restored` } - : {}) - }) if (containment.removed + containment.restored > 0) { - prodLog.narrate( + prodLog.warn( `[Brainy] repairIndex() reconciled VFS containment: removed ${containment.removed} ` + `stale/duplicate edge(s), restored ${containment.restored} missing edge(s).` ) } } - if (!this._vfsInitialized || !this._vfs) { - 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.narrate( + prodLog.warn( `[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.` ) } - // THE CEREMONY DOOR: an explicit `options.rebuild` names a family (or - // 'all') to rebuild UNCONDITIONALLY — no invariant consulted. Resolved - // here so the loop below can skip a family's normal report-driven pass - // once its unconditional rebuild has already run. - const explicitRebuildFamilies: ReadonlySet<'metadata' | 'vector' | 'graph'> = - options?.rebuild === 'all' - ? new Set<'metadata' | 'vector' | 'graph'>(['metadata', 'vector', 'graph']) - : new Set(options?.rebuild ?? []) - // Cross-layer repair: repairIndex must reconcile NATIVE derived // state from canonical, not just the JS metadata index. Consult each provider's // own validateInvariants() and rebuild any whose failing invariant asks for it // (heal: 'rebuild') — the native counterpart of detectAndRepairCorruption(). - const providerFamilies: ReadonlyArray = [ - ['metadata', this.metadataIndex], - ['vector', this.index], - ['graph', this.graphIndex] - ] - for (const [familyName, provider] of providerFamilies) { - if (explicitRebuildFamilies.has(familyName)) { - const p = provider as { rebuild?: () => Promise } | null - if (!p || typeof p.rebuild !== 'function') { - record(`provider:${familyName}`, { checked: false, healed: 0, skipped: 'no rebuild() contract' }) - continue - } - 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() — - // zero read downtime when a fact log is available, narrated - // fallback to the blocking rebuild() otherwise. - if (familyName === 'metadata') { - await this.rebuildMetadataIndexOnline() - } else { - await p.rebuild() - } - record(`provider:${familyName}`, { - checked: true, - healed: 1, - rebuilt: true, - reason: 'explicit rebuild requested' - }) - prodLog.narrate(`[Brainy] repairIndex(): '${familyName}' rebuild complete.`) - continue - } - + for (const provider of [this.metadataIndex, this.index, this.graphIndex]) { const p = provider as { validateInvariants?: () => Promise 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` - ) + if (!p || typeof p.validateInvariants !== 'function' || typeof p.rebuild !== 'function') continue let report: ProviderInvariantReport try { report = await p.validateInvariants() - } catch (err) { - record(`provider:${familyName}`, { checked: false, healed: 0, skipped: `validateInvariants threw: ${(err as Error).message}` }) + } catch { continue // a throwing validateInvariants is surfaced by validateIndexConsistency; skip repair here } - if (report.healthy) { - record(`provider:${report.provider}`, { checked: true, healed: 0 }) - continue - } + if (report.healthy) continue if (report.invariants.some((i) => !i.holds && i.heal === 'rebuild')) { - record(`provider:${report.provider}`, { - checked: true, healed: 1, - detail: `rebuilt from canonical (failing: ${report.invariants.filter((i) => !i.holds).map((i) => i.name).join(', ')})` - }) - prodLog.narrate( + prodLog.warn( `[Brainy] repairIndex(): provider '${report.provider}' has a failing invariant ` + `requiring a rebuild — reconciling its derived state from canonical.` ) - // See the explicit-rebuild branch above: 'metadata' routes through - // the online build-beside orchestrator (B3 D3). - if (familyName === 'metadata') { - await this.rebuildMetadataIndexOnline() - } else { - await p.rebuild() - } - } else if ( - report.invariants.some((i) => !i.holds && i.heal === 'repair') && - typeof (provider as { repair?: () => Promise }).repair === 'function' - ) { - // INCREMENTAL HEAL ROUTING (ADR-008 D4): a failing verdict whose heal - // is 'repair' routes to the provider's own repair() — O(missing), - // re-posting exactly what its ledger names, never a store-sized - // rebuild. The return shape is the provider's own; the RE-READ of the - // report is what decides success (the acceptance meta-pin's law: run - // the named heal once, re-read, nothing may still fail the same way). - const failingRepairs = report.invariants - .filter((i) => !i.holds && i.heal === 'repair') - .map((i) => i.name) - prodLog.narrate( - `[Brainy] repairIndex(): provider '${report.provider}' asks for an incremental ` + - `repair (${failingRepairs.join(', ')}) — running its own repair().` - ) - await (provider as { repair: () => Promise }).repair() - let cleared = false - let after: ProviderInvariantReport | null = null - try { - after = await p.validateInvariants() - cleared = !after.invariants.some( - (i) => !i.holds && i.heal === 'repair' && failingRepairs.includes(i.name) - ) - } catch { - // The post-heal re-read failing is itself reportable, never a crash. - } - record(`provider:${report.provider}`, { - checked: true, - healed: cleared ? failingRepairs.length : 0, - detail: cleared - ? `incremental repair cleared: ${failingRepairs.join(', ')}` - : `repair() ran but the re-read still fails (${ - after - ? after.invariants.filter((i) => !i.holds).map((i) => `${i.name}→${i.heal}`).join(', ') - : 're-read threw' - }) — escalate to repairIndex({ rebuild: ['${familyName}'] })`, - reason: cleared ? undefined : 'repair did not converge' - }) - } else { - record(`provider:${report.provider}`, { - checked: true, healed: 0, - detail: `unhealthy without a routable verdict (failing: ${report.invariants.filter((i) => !i.holds).map((i) => `${i.name}→${i.heal}`).join(', ')})` - }) + await p.rebuild() } } // detectAndRepairCorruption() above rebuilt the derived indexes from @@ -20106,24 +15988,10 @@ 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 - record('degraded-read-state', { checked: true, healed: 1, detail: 'degraded ids cleared, read-path warning re-armed' }) } - - const healedTotal = families.reduce((n, f) => n + f.healed, 0) - const report: RepairReport = { families, healedTotal, durationMs: Date.now() - startedAt } - 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'}@${f.durationMs ?? 0}ms`) - .join(', ') - ) - return report } /** @@ -20170,15 +16038,8 @@ export class Brainy implements BrainyInterface { private static isPackageNotInstalledError(error: unknown, pkg: string): boolean { const code = (error as { code?: string })?.code const message = error instanceof Error ? error.message : String(error) - // The package name must TERMINATE where it ends: an unanchored prefix match - // read a missing platform-binary SIBLING package (e.g. "-linux-x64-gnu", - // exactly what a deploy replacing node_modules mid-restart leaves behind) as - // " is not installed" — and a present-but-broken accelerator silently - // degraded to the default JS engines. A production storm was hunted for a - // day because of that swallow. The name must be followed by a quote, - // whitespace, punctuation, or end-of-message — never a longer name's tail. - const escaped = pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const namesPackage = new RegExp("(^|['\"\\s])" + escaped + "(?=$|['\"\\s.,)])").test(message) + const namesPackage = + message.includes(`'${pkg}'`) || message.includes(`"${pkg}"`) || message.includes(` ${pkg}`) const isResolutionFailure = code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND' || @@ -20389,20 +16250,6 @@ export class Brainy implements BrainyInterface { // persisted state is NOT listed — no walk at all on a clean reopen). await index.ready() - // Behind-stamp catch-up FIRST (SELF-ENGINE-LIFECYCLE-SPRINT ask (b)): - // adopted-but-behind state reconciles its exact missing window - // incrementally — bounded by that window's affected entities — instead - // of the whole-store rescan an unclean exit used to force. Single-flight - // like the walk below; a failed catch-up demotes to a LOUD rescan. - if (index.getPendingCatchUps().length > 0) { - if (!this._aggregationCatchUpFlight) { - this._aggregationCatchUpFlight = this.runAggregationCatchUp().finally(() => { - this._aggregationCatchUpFlight = null - }) - } - await this._aggregationCatchUpFlight - } - // Single-flight: concurrent queries share ONE walk instead of each wiping // the others' partial state and starting their own (the stampede that kept // a busy store from ever converging). The loop covers the rare case where @@ -20431,128 +16278,6 @@ export class Brainy implements BrainyInterface { } } - /** - * @description Build the aggregation view of a LIVE entity — top-level - * engine fields + the user bag, the same shape `entityForIndexing` and - * `entityForAggFromRawRecord` produce, so group keys and source filters - * resolve identically whichever door an entity arrives through. - */ - private aggViewFromEntity(e: Entity): Record { - return { - type: e.type, - ...(e.subtype !== undefined && { subtype: e.subtype }), - ...((e as unknown as Record).visibility !== undefined && { - visibility: (e as unknown as Record).visibility - }), - ...(e.confidence !== undefined && { confidence: e.confidence }), - ...(e.weight !== undefined && { weight: e.weight }), - createdAt: e.createdAt, - updatedAt: e.updatedAt, - ...(e.service !== undefined && { service: e.service }), - ...(e.data !== undefined && { data: e.data }), - ...(e.createdBy !== undefined && { createdBy: e.createdBy }), - metadata: e.metadata ?? {} - } - } - - /** Cap on a catch-up window's affected-entity count before demoting to a rescan. */ - private static readonly AGGREGATION_CATCHUP_MAX_AFFECTED = 5000 - - /** - * Reconcile every behind-stamp aggregate's exact missing window - * `(from, to]` using the fact log for the AFFECTED ID SET and time-travel - * reads for exact before/after states — cost bounded by writes since the - * last flush, never store size. Reconciliation targets the FIXED window - * end (`to` = the committed generation at adoption), so live write hooks - * compose exactly: every application on both paths is a precise old/new - * delta pair, and interleaving cannot drift totals. Any failure or an - * oversized window demotes to the announced full rescan — never a silent - * partial serve. - */ - private async runAggregationCatchUp(): Promise { - const index = this._aggregationIndex! - const catchups = index.getPendingCatchUps() - if (catchups.length === 0) return - - const startedAt = Date.now() - try { - // One fact scan covers every window (they share flush boundaries in - // practice); per-name windows filter per id below. - const from = Math.min(...catchups.map(c => c.from)) - const to = Math.max(...catchups.map(c => c.to)) - const scan = this.scanFacts({ fromGeneration: from + 1, toGeneration: to, kinds: ['noun'] }) - if (!scan) { - for (const c of catchups) { - index.demoteCatchUpToBackfill(c.name, 'no fact log on this store — window unreadable') - } - return - } - - // id → generations it changed at, inside the union window. - const affected = new Map() - for await (const batch of scan.batches()) { - for (const fact of batch.facts) { - for (const op of fact.ops) { - if (op.kind !== 'noun') continue - const gens = affected.get(op.id) - if (gens) gens.push(fact.generation) - else affected.set(op.id, [fact.generation]) - } - } - if (affected.size > Brainy.AGGREGATION_CATCHUP_MAX_AFFECTED) break - } - if (affected.size > Brainy.AGGREGATION_CATCHUP_MAX_AFFECTED) { - for (const c of catchups) { - index.demoteCatchUpToBackfill( - c.name, - `window touches >${Brainy.AGGREGATION_CATCHUP_MAX_AFFECTED} entities — a rescan is cheaper` - ) - } - return - } - - // Exact before/after views per unique generation bound, via time travel. - const dbCache = new Map>() - const dbAt = async (gen: number): Promise> => { - let db = dbCache.get(gen) - if (!db) { - db = await this.asOf(gen) - dbCache.set(gen, db) - } - return db - } - try { - for (const c of catchups) { - const beforeDb = await dbAt(c.from) - const afterDb = await dbAt(c.to) - let reconciled = 0 - for (const [id, gens] of affected) { - if (!gens.some(g => g > c.from && g <= c.to)) continue - const [before, after] = await Promise.all([beforeDb.get(id), afterDb.get(id)]) - index.reconcileEntity( - c.name, - id, - before ? this.aggViewFromEntity(before) : null, - after ? this.aggViewFromEntity(after) : null - ) - reconciled++ - } - index.finishCatchUp(c.name) - prodLog.info( - `[Aggregation] '${c.name}': caught up generations ${c.from}→${c.to} — ` + - `${reconciled} entit${reconciled === 1 ? 'y' : 'ies'} reconciled in ${Date.now() - startedAt}ms (no store rescan)` - ) - } - } finally { - await Promise.all(Array.from(dbCache.values(), db => db.release().catch(() => {}))) - } - } catch (err) { - for (const c of index.getPendingCatchUps()) { - index.demoteCatchUpToBackfill(c.name, `catch-up failed: ${(err as Error).message}`) - } - } - } - /** * One store walk fills EVERY aggregate currently pending backfill — M pending * aggregates cost one enumeration, not M. Only reached when an aggregate @@ -20569,16 +16294,6 @@ export class Brainy implements BrainyInterface { const startedAt = Date.now() for (const n of names) index.beginBackfill(n) - // SELF-ENGINE-LIFECYCLE-SPRINT ask (c): when the native provider offers - // the parallel whole-rebuild (`rebuildAggregate` — on the contract since - // 8.x but never invoked), collect the walk's views and hand them over in - // ONE call per aggregate instead of a per-entity FFI stream. Memory note: - // the collected views are metadata-only records (no vectors); at the - // scales where this walk is even reached the array is the cheap part — - // the per-entity FFI round-trips were the measured cost. - const useProviderRebuild = index.hasProviderRebuild() - const collected: Array> = [] - let scanned = 0 try { const PAGE = 500 @@ -20590,12 +16305,8 @@ export class Brainy implements BrainyInterface { }) for (const noun of page.items) { const record = noun as unknown as Record - if (useProviderRebuild) { - collected.push(record) - } else { - for (const n of names) { - index.backfillEntity(n, record) - } + for (const n of names) { + index.backfillEntity(n, record) } } scanned += page.items.length @@ -20628,181 +16339,20 @@ export class Brainy implements BrainyInterface { throw err } - if (useProviderRebuild) { - for (const n of names) { - if (!index.rebuildWithProvider(n, collected)) { - // Provider refused/absent for this one — stream it the JS way. - for (const record of collected) index.backfillEntity(n, record) - index.finishBackfill(n) - } - } - } else { - for (const n of names) index.finishBackfill(n) - } + for (const n of names) index.finishBackfill(n) this._aggregationBackfillFailure = null prodLog.info( - `[Aggregation] backfill walk finished: ${scanned} entities → ${names.length} aggregate(s) ` + - `in ${Date.now() - startedAt}ms${useProviderRebuild ? ' (native parallel rebuild)' : ''}` + `[Aggregation] backfill walk finished: ${scanned} entities → ${names.length} aggregate(s) in ${Date.now() - startedAt}ms` ) } /** - * @description Close and clean up: flush every buffered component, stamp - * the durability markers, release resources, then give up the writer lock. + * Close and cleanup * - * 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. - * - * IDEMPOTENT AND RE-ENTRANT. The teardown below runs ONCE. Concurrent - * callers share the one in-flight promise and settle together; a caller - * arriving after it finished gets that same settled promise (close is - * terminal — there is nothing left to redo, and a failed close has already - * released the lock and set `closed`). This is what makes the shutdown - * ownership question answerable at all: whoever calls first owns the close, - * everyone else — including the engine's own signal handler — joins it or - * steps aside. See `_closeInFlight`. - * @returns Nothing. - * @throws The first failure from the durable close steps, after the - * terminal releases have run. + * Now flushes HNSW dirty nodes before closing + * This ensures deferred persistence mode data is saved */ - close(): Promise { - // NOT `async`: an async wrapper allocates a FRESH promise per call, so - // callers would hold different handles to the same work. Returning the - // stored promise itself makes "one close" observable identity, not just - // observable behaviour. The gate is crossed with NO await before it, so - // two callers in the same tick — and a signal handler resuming mid-close - // — always see the same answer; `isClosing` is true from this assignment - // onward. (`_closeOnce()` is async, so a failure is always a rejection, - // never a synchronous throw out of this method.) - if (this._closeInFlight) return this._closeInFlight - const run = this._closeOnce() - this._closeInFlight = run - return run - } - - /** - * @description The close body — everything {@link close} promises, run - * exactly once by that method's gate. - * @returns Nothing. - * @throws The first failure from the durable close steps, after the - * terminal releases have run. - */ - private async _closeOnce(): Promise { - if (this._pendingEmbedIds.size === 0) await this.writeEmbedLowWater() - 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) { - clearTimeout(this._persistIdleTimer) - this._persistIdleTimer = null - } - if (this._persistBackgroundFlight) { - await this._persistBackgroundFlight.catch(() => {}) - } - // Drain the flush chain itself: the running flush AND the single follow-up - // queued behind it. The cadence's own handle above covers only the flushes - // the cadence started — a flush-request from another process, or an - // application's own flush() racing this close, is on the chain and nowhere - // else, and a flush landing mid-close writes behind the close's work. - // Bounded by construction: at most one follow-up exists, and awaiting it - // awaits its leader too, so the second pass is a no-op unless a writer - // raced this close. - for (let pass = 0; pass < 2; pass++) { - const inFlight = this._flushInFlight - const queued = this._flushQueued - if (!inFlight && !queued) break - if (inFlight) await inFlight.catch(() => {}) - if (queued) await queued.catch(() => {}) - } - + async close(): Promise { // Cancel any pending post-import background deduplication FIRST — it is a // writer (merge-deletes), and no delete pass may start mid- or post-close. this._backgroundDedup?.cancelPending() @@ -20817,91 +16367,49 @@ export class Brainy implements BrainyInterface { await this.generationStore.flushPendingSingleOps() } - // Phase 0b: REPACK cold history into sealed segments (D1+D3 — - // re-representation, never deletion; the only history transform under the - // archival profile), then auto-compact per config.retention. Repack runs - // FIRST so bounded-retention reclaim can drop whole segments. Both are - // time-bounded maintenance passes (8.9.0 law: flush() never pays these); - // both are housekeeping — failures warn, never fail a clean shutdown. - if (!this.isReadOnly && this.generationStore) { - try { - await this.generationStore.repackHistory({ timeBudgetMs: 5_000 }) - } catch (error) { - console.warn( - `History repacking failed (non-fatal): ${error instanceof Error ? error.message : String(error)}` - ) - } - } + // Phase 0b: Auto-compact generational history per config.retention (default + // on) BEFORE the generation store closes below. This is THE auto-compaction + // site (8.9.0 — flush() never compacts): time-bounded per pass, respects + // live Db pins and an explicit autoCompact: false; no-op on read-only + // instances. await this.autoCompactHistory() - // Watermark stamps ride this flush too — see stampProjectionWatermarks(). - // Read-only instances skip it (no writes, no committed-generation drift - // to certify; ensureInitialized()'s guard below never runs for them - // either, so this must not assume a writer's invariants). - if (!this.isReadOnly) { - this.stampProjectionWatermarks() - } - // Phase 1: Flush ALL components in parallel to persist buffered data // This is critical when cor native providers buffer data in Rust memory - // - // READ-ONLY GUARD, applied to EVERY flush here. A flush is a write by - // definition, and a reader has nothing of its own to persist — but these - // calls were not conditional, so a read-only open → read → close REWROTE - // four files under `_system/`: the metadata field registry (whose flush() - // saves it unconditionally, "even with no dirty fields"), and the three - // type/subtype statistics files the storage adapter's count flush stamps. - // Every one of them was re-stamped on a session that committed nothing. - // A reader must leave `_system/` exactly as it found it — the same law the - // clean-shutdown marker already lives under (see the generation-store - // guard below and `Brainy.openReadOnly`). await Promise.all([ // Flush HNSW dirty nodes (deferred persistence mode) (async () => { - if (this.index && !this.isReadOnly && typeof this.index.flush === 'function') { + if (this.index && typeof this.index.flush === 'function') { await this.index.flush() } })(), // Flush metadata index (field indexes + EntityIdMapper) (async () => { - if (this.metadataIndex && !this.isReadOnly && typeof this.metadataIndex.flush === 'function') { + if (this.metadataIndex && typeof this.metadataIndex.flush === 'function') { await this.metadataIndex.flush() } })(), // Flush graph adjacency index (LSM trees) (async () => { - if (this.graphIndex && !this.isReadOnly && typeof this.graphIndex.flush === 'function') { + if (this.graphIndex && typeof this.graphIndex.flush === 'function') { await this.graphIndex.flush() } })(), // Flush storage adapter counts (async () => { - if (this.storage && !this.isReadOnly && typeof this.storage.flushCounts === 'function') { + if (this.storage && typeof this.storage.flushCounts === 'function') { await this.storage.flushCounts() } })(), // Flush aggregation index state (async () => { - if (this._aggregationIndex && !this.isReadOnly) { + if (this._aggregationIndex) { await this._aggregationIndex.flush() } })(), - // 8.0 MVCC: detach the generation-bump hook and persist the counter. - // READ-ONLY GUARD: a reader's open() never sets the bump hook, never - // buffers pending single-ops, and — since generationStore.open() also - // leaves the clean-shutdown marker untouched for a reader — never - // consumes it either, so there is nothing of a writer's to persist or - // release here. Calling close() anyway would still WRITE: it - // unconditionally re-stamps `_system/clean-shutdown.json` (and can - // advance the fold checkpoint / counter files) at the generation this - // session merely observed — a reader vouching for a commit it never - // made. The marker is the writer's own evidence about the writer's own - // process; a read-only brain must leave `_system/` exactly as it found - // it. (Mirrors the same guard already applied to every other Phase-1 - // step below, and to the signal-path shutdown in - // registerShutdownHooks().) + // 8.0 MVCC: detach the generation-bump hook and persist the counter (async () => { - if (this.generationStore && !this.isReadOnly) { + if (this.generationStore) { await this.generationStore.close() } })() @@ -20914,54 +16422,23 @@ export class Brainy implements BrainyInterface { await this.stampEntityTree() } - // Phase 1c: the pending-embed CHECKPOINT — placed HERE and not earlier - // because this is the first point in the close where the durability law it - // must satisfy actually holds: `generationStore.close()` (in Phase 1 above) - // flushed the pending single-op tier, which fsyncs the fact log and then - // advances the manifest, so `head === committed` and every fact the - // checkpoint's generation covers is durable. Taken even when the set is - // NOT empty — that is the whole difference from the low-water mark, and it - // is what makes the next open's fold O(facts since this close) on a brain - // whose pending set never drains. Awaits any in-flight cadence write first - // so the last write to the file is this one. - if (!this.isReadOnly) { - await this._pendingEmbedCheckpointFlight?.catch(() => {}) - await this.writeEmbedCheckpoint() - } - // Phase 2: Close components to release resources (timers, file handles) // Data is already safe on disk from Phase 1 - // - // READ-ONLY GUARD, same law as Phase 1. Each of these closes is a WRITER: - // the graph index drains both LSM MemTables to SSTables and stamps its - // watermark, and the vector/metadata `close` hooks — optional doors the - // reference engine leaves unimplemented, but which a native provider fills - // in — persist their buffered state. None of that is a reader's to write. - // - // A reader still has to RELEASE what it holds, which is why this is a - // branch rather than a skip: `stopBackgroundFlush()` is the non-writing - // half of the graph index's close, clearing the auto-flush interval that - // would otherwise outlive the session. The optional hooks have no - // non-writing counterpart to call, and a provider that buffers nothing on - // a read-only open has nothing to release. await Promise.all([ (async () => { - if (!this.graphIndex) return - if (this.isReadOnly) { - this.graphIndex.stopBackgroundFlush() - } else if (typeof this.graphIndex.close === 'function') { + if (this.graphIndex && typeof this.graphIndex.close === 'function') { await this.graphIndex.close() } })(), (async () => { const index = this.index as JsHnswVectorIndex & VectorIndexOptionalHooks - if (index && !this.isReadOnly && typeof index.close === 'function') { + if (index && typeof index.close === 'function') { await index.close() } })(), (async () => { const metadataIndex = this.metadataIndex as MetadataIndexManager & MetadataIndexOptionalHooks - if (metadataIndex && !this.isReadOnly && typeof metadataIndex.close === 'function') { + if (metadataIndex && typeof metadataIndex.close === 'function') { await metadataIndex.close() } })(), @@ -20998,27 +16475,39 @@ 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() + } -/** - * @description Whether a `where` clause actually constrains the result set — - * i.e. it is a non-null object carrying at least one predicate key. An empty - * `where: {}` carries ZERO predicates and must behave exactly like an absent - * `where` everywhere it is consulted; treating it as "a filter is present" - * routes the query into the index-filter path, where `getIdsForFilter({})` - * answers `[]` by contract — a silent empty on a match-all query (the - * forbidden answer class: served-or-refused, never silently nothing). - * @param where - The raw `where` value from a query/selector params object. - * @returns `true` when `where` holds at least one predicate. - */ -function whereConstrains(where: unknown): where is Record { - return ( - where !== null && - typeof where === 'object' && - !Array.isArray(where) && - Object.keys(where).length > 0 - ) + // 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/coreTypes.ts b/src/coreTypes.ts index 4b018e94..e0248d17 100644 --- a/src/coreTypes.ts +++ b/src/coreTypes.ts @@ -284,12 +284,7 @@ export const STANDARD_ENTITY_FIELDS: ReadonlySet = new Set([ 'id', 'vector', 'connections', - // 'level' is deliberately ABSENT: it is HNSW plumbing, not an entity field. - // Listing it here made every by-name read of a user metadata field called - // `level` resolve to the engine's internal node layer instead — a silent - // shadow that broke sort/filter/aggregation on a perfectly natural field - // name (VENUE-BRAINY-ORDERBY-NOOP). Engine plumbing is invisible to the - // query surface; a bare `level` reads `entity.metadata.level`. + 'level', 'type', 'subtype', 'visibility', @@ -792,30 +787,6 @@ export interface DerivedFamilyDeclaration { rebuildable?: boolean } -/** - * @description The canonical count ledger a storage adapter maintains on its - * write path: per family, the user-facing `counted` scalar and the - * ALL-visibility `all` scalar (every tier — the coverage-ledger denominator). - * See {@link StorageAdapter.getCanonicalCounts}. - */ -export interface CanonicalCounts { - nouns: { counted: number; all: number } - verbs: { counted: number; all: number } - /** - * The count of canonical nouns holding a REAL (non-empty) vector — the - * coverage denominator a vector index's node-count ledger is measured - * against (`nodeCount === vectors.all` is the whole-store coverage - * verdict for the vector leg, the vector-side mirror of `nouns.all` for - * metadata/graph). A deferred-embed noun (`add({ deferEmbedding: true })`) - * counts only once its vector actually LANDS — its canonical record exists - * (counted in `nouns.all`) with an empty vector until then, so it is - * deliberately NOT counted here in the interim. - */ - vectors: { all: number } - /** An unprovable delete has left the `all` scalars unverified since the last recount. */ - suspect: boolean -} - export interface StorageAdapter { init(): Promise @@ -830,68 +801,14 @@ export interface StorageAdapter { * Save noun metadata separately * @param id Noun ID * @param metadata Noun metadata - * @param hasVector - OPTIONAL vectored-noun ledger hint: `true` when this - * write is a FRESH insert (`isNew`) whose vector is a real, non-empty - * array — the caller already knows this for free (the insert's own - * `vector` local), so the increment rides the SAME isNew gate that - * already protects `totalNounCountAll` from double-counting on HNSW - * neighbor-link re-saves (`saveNoun_internal` re-runs on every link - * change; this metadata seam does not). Absent/`false` ⇒ no ledger - * action. A deferred-embed insert passes `false` (its vector lands - * later — see {@link StorageAdapter.noteVectorLanded}). */ - saveNounMetadata(id: string, metadata: NounMetadata, hasVector?: boolean): Promise + saveNounMetadata(id: string, metadata: NounMetadata): Promise /** * Delete noun metadata * @param id Noun ID - * @param priorRecord - OPTIONAL already-known metadata (the caller's - * pre-delete read) — see {@link StorageAdapter.deleteNoun}. - * @param hadVector - OPTIONAL vectored-noun ledger hint: `true`/`false` - * when the caller already knows (read as a side effect of ITS OWN delete - * flow — e.g. `remove()`'s pre-read for the vector-index removal — never - * a read added FOR this ledger), `undefined` when genuinely unknown. A - * known `true` decrements the vectored-noun ledger; a known `false` is a - * no-op (it was never counted); `undefined` marks the ledger SUSPECT - * rather than guessing — the delete path must never add a canonical read - * to answer this question. */ - deleteNounMetadata(id: string, priorRecord?: NounMetadata | null, hadVector?: boolean): Promise - - /** - * OPTIONAL narrow ledger hook: record that a canonical noun's vector just - * LANDED for the first time. Exists ONLY for the deferred-embedding - * lifecycle — the landing commit (`system:embed-landing`) carries a vector - * write with no accompanying metadata operation, so the normal - * `saveNounMetadata(..., hasVector)` seam never fires for it. Callers MUST - * call this only when the noun held NO real vector before this write (the - * deferred-embed worker already holds that fact for free, from its own - * pre-embed read — never an added read). A backend without vectored-noun - * tracking is a no-op via this method's absence (feature-detected). - * @param id - The noun whose vector just landed. - */ - noteVectorLanded?(id: string): Promise - - /** - * OPTIONAL narrow ledger hook, the mirror of {@link noteVectorLanded}: - * record that a canonical noun's vector was just REMOVED — rewritten from - * a real (non-empty) vector to the "unvectored" empty-array shape. Exists - * for the ONE sanctioned reverse migration this engine supports: the VFS - * root's zero-norm fix (see `VirtualFileSystem.doInitializeRoot()` and - * `Brainy.unvectorNounForRootMigration()`), which rewrites a pre-fix - * store's all-zero placeholder root vector to `[]` and must decrement - * `vectors.all` through this hook so the coverage ledger never drifts. - * NOT a general-purpose "I removed a vector" callback — ordinary - * application data has no sanctioned path from vectored back to - * unvectored (`update()` refuses an empty vector as a dimension - * mismatch by design). Callers MUST call this only when the noun held a - * REAL vector immediately before this write (the caller already holds - * that fact for free, from its own pre-write read — never an added read). - * A backend without vectored-noun tracking is a no-op via this method's - * absence (feature-detected). - * @param id - The noun whose vector was just removed. - */ - noteVectorUnlanded?(id: string): Promise + deleteNounMetadata(id: string): Promise /** * Get noun with metadata combined @@ -940,11 +857,8 @@ export interface StorageAdapter { * REQUIRE re-reading the record being removed: when the internal read * returns `null` (replace race, or a ghost left by an earlier version) the * decrement falls back to this record instead of being silently skipped. - * @param hadVector OPTIONAL vectored-noun ledger hint — see - * {@link StorageAdapter.deleteNounMetadata}'s `hadVector` param, which - * this forwards to unchanged. */ - deleteNoun(id: string, priorMetadata?: NounMetadata | null, hadVector?: boolean): Promise + deleteNoun(id: string, priorMetadata?: NounMetadata | null): Promise /** * Save verb - Pure HNSW verb with core fields only @@ -1374,19 +1288,6 @@ export interface StorageAdapter { */ getVerbCount(): Promise - /** - * The canonical count ledger — O(1), no I/O. `counted` mirrors - * `getNounCount()` / `getVerbCount()` (public + internal tiers); `all` is - * the ALL-visibility scalar every unfiltered storage walk is measured - * against — the denominator a derived-index provider's coverage ledger - * subtracts from. `suspect` is `true` when an unprovable delete has left - * `all` unverified since the last sanctioned recount (`repairIndex()`). - * Optional: adapters without the ledger omit it; a consumer treats absence - * as "no denominator", never as zero. - * @returns Both scalars per family plus the suspect flag. - */ - getCanonicalCounts?(): Promise - /** * OPTIONAL — create a pre-upgrade backup of the whole store and return its * location, or `null` when there is nothing to back up (empty store). On the diff --git a/src/db/db.ts b/src/db/db.ts index 68428a7c..ac927fc5 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -59,10 +59,14 @@ import type { import type { StorageAdapter } from '../coreTypes.js' import { exportGraph } from './portableGraph.js' import type { ExportSelector, ExportOptions, PortableGraph } from './portableGraph.js' +import { + splitNounMetadataRecord, + splitVerbMetadataRecord +} from '../types/reservedFields.js' import { v4 as uuidv4 } from '../universal/uuid.js' import { coerceNewEntityId, resolveEntityId, ORIGINAL_ID_KEY } from '../utils/idNormalization.js' import { EntityNotFoundError } from '../errors/notFound.js' -import { SpeculativeOverlayError, CanonicalEnumerationUnavailableError } from './errors.js' +import { SpeculativeOverlayError } from './errors.js' import type { GenerationStore } from './generationStore.js' import type { ChangedIds, TransactReceipt, TxOperation } from './types.js' import { entityMatchesFind, resolveEntityField, UnsupportedWhereOperatorError } from './whereMatcher.js' @@ -516,37 +520,14 @@ export class Db { * (no generation history) — distinct from `persist()` (native whole-brain snapshot * that preserves history). Restore with `brain.import(backup)`. * - * `options.enumeration: 'canonical'` (default: `'index'`) walks the storage - * adapter's canonical noun/verb layout directly instead of the metadata/graph - * indexes, guaranteeing canon-completeness against index corruption — see - * {@link ExportOptions.enumeration}. It requires the LIVE, current-generation - * view: called on a historical `asOf()` pin or a speculative `with()` overlay it - * throws {@link CanonicalEnumerationUnavailableError} rather than silently mixing - * generations or missing the overlay's own entities. - * - * `options.includeHidden: true` admits BOTH hidden visibility tiers - * (`'internal'` and `'system'`) into a whole-brain/predicate export, in EITHER - * `enumeration` mode — see {@link ExportOptions.includeHidden}. Migration-grade - * exports set this; consumer-facing exports leave it off (default: false). - * * @param selector - WHAT to export (omit for the whole brain). See {@link ExportSelector}. - * @param options - HOW to export (vectors / VFS bytes / edge policy / enumeration mode). See {@link ExportOptions}. + * @param options - HOW to export (vectors / VFS bytes / edge policy). See {@link ExportOptions}. * @returns A versioned, portable `PortableGraph` document. - * @throws {@link CanonicalEnumerationUnavailableError} if `enumeration:'canonical'` is - * requested on a historical or speculative-overlay view. * @example * const backup = await brain.now().export({ collection: id }, { includeVectors: true }) - * @example - * // Canon-complete audit export, with an index-drift report attached. - * const audit = await brain.now().export({}, { enumeration: 'canonical', reportIndexDrift: true }) - * if (audit.drift) console.log(audit.drift.canonicalOnly, audit.drift.indexOnly) */ async export(selector: ExportSelector = {}, options: ExportOptions = {}): Promise { this.assertUsable('export') - if (options.enumeration === 'canonical') { - if (this.overlay) throw new CanonicalEnumerationUnavailableError(this.gen, 'overlay') - if (this.isHistorical()) throw new CanonicalEnumerationUnavailableError(this.gen, 'historical') - } return exportGraph(this, this.host.storage, selector, options) } @@ -701,15 +682,23 @@ export class Db { for (const op of ops) { switch (op.op) { case 'add': { - // Field-addressing law: the metadata bag is the user's, VERBATIM — - // no reserved-name lift, no drops. Engine scalars come ONLY from - // their dedicated op fields; a bag field named `confidence` is an - // ordinary user field, exactly as on the committed write path. - const custom = { ...(op.metadata as Record | undefined) } - const confidence = op.confidence - const weight = op.weight - const subtype = op.subtype - const service = op.service + // Reserved-field normalization — mirror of the brain.transact() + // write path: user-settable fields lift to their dedicated field + // (top-level wins), system-managed fields drop, and the entity's + // metadata bag carries ONLY custom fields. Speculative views skip + // the one-shot warnings — committing the same ops through + // `brain.transact()` warns on the real write path. + const { reserved, custom } = splitNounMetadataRecord( + op.metadata as Record | undefined + ) + const confidence = + op.confidence ?? (typeof reserved.confidence === 'number' ? reserved.confidence : undefined) + const weight = + op.weight ?? (typeof reserved.weight === 'number' ? reserved.weight : undefined) + const subtype = + op.subtype ?? (typeof reserved.subtype === 'string' ? reserved.subtype : undefined) + const service = + op.service ?? (typeof reserved.service === 'string' ? reserved.service : undefined) // Id normalization (8.0) — mirror of the committed transact() add // path: a natural key coerces to a STABLE UUID (v5), preserving the @@ -747,12 +736,16 @@ export class Db { `with(): entity ${updateId} not found at generation ${this.gen}` ) } - // Field-addressing law — mirror of the add case: the patch bag is - // the user's verbatim; engine scalars only from dedicated op fields. - const custom = { ...(op.metadata as Record | undefined) } - const confidence = op.confidence - const weight = op.weight - const subtype = op.subtype + // Same reserved-field normalization as the committed update path. + const { reserved, custom } = splitNounMetadataRecord( + op.metadata as Record | undefined + ) + const confidence = + op.confidence ?? (typeof reserved.confidence === 'number' ? reserved.confidence : undefined) + const weight = + op.weight ?? (typeof reserved.weight === 'number' ? reserved.weight : undefined) + const subtype = + op.subtype ?? (typeof reserved.subtype === 'string' ? reserved.subtype : undefined) const mergedMetadata = op.merge !== false ? ({ ...(base.metadata as object), ...custom } as T) @@ -814,14 +807,19 @@ export class Db { } if (duplicate) break - // Field-addressing law — relationship mirror of the add case: the - // edge bag is the user's verbatim; engine scalars only from - // dedicated op fields. - const custom = { ...(op.metadata as Record | undefined) } - const confidence = op.confidence - const weight = op.weight - const subtype = op.subtype - const service = op.service + // Reserved-field normalization — relationship mirror of the add + // op above (and of the committed relate() path). + const { reserved, custom } = splitVerbMetadataRecord( + op.metadata as Record | undefined + ) + const confidence = + op.confidence ?? (typeof reserved.confidence === 'number' ? reserved.confidence : undefined) + const weight = + op.weight ?? (typeof reserved.weight === 'number' ? reserved.weight : undefined) + const subtype = + op.subtype ?? (typeof reserved.subtype === 'string' ? reserved.subtype : undefined) + const service = + op.service ?? (typeof reserved.service === 'string' ? reserved.service : undefined) const id = uuidv4() overlay.verbs.set(id, { diff --git a/src/db/errors.ts b/src/db/errors.ts index da62eb0b..22f405be 100644 --- a/src/db/errors.ts +++ b/src/db/errors.ts @@ -23,12 +23,8 @@ * serve the full query surface via at-generation index materialization. * - {@link GenerationCompactedError} — `asOf()` asked for a generation whose * immutable records were reclaimed by `compactHistory()`. - * - {@link CanonicalEnumerationUnavailableError} — `export()`'s - * `enumeration:'canonical'` mode was called on a historical `asOf()` view or a - * speculative `with()` overlay; the canonical storage walk only ever answers - * "what is live right now." * - * All are exported from the package root (`@soulcraftlabs/brainy`). + * All three are exported from the package root (`@soulcraft/brainy`). */ /** @@ -164,64 +160,6 @@ export class GenerationCompactedError extends Error { } } -/** - * @description Thrown by `db.export(selector, { enumeration: 'canonical' })` when - * the `Db` it is called on is not the live, current-generation view: a historical - * `brain.asOf(g)` pin, or a speculative `db.with()` overlay. - * - * Canonical enumeration mode walks the storage adapter's canonical shard layout - * directly (`storage.getNouns()`/`getVerbs()`) instead of the metadata/graph - * indexes — but that walk has no generation parameter, it can only ever answer - * "what is live right now." Serving it against a historical pin would silently - * mix generations (today's canonical records under yesterday's selector), and - * against a speculative overlay it would silently miss the overlay's own - * in-memory entities (which never touched storage). Both are exactly the kind of - * silently-wrong result canonical mode exists to prevent elsewhere — so this - * boundary throws instead. - * - * `enumeration: 'index'` (the default) is unaffected: it composes with - * `asOf()`/`with()` exactly as before, via the generation-correct `find()` walk. - * - * @example - * const past = await brain.asOf(g1) - * try { - * await past.export({}, { enumeration: 'canonical' }) - * } catch (err) { - * if (err instanceof CanonicalEnumerationUnavailableError) { - * // Time-travel export: use the default index-based enumeration instead. - * await past.export({}, { enumeration: 'index' }) - * } - * } - */ -export class CanonicalEnumerationUnavailableError extends Error { - /** The view's pinned generation. */ - public readonly generation: number - /** Why canonical mode cannot serve this view. */ - public readonly reason: 'historical' | 'overlay' - - /** - * @param generation - The view's pinned generation. - * @param reason - `'historical'` (a past `asOf()` pin) or `'overlay'` (a speculative `with()`). - */ - constructor(generation: number, reason: 'historical' | 'overlay') { - const what = - reason === 'historical' - ? `a historical view pinned at generation ${generation}` - : `a speculative with() overlay (base generation ${generation})` - super( - `export()'s enumeration:'canonical' requires the live, current-generation view — ` + - `it was called on ${what}. The canonical storage walk has no generation parameter, ` + - `so it can only answer "what is live right now"; serving it here would silently ` + - `mix generations (historical) or miss the overlay's own in-memory entities ` + - `(overlay). Use enumeration:'index' (the default) for a time-travel or what-if ` + - `export, or pin brain.now() for a live canonical export.` - ) - this.name = 'CanonicalEnumerationUnavailableError' - this.generation = generation - this.reason = reason - } -} - /** One entity/relationship left in an unreconciled state by a failed rollback. */ export interface UnreconciledRecord { /** The entity or relationship id. */ @@ -351,63 +289,3 @@ export class PendingFlushDurabilityError extends Error { this.failedAttempts = failedAttempts } } - -/** - * @description Thrown by {@link GenerationStore.commitTransaction} when the - * PENDING single-op tier is non-empty — i.e. one or more `commitSingleOp()` - * generations are buffered in memory, not yet flushed to - * `committedRanges` via `flushPendingSingleOps()`. - * - * The invariant `reservedGensAsc()` (and everything built on it — - * `resolveManyAt`, `resolveAt`, `changedBetween`, the hot-tail window) relies - * on is documented, not enforced by types: pending generations must always be - * numerically greater than every committed one, because the ONLY sanctioned - * callers of `commitTransaction()` — `Brainy.transact()` and - * `Brainy.compactHistory()` — flush the pending tier FIRST. A caller that - * invokes `commitTransaction()` directly while single-ops are still pending - * breaks that invariant: the new commit lands in `committedRanges` ABOVE - * generations still sitting in `pendingGens`, so the committed-then-pending - * concatenation `reservedGensAsc()` yields is no longer ascending. The - * concrete failure this produces is silent, not a crash: `resolveManyAt` - * walks committed ranges before pending ones, so it can report a NEWER - * generation as the "first after" a pin than an older, still-pending one that - * actually touched the id first — a wrong before-image at a point-in-time - * read, without a compensating error to warn a caller anything went wrong. - * - * This error refuses the commit outright, before any staging I/O: nothing is - * written, the generation counter reservation is untouched, and - * `committedRanges`/`pendingGens` are exactly as they were. Call - * `flushPendingSingleOps()` first (or go through `Brainy.transact()`, which - * already does). - * - * @example - * try { - * await generationStore.commitTransaction({ touched, execute }) - * } catch (err) { - * if (err instanceof PendingSingleOpsUnflushedError) { - * await generationStore.flushPendingSingleOps() - * await generationStore.commitTransaction({ touched, execute }) // now safe - * } - * } - */ -export class PendingSingleOpsUnflushedError extends Error { - /** How many un-flushed single-op generations were buffered at refusal time. */ - public readonly pendingCount: number - - /** - * @param pendingCount - `pendingGens.length` at the moment of refusal (always ≥ 1). - */ - constructor(pendingCount: number) { - super( - `commitTransaction() refused: ${pendingCount} pending single-op generation(s) ` + - `are still buffered and un-flushed. Flush the pending single-op tier before ` + - `committing a transaction — Brainy.transact() does this automatically; a ` + - `direct commitTransaction() call with pending generations would leave the ` + - `generation order unsorted (committed generations landing above lower, ` + - `still-pending ones) and make point-in-time reads (resolveManyAt/resolveAt) ` + - `return the wrong before-image. Call flushPendingSingleOps() first, then retry.` - ) - this.name = 'PendingSingleOpsUnflushedError' - this.pendingCount = pendingCount - } -} diff --git a/src/db/factLog.ts b/src/db/factLog.ts index 728be4b1..4c5e95fd 100644 --- a/src/db/factLog.ts +++ b/src/db/factLog.ts @@ -40,61 +40,11 @@ * The manifest (`_generations/facts/manifest.json`, JSON — forensics stay * terminal-readable) is the single source of truth for the segment SET; * rotation flips it atomically (write-new → fsync → rename) BEFORE the new - * tail's first byte exists, so no segment file is ever unaccounted for. Its - * per-segment `firstGeneration`/`lastGeneration` are LOAD-BEARING at open: a - * recovery pass looking for facts above a bound reads only the segments those - * bounds cannot rule out (the prune law — see `segmentsHoldingFactsAbove`). - * - * ## Mixed-version logs (the v2 live-write cutover) - * - * The segment header's `formatVersion` selects the decoder PER SEGMENT: - * v1 segments (ops-shaped facts, the format above) stay readable forever and - * are NEVER rewritten; a NEW tail segment writes the v2 format - * (`src/db/factLogFormat.ts` — record envelope, minted dense ints, genesis, - * sector seals) whenever the int minter is installed ({@link FactLog.setIntMinter} — - * the brain wires it from the metadata index's id mapper right after init). - * A bare `FactLog` with no minter keeps writing v1 (there is no authority - * that could reproduce int assignments, and 0 is never written). Cutover - * mechanics on an existing v1 log: an EMPTY v1 tail is re-headed to v2 in - * place; a non-empty v1 tail is sealed by an immediate rotation and the new - * tail is v2. Decoded v2 facts map back to the SAME {@link CommitFact} shape - * v1 consumers read (noun/verb ops with `{metadata, vector} | null` records) — - * the vector wrapper object is reconstructed from the record's metadata leg - * through the reserved-field hydration law (see `commitFactFromV2`). - * - * V2 tails additionally: write the `log.genesis` record (id-space width 64 + - * the brain id, minted once into the manifest's additive `brainId` field) as - * the first record of the FIRST fact of a brand-new log, and seal every - * `sync()` to the header-declared sector size with pad frames that are - * invisible to readers (torn-page defense at group-commit boundaries). + * tail's first byte exists, so no segment file is ever unaccounted for. */ import { encode as defaultEncode, decode as defaultDecode } from '@msgpack/msgpack' import { crc32c } from '../utils/crc32c.js' import { prodLog } from '../utils/logger.js' -import { - FACT_LOG_FORMAT_V1, - FACT_LOG_FORMAT_V2, - DEFAULT_SEAL_SIZE, - parseSegmentHeader, - encodeSegmentHeaderV2, - encodeFactV2, - decodeFact as decodeFormatFact, - decodeGroupV2, - encodePadFrame, - minPadFrameBytes, - type CommitFactV2, - type LogRecord, - type EmbedPendingRecord, - type EmbedLandedRecord, - type BlobManifestRecord, - type BootstrapBaselineRecord, - type ProjectionNoteRecord -} from './factLogFormat.js' -import { - splitNounMetadataRecord -} from '../types/reservedFields.js' -import { NounType } from '../types/graphTypes.js' -import { v4 as uuidv4 } from '../universal/uuid.js' // Swappable msgpack implementation — defaults to the JS codec; a native // provider (registered via the plugin registry's 'msgpack' key) may replace @@ -115,12 +65,7 @@ export function setFactCodec(impl: { export const FACTS_PREFIX = '_generations/facts' /** The facts manifest path (JSON). */ export const FACTS_MANIFEST_PATH = `${FACTS_PREFIX}/manifest.json` -/** - * The v1 segment format version — the MANIFEST's formatVersion gate and the - * header value of v1 (minter-less) tails. NOT the live-write ceiling: new - * tails write `FACT_LOG_FORMAT_V2` (src/db/factLogFormat.ts) whenever the - * int minter is installed; both versions are read forever, per segment. - */ +/** Current segment format version (header field; additive-only within a major). */ export const FACTS_FORMAT_VERSION = 1 /** Rotation threshold: seal the tail segment once it exceeds this many bytes. */ const SEGMENT_ROTATE_BYTES = 8 * 1024 * 1024 @@ -138,31 +83,6 @@ export interface FactOp { record: { metadata: unknown | null; vector: unknown | null } | null } -/** - * V2-native records beyond noun/verb ops that a fact may carry through the - * ENCODER (types 6/7/8/9/10 of the v2 registry: embed markers, blob - * manifests, projection notes, bootstrap baselines). The deferred-embedding - * lifecycle PRODUCES types 6/7 today: `embed.pending` rides the deferred - * write's own commit fact and `embed.landed` rides the background worker's - * landing commit (recovery folds the pair back out of the log at open). The - * blob lifecycle remodels onto type 8 in a later leg. - */ -export type FactMarkerRecord = - | EmbedPendingRecord - | EmbedLandedRecord - | BlobManifestRecord - | ProjectionNoteRecord - | BootstrapBaselineRecord - -/** - * Mints the dense integer handle for an entity/verb id at fact-append time — - * REQUIRED to be reproducible: a rebuilt id mapper must reproduce the same - * assignments exactly, so the only legal implementation delegates to the - * metadata index's id mapper (`getOrAssign`). Returns a POSITIVE bigint; a - * minter that cannot resolve its mapper throws — an int of 0 is never written. - */ -export type FactIntMinter = (kind: 'noun' | 'verb', id: string) => bigint - /** One committed generation, as scanned back out of the log. */ export interface CommitFact { generation: number @@ -170,12 +90,6 @@ export interface CommitFact { ops: FactOp[] meta?: Record blobHashes?: string[] - /** - * V2-native marker records riding this fact (see {@link FactMarkerRecord}). - * Optional and additive: absent on every v1 fact and on every fact the - * current writers produce; requires a v2 tail to encode. - */ - records?: FactMarkerRecord[] } /** The telemetry a scan batch carries (frozen shape). */ @@ -188,26 +102,12 @@ export interface FactScanBatch { segmentId: string } -/** - * Liveness bound on a scan's FIRST batch (Stage-2 co-freeze, D1 contract): - * `batches()` must yield its first batch — or fail loudly — within this many - * ms of the first pull. A backlogged or damaged store may be SLOW, but it may - * never be SILENT: a consumer awaiting the first batch is otherwise - * indistinguishable from a wedge (the exact failure shape a production heal - * hit against a generations-backlogged brain). - */ -export const SCANFACTS_FIRST_BATCH_MS = 10_000 - /** The telemetry a scan OPEN returns (frozen shape). */ export interface FactScanHandle { headGeneration: number segmentCount: number approxFactCount: number - /** - * Ordered batches; a detected gap aborts LOUDLY, never a silent skip. - * Liveness contract: the FIRST batch resolves or rejects within - * {@link SCANFACTS_FIRST_BATCH_MS} of the first pull — never a silent hang. - */ + /** Ordered batches; a detected gap aborts LOUDLY, never a silent skip. */ batches: () => AsyncGenerator /** Close telemetry — the invariant cross-check, valid after iteration ends. */ summary: () => { factsYielded: number; segmentsRead: number } @@ -229,12 +129,6 @@ interface FactsManifest { /** The append target. Its true content is established by scanning (crash tolerance). */ tailSegment: string | null updatedAt: string - /** - * This brain's stable id (additive, v2 cutover): minted as a uuid at the - * first v2 tail creation and never changed; the `log.genesis` record - * carries it. Absent on logs that have never had a v2 tail. - */ - brainId?: string } /** The narrow byte-level storage surface the fact log rides. */ @@ -343,339 +237,40 @@ function decodeFact(payload: Uint8Array): CommitFact { } } -/** - * Deep-normalize a decoded v2 JSON position (metadata legs, meta maps, - * notes) back to plain-JSON values: the v2 codec decodes msgpack int64/uint64 - * as `bigint` (its u64 wire discipline), but canonical records are JSON — a - * metadata timestamp like `createdAt: 1786…` must come back as the NUMBER it - * was encoded from. Safe-range bigints narrow exactly; anything beyond the - * safe-integer range in a JSON position refuses loudly (it cannot have come - * from a JSON write). - */ -function normalizeWireJson(value: unknown): unknown { - if (typeof value === 'bigint') { - if (value > BigInt(Number.MAX_SAFE_INTEGER) || value < -BigInt(Number.MAX_SAFE_INTEGER)) { - throw new Error( - `fact log v2: decoded integer ${value} exceeds the JS safe-integer range in a JSON position` - ) - } - return Number(value) - } - if (Array.isArray(value)) return value.map(normalizeWireJson) - if (value && typeof value === 'object' && !(value instanceof Uint8Array)) { - const out: Record = {} - for (const [k, v] of Object.entries(value)) out[k] = normalizeWireJson(v) - return out - } - return value -} - -/** - * JSON-serialization equivalence for a v2 ENCODE-side JSON position: drop - * undefined-valued object keys and map undefined array elements to null — - * exactly what `JSON.stringify` does when canonical records are persisted. - * Commit facts are built from write-cache-WARM objects that may still carry - * undefined-valued engine keys (`service: undefined`, …) which the durable - * JSON never had; msgpack would preserve them as nil (the v1 capture's known - * wart), so the v2 capture — the future storage authority — sanitizes to the - * DURABLE truth instead. - */ -function toJsonSafe(value: unknown): unknown { - if (value === undefined) return null - if (Array.isArray(value)) return value.map((v) => (v === undefined ? null : toJsonSafe(v))) - if (value && typeof value === 'object' && !(value instanceof Uint8Array)) { - const out: Record = {} - for (const [k, v] of Object.entries(value)) { - if (v === undefined) continue - out[k] = toJsonSafe(v) - } - return out - } - return value -} - -/** Mirror of the storage layer's stored-timestamp normalization, minus its - * `Date.now()` fallback (a DECODER must be deterministic — an unreadable - * timestamp is omitted, and the divergence surfaces via the oracle). */ -function reconstructTimestamp(value: unknown): number | undefined { - if (typeof value === 'number' && value > 0) return value - if ( - value !== null && - typeof value === 'object' && - typeof (value as { seconds?: unknown }).seconds === 'number' - ) { - return (value as { seconds: number }).seconds * 1000 - } - return undefined -} - -/** - * Rebuild a noun's canonical VECTOR-FILE wrapper from a v2 after-image — - * the read-side of the hydration law. Canonical noun vector files hold the - * denormalized enumerable entity (`{id, vector, connections, level, type, - * …reserved fields…, metadata}` — the write path's composition); the v2 - * record deliberately carries only the ENTITY state (metadata leg + embedding - * floats), because connections/level are derived HNSW residue with their own - * rebuild paths (empty in every 8.x write) and the denormalized top-level - * fields are projections of the metadata leg. This reconstruction applies - * the SAME split/hydrate law the storage layer uses - * (`splitNounMetadataRecord` — the single source of truth in - * src/types/reservedFields.ts; field map mirrors - * `BaseStorage.hydrateNounWithMetadata`, undefined keys omitted exactly as - * JSON serialization omits them), so in the no-drift case the reconstructed - * wrapper digests byte-equal to canonical. A drifted denormalized copy - * surfaces as an oracle `state-differs` — named, never silently absorbed. - */ -export function reconstructNounWrapper( - id: string, - metadataLeg: unknown, - floats: number[] -): Record { - const { reserved, custom } = splitNounMetadataRecord( - (metadataLeg ?? null) as Record | null - ) - const wrapper: Record = { - id, - vector: floats, - connections: {}, - level: 0, - type: (reserved.noun as string) || NounType.Thing - } - if (reserved.subtype !== undefined) wrapper.subtype = reserved.subtype - if (reserved.visibility !== undefined) wrapper.visibility = reserved.visibility - const createdAt = reconstructTimestamp(reserved.createdAt) - if (createdAt !== undefined) wrapper.createdAt = createdAt - const updatedAt = reconstructTimestamp(reserved.updatedAt) - if (updatedAt !== undefined) wrapper.updatedAt = updatedAt - if (reserved.confidence !== undefined) wrapper.confidence = reserved.confidence - if (reserved.weight !== undefined) wrapper.weight = reserved.weight - if (reserved.service !== undefined) wrapper.service = reserved.service - if (reserved.data !== undefined) wrapper.data = reserved.data - if (reserved.createdBy !== undefined) wrapper.createdBy = reserved.createdBy - wrapper._rev = typeof reserved._rev === 'number' ? reserved._rev : 1 - wrapper.metadata = custom - return wrapper -} - -/** Coerce a candidate embedding to `number[]`: plain arrays pass through - * (element-checked); numeric typed arrays (the JS HNSW rebuild path stores - * `Float32Array` vectors on the memory adapter) widen via `Array.from`. */ -function floatsOf(candidate: unknown, context: string): number[] | undefined { - if (Array.isArray(candidate)) { - for (const el of candidate) { - if (typeof el !== 'number') { - throw new Error(`fact log v2: ${context} vector carries a non-number element`) - } - } - return candidate as number[] - } - if (ArrayBuffer.isView(candidate) && !(candidate instanceof DataView)) { - return Array.from(candidate as unknown as ArrayLike) - } - return undefined -} - -/** Extract the embedding float array from a canonical vector value: a bare - * float array (or numeric typed array) passes through; a wrapper object - * yields its `vector` floats; `null` stays `null`; anything else refuses - * loudly. */ -function embeddingLegOf(value: unknown, context: string): number[] | null { - if (value === null || value === undefined) return null - const direct = floatsOf(value, context) - if (direct !== undefined) return direct - if (typeof value === 'object') { - const nested = floatsOf((value as { vector?: unknown }).vector, context) - if (nested !== undefined) return nested - } - throw new Error( - `fact log v2: ${context} has a canonical vector record with no float vector — ` + - `cannot encode its after-image` - ) -} - -/** - * Map one decoded v2 fact to the {@link CommitFact} shape every consumer - * already reads: noun/verb after-images and tombstones become ops (vector - * wrappers reconstructed — see {@link reconstructNounWrapper}); a - * `batch.meta` record becomes `meta` when the fact position carries none; - * `log.genesis` is log-level metadata (its width was verified at decode) and - * is not an op; marker records surface on the additive `records` field so - * nothing is silently dropped. Decoded JSON positions are normalized back - * from the codec's bigint discipline ({@link normalizeWireJson}). - */ -function commitFactFromV2(f: CommitFactV2): CommitFact { - const ops: FactOp[] = [] - const markers: FactMarkerRecord[] = [] - let batchMeta: Record | undefined - for (const r of f.records) { - switch (r.type) { - case 'noun.afterImage': { - const metadata = normalizeWireJson(r.metadata) ?? null - let vector: unknown | null = null - if (r.vectorLeg !== null) { - if (!Array.isArray(r.vectorLeg)) { - throw new Error( - `fact log v2: noun.afterImage ${r.id} carries a vector ref — this reader ` + - `resolves inline vectors only (refs are a later leg); refusing` - ) - } - vector = reconstructNounWrapper(r.id, metadata, r.vectorLeg) - } - ops.push({ kind: 'noun', id: r.id, record: { metadata, vector } }) - break - } - case 'noun.tombstone': - ops.push({ kind: 'noun', id: r.id, record: null }) - break - case 'verb.afterImage': { - const metadata = normalizeWireJson(r.metadata) ?? null - if (r.vectorLeg !== null && !Array.isArray(r.vectorLeg)) { - throw new Error( - `fact log v2: verb.afterImage ${r.id} carries a vector ref — this reader ` + - `resolves inline vectors only (refs are a later leg); refusing` - ) - } - // The canonical verb vector-file wrapper: endpoints + verb name ride - // as first-class v2 wire fields precisely so this reconstruction is - // exact ({id, vector, connections:{}, verb, sourceId, targetId} — - // verbs carry no `level`). - const vector: Record = { - id: r.id, - vector: r.vectorLeg ?? [], - connections: {}, - verb: r.verb, - sourceId: r.sourceId, - targetId: r.targetId - } - ops.push({ kind: 'verb', id: r.id, record: { metadata, vector } }) - break - } - case 'verb.tombstone': - ops.push({ kind: 'verb', id: r.id, record: null }) - break - case 'batch.meta': - batchMeta = normalizeWireJson(r.meta) as Record - break - case 'log.genesis': - break // the log's birth certificate — log-level metadata, not an op - case 'projection.note': - markers.push({ ...r, note: normalizeWireJson(r.note) as Record }) - break - case 'bootstrap.baseline': - markers.push({ ...r, metadata: normalizeWireJson(r.metadata) }) - break - default: - // embed.pending / embed.landed / blob.manifest carry no loose JSON maps. - markers.push(r) - break - } - } - const meta = f.meta ? (normalizeWireJson(f.meta) as Record) : batchMeta - return { - generation: f.generation, - timestamp: f.timestamp, - ops, - ...(meta ? { meta } : {}), - ...(f.blobHashes && f.blobHashes.length > 0 ? { blobHashes: f.blobHashes } : {}), - ...(markers.length > 0 ? { records: markers } : {}) - } -} - -/** One intact v2 frame's extent inside a segment (byte-slicing support). */ -interface V2FrameExtent { - /** Byte offset just past this frame. */ - end: number - /** The frame's generation (0 for pad filler). */ - generation: number - /** True when the frame is a pad (invisible filler). */ - isPad: boolean -} - -/** Walk a v2 segment's intact frames (torn-tail terminated), returning each - * frame's extent — the byte-level view truncation slices against, so kept - * frames are never re-encoded (byte-immutability of CRC-covered frames). */ -function walkV2Frames(bytes: Uint8Array): V2FrameExtent[] { - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) - const extents: V2FrameExtent[] = [] - let offset = HEADER_BYTES - while (offset + FRAME_PREFIX_BYTES <= bytes.length) { - const length = view.getUint32(offset, true) - const expectedCrc = view.getUint32(offset + 4, true) - const start = offset + FRAME_PREFIX_BYTES - const end = start + length - if (end > bytes.length) break // torn tail - const payload = bytes.subarray(start, end) - if (crc32c(payload) !== expectedCrc) break // torn tail - const fact = decodeFormatFact(payload, FACT_LOG_FORMAT_V2, { - expectedIdSpaceWidth: 64 - }) as CommitFactV2 - extents.push({ end, generation: fact.generation, isPad: fact.records.length === 0 }) - offset = end - } - return extents -} - -/** - * The byte offset a v2 segment is cut at to keep exactly the facts with - * `generation ≤ keepThrough`: the end of the last kept FACT frame (pads - * between kept facts sit inside the retained span; pads after the cut are - * dropped and re-sealed at the next sync). When nothing is dropped the cut - * lands after the last intact frame — trailing pads retained, only a torn - * suffix (if any) removed. - */ -function v2CutOffset(extents: V2FrameExtent[], keepThrough: number): number { - let cut = HEADER_BYTES - let lastIntactEnd = HEADER_BYTES - for (const e of extents) { - lastIntactEnd = e.end - if (e.isPad) continue - if (e.generation <= keepThrough) { - cut = e.end - } else { - return cut // first beyond-keep fact: everything from here (pads included) goes - } - } - return lastIntactEnd -} - /** * Parse a segment's bytes: verify the header, then walk frames until the end * or a torn tail (length overrun / CRC mismatch), which terminates the walk — - * everything before it is intact. The header's formatVersion selects the - * decoder: the v1 walk below is byte-identical to the original v1 reader; - * v2 segments decode through the reference codec (`decodeGroupV2`, pads - * invisible, id-space width verified at 64 — a disagreeing genesis throws - * the codec's typed `GenesisWidthMismatchError`). Returns the decoded facts - * plus the byte length of the VALID prefix (header + intact frames), which - * reconciliation uses to cut a torn tail without re-encoding. + * everything before it is intact. Returns the decoded facts plus the byte + * length of the VALID prefix (header + intact frames), which reconciliation + * uses to cut a torn tail without re-encoding. */ function parseSegment( file: string, bytes: Uint8Array -): { facts: CommitFact[]; validBytes: number; formatVersion: number; sealSize?: number } { +): { facts: CommitFact[]; validBytes: number } { if (bytes.length < HEADER_BYTES) { prodLog.warn(`[FactLog] segment ${file} shorter than its header — treating as empty`) - return { facts: [], validBytes: 0, formatVersion: 0 } + return { facts: [], validBytes: 0 } } - let header: { formatVersion: number; sealSize?: number } - try { - header = parseSegmentHeader(bytes.subarray(0, HEADER_BYTES)) - } catch (err) { - throw new Error(`fact log: segment ${file}: ${(err as Error).message}`) + for (let i = 0; i < MAGIC.length; i++) { + if (bytes[i] !== MAGIC[i]) { + throw new Error(`fact log: segment ${file} has a bad magic — not a fact segment`) + } } - - if (header.formatVersion === FACT_LOG_FORMAT_V2) { - const group = decodeGroupV2(bytes.subarray(HEADER_BYTES), { expectedIdSpaceWidth: 64 }) - return { - facts: group.facts.map(commitFactFromV2), - validBytes: HEADER_BYTES + group.validBytes, - formatVersion: FACT_LOG_FORMAT_V2, - sealSize: header.sealSize + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const version = view.getUint32(8, true) + if (version !== FACTS_FORMAT_VERSION) { + throw new Error( + `fact log: segment ${file} has formatVersion ${version}; this build reads ${FACTS_FORMAT_VERSION}` + ) + } + for (let i = 20; i < HEADER_BYTES; i++) { + if (bytes[i] !== 0) { + // Non-zero reserved bytes = a future format this build cannot verify. + throw new Error(`fact log: segment ${file} has non-zero reserved header bytes — unverifiable`) } } - // v1 walk — byte-identical to the original reader. - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) const facts: CommitFact[] = [] let offset = HEADER_BYTES while (offset + FRAME_PREFIX_BYTES <= bytes.length) { @@ -689,75 +284,7 @@ function parseSegment( facts.push(decodeFact(payload)) offset = end } - return { facts, validBytes: offset, formatVersion: FACT_LOG_FORMAT_V1 } -} - -/** - * THE PRUNE LAW — which segment files a pass looking for facts ABOVE - * `committedGeneration` actually has to read, and how many the manifest's own - * recorded bounds took off the table. - * - * A sealed segment's `lastGeneration` is written at SEAL time and never - * mutated upward afterwards ({@link FactLog.rotate}, unchanged since the log - * was introduced): the tail's bytes are fsynced FIRST (`await this.sync()` — - * "sealed segments are always fully durable"), the entry is then built from - * the content that fsync covered, and only then does the manifest flip — - * atomically (tmp+rename) and fsynced — which in the SAME write re-points - * `tailSegment` at a new file, so the sealed file is never appended to again. - * A crash anywhere in that order is safe in the pruning direction: crash - * before the manifest write and the segment is still the TAIL (read whole); - * crash after it and the entry describes bytes that were already durable. The - * only later mutation of a sealed segment is `open()`'s straddle truncation, - * which REMOVES facts and re-derives the entry from the actual bytes — so a - * recorded bound can drift DOWN with its file, never up. - * - * Therefore: `lastGeneration = L` proves the file holds no fact above L, and - * a pass above `committedGeneration >= L` can skip it whole — no read, no - * CRC decode, no msgpack. What the manifest cannot PROVE is never pruned: an - * entry with no numeric `lastGeneration` (a legacy or hand-repaired manifest) - * is read, and the unsealed tail is always read. - * - * This is the difference between an open that costs O(whole fact log) and one - * that costs O(the facts that could matter). MEASURED in production: a 16k-row - * brain at generation ~478,819 paid 34-37s of segment reads and CRC decoding - * in `generation-store-open-fold` on EVERY open — to answer a question whose - * answer, after a clean close, is always "nothing". - */ -function segmentsHoldingFactsAbove( - stored: FactsManifest, - committedGeneration: number -): { files: string[]; pruned: number } { - const files: string[] = [] - let pruned = 0 - for (const entry of stored.segments) { - const last = (entry as Partial).lastGeneration - if (typeof last === 'number' && Number.isFinite(last) && last <= committedGeneration) { - pruned++ - continue - } - files.push(entry.file) - } - if (stored.tailSegment) files.push(stored.tailSegment) - return { files, pruned } -} - -/** - * Say what the open actually read. One line, and only when the log holds more - * than one segment (a single-segment log has nothing to prune and nothing to - * report) — the operator's receipt that the open is paying for the tail, not - * for the whole history. - */ -function narrateAboveScan( - pass: string, - committedGeneration: number, - read: number, - pruned: number -): void { - if (read + pruned <= 1) return - prodLog.narrate( - `[FactLog] ${pass} above generation ${committedGeneration}: ${read} segment(s) read, ` + - `${pruned} pruned of ${read + pruned} (sealed at or below the bound)` - ) + return { facts, validBytes: offset } } /** @@ -777,129 +304,23 @@ export class FactLog { } /** Decoded facts of the TAIL segment (bounded by the rotation threshold). */ private tailFacts: CommitFact[] = [] - /** Byte size of the tail segment file (valid prefix, pads included — - * pads count toward bytes but NEVER toward facts). */ + /** Byte size of the tail segment file (valid prefix). */ private tailBytes = 0 /** Highest generation in the log (0 = empty). */ private head = 0 /** Segment paths appended since the last sync (the fsync batch). */ private readonly dirtySegments = new Set() - /** The TAIL segment's on-disk format version (selects the live encoder). */ - private tailVersion: number = FACT_LOG_FORMAT_V1 - /** The tail's sector-seal size (v2 tails; from its header on reopen). */ - private tailSealSize: number = DEFAULT_SEAL_SIZE - /** The v2 int minter (see {@link FactIntMinter}); null = v1 live writes. */ - private intMinter: FactIntMinter | null = null constructor(storage: FactLogStorage, options?: { rotateBytes?: number }) { this.storage = storage this.rotateBytes = options?.rotateBytes ?? SEGMENT_ROTATE_BYTES } - /** - * Install the v2 int minter — the capability gate for v2 LIVE WRITES. - * With a minter installed, every NEW tail segment writes the v2 format and - * after-image records carry minted dense ints; without one, live writes - * stay v1 (no authority could reproduce int assignments, and 0 is never - * written). The brain wires this from the metadata index's id mapper right - * after the index is ready; an existing v1 tail cuts over on the next - * append (empty tail: re-headed in place; non-empty: sealed by rotation). - */ - setIntMinter(mint: FactIntMinter): void { - this.intMinter = mint - } - /** The highest committed generation the log holds (0 = empty). */ headGeneration(): number { return this.head } - /** - * True when this log has EVER had a v2 tail — the manifest's `brainId` is - * minted at every v2 tail creation seam and never removed (the tail-version - * check is a belt-and-braces second signal). Only v2 facts can carry marker - * records, so marker folds (e.g. the deferred-embed recovery scan) skip - * v1-only logs WHOLESALE on this one cheap check — no segment is read. - */ - hasV2History(): boolean { - return this.manifest.brainId !== undefined || this.tailVersion === FACT_LOG_FORMAT_V2 - } - - /** - * STREAMING twin of {@link FactLog.peekFactsAbove} for the recovery fold: - * yields facts above the bound one SEGMENT at a time, ascending, without - * ever materializing the whole log (a production first-boot fold OOM-class - * allocation storm came from exactly that — GBs of decoded after-images in - * one array while the process looked hung). Memory is one segment's worth. - * Works manifest-direct (safe before {@link FactLog.open}). Ordering is - * structural (segments rotate in order; appends are ordered within one) and - * ASSERTED — a violation aborts loudly, never a silent misordered replay. - * - * Reads only the segments that CAN hold a fact above the bound — see - * {@link segmentsHoldingFactsAbove}. A bounded fold above a high checkpoint - * therefore reads its own tail, not the whole history it already proved - * durable. - */ - async *streamFactsAbove(committedGeneration: number): AsyncGenerator { - const stored = (await this.storage.readRawObject(FACTS_MANIFEST_PATH)) as FactsManifest | null - if (!stored || typeof stored !== 'object' || !Array.isArray(stored.segments)) return - if (stored.formatVersion !== FACTS_FORMAT_VERSION) return - const { files, pruned } = segmentsHoldingFactsAbove(stored, committedGeneration) - narrateAboveScan('recovery fold', committedGeneration, files.length, pruned) - let lastGen = committedGeneration - for (const file of files) { - const bytes = await this.storage.readRawBytes(`${FACTS_PREFIX}/${file}`) - if (bytes === null) continue - const { facts } = parseSegment(file, bytes) - const batch: CommitFact[] = [] - for (const f of facts) { - if (f.generation <= committedGeneration) continue - if (f.generation <= lastGen) { - throw new Error( - `fact log: streamFactsAbove found non-ascending generations ` + - `(${f.generation} after ${lastGen} in ${file}) — refusing to replay out of order` - ) - } - lastGen = f.generation - batch.push(f) - } - if (batch.length > 0) yield batch - } - } - - /** - * Read (without truncating) every intact fact ABOVE a generation — the - * log-authority recovery surface: after a crash, facts beyond the - * manifest watermark that survived with valid CRCs are ACKED writes in - * durable-at-ack mode, and the owner REPLAYS them instead of letting - * open() truncate them. Must be called BEFORE open() (it reads the raw - * segments directly; the torn tail's invalid suffix is ignored exactly - * like open() would). - * - * Reads only the segments that CAN hold such a fact — see - * {@link segmentsHoldingFactsAbove}. This runs on EVERY log-authority open, - * including the clean one where the answer is always empty, so the segments - * the manifest already proves irrelevant are never opened at all. - */ - async peekFactsAbove(committedGeneration: number): Promise { - const stored = (await this.storage.readRawObject(FACTS_MANIFEST_PATH)) as FactsManifest | null - if (!stored || typeof stored !== 'object' || !Array.isArray(stored.segments)) return [] - if (stored.formatVersion !== FACTS_FORMAT_VERSION) return [] - const out: CommitFact[] = [] - const { files, pruned } = segmentsHoldingFactsAbove(stored, committedGeneration) - narrateAboveScan('above-manifest peek', committedGeneration, files.length, pruned) - for (const file of files) { - const bytes = await this.storage.readRawBytes(`${FACTS_PREFIX}/${file}`) - if (bytes === null) continue - const { facts } = parseSegment(file, bytes) - for (const f of facts) { - if (f.generation > committedGeneration) out.push(f) - } - } - out.sort((a, b) => a.generation - b.generation) - return out - } - /** * Open the log and reconcile it to committed truth: read the manifest, * establish the tail's intact content (torn-tail scan), then TRUNCATE any @@ -949,17 +370,11 @@ export class FactLog { const tailPath = `${FACTS_PREFIX}/${this.manifest.tailSegment}` const bytes = await this.storage.readRawBytes(tailPath) if (bytes === null) { - // Manifest named a tail whose first byte never landed — an empty - // tail. Its header (and format version) is established at the next - // append (see the tail-provisioning ladder there). + // Manifest named a tail whose first byte never landed — an empty tail. this.tailFacts = [] this.tailBytes = 0 } else { - const parsed = parseSegment(this.manifest.tailSegment, bytes) - const { facts, validBytes } = parsed - this.tailVersion = - parsed.formatVersion === FACT_LOG_FORMAT_V2 ? FACT_LOG_FORMAT_V2 : FACT_LOG_FORMAT_V1 - this.tailSealSize = parsed.sealSize ?? DEFAULT_SEAL_SIZE + const { facts, validBytes } = parseSegment(this.manifest.tailSegment, bytes) const kept = facts.filter((f) => f.generation <= committedGeneration) if (kept.length !== facts.length || validBytes !== bytes.length) { const dropped = facts.length - kept.length @@ -969,16 +384,7 @@ export class FactLog { `${committedGeneration} from the tail (never committed)` ) } - if (this.tailVersion === FACT_LOG_FORMAT_V2) { - // V2: byte-slice at frame boundaries — CRC-covered frames are - // byte-immutable; a truncation never re-encodes what it keeps. - const cut = v2CutOffset(walkV2Frames(bytes), committedGeneration) - await this.storage.writeRawBytes(tailPath, bytes.subarray(0, cut)) - this.tailFacts = kept - this.tailBytes = cut - } else { - await this.rewriteTail(kept) - } + await this.rewriteTail(kept) } else { this.tailFacts = facts this.tailBytes = validBytes @@ -993,15 +399,6 @@ export class FactLog { * Append one committed generation's fact. NOT durable until {@link sync} — * the caller batches durability at its commit barrier (transact syncs in * the same call; Model-B group-commit syncs at flush). - * - * Tail provisioning (in order): a missing tail starts one; a named tail - * whose header never landed (manifest-first crash) gets its header now; an - * existing V1 tail cuts over to v2 once the minter is installed (empty: - * re-headed in place, non-empty: sealed by rotation — v1 segments are never - * rewritten); a full tail rotates. The frame then encodes in the TAIL's - * format: v2 tails carry after-image records with minted ints (and the - * genesis record on the very first fact of a brand-new log); v1 tails keep - * the v1 wire format byte-identically. */ async append(fact: CommitFact): Promise { if (fact.generation <= this.head) { @@ -1011,42 +408,10 @@ export class FactLog { } if (this.manifest.tailSegment === null) { await this.startTail(fact.generation) - } else if (this.tailBytes === 0) { - await this.reinitializeTailHeader() - } else if (this.intMinter !== null && this.tailVersion === FACT_LOG_FORMAT_V1) { - if (this.tailFacts.length === 0 && this.tailBytes <= HEADER_BYTES) { - await this.upgradeEmptyTailToV2() - } else { - await this.rotate(fact.generation) - } } else if (this.tailBytes >= this.rotateBytes) { await this.rotate(fact.generation) } - - let frame: Uint8Array - if (this.tailVersion === FACT_LOG_FORMAT_V2) { - const records = this.buildV2Records(fact) - if (this.needsGenesis()) { - if (this.ensureBrainId()) await this.persistManifest() - records.unshift(this.genesisRecord()) - } - frame = encodeFactV2({ - generation: fact.generation, - timestamp: fact.timestamp, - records, - ...(fact.meta ? { meta: toJsonSafe(fact.meta) as Record } : {}), - ...(fact.blobHashes && fact.blobHashes.length > 0 ? { blobHashes: fact.blobHashes } : {}) - }) - } else { - if (fact.records && fact.records.length > 0) { - throw new Error( - `fact log: marker records (${fact.records.map((r) => r.type).join(', ')}) require a ` + - `v2 tail segment — this log's tail is v1 (no int minter installed); refusing rather ` + - `than silently dropping them` - ) - } - frame = encodeFrame(fact) - } + const frame = encodeFrame(fact) const tailPath = `${FACTS_PREFIX}/${this.manifest.tailSegment}` await this.storage.appendRawBytes(tailPath, frame) this.tailFacts.push(fact) @@ -1055,66 +420,14 @@ export class FactLog { this.dirtySegments.add(tailPath) } - /** - * Fsync every segment appended since the last sync. SEALS AT SYNC: a v2 - * tail is first padded to its sector-seal boundary (one pad frame, - * invisible to readers; a gap smaller than the smallest constructible pad - * frame pads through one extra sector — the codec's rule), so every - * durability barrier leaves the tail sector-aligned: a torn page can only - * tear INSIDE the group being written, never a previously-sealed one. - */ + /** Fsync every segment appended since the last sync. */ async sync(): Promise { - await this.padTailToSealBoundary() if (this.dirtySegments.size === 0) return const paths = [...this.dirtySegments] this.dirtySegments.clear() await this.storage.syncRawObjects(paths) } - // --- GROUP COMMIT ON THE LOG (durable-at-ack mode) ------------------------ - // Classic group commit: concurrent writers append, then join ONE fsync - // whose completion releases every covered ack. Two slots — the running - // sync and at most one queued behind it — give the covering guarantee: - // an append followed by ensureSynced() is always covered, because the - // sync it awaits STARTS after the append landed (a running sync that - // may have snapshotted earlier is never joined; the queued one is). - private syncRunning: Promise | null = null - private syncQueued: Promise | null = null - - /** - * Await a sync that covers every byte appended before this call. Many - * concurrent callers share one fsync (solo caller = immediate sync). The - * durability contract of an acked write in log-durable mode: this promise - * resolving means the caller's frames survive power loss. - */ - async ensureSynced(): Promise { - if (this.syncQueued) { - // A sync that has NOT started yet exists — it will snapshot after our - // append, so it covers us. - return this.syncQueued - } - if (this.syncRunning) { - // The running sync may have snapshotted before our append — queue the - // next one behind it and join that. - const queued = this.syncRunning - .catch(() => {}) - .then(() => { - // Promote: the queued sync becomes the running one. - this.syncQueued = null - this.syncRunning = this.sync().finally(() => { - this.syncRunning = null - }) - return this.syncRunning - }) - this.syncQueued = queued - return queued - } - this.syncRunning = this.sync().finally(() => { - this.syncRunning = null - }) - return this.syncRunning - } - /** * Open a scan over committed facts. The scan runs against a MANIFEST * SNAPSHOT (sealed segments + the tail's decoded facts at open) — exactly- @@ -1127,8 +440,6 @@ export class FactLog { toGeneration?: number kinds?: Array<'noun' | 'verb'> batchSize?: number - /** Test override for the first-batch liveness bound (default {@link SCANFACTS_FIRST_BATCH_MS}). */ - firstBatchTimeoutMs?: number }): FactScanHandle { const from = options?.fromGeneration ?? 1 const to = options?.toGeneration ?? this.head @@ -1203,43 +514,11 @@ export class FactLog { } } - // Liveness wrapper: the FIRST pull races the contract deadline. Only the - // first — the bound is time-to-first-batch (proof the producer is alive), - // not per-batch pacing; and it runs only while a pull is actually pending, - // so consumer think-time between pulls never counts against the producer. - const firstBatchTimeoutMs = options?.firstBatchTimeoutMs ?? SCANFACTS_FIRST_BATCH_MS - async function* batchesWithLiveness(this: void): AsyncGenerator { - const inner = batches() - let timer: NodeJS.Timeout | undefined - try { - const deadline = new Promise((_, reject) => { - timer = setTimeout( - () => - reject( - new Error( - `fact log: scanFacts produced no first batch within ${firstBatchTimeoutMs}ms ` + - `(liveness contract) — the store is wedged or unreadably slow; aborting scan LOUDLY ` + - `instead of hanging the consumer.` - ) - ), - firstBatchTimeoutMs - ) - timer.unref?.() - }) - const first = await Promise.race([inner.next(), deadline]) - if (first.done) return - yield first.value - } finally { - clearTimeout(timer) - } - yield* inner - } - return { headGeneration: this.head, segmentCount: segments.length + (tailSnapshot.length > 0 ? 1 : 0), approxFactCount, - batches: batchesWithLiveness, + batches, summary: () => ({ factsYielded, segmentsRead }) } } @@ -1272,25 +551,7 @@ export class FactLog { `(head ${this.head}) — the fact to drop was already sealed; the log needs reopen` ) } - if (this.tailVersion === FACT_LOG_FORMAT_V2) { - // V2: byte-slice at frame boundaries (kept frames stay byte-identical; - // pads between kept facts are retained inside the prefix, trailing pads - // go and the next sync re-seals). The dropped frames may be unsynced — - // readRawBytes is read-after-write coherent over the append path. - const file = this.manifest.tailSegment - if (!file) return - const tailPath = `${FACTS_PREFIX}/${file}` - const bytes = await this.storage.readRawBytes(tailPath) - if (bytes === null) { - throw new Error(`fact log: dropAbove(${keepThrough}) cannot read the tail segment ${file}`) - } - const cut = v2CutOffset(walkV2Frames(bytes), keepThrough) - await this.storage.writeRawBytes(tailPath, bytes.subarray(0, cut)) - this.tailFacts = kept - this.tailBytes = cut - } else { - await this.rewriteTail(kept) - } + await this.rewriteTail(kept) this.head = this.computeHead() } @@ -1303,42 +564,25 @@ export class FactLog { return 0 } - /** The header bytes for a NEW tail: v2 whenever the minter is installed. */ - private newTailHeader(firstGeneration: number): Uint8Array { - return this.intMinter !== null - ? encodeSegmentHeaderV2(firstGeneration, DEFAULT_SEAL_SIZE) - : buildHeader(firstGeneration) - } - - /** Record the just-created tail's format in memory (mirrors its header). */ - private noteFreshTail(): void { - this.tailVersion = this.intMinter !== null ? FACT_LOG_FORMAT_V2 : FACT_LOG_FORMAT_V1 - this.tailSealSize = DEFAULT_SEAL_SIZE - } - /** Create the very first tail segment (manifest-first, then header bytes). */ private async startTail(firstGeneration: number): Promise { const file = segmentFileName(firstGeneration) this.manifest.tailSegment = file - if (this.intMinter !== null) this.ensureBrainId() await this.persistManifest() - await this.storage.appendRawBytes(`${FACTS_PREFIX}/${file}`, this.newTailHeader(firstGeneration)) + await this.storage.appendRawBytes(`${FACTS_PREFIX}/${file}`, buildHeader(firstGeneration)) this.tailFacts = [] this.tailBytes = HEADER_BYTES - this.noteFreshTail() } /** * Seal the tail into the manifest and start a new one. Manifest-first: the * flip both seals the old tail AND names the new one atomically, so no - * segment file ever exists unaccounted for. The NEW tail's format follows - * the minter gate ({@link newTailHeader}) — this is also the v1→v2 cutover - * seam for a non-empty v1 tail (sealed as-is, never rewritten). + * segment file ever exists unaccounted for. */ private async rotate(nextGeneration: number): Promise { const sealedFile = this.manifest.tailSegment if (!sealedFile) return - // Seal what the tail actually holds (sync() also sector-seals a v2 tail). + // Seal what the tail actually holds. await this.sync() // sealed segments are always fully durable const entry: SegmentEntry = { file: sealedFile, @@ -1350,187 +594,10 @@ export class FactLog { const newFile = segmentFileName(nextGeneration) this.manifest.segments.push(entry) this.manifest.tailSegment = newFile - if (this.intMinter !== null) this.ensureBrainId() await this.persistManifest() - await this.storage.appendRawBytes(`${FACTS_PREFIX}/${newFile}`, this.newTailHeader(nextGeneration)) + await this.storage.appendRawBytes(`${FACTS_PREFIX}/${newFile}`, buildHeader(nextGeneration)) this.tailFacts = [] this.tailBytes = HEADER_BYTES - this.noteFreshTail() - } - - /** - * The v1→v2 cutover for an EMPTY v1 tail: re-head it in place (nothing but - * the 32-byte header exists, so no v1 frame is ever rewritten). Also the - * cheapest cutover shape: brand-new brains whose first tail predates the - * minter installation converge here on their first post-install append. - */ - private async upgradeEmptyTailToV2(): Promise { - const file = this.manifest.tailSegment - if (!file) return - if (this.ensureBrainId()) await this.persistManifest() - const first = this.segmentFirstGenerationFromName(file) - const path = `${FACTS_PREFIX}/${file}` - await this.storage.writeRawBytes(path, encodeSegmentHeaderV2(first, DEFAULT_SEAL_SIZE)) - this.tailBytes = HEADER_BYTES - this.tailVersion = FACT_LOG_FORMAT_V2 - this.tailSealSize = DEFAULT_SEAL_SIZE - this.dirtySegments.add(path) - } - - /** - * A manifest-named tail whose header never landed (crash between the - * manifest flip and the first header byte — previously this appended - * frames into a headerless file the next open could not parse): write the - * header now, in the CURRENT format gate. - */ - private async reinitializeTailHeader(): Promise { - const file = this.manifest.tailSegment - if (!file) return - if (this.intMinter !== null && this.ensureBrainId()) await this.persistManifest() - const first = this.segmentFirstGenerationFromName(file) - const path = `${FACTS_PREFIX}/${file}` - await this.storage.writeRawBytes(path, this.newTailHeader(first)) - this.tailBytes = HEADER_BYTES - this.noteFreshTail() - this.dirtySegments.add(path) - } - - /** True when the NEXT appended fact is the first fact of a brand-new v2 - * log — the one that must open with the log.genesis record. */ - private needsGenesis(): boolean { - return ( - this.tailVersion === FACT_LOG_FORMAT_V2 && - this.manifest.segments.length === 0 && - this.tailFacts.length === 0 - ) - } - - /** Mint the brain id into the manifest if absent; true when it changed. */ - private ensureBrainId(): boolean { - if (this.manifest.brainId) return false - this.manifest.brainId = uuidv4() - return true - } - - /** The log's birth certificate (id-space width 64 — the only width this - * writer mints; a reader expecting another width refuses at decode). */ - private genesisRecord(): LogRecord { - const brainId = this.manifest.brainId - if (!brainId) { - throw new Error( - 'fact log v2: genesis requires a brainId in the facts manifest — invariant violated' - ) - } - return { type: 'log.genesis', idSpaceWidth: 64, brainId, createdAt: Date.now() } - } - - /** - * Convert one CommitFact's ops (+ optional marker records) to v2 wire - * records, MINTING ints at append time: entity/verb ints come from the - * injected minter (the metadata index's id mapper — the one authority a - * rebuild reproduces exactly). Verb endpoints and the verb name ride as - * first-class wire fields, lifted from the canonical verb vector wrapper. - * Every refusal here is loud — an after-image without a mintable int, a - * verb without endpoints, or a vector record without floats fails the - * WRITE, never writes a 0. - */ - private buildV2Records(fact: CommitFact): LogRecord[] { - const mint = (kind: 'noun' | 'verb', id: string): bigint => { - if (this.intMinter === null) { - throw new Error( - `fact log v2: no int minter is installed — cannot mint the ${kind} int for ${id}; ` + - `refusing to write a v2 after-image (an int of 0 is never written)` - ) - } - const minted = this.intMinter(kind, id) - // Reserved-root exemption: int 0 is legitimate for exactly one id — - // the all-zeros VFS root, minted 0 by construction at genesis on - // existing brains. Zero anywhere else is a corrupt mint. - const isReservedRoot = - minted === 0n && id === '00000000-0000-0000-0000-000000000000' - if (typeof minted !== 'bigint' || minted < 0n || (minted === 0n && !isReservedRoot)) { - throw new Error( - `fact log v2: the int minter returned ${String(minted)} for ${kind} ${id} — ` + - `minted ints are positive bigints (int 0 reserved for the VFS root alone); ` + - `refusing to write` - ) - } - return minted - } - - const records: LogRecord[] = [] - for (const op of fact.ops) { - if (op.kind === 'noun') { - if (op.record === null) { - records.push({ type: 'noun.tombstone', id: op.id }) - continue - } - records.push({ - type: 'noun.afterImage', - id: op.id, - entityInt: mint('noun', op.id), - metadata: toJsonSafe(op.record.metadata ?? null), - vectorLeg: embeddingLegOf(op.record.vector, `noun ${op.id}`) - }) - } else { - if (op.record === null) { - records.push({ type: 'verb.tombstone', id: op.id }) - continue - } - const wrapper = op.record.vector as Record | null - const verbName = wrapper?.verb - const sourceId = wrapper?.sourceId - const targetId = wrapper?.targetId - if ( - typeof verbName !== 'string' || - typeof sourceId !== 'string' || - typeof targetId !== 'string' - ) { - throw new Error( - `fact log v2: verb ${op.id} has no canonical endpoints (verb/sourceId/targetId ` + - `live in its vector record, which is missing or torn) — refusing to write an ` + - `after-image that could not be replayed` - ) - } - const floats = floatsOf(wrapper?.vector, `verb ${op.id}`) ?? [] - records.push({ - type: 'verb.afterImage', - id: op.id, - verbInt: mint('verb', op.id), - metadata: toJsonSafe(op.record.metadata ?? null), - vectorLeg: floats, - verb: verbName, - sourceId, - sourceInt: mint('noun', sourceId), - targetId, - targetInt: mint('noun', targetId) - }) - } - } - for (const marker of fact.records ?? []) records.push(marker) - return records - } - - /** - * Pad a v2 tail to its next sector-seal boundary with ONE pad frame — - * called from {@link sync} so alignment holds at every durability barrier. - * Pads count toward {@link tailBytes} but never toward facts (they are - * invisible to every reader); a gap smaller than the smallest constructible - * pad frame pads through one extra sector (the codec's rule). No-op for v1 - * tails, empty tails, and already-aligned tails. - */ - private async padTailToSealBoundary(): Promise { - if (this.tailVersion !== FACT_LOG_FORMAT_V2) return - const file = this.manifest.tailSegment - if (!file || this.tailBytes <= HEADER_BYTES) return - const remainder = this.tailBytes % this.tailSealSize - if (remainder === 0) return - let padBytes = this.tailSealSize - remainder - if (padBytes < minPadFrameBytes()) padBytes += this.tailSealSize - const tailPath = `${FACTS_PREFIX}/${file}` - await this.storage.appendRawBytes(tailPath, encodePadFrame(padBytes)) - this.tailBytes += padBytes - this.dirtySegments.add(tailPath) } /** Atomically persist the manifest (write-new → fsync → rename downstream). */ @@ -1559,24 +626,17 @@ export class FactLog { this.tailBytes = total } - /** Cut a SEALED segment back to `committedGeneration` (atomic replace). - * v2 segments byte-slice at frame boundaries (kept frames — pads - * included — are never re-encoded); the v1 re-encode path is unchanged. */ + /** Cut a SEALED segment back to `committedGeneration` (atomic replace). */ private async truncateSegmentTo(file: string, committedGeneration: number): Promise { const path = `${FACTS_PREFIX}/${file}` const bytes = await this.storage.readRawBytes(path) if (bytes === null) return - const { facts, formatVersion } = parseSegment(file, bytes) + const { facts } = parseSegment(file, bytes) const kept = facts.filter((f) => f.generation <= committedGeneration) prodLog.warn( `[FactLog] truncating sealed segment ${file} to generation ${committedGeneration} ` + `(${facts.length - kept.length} uncommitted fact(s) dropped)` ) - if (formatVersion === FACT_LOG_FORMAT_V2) { - const cut = v2CutOffset(walkV2Frames(bytes), committedGeneration) - await this.storage.writeRawBytes(path, bytes.subarray(0, cut)) - return - } const first = kept[0]?.generation ?? this.segmentFirstGenerationFromName(file) const parts: Uint8Array[] = [buildHeader(first)] for (const f of kept) parts.push(encodeFrame(f)) diff --git a/src/db/factLogFormat.ts b/src/db/factLogFormat.ts deleted file mode 100644 index d5492da8..00000000 --- a/src/db/factLogFormat.ts +++ /dev/null @@ -1,1333 +0,0 @@ -/** - * @module db/factLogFormat - * @description Fact-log format v2 (record envelope + sector seals) — the pure - * encode/decode functions for the versioned on-disk fact-log byte format. - * No I/O and no storage dependencies live here: this module is the REFERENCE - * IMPLEMENTATION of the format, and a second (native) reader parses these - * exact bytes. Byte-level behavior is a two-implementation contract — bytes - * change only behind a format-version bump, never in place. - * - * ## Segment header (32 bytes, both versions) - * - * magic "BFACTS\0\0" (8B) | formatVersion:u32 LE | firstGeneration:u64 LE | - * v1: reserved 12B (ZEROED, verified) - * v2: sealSize:u16 LE at offset +20 | reserved 10B (ZEROED, verified) - * - * V1 segments remain readable forever via the v1 decode path — never rewritten. - * - * ## Frame (unchanged from v1) - * - * payloadLength:u32 LE | crc32c:u32 LE (of payload) | msgpack payload - * - * A bad length (overruns the buffer) or CRC mismatch is a TORN TAIL: it - * terminates the scan; everything before it is intact. - * - * ## V2 fact payload (msgpack, positional — same 5 positions as v1, but - * position 2 is `records`, not v1's `ops`) - * - * fact := [ generation:u64, timestamp:u64, records, meta|nil, blobHashes|nil ] - * record := [ recordType:u8, recordVersion:u8, cipherFlag:u8, keyId:bin16|nil, - * ...type-specific fields ] - * - * `cipherFlag`/`keyId` are RESERVED crypto envelope fields: `0`/`nil` (a - * plaintext record) is the ONLY legal combination this release writes or - * reads. Any nonzero cipherFlag or non-nil keyId refuses with the typed - * {@link UnknownLogRecordError} ("encrypted records need a newer reader") — - * so record-level encryption can land later without a format-version bump on - * the one compat surface. No crypto logic exists here; the bytes are reserved - * only. Pad records (type 0) are exempt: they are skipped WHOLESALE as - * length-only filler, so their fields beyond [type, version] are never - * inspected (this keeps pad frames byte-stable across the envelope change). - * - * Record type registry (all recordVersion = 1; type-specific fields listed — - * every record carries the 4-field envelope above first): - * - * 0 pad [] — length-only filler; readers SKIP; crc-covered - * 1 noun.afterImage [id bin16, entityInt u64, metadata, vectorLeg] - * 2 noun.tombstone [id bin16] - * 3 verb.afterImage [id bin16, verbInt u64, metadata, vectorLeg, - * verb str, sourceId bin16, sourceInt u64, - * targetId bin16, targetInt u64] - * 4 verb.tombstone [id bin16] - * 5 batch.meta [metaMap] — at most ONE per fact - * 6 embed.pending [id bin16, enqueuedAt u64] - * 7 embed.landed [id bin16, vector — INLINE float[] only] - * 8 blob.manifest [hash bin32, size u64, mimeType str, refOp u8 (0=add,1=release)] - * 9 projection.note [noteMap] — opaque map, reserved consumer - * 10 bootstrap.baseline [id bin16, kind u8 (0=noun,1=verb), metadata, vectorLeg] - * 11 log.genesis [idSpaceWidth u8 (32|64), brainId bin16, createdAt u64] - * — MUST be the first record of the first fact in a - * v2 log (first-record-of-fact is enforced here; the - * first-fact-of-log half belongs to the log layer) - * - * vectorLeg := float[] | ['ref', sameAsGeneration u64] | nil - * - * Integer wire discipline (reference encoder): every field declared u64 above - * rides as msgpack uint64 (0xcf, fixed 8 bytes); u8 fields ride as minimal - * msgpack uints (positive fixint). The decoder is liberal and accepts any - * msgpack unsigned-integer width for these fields. `entityInt`/`verbInt`/ - * `sourceInt`/`targetInt` surface as `bigint` (full u64 range); scalar - * counters and timestamps surface as `number` and refuse values beyond - * `Number.MAX_SAFE_INTEGER` loudly. - * - * ## Decoder law - * - * An unknown recordType, or a recordVersion newer than this reader knows, - * throws {@link UnknownLogRecordError} — NEVER skip-and-continue (type 0 pad - * is the sole exception: skipped by definition). A log.genesis whose - * idSpaceWidth disagrees with the caller's expected width throws - * {@link GenesisWidthMismatchError} naming both widths. - * - * ## Sector seals - * - * A "sealed group" is one or more frames padded to the next `sealSize` - * boundary with ONE pad frame — a frame whose fact is - * `[0, 0, [[0, 1, filler?]], nil, nil]` (generation 0 marks filler; real - * facts start at 1). Pad frames are invisible to readers. When the gap to the - * boundary is smaller than the smallest constructible pad frame, the group is - * padded through to the boundary AFTER next (one extra sealSize) — chosen as - * the simpler correct approach over rewriting the previous frame's payload: - * input frames stay byte-immutable, alignment still holds, and the cost is at - * most one sector on a rare (<1%) size coincidence. - */ -import { encode as msgpackEncode, decode as msgpackDecode } from '@msgpack/msgpack' -import { crc32c } from '../utils/crc32c.js' -import type { CommitFact } from './factLog.js' - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** Segment magic: ASCII "BFACTS" + two NULs (shared by v1 and v2 headers). */ -export const FACT_SEGMENT_MAGIC: Uint8Array = new Uint8Array([ - 0x42, 0x46, 0x41, 0x43, 0x54, 0x53, 0x00, 0x00 -]) - -/** Segment format version 1 (ops-shaped facts, 12 zeroed reserved bytes). */ -export const FACT_LOG_FORMAT_V1 = 1 - -/** Segment format version 2 (record envelope + sector seals). */ -export const FACT_LOG_FORMAT_V2 = 2 - -/** Segment header size in bytes (identical for v1 and v2). */ -export const SEGMENT_HEADER_BYTES = 32 - -/** Frame prefix size: payloadLength(4) + crc32c(4). */ -export const FRAME_PREFIX_BYTES = 8 - -/** Default sector-seal size (bytes) when the caller does not probe a device. */ -export const DEFAULT_SEAL_SIZE = 4096 - -/** The record version this reader knows (all registry types are version 1). */ -export const LOG_RECORD_VERSION = 1 - -/** - * The only legal `cipherFlag` value this release: plaintext. The encoder - * always writes it (with a nil keyId); the decoder refuses anything else - * with {@link UnknownLogRecordError} — encrypted records need a newer reader. - */ -export const LOG_RECORD_CIPHER_PLAINTEXT = 0 - -/** The v2 record-type registry — wire codes for every record type. */ -export const LOG_RECORD_TYPES = { - PAD: 0, - NOUN_AFTER_IMAGE: 1, - NOUN_TOMBSTONE: 2, - VERB_AFTER_IMAGE: 3, - VERB_TOMBSTONE: 4, - BATCH_META: 5, - EMBED_PENDING: 6, - EMBED_LANDED: 7, - BLOB_MANIFEST: 8, - PROJECTION_NOTE: 9, - BOOTSTRAP_BASELINE: 10, - LOG_GENESIS: 11 -} as const - -/** A wire code from the v2 record-type registry. */ -export type LogRecordTypeCode = (typeof LOG_RECORD_TYPES)[keyof typeof LOG_RECORD_TYPES] - -const U64_MAX = (1n << 64n) - 1n - -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- - -/** - * A record whose type or version this reader does not know. Thrown — never - * skipped — so an old reader can NEVER silently drop data written by a newer - * writer. Carries the offending type/version for programmatic handling. - */ -export class UnknownLogRecordError extends Error { - /** The wire recordType that was not understood. */ - public readonly recordType: number - /** The wire recordVersion that was not understood. */ - public readonly recordVersion: number - - constructor(recordType: number, recordVersion: number, message: string) { - super(message) - this.name = 'UnknownLogRecordError' - this.recordType = recordType - this.recordVersion = recordVersion - } -} - -/** - * A log.genesis record whose id-space width disagrees with the width the - * caller expects. Decoding across id-space widths is refused loudly — the - * error names both widths. - */ -export class GenesisWidthMismatchError extends Error { - /** The width the caller expected (32 or 64). */ - public readonly expectedWidth: number - /** The width the genesis record declares (32 or 64). */ - public readonly actualWidth: number - - constructor(expectedWidth: number, actualWidth: number) { - super( - `fact log v2: log.genesis declares a ${actualWidth}-bit id space but this reader ` + - `expected ${expectedWidth}-bit — refusing to decode across id-space widths` - ) - this.name = 'GenesisWidthMismatchError' - this.expectedWidth = expectedWidth - this.actualWidth = actualWidth - } -} - -// --------------------------------------------------------------------------- -// Record + fact types (the TS surface of the wire registry) -// --------------------------------------------------------------------------- - -/** A vector reference: "same vector as the one generation N carried inline". */ -export interface VectorRef { - /** The generation whose record carried the INLINE vector (single-hop only). */ - sameAsGeneration: number -} - -/** A record's vector leg: inline floats, a single-hop ref, or none. */ -export type VectorLeg = number[] | VectorRef | null - -/** Type 1 — the after-image of a noun: what the entity BECAME. */ -export interface NounAfterImageRecord { - type: 'noun.afterImage' - id: string - /** The entity's u64 integer handle (full range — hence bigint). */ - entityInt: bigint - metadata: unknown - vectorLeg: VectorLeg -} - -/** Type 2 — a body-less noun tombstone: the entity was removed. */ -export interface NounTombstoneRecord { - type: 'noun.tombstone' - id: string -} - -/** Type 3 — the after-image of a verb (relationship), endpoints included. */ -export interface VerbAfterImageRecord { - type: 'verb.afterImage' - id: string - /** The verb's u64 integer handle (full range — hence bigint). */ - verbInt: bigint - metadata: unknown - vectorLeg: VectorLeg - /** The verb name (relationship type). */ - verb: string - sourceId: string - sourceInt: bigint - targetId: string - targetInt: bigint -} - -/** Type 4 — a body-less verb tombstone: the relationship was removed. */ -export interface VerbTombstoneRecord { - type: 'verb.tombstone' - id: string -} - -/** Type 5 — batch-level metadata; at most ONE per fact. */ -export interface BatchMetaRecord { - type: 'batch.meta' - meta: Record -} - -/** Type 6 — an embedding was enqueued for the id (vector not yet available). */ -export interface EmbedPendingRecord { - type: 'embed.pending' - id: string - /** Enqueue time (epoch ms). */ - enqueuedAt: number -} - -/** Type 7 — a deferred embedding landed; carries the INLINE vector only. */ -export interface EmbedLandedRecord { - type: 'embed.landed' - id: string - /** The landed vector — inline floats only; refs are not allowed here. */ - vector: number[] -} - -/** Type 8 — a blob reference-count event (content-addressed by hash). */ -export interface BlobManifestRecord { - type: 'blob.manifest' - /** The blob's content hash — 64 lowercase hex chars (bin32 on the wire). */ - hash: string - size: number - mimeType: string - refOp: 'add' | 'release' -} - -/** Type 9 — an opaque note for a reserved projection consumer. */ -export interface ProjectionNoteRecord { - type: 'projection.note' - note: Record -} - -/** Type 10 — a bootstrap baseline row (initial-load after-image). */ -export interface BootstrapBaselineRecord { - type: 'bootstrap.baseline' - id: string - kind: 'noun' | 'verb' - metadata: unknown - vectorLeg: VectorLeg -} - -/** Type 11 — the log's birth certificate; first record of the first fact. */ -export interface LogGenesisRecord { - type: 'log.genesis' - /** The integer-handle width this log's records use. */ - idSpaceWidth: 32 | 64 - brainId: string - /** Creation time (epoch ms). */ - createdAt: number -} - -/** Any decodable v2 record (pads are skipped, never surfaced). */ -export type LogRecord = - | NounAfterImageRecord - | NounTombstoneRecord - | VerbAfterImageRecord - | VerbTombstoneRecord - | BatchMetaRecord - | EmbedPendingRecord - | EmbedLandedRecord - | BlobManifestRecord - | ProjectionNoteRecord - | BootstrapBaselineRecord - | LogGenesisRecord - -/** One committed generation in v2 shape: a record envelope, not v1 ops. */ -export interface CommitFactV2 { - generation: number - timestamp: number - records: LogRecord[] - meta?: Record - blobHashes?: string[] -} - -/** A parsed segment header (v1 has no sealSize; v2 always carries one). */ -export interface SegmentHeader { - formatVersion: number - firstGeneration: number - /** Sector-seal size (v2 only) — `undefined` on v1 headers. */ - sealSize?: number -} - -/** Options for {@link encodeFactV2}. */ -export interface EncodeFactV2Options { - /** - * Single-hop validator for vector refs: the set (or predicate) of - * generations whose records carried an INLINE vector. REQUIRED whenever any - * record carries a `VectorRef` — encoding an unverifiable ref is refused. - */ - inlineVectorGenerations?: Set | ((generation: number) => boolean) -} - -/** Options for the v2 decode path of {@link decodeFact}. */ -export interface DecodeFactV2Options { - /** - * The id-space width the caller expects. When set and the fact carries a - * log.genesis record, a disagreeing width throws - * {@link GenesisWidthMismatchError}. - */ - expectedIdSpaceWidth?: 32 | 64 -} - -/** The result of decoding a frame group: intact facts + valid byte length. */ -export interface DecodedFrameGroup { - facts: CommitFactV2[] - /** Byte length of the intact prefix (whole frames that decoded cleanly). */ - validBytes: number -} - -// --------------------------------------------------------------------------- -// msgpack wire helpers -// --------------------------------------------------------------------------- - -/** - * The v2 codec: `useBigInt64` makes bigints ride as fixed 8-byte uint64/int64 - * (the u64 wire discipline) while JS numbers keep exact-value round-trips - * (integers ≤ 32-bit ride minimal; larger numbers ride float64, which holds - * every safe integer exactly). - */ -const enc = (value: unknown): Uint8Array => msgpackEncode(value, { useBigInt64: true }) -const dec = (bytes: Uint8Array): unknown => msgpackDecode(bytes, { useBigInt64: true }) - -/** Coerce an encode-side u64 field to bigint, refusing out-of-range values. */ -function toWireU64(value: number | bigint, field: string): bigint { - let big: bigint - if (typeof value === 'bigint') { - big = value - } else if (Number.isSafeInteger(value)) { - big = BigInt(value) - } else { - throw new Error(`fact log v2: ${field} must be a safe integer or bigint; got ${value}`) - } - if (big < 0n || big > U64_MAX) { - throw new Error(`fact log v2: ${field} is out of u64 range: ${big}`) - } - return big -} - -/** Decode-side u64 → bigint (liberal: accepts any msgpack uint width). */ -function wireToBigint(value: unknown, field: string): bigint { - if (typeof value === 'bigint') { - if (value < 0n || value > U64_MAX) { - throw new Error(`fact log v2: ${field} is out of u64 range: ${value}`) - } - return value - } - if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) { - return BigInt(value) - } - throw new Error(`fact log v2: ${field} is not an unsigned integer`) -} - -/** Decode-side u64 → number, refusing values beyond safe-integer range. */ -function wireToNumber(value: unknown, field: string): number { - const big = wireToBigint(value, field) - if (big > BigInt(Number.MAX_SAFE_INTEGER)) { - throw new Error(`fact log v2: ${field} ${big} exceeds Number.MAX_SAFE_INTEGER`) - } - return Number(big) -} - -/** Decode-side u8 (record types, kinds, flags). */ -function wireToU8(value: unknown, field: string): number { - const n = typeof value === 'bigint' ? Number(value) : value - if (typeof n !== 'number' || !Number.isInteger(n) || n < 0 || n > 255) { - throw new Error(`fact log v2: ${field} is not a u8`) - } - return n -} - -/** uuid string → 16 raw bytes (bin16 on the wire). */ -function uuidToBytes(id: string): Uint8Array { - const hex = id.replace(/-/g, '') - if (hex.length !== 32 || /[^0-9a-fA-F]/.test(hex)) { - throw new Error(`fact log v2: id is not a uuid: ${id}`) - } - const bytes = new Uint8Array(16) - for (let i = 0; i < 16; i++) { - bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) - } - return bytes -} - -/** 16 raw bytes → canonical lowercase uuid string. */ -function bytesToUuid(bytes: unknown, field: string): string { - if (!(bytes instanceof Uint8Array) || bytes.length !== 16) { - throw new Error(`fact log v2: ${field} is not a bin16 id`) - } - let hex = '' - for (let i = 0; i < 16; i++) hex += bytes[i].toString(16).padStart(2, '0') - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` -} - -/** 64-hex-char content hash → 32 raw bytes (bin32 on the wire). */ -function hashToBytes(hash: string): Uint8Array { - if (typeof hash !== 'string' || !/^[0-9a-fA-F]{64}$/.test(hash)) { - throw new Error(`fact log v2: blob hash must be 64 hex chars; got ${String(hash).slice(0, 80)}`) - } - const bytes = new Uint8Array(32) - for (let i = 0; i < 32; i++) { - bytes[i] = parseInt(hash.slice(i * 2, i * 2 + 2), 16) - } - return bytes -} - -/** 32 raw bytes → 64-char lowercase hex content hash. */ -function bytesToHash(bytes: unknown): string { - if (!(bytes instanceof Uint8Array) || bytes.length !== 32) { - throw new Error('fact log v2: blob hash is not bin32') - } - let hex = '' - for (let i = 0; i < 32; i++) hex += bytes[i].toString(16).padStart(2, '0') - return hex -} - -/** True for a plain map object (not null/array/binary). */ -function isPlainMap(value: unknown): value is Record { - return ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - !(value instanceof Uint8Array) - ) -} - -// --------------------------------------------------------------------------- -// Segment header (v1 read + v2 read/write) -// --------------------------------------------------------------------------- - -/** - * Build a v2 segment header: magic + formatVersion 2 + firstGeneration u64 LE - * + sealSize u16 LE at offset +20. The remaining 10 reserved bytes stay zero - * and are verified by every reader. - * - * @param firstGeneration - The first generation this segment will hold. - * @param sealSize - The sector-seal size groups in this segment align to - * (device atomic-write probing is the caller's business; default 4096). - */ -export function encodeSegmentHeaderV2( - firstGeneration: number, - sealSize: number = DEFAULT_SEAL_SIZE -): Uint8Array { - if (!Number.isSafeInteger(firstGeneration) || firstGeneration < 0) { - throw new Error(`fact log v2: firstGeneration must be a non-negative integer; got ${firstGeneration}`) - } - assertValidSealSize(sealSize) - const header = new Uint8Array(SEGMENT_HEADER_BYTES) - header.set(FACT_SEGMENT_MAGIC, 0) - const view = new DataView(header.buffer) - view.setUint32(8, FACT_LOG_FORMAT_V2, true) - view.setBigUint64(12, BigInt(firstGeneration), true) - view.setUint16(20, sealSize, true) - // bytes 22..31 stay zero (reserved, verified) - return header -} - -/** - * Parse a segment header — reads BOTH v1 (version 1, twelve zeroed reserved - * bytes, no sealSize) and v2 (version 2, sealSize u16 LE at +20, ten zeroed - * reserved bytes). Bad magic, non-zero reserved bytes, or an unknown version - * throw loudly; nothing is guessed. - * - * @param bytes - At least the first {@link SEGMENT_HEADER_BYTES} of a segment. - * @returns The parsed header; `sealSize` is `undefined` for v1 headers. - */ -export function parseSegmentHeader(bytes: Uint8Array): SegmentHeader { - if (bytes.length < SEGMENT_HEADER_BYTES) { - throw new Error( - `fact log: segment header needs ${SEGMENT_HEADER_BYTES} bytes; got ${bytes.length}` - ) - } - for (let i = 0; i < FACT_SEGMENT_MAGIC.length; i++) { - if (bytes[i] !== FACT_SEGMENT_MAGIC[i]) { - throw new Error('fact log: bad magic — not a fact segment') - } - } - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) - const formatVersion = view.getUint32(8, true) - const firstGenerationBig = view.getBigUint64(12, true) - if (firstGenerationBig > BigInt(Number.MAX_SAFE_INTEGER)) { - throw new Error(`fact log: firstGeneration ${firstGenerationBig} exceeds Number.MAX_SAFE_INTEGER`) - } - const firstGeneration = Number(firstGenerationBig) - - if (formatVersion === FACT_LOG_FORMAT_V1) { - assertReservedZero(bytes, 20) - return { formatVersion, firstGeneration } - } - if (formatVersion === FACT_LOG_FORMAT_V2) { - const sealSize = view.getUint16(20, true) - assertReservedZero(bytes, 22) - return { formatVersion, firstGeneration, sealSize } - } - throw new Error( - `fact log: segment formatVersion ${formatVersion}; this build reads 1 and 2 — ` + - `a newer reader is required` - ) -} - -/** Verify header bytes [from, 32) are zero — anything else is unverifiable. */ -function assertReservedZero(bytes: Uint8Array, from: number): void { - for (let i = from; i < SEGMENT_HEADER_BYTES; i++) { - if (bytes[i] !== 0) { - throw new Error('fact log: non-zero reserved header bytes — unverifiable') - } - } -} - -/** Refuse seal sizes the header cannot carry or a pad frame cannot fill. */ -function assertValidSealSize(sealSize: number): void { - if (!Number.isInteger(sealSize) || sealSize < 64 || sealSize > 0xffff) { - throw new Error( - `fact log v2: sealSize must be an integer in [64, 65535]; got ${sealSize}` - ) - } -} - -// --------------------------------------------------------------------------- -// Frames -// --------------------------------------------------------------------------- - -/** Wrap a msgpack payload in the frame envelope (length + crc32c + payload). */ -function buildFrame(payload: Uint8Array): Uint8Array { - const frame = new Uint8Array(FRAME_PREFIX_BYTES + payload.length) - const view = new DataView(frame.buffer) - view.setUint32(0, payload.length, true) - view.setUint32(4, crc32c(payload), true) - frame.set(payload, FRAME_PREFIX_BYTES) - return frame -} - -/** - * Verify a complete frame (exact length, CRC) and return its msgpack payload - * (a view into the frame — copy if you outlive the frame). The bridge between - * frame-level producers ({@link encodeFactV2}, {@link sealGroup}) and the - * payload-level {@link decodeFact}. - */ -export function framePayload(frame: Uint8Array): Uint8Array { - if (frame.length < FRAME_PREFIX_BYTES) { - throw new Error(`fact log: frame shorter than its ${FRAME_PREFIX_BYTES}-byte prefix`) - } - const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength) - const length = view.getUint32(0, true) - if (FRAME_PREFIX_BYTES + length !== frame.length) { - throw new Error( - `fact log: frame declares ${length} payload bytes but carries ${frame.length - FRAME_PREFIX_BYTES}` - ) - } - const payload = frame.subarray(FRAME_PREFIX_BYTES) - const expectedCrc = view.getUint32(4, true) - if (crc32c(payload) !== expectedCrc) { - throw new Error('fact log: frame payload fails its crc32c') - } - return payload -} - -// --------------------------------------------------------------------------- -// vectorLeg encode/decode -// --------------------------------------------------------------------------- - -/** Encode a vector leg; refs must pass the single-hop validator. */ -function encodeVectorLeg( - leg: VectorLeg | undefined, - options: EncodeFactV2Options | undefined, - context: string -): unknown { - if (leg === null || leg === undefined) return null - if (Array.isArray(leg)) { - for (const value of leg) { - if (typeof value !== 'number') { - throw new Error(`fact log v2: ${context} inline vector has a non-number element`) - } - } - return leg - } - if (isPlainMap(leg) && typeof (leg as VectorRef).sameAsGeneration === 'number') { - const target = (leg as VectorRef).sameAsGeneration - const validator = options?.inlineVectorGenerations - if (!validator) { - throw new Error( - `fact log v2: ${context} carries a vector ref to generation ${target} but no ` + - `single-hop validator was provided — refusing to encode an unverifiable ref` - ) - } - const targetIsInline = typeof validator === 'function' ? validator(target) : validator.has(target) - if (!targetIsInline) { - throw new Error( - `fact log v2: ${context} vector ref targets generation ${target}, which did not ` + - `carry an inline vector — refs must be single-hop` - ) - } - return ['ref', toWireU64(target, `${context} sameAsGeneration`)] - } - throw new Error(`fact log v2: ${context} has a malformed vector leg`) -} - -/** Decode a vector leg: floats, a single-hop ref, or null. */ -function decodeVectorLeg(wire: unknown, context: string): VectorLeg { - if (wire === null || wire === undefined) return null - if (Array.isArray(wire)) { - if (wire.length === 2 && wire[0] === 'ref') { - return { sameAsGeneration: wireToNumber(wire[1], `${context} sameAsGeneration`) } - } - return wire.map((value, i) => { - if (typeof value === 'number') return value - if (typeof value === 'bigint') return Number(value) - throw new Error(`fact log v2: ${context} vector element ${i} is not a number`) - }) - } - throw new Error(`fact log v2: ${context} has a malformed vector leg`) -} - -// --------------------------------------------------------------------------- -// Record encode/decode -// --------------------------------------------------------------------------- - -/** - * Encode one record into its positional wire array. Every record leads with - * the 4-field envelope [type, version, cipherFlag, keyId]; this release - * writes cipherFlag {@link LOG_RECORD_CIPHER_PLAINTEXT} and a nil keyId - * always (the fields are crypto-RESERVED, carrying no logic yet). - */ -function encodeRecord(record: LogRecord, options: EncodeFactV2Options | undefined): unknown[] { - const T = LOG_RECORD_TYPES - const V = LOG_RECORD_VERSION - const C = LOG_RECORD_CIPHER_PLAINTEXT - const K = null // keyId: nil until record-level encryption exists - switch (record.type) { - case 'noun.afterImage': - return [ - T.NOUN_AFTER_IMAGE, - V, - C, - K, - uuidToBytes(record.id), - toWireU64(record.entityInt, 'entityInt'), - record.metadata ?? null, - encodeVectorLeg(record.vectorLeg, options, `noun.afterImage ${record.id}`) - ] - case 'noun.tombstone': - return [T.NOUN_TOMBSTONE, V, C, K, uuidToBytes(record.id)] - case 'verb.afterImage': { - if (typeof record.verb !== 'string' || record.verb.length === 0) { - throw new Error(`fact log v2: verb.afterImage ${record.id} needs a non-empty verb name`) - } - return [ - T.VERB_AFTER_IMAGE, - V, - C, - K, - uuidToBytes(record.id), - toWireU64(record.verbInt, 'verbInt'), - record.metadata ?? null, - encodeVectorLeg(record.vectorLeg, options, `verb.afterImage ${record.id}`), - record.verb, - uuidToBytes(record.sourceId), - toWireU64(record.sourceInt, 'sourceInt'), - uuidToBytes(record.targetId), - toWireU64(record.targetInt, 'targetInt') - ] - } - case 'verb.tombstone': - return [T.VERB_TOMBSTONE, V, C, K, uuidToBytes(record.id)] - case 'batch.meta': - if (!isPlainMap(record.meta)) { - throw new Error('fact log v2: batch.meta requires a map') - } - return [T.BATCH_META, V, C, K, record.meta] - case 'embed.pending': - return [ - T.EMBED_PENDING, - V, - C, - K, - uuidToBytes(record.id), - toWireU64(record.enqueuedAt, 'enqueuedAt') - ] - case 'embed.landed': { - if (!Array.isArray(record.vector) || record.vector.some((v) => typeof v !== 'number')) { - throw new Error( - `fact log v2: embed.landed ${record.id} carries an INLINE float vector only — ` + - `refs and nil are not allowed here` - ) - } - return [T.EMBED_LANDED, V, C, K, uuidToBytes(record.id), record.vector] - } - case 'blob.manifest': { - if (typeof record.mimeType !== 'string') { - throw new Error('fact log v2: blob.manifest mimeType must be a string') - } - if (record.refOp !== 'add' && record.refOp !== 'release') { - throw new Error(`fact log v2: blob.manifest refOp must be 'add' or 'release'`) - } - return [ - T.BLOB_MANIFEST, - V, - C, - K, - hashToBytes(record.hash), - toWireU64(record.size, 'blob size'), - record.mimeType, - record.refOp === 'add' ? 0 : 1 - ] - } - case 'projection.note': - if (!isPlainMap(record.note)) { - throw new Error('fact log v2: projection.note requires a map') - } - return [T.PROJECTION_NOTE, V, C, K, record.note] - case 'bootstrap.baseline': { - if (record.kind !== 'noun' && record.kind !== 'verb') { - throw new Error(`fact log v2: bootstrap.baseline kind must be 'noun' or 'verb'`) - } - return [ - T.BOOTSTRAP_BASELINE, - V, - C, - K, - uuidToBytes(record.id), - record.kind === 'noun' ? 0 : 1, - record.metadata ?? null, - encodeVectorLeg(record.vectorLeg, options, `bootstrap.baseline ${record.id}`) - ] - } - case 'log.genesis': { - if (record.idSpaceWidth !== 32 && record.idSpaceWidth !== 64) { - throw new Error( - `fact log v2: log.genesis idSpaceWidth must be 32 or 64; got ${record.idSpaceWidth}` - ) - } - return [ - T.LOG_GENESIS, - V, - C, - K, - record.idSpaceWidth, - uuidToBytes(record.brainId), - toWireU64(record.createdAt, 'createdAt') - ] - } - default: { - // Pads are the sealer's business ({@link sealGroup}); anything else - // here is an unencodable record — refuse instead of writing bytes a - // reader would have to guess about. - const unknown = record as { type?: unknown } - throw new Error(`fact log v2: cannot encode record type ${String(unknown.type)}`) - } - } -} - -/** Exact wire arity per record type (envelope of 4 + type-specific fields). */ -const RECORD_ARITY: Record = { - [LOG_RECORD_TYPES.NOUN_AFTER_IMAGE]: 8, - [LOG_RECORD_TYPES.NOUN_TOMBSTONE]: 5, - [LOG_RECORD_TYPES.VERB_AFTER_IMAGE]: 13, - [LOG_RECORD_TYPES.VERB_TOMBSTONE]: 5, - [LOG_RECORD_TYPES.BATCH_META]: 5, - [LOG_RECORD_TYPES.EMBED_PENDING]: 6, - [LOG_RECORD_TYPES.EMBED_LANDED]: 6, - [LOG_RECORD_TYPES.BLOB_MANIFEST]: 8, - [LOG_RECORD_TYPES.PROJECTION_NOTE]: 5, - [LOG_RECORD_TYPES.BOOTSTRAP_BASELINE]: 8, - [LOG_RECORD_TYPES.LOG_GENESIS]: 7 -} - -/** - * Decode one wire record. Returns `null` for pads (skipped by definition). - * Unknown type / newer version throw {@link UnknownLogRecordError} — never - * skip-and-continue. The reserved crypto envelope is verified BEFORE the - * arity check (an encrypted record's field layout is a newer reader's - * business, not a malformed-record error): any nonzero cipherFlag or non-nil - * keyId refuses with the same typed error class. - */ -function decodeRecord(raw: unknown): LogRecord | null { - if (!Array.isArray(raw) || raw.length < 2) { - throw new Error('fact log v2: malformed record envelope (need [type, version, cipherFlag, keyId, ...])') - } - const recordType = wireToU8(raw[0], 'recordType') - const recordVersion = wireToU8(raw[1], 'recordVersion') - - if (recordType === LOG_RECORD_TYPES.PAD) { - // Length-only filler: skipped wholesale, filler fields never inspected - // (pads therefore carry no crypto envelope — by definition, not omission). - return null - } - const arity = RECORD_ARITY[recordType] - if (arity === undefined) { - throw new UnknownLogRecordError( - recordType, - recordVersion, - `fact log v2: unknown record type ${recordType} (record version ${recordVersion}) — ` + - `a newer reader is required to decode this log` - ) - } - if (recordVersion > LOG_RECORD_VERSION) { - throw new UnknownLogRecordError( - recordType, - recordVersion, - `fact log v2: record type ${recordType} carries record version ${recordVersion}; ` + - `this reader knows version ${LOG_RECORD_VERSION} — a newer reader is required to decode this log` - ) - } - if (recordVersion !== LOG_RECORD_VERSION) { - throw new Error(`fact log v2: record type ${recordType} has invalid record version ${recordVersion}`) - } - if (raw.length < 4) { - throw new Error('fact log v2: malformed record envelope (need [type, version, cipherFlag, keyId, ...])') - } - const cipherFlag = wireToU8(raw[2], 'cipherFlag') - const keyId = raw[3] - if (cipherFlag !== LOG_RECORD_CIPHER_PLAINTEXT || (keyId !== null && keyId !== undefined)) { - throw new UnknownLogRecordError( - recordType, - recordVersion, - `fact log v2: record type ${recordType} carries cipherFlag ${cipherFlag}` + - `${keyId !== null && keyId !== undefined ? ' and a keyId' : ''} — ` + - `encrypted records need a newer reader` - ) - } - if (raw.length !== arity) { - throw new Error( - `fact log v2: record type ${recordType} expects ${arity} wire fields; got ${raw.length}` - ) - } - - switch (recordType) { - case LOG_RECORD_TYPES.NOUN_AFTER_IMAGE: - return { - type: 'noun.afterImage', - id: bytesToUuid(raw[4], 'noun.afterImage id'), - entityInt: wireToBigint(raw[5], 'entityInt'), - metadata: raw[6] ?? null, - vectorLeg: decodeVectorLeg(raw[7], 'noun.afterImage') - } - case LOG_RECORD_TYPES.NOUN_TOMBSTONE: - return { type: 'noun.tombstone', id: bytesToUuid(raw[4], 'noun.tombstone id') } - case LOG_RECORD_TYPES.VERB_AFTER_IMAGE: { - if (typeof raw[8] !== 'string') { - throw new Error('fact log v2: verb.afterImage verb name is not a string') - } - return { - type: 'verb.afterImage', - id: bytesToUuid(raw[4], 'verb.afterImage id'), - verbInt: wireToBigint(raw[5], 'verbInt'), - metadata: raw[6] ?? null, - vectorLeg: decodeVectorLeg(raw[7], 'verb.afterImage'), - verb: raw[8], - sourceId: bytesToUuid(raw[9], 'verb.afterImage sourceId'), - sourceInt: wireToBigint(raw[10], 'sourceInt'), - targetId: bytesToUuid(raw[11], 'verb.afterImage targetId'), - targetInt: wireToBigint(raw[12], 'targetInt') - } - } - case LOG_RECORD_TYPES.VERB_TOMBSTONE: - return { type: 'verb.tombstone', id: bytesToUuid(raw[4], 'verb.tombstone id') } - case LOG_RECORD_TYPES.BATCH_META: { - if (!isPlainMap(raw[4])) throw new Error('fact log v2: batch.meta payload is not a map') - return { type: 'batch.meta', meta: raw[4] } - } - case LOG_RECORD_TYPES.EMBED_PENDING: - return { - type: 'embed.pending', - id: bytesToUuid(raw[4], 'embed.pending id'), - enqueuedAt: wireToNumber(raw[5], 'enqueuedAt') - } - case LOG_RECORD_TYPES.EMBED_LANDED: { - const leg = decodeVectorLeg(raw[5], 'embed.landed') - if (!Array.isArray(leg)) { - throw new Error( - 'fact log v2: embed.landed must carry an INLINE float vector — refs and nil are not allowed here' - ) - } - return { type: 'embed.landed', id: bytesToUuid(raw[4], 'embed.landed id'), vector: leg } - } - case LOG_RECORD_TYPES.BLOB_MANIFEST: { - if (typeof raw[6] !== 'string') { - throw new Error('fact log v2: blob.manifest mimeType is not a string') - } - const refOp = wireToU8(raw[7], 'refOp') - if (refOp !== 0 && refOp !== 1) { - throw new Error(`fact log v2: blob.manifest refOp must be 0 (add) or 1 (release); got ${refOp}`) - } - return { - type: 'blob.manifest', - hash: bytesToHash(raw[4]), - size: wireToNumber(raw[5], 'blob size'), - mimeType: raw[6], - refOp: refOp === 0 ? 'add' : 'release' - } - } - case LOG_RECORD_TYPES.PROJECTION_NOTE: { - if (!isPlainMap(raw[4])) throw new Error('fact log v2: projection.note payload is not a map') - return { type: 'projection.note', note: raw[4] } - } - case LOG_RECORD_TYPES.BOOTSTRAP_BASELINE: { - const kind = wireToU8(raw[5], 'bootstrap.baseline kind') - if (kind !== 0 && kind !== 1) { - throw new Error(`fact log v2: bootstrap.baseline kind must be 0 (noun) or 1 (verb); got ${kind}`) - } - return { - type: 'bootstrap.baseline', - id: bytesToUuid(raw[4], 'bootstrap.baseline id'), - kind: kind === 0 ? 'noun' : 'verb', - metadata: raw[6] ?? null, - vectorLeg: decodeVectorLeg(raw[7], 'bootstrap.baseline') - } - } - case LOG_RECORD_TYPES.LOG_GENESIS: { - const width = wireToU8(raw[4], 'idSpaceWidth') - if (width !== 32 && width !== 64) { - throw new Error(`fact log v2: log.genesis idSpaceWidth must be 32 or 64; got ${width}`) - } - return { - type: 'log.genesis', - idSpaceWidth: width, - brainId: bytesToUuid(raw[5], 'log.genesis brainId'), - createdAt: wireToNumber(raw[6], 'createdAt') - } - } - default: - // Unreachable: every arity-table type is handled above. - throw new Error(`fact log v2: unhandled record type ${recordType}`) - } -} - -// --------------------------------------------------------------------------- -// Fact encode/decode -// --------------------------------------------------------------------------- - -/** - * Encode one committed generation as a complete v2 FRAME (length + crc32c + - * msgpack payload) ready for appending or sealing. - * - * Writer-enforced invariants (refusals, never silent fixes): at least one - * record; no pad records (pads belong to {@link sealGroup}); at most one - * batch.meta; log.genesis only as the first record; vector refs only with a - * passing single-hop validator; embed.landed vectors inline only. - * - * @param fact - The fact to encode (generation ≥ 1; generation 0 marks filler). - * @param options - Single-hop validation for vector refs. - * @returns The complete frame bytes. - */ -export function encodeFactV2(fact: CommitFactV2, options?: EncodeFactV2Options): Uint8Array { - if (!Number.isSafeInteger(fact.generation) || fact.generation < 1) { - throw new Error(`fact log v2: generation must be a positive integer; got ${fact.generation}`) - } - if (!Number.isSafeInteger(fact.timestamp) || fact.timestamp < 0) { - throw new Error(`fact log v2: timestamp must be a non-negative integer; got ${fact.timestamp}`) - } - // records MAY be empty: a committed generation whose ops all collapsed - // (e.g. a batch whose relates deduped to no-ops) is still a real - // generation — v1 encoded empty ops the same way; refusing here would - // fork the two formats' commit semantics. - if (!Array.isArray(fact.records)) { - throw new Error('fact log v2: records must be an array') - } - if (fact.meta !== undefined && !isPlainMap(fact.meta)) { - throw new Error('fact log v2: fact meta must be a map when present') - } - if ( - fact.blobHashes !== undefined && - (!Array.isArray(fact.blobHashes) || fact.blobHashes.some((h) => typeof h !== 'string')) - ) { - throw new Error('fact log v2: blobHashes must be an array of strings when present') - } - - let batchMetaCount = 0 - const wireRecords = fact.records.map((record, index) => { - if (record.type === 'batch.meta' && ++batchMetaCount > 1) { - throw new Error('fact log v2: at most one batch.meta record per fact') - } - if (record.type === 'log.genesis' && index !== 0) { - throw new Error('fact log v2: log.genesis must be the first record of its fact') - } - return encodeRecord(record, options) - }) - - const payload = enc([ - toWireU64(fact.generation, 'generation'), - toWireU64(fact.timestamp, 'timestamp'), - wireRecords, - fact.meta ?? null, - fact.blobHashes && fact.blobHashes.length > 0 ? fact.blobHashes : null - ]) - return buildFrame(payload) -} - -/** - * Decode one fact PAYLOAD (the msgpack bytes inside a frame — see - * {@link framePayload}). The segment's formatVersion, read from its header, - * selects the schema: version 1 decodes the v1 ops shape into a - * {@link CommitFact}; version 2 decodes the record envelope into a - * {@link CommitFactV2}. Any other version is refused. - */ -export function decodeFact(payload: Uint8Array, segmentFormatVersion: 1): CommitFact -export function decodeFact( - payload: Uint8Array, - segmentFormatVersion: 2, - options?: DecodeFactV2Options -): CommitFactV2 -export function decodeFact( - payload: Uint8Array, - segmentFormatVersion: number, - options?: DecodeFactV2Options -): CommitFact | CommitFactV2 -export function decodeFact( - payload: Uint8Array, - segmentFormatVersion: number, - options?: DecodeFactV2Options -): CommitFact | CommitFactV2 { - if (segmentFormatVersion === FACT_LOG_FORMAT_V1) return decodeFactV1(payload) - if (segmentFormatVersion === FACT_LOG_FORMAT_V2) return decodeFactV2(payload, options) - throw new Error( - `fact log: no decoder for segment formatVersion ${segmentFormatVersion} — this build reads 1 and 2` - ) -} - -/** - * The v1 decode path — byte-identical in behavior to the v1 log's own - * decoder (positional ops, bin16 ids, body-less tombstones). Kept here so v1 - * segments stay readable through the same entry point forever. - */ -function decodeFactV1(payload: Uint8Array): CommitFact { - const raw = msgpackDecode(payload) as unknown[] - const [generation, timestamp, ops, meta, blobHashes] = raw as [ - number, - number, - Array<[number, Uint8Array, [unknown, unknown] | null]>, - Record | null, - string[] | null - ] - return { - generation: Number(generation), - timestamp: Number(timestamp), - ops: ops.map(([kind, idBytes, record]) => ({ - kind: kind === 0 ? ('noun' as const) : ('verb' as const), - id: bytesToUuid(idBytes, 'op id'), - record: record === null ? null : { metadata: record[0] ?? null, vector: record[1] ?? null } - })), - ...(meta ? { meta } : {}), - ...(blobHashes && blobHashes.length > 0 ? { blobHashes } : {}) - } -} - -/** The v2 decode path: record envelope, decoder-law enforcement, pad skip. */ -function decodeFactV2(payload: Uint8Array, options?: DecodeFactV2Options): CommitFactV2 { - const raw = dec(payload) - if (!Array.isArray(raw) || raw.length !== 5) { - throw new Error('fact log v2: fact payload must be a positional array of 5') - } - const [genWire, tsWire, recordsWire, metaWire, blobsWire] = raw - if (!Array.isArray(recordsWire)) { - throw new Error('fact log v2: fact records position is not an array') - } - - const records: LogRecord[] = [] - let batchMetaCount = 0 - recordsWire.forEach((rawRecord, index) => { - const record = decodeRecord(rawRecord) - if (record === null) return // pad: length-only filler, skipped by definition - if (record.type === 'log.genesis') { - if (index !== 0) { - throw new Error('fact log v2: log.genesis must be the first record of its fact') - } - const expected = options?.expectedIdSpaceWidth - if (expected !== undefined && record.idSpaceWidth !== expected) { - throw new GenesisWidthMismatchError(expected, record.idSpaceWidth) - } - } - if (record.type === 'batch.meta' && ++batchMetaCount > 1) { - throw new Error('fact log v2: at most one batch.meta record per fact') - } - records.push(record) - }) - - let meta: Record | undefined - if (metaWire !== null && metaWire !== undefined) { - if (!isPlainMap(metaWire)) throw new Error('fact log v2: fact meta position is not a map') - meta = metaWire - } - let blobHashes: string[] | undefined - if (blobsWire !== null && blobsWire !== undefined) { - if (!Array.isArray(blobsWire) || blobsWire.some((h) => typeof h !== 'string')) { - throw new Error('fact log v2: fact blobHashes position is not a string array') - } - blobHashes = blobsWire - } - - return { - generation: wireToNumber(genWire, 'generation'), - timestamp: wireToNumber(tsWire, 'timestamp'), - records, - ...(meta ? { meta } : {}), - ...(blobHashes && blobHashes.length > 0 ? { blobHashes } : {}) - } -} - -// --------------------------------------------------------------------------- -// Sector seals -// --------------------------------------------------------------------------- - -/** - * Smallest constructible pad frame in bytes (frame prefix + the bare pad - * record fact), memoized. Exported for streaming writers that pad an - * append-only tail to a seal boundary: a gap smaller than this cannot hold - * any frame, so the writer pads through one extra sector (the same rule - * {@link sealGroup} applies). - */ -let minPadFrameBytesMemo: number | null = null -export function minPadFrameBytes(): number { - if (minPadFrameBytesMemo === null) { - minPadFrameBytesMemo = - FRAME_PREFIX_BYTES + - enc([0n, 0n, [[LOG_RECORD_TYPES.PAD, LOG_RECORD_VERSION]], null, null]).length - } - return minPadFrameBytesMemo -} - -/** - * Build a pad frame of EXACTLY `totalBytes`: a filler fact - * `[0, 0, [[0, 1, filler?]], nil, nil]` sized via a binary filler field. - * Readers skip pad records by definition, so filler fields are never - * inspected — only their length matters. - */ -function buildPadFrame(totalBytes: number): Uint8Array { - const targetPayload = totalBytes - FRAME_PREFIX_BYTES - const attempt = (record: unknown[]): Uint8Array => enc([0n, 0n, [record], null, null]) - - let payload = attempt([LOG_RECORD_TYPES.PAD, LOG_RECORD_VERSION]) - if (payload.length !== targetPayload) { - // One byte short: a fixint filler adds exactly one byte. - payload = attempt([LOG_RECORD_TYPES.PAD, LOG_RECORD_VERSION, 0]) - } - if (payload.length !== targetPayload) { - // Binary filler: msgpack bin grows byte-for-byte within a size class; - // iterate to absorb the class-header steps (bin8 → bin16 → bin32). - let fillerLength = Math.max(0, targetPayload - payload.length - 1) - let converged = false - for (let i = 0; i < 8; i++) { - const candidate = attempt([ - LOG_RECORD_TYPES.PAD, - LOG_RECORD_VERSION, - new Uint8Array(fillerLength) - ]) - const diff = targetPayload - candidate.length - if (diff === 0) { - payload = candidate - converged = true - break - } - fillerLength += diff - if (fillerLength < 0) break - } - if (!converged) { - // Class-boundary holes: a single bin filler steps its header by one - // byte at each msgpack size class (bin8→bin16→bin32), leaving exactly - // one unreachable payload size per boundary (the 291-byte production - // case). Bridge with a trailing fixint (+1 byte) beside the bin — - // {bin(n)} ∪ {bin(n) + fixint} covers every size ≥ minimum. - let bridged = Math.max(0, targetPayload - payload.length - 2) - for (let i = 0; i < 8; i++) { - const candidate = attempt([ - LOG_RECORD_TYPES.PAD, - LOG_RECORD_VERSION, - new Uint8Array(bridged), - 0 - ]) - const diff = targetPayload - candidate.length - if (diff === 0) { - payload = candidate - converged = true - break - } - bridged += diff - if (bridged < 0) break - } - } - if (!converged) { - throw new Error(`fact log v2: a pad frame of ${totalBytes} bytes is not constructible`) - } - } - return buildFrame(payload) -} - -/** - * Build a pad frame of EXACTLY `totalBytes` — the streaming-append counterpart - * of {@link sealGroup} for writers that append pads directly to a live tail - * instead of sealing an in-memory group. Refuses sizes smaller than the - * smallest constructible pad frame ({@link minPadFrameBytes}); readers skip - * the result by definition (a type-0 record is length-only filler). - * - * @param totalBytes - The exact frame size to construct (prefix included). - * @returns The complete pad frame bytes. - */ -export function encodePadFrame(totalBytes: number): Uint8Array { - if (!Number.isInteger(totalBytes) || totalBytes < minPadFrameBytes()) { - throw new Error( - `fact log v2: a pad frame must be at least ${minPadFrameBytes()} bytes; got ${totalBytes}` - ) - } - return buildPadFrame(totalBytes) -} - -/** - * Seal a group of frames to a sector boundary: concatenate the frames and pad - * to the next `sealSize` multiple with ONE pad frame. An already-aligned - * group gets no pad. When the gap is smaller than the smallest constructible - * pad frame, the group is padded through to the boundary AFTER next (one - * extra sealSize) — input frames are never rewritten. - * - * @param frames - Complete, well-formed frames (verified; garbage is refused). - * @param sealSize - The sector-seal size (device probing is the caller's - * business; default {@link DEFAULT_SEAL_SIZE}). - * @returns The sector-aligned group (`length % sealSize === 0`). - */ -export function sealGroup(frames: Uint8Array[], sealSize: number = DEFAULT_SEAL_SIZE): Uint8Array { - assertValidSealSize(sealSize) - if (!Array.isArray(frames) || frames.length === 0) { - throw new Error('fact log v2: sealGroup needs at least one frame') - } - frames.forEach((frame, i) => { - try { - framePayload(frame) - } catch (error) { - throw new Error( - `fact log v2: sealGroup frame ${i} is not a well-formed frame: ${(error as Error).message}` - ) - } - }) - - const total = frames.reduce((n, f) => n + f.length, 0) - const remainder = total % sealSize - let padBytes = remainder === 0 ? 0 : sealSize - remainder - if (padBytes !== 0 && padBytes < minPadFrameBytes()) { - padBytes += sealSize // gap too small for any frame — pad through one more sector - } - - const sealed = new Uint8Array(total + padBytes) - let offset = 0 - for (const frame of frames) { - sealed.set(frame, offset) - offset += frame.length - } - if (padBytes > 0) { - sealed.set(buildPadFrame(padBytes), offset) - } - return sealed -} - -/** - * Decode a sequence of v2 frames (a sealed group, or a segment body after its - * 32-byte header) with the torn-tail discipline: a frame whose length overruns - * the buffer or whose CRC fails TERMINATES the walk — everything before it is - * intact and returned; nothing after it is guessed at. Pad frames are dropped - * (invisible). CRC-valid frames with unknown record types still throw - * {@link UnknownLogRecordError} — physical damage truncates, format novelty - * refuses. - */ -export function decodeGroupV2(bytes: Uint8Array, options?: DecodeFactV2Options): DecodedFrameGroup { - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) - const facts: CommitFactV2[] = [] - let offset = 0 - while (offset + FRAME_PREFIX_BYTES <= bytes.length) { - const length = view.getUint32(offset, true) - const expectedCrc = view.getUint32(offset + 4, true) - const start = offset + FRAME_PREFIX_BYTES - const end = start + length - if (end > bytes.length) break // torn tail: frame length overruns the buffer - const payload = bytes.subarray(start, end) - if (crc32c(payload) !== expectedCrc) break // torn tail: payload CRC mismatch - const fact = decodeFactV2(payload, options) - // Pad filler carries generation 0 (writers can never mint it — encode - // refuses generation < 1). A zero-record fact at a REAL generation is a - // legitimate commit (an all-deduped batch) and must stay visible — - // discriminating on record count would silently swallow generations. - if (fact.generation > 0) facts.push(fact) - offset = end - } - return { facts, validBytes: offset } -} diff --git a/src/db/familyStamp.ts b/src/db/familyStamp.ts index 2f01e935..98342884 100644 --- a/src/db/familyStamp.ts +++ b/src/db/familyStamp.ts @@ -12,11 +12,9 @@ * the verified surface is a small set of rollup invariants (entity/ * relationship counts) plus `sourceGeneration`. * - * `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: + * `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: * * - equal + invariants hold → coherent, serve. * - behind → the projection missed the tail (crash between commit and stamp); @@ -26,9 +24,6 @@ * - 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. @@ -75,12 +70,6 @@ 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 @@ -129,15 +118,12 @@ export function verifyFamilyStamp( ): StampVerdict { if (stamp === null) return { state: 'absent' } if (stamp.sourceGeneration > 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 } + // 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}`] + } } if (stamp.sourceGeneration < head) { return { state: 'behind', stampSource: stamp.sourceGeneration, head } diff --git a/src/db/faultInjectionStorage.ts b/src/db/faultInjectionStorage.ts deleted file mode 100644 index cafd4198..00000000 --- a/src/db/faultInjectionStorage.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * @module db/faultInjectionStorage - * @description Deterministic fault injection at the fact log's raw-byte - * storage surface — the test harness half of the durability protocol. Wraps - * any adapter exposing the {@link FactLogStorage} primitives (the exact - * surface the fact log appends and syncs through) and injects the three - * crash shapes durability tests must prove against: - * - * - **torn write** ({@link FaultInjectionStorage.tearWriteAtByte}): the next - * append persists only its first N bytes, then reports success — the shape - * of power loss after a partially-flushed page. The caller-side "crash" is - * simulated by abandoning in-memory state and reopening from storage. - * - **dropped sync** ({@link FaultInjectionStorage.dropNextSync}): the next - * sync becomes a silent no-op — an fsync the device acknowledged into a - * volatile cache and lost. - * - **failed append** ({@link FaultInjectionStorage.failNextAppend}): the next - * append throws {@link FaultInjectedError} without writing a byte — EIO or - * a full disk, surfaced to the writer. - * - * Every injected fault is journaled on {@link FaultInjectionStorage.injectedFaults} - * so tests can assert not just the outcome but that the fault actually fired. - * Knobs are one-shot (they disarm on firing) and re-arming overwrites the - * pending shot. All other operations pass through untouched. - */ -import type { FactLogStorage } from './factLog.js' - -/** The error a {@link FaultInjectionStorage.failNextAppend} shot throws. */ -export class FaultInjectedError extends Error { - /** The operation the fault fired on. */ - public readonly operation: 'append' - /** The storage path the operation targeted. */ - public readonly path: string - - constructor(operation: 'append', path: string) { - super(`fault injection: ${operation} to ${path} failed by test design`) - this.name = 'FaultInjectedError' - this.operation = operation - this.path = path - } -} - -/** One journaled fault event — proof the injected fault actually fired. */ -export interface InjectedFault { - kind: 'torn-write' | 'dropped-sync' | 'failed-append' - /** The target path (torn-write / failed-append). */ - path?: string - /** The paths a dropped sync was asked to make durable. */ - paths?: string[] - /** Bytes the caller asked to append (torn-write). */ - requestedBytes?: number - /** Bytes actually persisted (torn-write). */ - writtenBytes?: number -} - -/** - * A {@link FactLogStorage} wrapper that injects deterministic storage faults. - * Construct it around any conforming adapter and hand it wherever a - * FactLogStorage is accepted — unarmed, it is a transparent passthrough. - */ -export class FaultInjectionStorage implements FactLogStorage { - private readonly inner: FactLogStorage - /** Pending torn-write byte count, or null when unarmed. */ - private tearAtByte: number | null = null - /** Pending dropped-sync shot. */ - private dropSyncArmed = false - /** Pending failed-append shot. */ - private failAppendArmed = false - /** Journal of every fault that fired, in firing order. */ - public readonly injectedFaults: InjectedFault[] = [] - - constructor(inner: FactLogStorage) { - this.inner = inner - } - - /** - * Arm a torn write: the NEXT {@link appendRawBytes} persists only the first - * `n` bytes of its buffer (all of it when `n` exceeds the buffer) and then - * reports success. One-shot. - */ - tearWriteAtByte(n: number): void { - if (!Number.isInteger(n) || n < 0) { - throw new Error(`fault injection: tearWriteAtByte needs a non-negative integer; got ${n}`) - } - this.tearAtByte = n - } - - /** Arm a dropped sync: the NEXT {@link syncRawObjects} silently does nothing. One-shot. */ - dropNextSync(): void { - this.dropSyncArmed = true - } - - /** - * Arm a failed append: the NEXT {@link appendRawBytes} throws - * {@link FaultInjectedError} without writing. One-shot; wins over a - * simultaneously-armed torn write (nothing is written at all). - */ - failNextAppend(): void { - this.failAppendArmed = true - } - - /** Append bytes — the injection point for torn writes and failed appends. */ - async appendRawBytes(path: string, bytes: Uint8Array): Promise { - if (this.failAppendArmed) { - this.failAppendArmed = false - this.injectedFaults.push({ kind: 'failed-append', path }) - throw new FaultInjectedError('append', path) - } - if (this.tearAtByte !== null) { - const writtenBytes = Math.min(this.tearAtByte, bytes.length) - this.tearAtByte = null - this.injectedFaults.push({ - kind: 'torn-write', - path, - requestedBytes: bytes.length, - writtenBytes - }) - if (writtenBytes > 0) { - await this.inner.appendRawBytes(path, bytes.subarray(0, writtenBytes)) - } - return - } - return this.inner.appendRawBytes(path, bytes) - } - - /** Make paths durable — the injection point for dropped syncs. */ - async syncRawObjects(paths: string[]): Promise { - if (this.dropSyncArmed) { - this.dropSyncArmed = false - this.injectedFaults.push({ kind: 'dropped-sync', paths: [...paths] }) - return - } - return this.inner.syncRawObjects(paths) - } - - /** Passthrough. */ - async readRawBytes(path: string): Promise { - return this.inner.readRawBytes(path) - } - - /** Passthrough. */ - async writeRawBytes(path: string, bytes: Uint8Array): Promise { - return this.inner.writeRawBytes(path, bytes) - } - - /** Passthrough. */ - async rawByteSize(path: string): Promise { - return this.inner.rawByteSize(path) - } - - /** Passthrough. */ - async readRawObject(path: string): Promise { - return this.inner.readRawObject(path) - } - - /** Passthrough. */ - async writeRawObject(path: string, data: any): Promise { - return this.inner.writeRawObject(path, data) - } - - /** Passthrough. */ - async deleteRawObject(path: string): Promise { - return this.inner.deleteRawObject(path) - } -} diff --git a/src/db/fieldAddressing.ts b/src/db/fieldAddressing.ts deleted file mode 100644 index da04e74c..00000000 --- a/src/db/fieldAddressing.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * @module db/fieldAddressing - * @description The one field-addressing law for every query surface (find()'s - * `where` / `orderBy` / `groupBy`, aggregation `source.where`), ruled - * 2026-08-03 after a production incident in which a user metadata field - * named `level` was silently shadowed by the engine's internal HNSW node - * layer (VENUE-BRAINY-ORDERBY-NOOP — thread id kept verbatim as the audit - * key; it names no product): - * - * 1. A BARE field name addresses the user's metadata field. Always. - * No priority resolution, no fallback chain — `orderBy: 'level'` - * reads `entity.metadata.level`, full stop. - * 2. `system.` addresses an engine scalar, reachable ONLY with the - * explicit prefix. The entity map is exactly ten scalars; the relation - * map mirrors it with `verb`/`sourceId`/`targetId` as the structural - * members. - * 3. Engine plumbing (`vector`, `connections`, `level`, `data`, `_rev`) is - * INVISIBLE to the query surface in either spelling — `system.level` - * refuses; bare `level` is the user's field. - * 4. `metadata.` is the explicit spelling of the bare form — - * identical semantics on every path. - * 5. Anything unresolvable refuses with a TYPED error naming both - * candidate spellings — an accepted name either works or refuses; - * there is no third state. - * - * This module is the SINGLE source of truth for the law: parsing, the maps, - * and the refusal builders live here so the JS engine, the provider seams, - * and the cross-engine conformance suite can never drift on the contract. - */ - -import type { HNSWNounWithMetadata, HNSWVerbWithMetadata } from '../coreTypes.js' - -/** - * @description The entity-side `system.*` map — EXACTLY the ten engine - * scalars David ruled queryable (2026-08-03). Adding a name here is a - * cross-engine contract change: the native accelerator's conformance suite - * pins this list verbatim, so any edit must ship as a paired release. - */ -export const SYSTEM_ENTITY_SCALARS: ReadonlySet = new Set([ - 'id', - 'type', - 'subtype', - 'createdAt', - 'updatedAt', - 'confidence', - 'weight', - 'visibility', - 'service', - 'createdBy' -]) - -/** - * @description The relation-side `system.*` map — the verb mirror of - * {@link SYSTEM_ENTITY_SCALARS}: `verb`, `sourceId`, `targetId` are the - * structural members beside the eight shared scalars. Same one law, same - * pairing rule for edits. - */ -export const SYSTEM_RELATION_SCALARS: ReadonlySet = new Set([ - 'verb', - 'sourceId', - 'targetId', - 'subtype', - 'createdAt', - 'updatedAt', - 'confidence', - 'weight', - 'visibility', - 'service', - 'createdBy' -]) - -/** - * @description Engine plumbing — never addressable from the query surface in - * ANY spelling. `level` is the HNSW node layer (the incident field: listing - * it as resolvable shadowed real user data); `data` is the payload container, - * not a scalar — content is reached through the content/text-search APIs, - * and addressing it as a sortable field would lie about its shape. - */ -export const PLUMBING_FIELDS: ReadonlySet = new Set([ - 'vector', - 'connections', - 'level', - 'data', - '_rev' -]) - -/** @description Which record kind a field address is being resolved against. */ -export type FieldAddressKind = 'entity' | 'relation' - -/** - * @description A parsed, law-valid field address. `scope` says which side of - * the record the name lives on; `field` is the unprefixed name to read. - */ -export interface FieldAddress { - /** 'metadata' = the user's field (bare or `metadata.`-prefixed); 'system' = an engine scalar. */ - scope: 'metadata' | 'system' - /** The field name with any scope prefix removed. */ - field: string - /** The exact spelling the caller used — preserved for error text and telemetry. */ - raw: string -} - -/** - * Parse a query-surface field name under the one law. Pure and data-blind: - * this validates the ADDRESS (spelling + map membership), not whether any - * row actually carries the field — data-aware refusals (the did-you-mean - * for a bare system-scalar name no row carries) belong to the query layer, - * which calls {@link buildUnresolvableMessage} with index knowledge. - * - * @param raw - The field name as the caller wrote it (`level`, - * `metadata.level`, `system.createdAt`, …) - * @param kind - Entity or relation resolution (selects the system map) - * @returns The parsed {@link FieldAddress} - * @throws {InvalidFieldAddressError} for a `system.*` name outside the ruled - * map (including every plumbing field) or a malformed spelling — the error - * text enumerates the valid system scalars so the fix is in the message. - * - * @example - * parseFieldAddress('level', 'entity') // { scope: 'metadata', field: 'level' } - * parseFieldAddress('metadata.level', 'entity') // { scope: 'metadata', field: 'level' } - * parseFieldAddress('system.createdAt', 'entity') // { scope: 'system', field: 'createdAt' } - * parseFieldAddress('system.level', 'entity') // throws — plumbing is invisible - */ -export function parseFieldAddress( - raw: string, - kind: FieldAddressKind -): FieldAddress { - const systemMap = - kind === 'entity' ? SYSTEM_ENTITY_SCALARS : SYSTEM_RELATION_SCALARS - - if (raw.startsWith('system.')) { - const field = raw.slice('system.'.length) - if (!systemMap.has(field)) { - throw new InvalidFieldAddressError(raw, kind, systemMap) - } - return { scope: 'system', field, raw } - } - - if (raw.startsWith('metadata.')) { - const field = raw.slice('metadata.'.length) - if (field.length === 0) { - throw new InvalidFieldAddressError(raw, kind, systemMap) - } - return { scope: 'metadata', field, raw } - } - - if (raw.length === 0) { - throw new InvalidFieldAddressError(raw, kind, systemMap) - } - - // Bare name = the user's metadata field. Always. Even when the same name - // exists in the system map — `confidence` as a bare name is the user's - // metadata field named confidence; the engine scalar is system.confidence. - return { scope: 'metadata', field: raw, raw } -} - -/** - * Read the addressed value off an entity. The ONLY sanctioned way a query - * surface turns a {@link FieldAddress} into a value — direct property reads - * against records re-create the shadow class this module exists to kill. - * - * @returns The value, or `undefined` when the record does not carry it - * (missing values sort LAST in both directions per the ordering contract — - * they are never grounds for dropping a row). - */ -export function readEntityFieldAddress( - entity: HNSWNounWithMetadata, - address: FieldAddress -): unknown { - const rec = entity as unknown as Record - const bag = - rec.metadata && typeof rec.metadata === 'object' - ? (rec.metadata as Record) - : null - - if (address.scope === 'system') { - // System scalars live at the record's top level, NEVER in the user's - // bag — a user field named `confidence` must be unreachable from - // system.confidence (and vice versa). Entity views carry the scalars - // top-level directly; record-derived views spell the type `noun`. - const top = rec[address.field] - if (top !== undefined) return top - if (address.field === 'type') return rec.noun - return undefined - } - - // User scope: the bag IS the user's namespace, authoritative — EVERY name - // reads from it, engine spellings included (`bag.confidence` is the user's - // confidence field under the field-addressing law). - if (bag) return bag[address.field] - - // No bag at all: a LEGACY flat record (pre-nested-bag storage). Its keys - // matching system/plumbing names are the ENGINE's — the pre-law write door - // refused user colliders — so a bare system name reads as ABSENT rather - // than resurrecting the shadow this module exists to kill. Same for the - // legacy 'noun' spelling. - if ( - SYSTEM_ENTITY_SCALARS.has(address.field) || - PLUMBING_FIELDS.has(address.field) || - address.field === 'noun' - ) { - return undefined - } - return rec[address.field] -} - -/** - * Relation twin of {@link readEntityFieldAddress}. The stored flat record - * keys the relation type under `verb`; public Relation shapes may carry it - * as `type` — both spellings of the record are read, the ADDRESS is always - * `system.verb`. - */ -export function readRelationFieldAddress( - verb: HNSWVerbWithMetadata, - address: FieldAddress -): unknown { - if (address.scope === 'system') { - const rec = verb as unknown as Record - if (address.field === 'verb') return rec.verb ?? rec.type - return rec[address.field] - } - return verb.metadata?.[address.field] -} - -/** - * Build the ruled did-you-mean refusal text for a bare name that resolved to - * metadata but is UNKNOWN to the index — the data-aware half of the law, - * called by the query layer once it has consulted the known-field set: - * - * "no metadata field 'createdAt' — did you mean system.createdAt or - * metadata.createdAt?" - * - * When the bare name is NOT a system scalar the system candidate is omitted - * (there is only one thing the caller could have meant; the refusal exists - * because refusing beats silently sorting nothing). - */ -export function buildUnresolvableMessage( - raw: string, - kind: FieldAddressKind -): string { - const systemMap = - kind === 'entity' ? SYSTEM_ENTITY_SCALARS : SYSTEM_RELATION_SCALARS - if (systemMap.has(raw)) { - return ( - `no metadata field '${raw}' — did you mean system.${raw} or metadata.${raw}? ` + - `(bare names always address your metadata; engine fields need the system. prefix)` - ) - } - return ( - `no metadata field '${raw}' on this store — nothing carries it, so an ordered or ` + - `filtered read against it cannot mean anything. Spell it metadata.${raw} once the ` + - `field exists, or check the field name (system.${raw} is NOT valid — '${raw}' is ` + - `not one of the engine's system scalars).` - ) -} - -/** - * @description Refusal for a syntactically valid address that resolves to - * NOTHING — a bare name no user field carries. Carries the did-you-mean - * (both candidate spellings when the name collides with a system scalar) so - * the fix ships inside the error. Thrown by the query layer with index - * knowledge, never by the pure parser. - */ -/** - * Cross-package identity normalizer (the seam belt): the native accelerator - * throws ITS OWN UnresolvableFieldError class, which fails `instanceof` - * against this package's export — consumers were forced to match by name. - * Every provider-boundary catch routes suspected field-refusals through - * here: a foreign refusal (matched by name, duck fields tolerated) is - * rethrown as THIS package's class, so exactly one identity ever reaches - * consumers. Anything else returns null (caller rethrows the original). - */ -export function asBrainyFieldRefusal(err: unknown): UnresolvableFieldError | null { - if (err instanceof UnresolvableFieldError) return err - const e = err as { name?: string; message?: string; raw?: string; kind?: string } | null - if (e && e.name === 'UnresolvableFieldError') { - return new UnresolvableFieldError( - e.raw ?? 'unknown-field', - (e.kind as FieldAddressKind) ?? 'entity', - e.message - ) - } - return null -} - -export class UnresolvableFieldError extends Error { - public readonly raw: string - public readonly kind: FieldAddressKind - - constructor(raw: string, kind: FieldAddressKind, messageOverride?: string) { - super(messageOverride ?? buildUnresolvableMessage(raw, kind)) - this.name = 'UnresolvableFieldError' - this.raw = raw - this.kind = kind - } -} - -/** - * @description Refusal for a malformed or out-of-map field ADDRESS — - * `system.` (including all plumbing), an empty - * name, or a bare `metadata.` prefix. The message carries the full valid - * system map so the fix never needs a docs lookup. - */ -export class InvalidFieldAddressError extends UnresolvableFieldError { - constructor(raw: string, kind: FieldAddressKind, systemMap: ReadonlySet) { - const valid = [...systemMap].map((f) => `system.${f}`).join(', ') - super( - raw, - kind, - `'${raw}' is not an addressable ${kind} field. Bare names address your own ` + - `metadata fields; engine fields are exactly: ${valid}. Engine plumbing ` + - `(vector, connections, level, data, _rev) is not part of the query surface.` - ) - this.name = 'InvalidFieldAddressError' - } -} - - -/** - * @description Refusal for a find() option that is accepted by the type - * surface but NOT implemented — an accepted option must work or refuse; - * accepted-and-ignored died as a class (sealed 2026-08-03). Names the - * option and the honest state so nobody discovers a no-op by measurement. - */ -export class UnsupportedFindOptionError extends Error { - public readonly option: string - - constructor(option: string) { - super( - `find() option '${option}' is not implemented — it used to be silently ` + - `ignored, which read as working. Remove it from the call (or track the ` + - `feature request); it will be honored or refused, never swallowed.` - ) - this.name = 'UnsupportedFindOptionError' - this.option = option - } -} - -/** - * @description The capability signal both engines' conformance suites arm on - * (never a version guess): its presence at the package root means the one - * field-addressing law is LIVE on every query surface — bare = user metadata, - * `system.*` = the ruled scalars, plumbing invisible, refusals typed. - */ -export const FIELD_ADDRESSING_CAPABILITY = 'field-addressing/v1' diff --git a/src/db/generationSegments.ts b/src/db/generationSegments.ts deleted file mode 100644 index 91451281..00000000 --- a/src/db/generationSegments.ts +++ /dev/null @@ -1,570 +0,0 @@ -/** - * @module db/generationSegments - * @description The generation-segment store — Stage-2 D1+D3+repacking's file - * format (co-frozen 2026-07-19; design: the d1-d3-repacking spec). - * - * Packs CONSECUTIVE cold generations' record-sets (before-images + delta) - * into append-once segment files with derived sidecar indexes, so history - * scales in SEGMENTS (tens) instead of FILES-PER-GENERATION (hundreds of - * thousands), and cold-open reads ONE manifest instead of listing the - * backlog. Layout under `_generations/segments/`: - * - * - `seg-.bgs` — magic "BGS1", then one frame per - * generation: `u32 payloadLen | u32 crc32c | msgpack payload`. Payload is - * POSITIONAL: `[generation, timestamp, delta, records[], flags]` with - * records `[kindByte, id, record]`. `flags` reserves encoding evolution - * (bit 0 = compressed payload — v1 always 0; a future writer upgrade, - * never a format break). Sealed segments are IMMUTABLE — the fact log's - * own law, generalized. - * - `seg-.idx` — DERIVED sidecar (msgpack): per-generation frame - * offsets (point reads = one ranged read, never a listing) + per-id - * generation postings (per-id chain rebuilds read only what they need). - * Corrupt/missing → rebuilt from its segment in one sequential read, - * loudly. - * - `manifest.json` — the segment catalogue + `compactedBelow` (D3's - * horizon marker). Cold-open reads THIS; the packed backlog is never - * listed. - * - * D3 semantics carried here: bounded-retention reclaim drops WHOLE segments - * at boundaries (O(1) per segment, no rewrite); under the archival profile - * (`retention: 'all'`) nothing here is ever dropped — folding is the only - * transform (re-representation, never deletion). - */ - -import { encode as msgpackEncode, decode as msgpackDecode } from '@msgpack/msgpack' -import { crc32c } from '../utils/crc32c.js' -import type { FactLogStorage } from './factLog.js' -import { prodLog } from '../utils/logger.js' - -/** Directory for segment files + manifest, under the generations prefix. */ -export const SEGMENTS_PREFIX = '_generations/segments' - -/** Target sealed-segment size (co-freeze proposal; tunable on evidence). */ -export const SEGMENT_TARGET_BYTES = 64 * 1024 * 1024 - -const MAGIC = new TextEncoder().encode('BGS1') -const FRAME_PREFIX_BYTES = 8 // u32 payloadLen + u32 crc32c -const MANIFEST_PATH = `${SEGMENTS_PREFIX}/manifest.json` - -/** One generation's fold input — exactly what the live tier holds for it. */ -export interface FoldGeneration { - generation: number - timestamp: number - /** The tx.json delta object, carried verbatim. */ - delta: unknown - /** The before-image record-set (empty for record-less generations). */ - records: Array<{ kind: 'noun' | 'verb'; id: string; record: unknown }> -} - -/** Manifest entry for one sealed segment. */ -export interface SegmentMeta { - file: string - firstGeneration: number - lastGeneration: number - frames: number - bytes: number - /** crc32c of the full segment byte stream — the digest chain's link. */ - checksum: number -} - -interface SegmentManifest { - version: 1 - compactedBelow: number - segments: SegmentMeta[] -} - -interface SidecarIndex { - version: 1 - /** [generation, frameOffset, frameLen] ascending by generation. */ - generations: Array<[number, number, number]> - /** `${kindByte}:${id}` → ascending generations holding a record for it. */ - ids: Record -} - -const segmentFileName = (firstGeneration: number): string => - `seg-${String(firstGeneration).padStart(20, '0')}.bgs` -const sidecarFileName = (firstGeneration: number): string => - `seg-${String(firstGeneration).padStart(20, '0')}.idx` - -/** - * The generation-segment store. Owns the packed tier ONLY — the live - * per-generation tier and the routing between tiers belong to - * `GenerationStore`. All mutating entry points here are called under the - * generation store's commit mutex. - */ -export class GenerationSegmentStore { - private readonly storage: FactLogStorage - private manifest: SegmentManifest = { version: 1, compactedBelow: 0, segments: [] } - /** Sidecar cache — segments are immutable, so entries never invalidate. */ - private readonly sidecars = new Map() - - constructor(storage: FactLogStorage) { - this.storage = storage - } - - /** Load the manifest (ONE read — never a directory listing). */ - async open(): Promise { - const raw = (await this.storage.readRawObject(MANIFEST_PATH)) as SegmentManifest | null - if (raw) { - if (raw.version !== 1) { - throw new Error( - `[GenerationSegments] manifest version ${String(raw.version)} is newer than this ` + - `engine understands — refusing to serve partial history. Upgrade the engine.` - ) - } - this.manifest = raw - } - } - - /** The packed tier's catalogue (ascending, immutable snapshot). */ - segments(): readonly SegmentMeta[] { - return this.manifest.segments - } - - /** D3's horizon marker: generations below this were reclaimed (bounded profiles only). */ - compactedBelow(): number { - return this.manifest.compactedBelow - } - - /** The covering sealed segment for `gen`, or null if it lives outside the packed tier. */ - private coveringSegment(gen: number): SegmentMeta | null { - // Manifest is ascending and ranges never overlap — binary search. - const segs = this.manifest.segments - let lo = 0 - let hi = segs.length - 1 - while (lo <= hi) { - const mid = (lo + hi) >> 1 - const s = segs[mid] - if (gen < s.firstGeneration) hi = mid - 1 - else if (gen > s.lastGeneration) lo = mid + 1 - else return s - } - return null - } - - /** True when `gen` is packed (readable from this tier). */ - hasGeneration(gen: number): boolean { - 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 - * ascending, contiguous with the packed tier (first = last packed + 1 when - * segments exist), and already durable in the live tier. Crash between the - * segment write and the caller's live-tier delete leaves a DUPLICATE - * representation — resolved live-tier-wins by the reader; never a gap. - */ - async fold(gens: FoldGeneration[]): Promise { - if (gens.length === 0) { - throw new Error('[GenerationSegments] fold() requires at least one generation') - } - for (let i = 1; i < gens.length; i++) { - if (gens[i].generation <= gens[i - 1].generation) { - 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( - `[GenerationSegments] fold() overlaps the packed tier: ${gens[0].generation} ≤ ` + - `sealed ${last.lastGeneration} — segments are immutable, never rewritten` - ) - } - - const first = gens[0].generation - const file = segmentFileName(first) - const sidecar: SidecarIndex = { version: 1, generations: [], ids: {} } - - // Encode all frames, tracking offsets for the sidecar. - const parts: Uint8Array[] = [MAGIC] - let offset = MAGIC.length - for (const g of gens) { - const payload = msgpackEncode([ - g.generation, - g.timestamp, - g.delta, - g.records.map((r) => [r.kind === 'noun' ? 0 : 1, r.id, r.record]), - 0 // flags: v1 = uncompressed - ]) - const frame = new Uint8Array(FRAME_PREFIX_BYTES + payload.length) - const view = new DataView(frame.buffer) - view.setUint32(0, payload.length, true) - view.setUint32(4, crc32c(payload), true) - frame.set(payload, FRAME_PREFIX_BYTES) - sidecar.generations.push([g.generation, offset, frame.length]) - for (const r of g.records) { - const key = `${r.kind === 'noun' ? 0 : 1}:${r.id}` - ;(sidecar.ids[key] ??= []).push(g.generation) - } - parts.push(frame) - offset += frame.length - } - const total = parts.reduce((n, p) => n + p.length, 0) - const bytes = new Uint8Array(total) - let at = 0 - for (const p of parts) { - bytes.set(p, at) - at += p.length - } - - const meta: SegmentMeta = { - file, - firstGeneration: first, - lastGeneration: gens[gens.length - 1].generation, - frames: gens.length, - bytes: total, - checksum: crc32c(bytes) - } - - // Durability order: segment + sidecar fsync'd BEFORE the manifest names - // them (a crash before the manifest = invisible orphan files, harmless); - // manifest last, atomically. - const segPath = `${SEGMENTS_PREFIX}/${file}` - const idxPath = `${SEGMENTS_PREFIX}/${sidecarFileName(first)}` - await this.storage.writeRawBytes(segPath, bytes) - await this.storage.writeRawBytes(idxPath, msgpackEncode(sidecar)) - await this.storage.syncRawObjects([segPath, idxPath]) - const next: SegmentManifest = { - ...this.manifest, - segments: [...this.manifest.segments, meta] - } - await this.storage.writeRawObject(MANIFEST_PATH, next) - await this.storage.syncRawObjects([MANIFEST_PATH]) - this.manifest = next - this.sidecars.set(file, sidecar) - return meta - } - - /** Load (or rebuild, loudly) a segment's sidecar. */ - private async sidecarFor(meta: SegmentMeta): Promise { - const cached = this.sidecars.get(meta.file) - if (cached) return cached - const idxPath = `${SEGMENTS_PREFIX}/${sidecarFileName(meta.firstGeneration)}` - const raw = await this.storage.readRawBytes(idxPath) - if (raw) { - try { - const idx = msgpackDecode(raw) as SidecarIndex - if (idx.version === 1) { - this.sidecars.set(meta.file, idx) - return idx - } - } catch { - // fall through to rebuild - } - } - // Sidecars are DERIVED: rebuild from the segment, loudly — never serve - // wrong offsets silently. - prodLog.warn( - `[GenerationSegments] sidecar for ${meta.file} missing or unreadable — rebuilding from the segment` - ) - const rebuilt = await this.rebuildSidecar(meta) - await this.storage.writeRawBytes(idxPath, msgpackEncode(rebuilt)) - this.sidecars.set(meta.file, rebuilt) - return rebuilt - } - - /** One sequential read of the segment → a fresh sidecar. Verifies every frame CRC. */ - private async rebuildSidecar(meta: SegmentMeta): Promise { - const frames = await this.readAllFrames(meta) - const idx: SidecarIndex = { version: 1, generations: [], ids: {} } - for (const f of frames) { - idx.generations.push([f.generation, f.offset, f.frameLen]) - for (const r of f.records) { - const key = `${r.kind === 'noun' ? 0 : 1}:${r.id}` - ;(idx.ids[key] ??= []).push(f.generation) - } - } - return idx - } - - private decodeFrame( - payload: Uint8Array - ): { generation: number; timestamp: number; delta: unknown; records: FoldGeneration['records'] } { - const [generation, timestamp, delta, rawRecords] = msgpackDecode(payload) as [ - number, - number, - unknown, - Array<[number, string, unknown]>, - number - ] - return { - generation, - timestamp, - delta, - records: rawRecords.map(([kindByte, id, record]) => ({ - kind: kindByte === 0 ? ('noun' as const) : ('verb' as const), - id, - record - })) - } - } - - private async readAllFrames(meta: SegmentMeta): Promise< - Array & { offset: number; frameLen: number }> - > { - const bytes = await this.storage.readRawBytes(`${SEGMENTS_PREFIX}/${meta.file}`) - if (!bytes) { - throw new Error( - `[GenerationSegments] sealed segment ${meta.file} is MISSING — packed history is damaged; ` + - `refusing to continue silently` - ) - } - const out: Array & { offset: number; frameLen: number }> = [] - let at = MAGIC.length - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) - while (at + FRAME_PREFIX_BYTES <= bytes.length) { - const payloadLen = view.getUint32(at, true) - const crc = view.getUint32(at + 4, true) - const payload = bytes.subarray(at + FRAME_PREFIX_BYTES, at + FRAME_PREFIX_BYTES + payloadLen) - if (payload.length !== payloadLen || crc32c(payload) !== crc) { - throw new Error( - `[GenerationSegments] frame CRC mismatch in ${meta.file} at offset ${at} — ` + - `packed history is damaged; refusing to serve it` - ) - } - out.push({ ...this.decodeFrame(payload), offset: at, frameLen: FRAME_PREFIX_BYTES + payloadLen }) - at += FRAME_PREFIX_BYTES + payloadLen - } - return out - } - - /** Read one packed generation's frame via its sidecar offset (one ranged read). */ - private async readFrame( - gen: number - ): Promise | null> { - const meta = this.coveringSegment(gen) - if (!meta) return null - const idx = await this.sidecarFor(meta) - // generations ascending → binary search. - const gens = idx.generations - let lo = 0 - let hi = gens.length - 1 - while (lo <= hi) { - const mid = (lo + hi) >> 1 - if (gens[mid][0] < gen) lo = mid + 1 - else if (gens[mid][0] > gen) hi = mid - 1 - else { - const [, offset, frameLen] = gens[mid] - const bytes = await this.storage.readRawBytes(`${SEGMENTS_PREFIX}/${meta.file}`) - if (!bytes) { - throw new Error(`[GenerationSegments] sealed segment ${meta.file} is MISSING`) - } - const frame = bytes.subarray(offset, offset + frameLen) - const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength) - const payloadLen = view.getUint32(0, true) - const crc = view.getUint32(4, true) - const payload = frame.subarray(FRAME_PREFIX_BYTES, FRAME_PREFIX_BYTES + payloadLen) - if (payload.length !== payloadLen || crc32c(payload) !== crc) { - throw new Error( - `[GenerationSegments] frame CRC mismatch for generation ${gen} in ${meta.file} — ` + - `packed history is damaged; refusing to serve it` - ) - } - return this.decodeFrame(payload) - } - } - // 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, and that segment declares a complete ${meta.frames}-frame ` + - `span — the manifest and the sidecar disagree; packed history is damaged` - ) - } - - /** The packed tier's delta for `gen` (null = not packed). */ - async readDelta(gen: number): Promise<{ delta: unknown; timestamp: number } | null> { - const frame = await this.readFrame(gen) - return frame ? { delta: frame.delta, timestamp: frame.timestamp } : null - } - - /** The packed tier's full record-set for `gen` (null = not packed). */ - async readRecords(gen: number): Promise { - const frame = await this.readFrame(gen) - return frame ? frame.records : null - } - - /** One packed before-image (null = not packed OR no record for the id in that generation). */ - async readRecord(gen: number, kind: 'noun' | 'verb', id: string): Promise { - const frame = await this.readFrame(gen) - if (!frame) return null - const hit = frame.records.find((r) => r.kind === kind && r.id === id) - return hit ? hit.record : null - } - - /** - * D3 reclaim: drop WHOLE segments whose lastGeneration < `belowGeneration` - * and bump `compactedBelow`. Partial segments are never dropped — the - * boundary waits. NEVER called under the archival profile (the caller - * enforces retention semantics; this method only executes boundary drops). - */ - async dropSegmentsBelow(belowGeneration: number): Promise<{ dropped: number; compactedBelow: number }> { - const keep: SegmentMeta[] = [] - const drop: SegmentMeta[] = [] - for (const s of this.manifest.segments) { - ;(s.lastGeneration < belowGeneration ? drop : keep).push(s) - } - if (drop.length === 0) { - return { dropped: 0, compactedBelow: this.manifest.compactedBelow } - } - const compactedBelow = Math.max( - this.manifest.compactedBelow, - drop[drop.length - 1].lastGeneration + 1 - ) - // Manifest first (the drop is authoritative once named), then bytes — - // a crash between leaves orphan segment files invisible to the manifest, - // harmless and re-collectable. - const next: SegmentManifest = { ...this.manifest, compactedBelow, segments: keep } - await this.storage.writeRawObject(MANIFEST_PATH, next) - await this.storage.syncRawObjects([MANIFEST_PATH]) - this.manifest = next - for (const s of drop) { - await this.storage.deleteRawObject(`${SEGMENTS_PREFIX}/${s.file}`) - await this.storage.deleteRawObject(`${SEGMENTS_PREFIX}/${sidecarFileName(s.firstGeneration)}`) - this.sidecars.delete(s.file) - } - return { dropped: drop.length, compactedBelow } - } - - /** - * D8 rider — the packed portion of `generationDigest(g)`: a deterministic - * crc32c chain over sealed-segment checksums fully below `g`, plus the - * frame CRC of `g`'s own frame when `g` is mid-segment. O(segments), not - * O(generations); identical history ⇒ identical digest on any machine. - * The live-tier portion is composed by the caller. - */ - async digestThroughPacked(g: number): Promise { - let digest = 0 - let covered = false - for (const s of this.manifest.segments) { - if (s.lastGeneration <= g) { - digest = crc32c(new TextEncoder().encode(`${digest}:${s.checksum}`)) - if (s.lastGeneration === g) covered = true - } else if (s.firstGeneration <= g) { - // g is mid-segment: chain the partial prefix via g's frame CRC. - const frame = await this.readFrame(g) - if (frame === null) return null - const idx = await this.sidecarFor(s) - const upTo = idx.generations.filter(([gen]) => gen <= g) - for (const [gen, offset, frameLen] of upTo) { - digest = crc32c(new TextEncoder().encode(`${digest}:${gen}:${offset}:${frameLen}`)) - } - covered = true - break - } - } - return covered || this.manifest.segments.length > 0 ? digest : null - } -} diff --git a/src/db/generationStore.ts b/src/db/generationStore.ts index 89f83a8f..4e3738d9 100644 --- a/src/db/generationStore.ts +++ b/src/db/generationStore.ts @@ -32,13 +32,7 @@ */ import { prodLog } from '../utils/logger.js' -import { - GenerationCompactedError, - GenerationConflictError, - PendingFlushDurabilityError, - PendingSingleOpsUnflushedError, - StoreInconsistentError -} from './errors.js' +import { GenerationCompactedError, GenerationConflictError, PendingFlushDurabilityError, StoreInconsistentError } from './errors.js' import type { UnreconciledRecord } from './errors.js' import { TransactionRollbackError } from '../transaction/errors.js' import type { @@ -51,17 +45,7 @@ import type { GenerationStorage, TxLogEntry } from './types.js' -import { readLogAuthority } from './logAuthority.js' -import { - FactLog, - storageSupportsFactLog, - type CommitFact, - type FactOp, - type FactIntMinter, - type FactMarkerRecord -} from './factLog.js' -import { GenerationSegmentStore, type FoldGeneration } from './generationSegments.js' -import { crc32c } from '../utils/crc32c.js' +import { FactLog, storageSupportsFactLog, type CommitFact, type FactOp } from './factLog.js' /** * The byte-identical before-images of every id a commit touches, read UNDER @@ -81,56 +65,9 @@ export interface CommitBeforeImages { export const GENERATION_COUNTER_PATH = '_system/generation.json' /** Storage-root-relative path of the commit manifest. */ export const MANIFEST_PATH = '_system/manifest.json' -/** - * The clean-shutdown marker (log-authority recovery gate): written+fsynced at - * a clean close carrying the committed generation; CONSUMED at every open. - * Absent or generation-mismatched at open = unclean shutdown = the replay - * fold, bounded below by the fold checkpoint when one is stored (whole-log - * without one). Its absence is always safe (costs one fold, loses nothing). - */ -export const CLEAN_SHUTDOWN_PATH = '_system/clean-shutdown.json' -/** - * The fold checkpoint (log-authority recovery BOUND): `{ generation: G }` - * asserts that every entity whose latest fact is ≤ G has durable canonical - * bytes — so an unclean open folds only `(G, head]` instead of the whole log. - * Stamped strictly AFTER a canonical-sync barrier over every live entity - * touched since the last stamp (stamp-after-data); absent or torn = fold from - * 0 (always safe, just bigger). The chain of stamps starts only at a provable - * point: an empty brain, or the end of a whole-log fold. - */ -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. @@ -149,43 +86,12 @@ export function contiguousRuns(gens: FoldGeneration[]): FoldGeneration[][] { * IS committed); the tx-log append has NOT happened yet. A crash here must * keep the transaction (the tx-log is advisory metadata, not the source of * commit truth). - * - `'transact-after-fact-sync'` — the batch's fact is appended AND fsynced, - * but neither the counter nor the manifest advanced. A crash here must cost - * the whole batch: recovery restores the before-images and open() truncates - * the synced fact back to the manifest watermark. - * - * Single-op (Model-B group-commit) phases — `commitSingleOp`: - * - * - `'singleop-after-execute'` — the live canonical write has applied (tmp+ - * rename, not individually fsynced); no history, fact, or generation record - * exists yet. A crash here must cost only the never-returned ack — the - * baseline stays intact and the log stays at the committed watermark. - * - `'singleop-after-fact-append'` — the fact is appended (and, in at-ack - * mode, fsynced); the manifest never saw the generation. A crash here must - * cost the buffered history + the fact (open() truncates it back), never - * the baseline. - * - * Pending-tier flush phases — `flushPendingSingleOps`: - * - * - `'flush-after-staging'` — the window's record-set dirs are written but not - * fsynced and the manifest never advanced. A crash here must cost only the - * window's HISTORY (drop-without-restore) — the acked live writes stay. - * - `'flush-before-manifest'` — staging is fsynced and the facts are fsynced, - * but the manifest never advanced. A crash here must cost only the window's - * history and its facts (truncated at open) — the acked live writes stay. - * - `'before-manifest-rename'` is ALSO fired by the flush path just before its - * commit point (see `flushPendingSingleOpsUnlocked`). */ export type CommitFaultPhase = | 'after-staging' | 'after-execute' | 'before-manifest-rename' | 'after-manifest-rename' - | 'transact-after-fact-sync' - | 'singleop-after-execute' - | 'singleop-after-fact-append' - | 'flush-after-staging' - | 'flush-before-manifest' /** * @description Identifies which ids a transaction touches, split by kind. @@ -226,38 +132,6 @@ export class GenerationStore { */ private factLog: FactLog | null = null - /** - * Fact-log durability mode. 'deferred' (default) = the fact becomes - * durable at the group-commit flush, together with the buffered history — - * the pre-log-authority contract, zero added ack latency. 'at-ack' = - * every single-op ack awaits a covering log fsync (shared via the log's - * group commit) — the log-authority contract: an acked write's fact - * survives power loss. Set by the owner from the stored authority switch - * at open; transact() is durable-at-return in BOTH modes (unchanged). - */ - private logDurability: 'deferred' | 'at-ack' = 'deferred' - - /** Switch the fact-log durability mode (see {@link logDurability}). */ - setLogDurability(mode: 'deferred' | 'at-ack'): void { - this.logDurability = mode - } - - /** - * The fact log's v2 int minter — injected by the OWNER (brainy wires the - * metadata index's id mapper here right after the index is ready), because - * this store cannot know the mapper. With the minter installed, new fact - * segments write the v2 format and after-image records carry minted dense - * ints reproducible by an id-mapper rebuild. Survives reopen: `open()` - * re-installs it on the fresh {@link FactLog} instance. - */ - private intMinter: FactIntMinter | null = null - - /** Install the fact log's v2 int minter (see {@link intMinter}). */ - setIntMinter(mint: FactIntMinter): void { - this.intMinter = mint - this.factLog?.setIntMinter(mint) - } - /** Latest reserved/observed generation (≥ {@link committed}). */ private counter = 0 /** Committed-transaction watermark (manifest generation). */ @@ -265,37 +139,6 @@ export class GenerationStore { /** Compaction horizon — record-sets ≤ this are reclaimed. */ private horizonGen = 0 - /** - * Fold-checkpoint accumulator: every entity whose CANONICAL live bytes were - * (re)written since the last stamped checkpoint. Drained by - * {@link advanceFoldCheckpointUnlocked} — synced first, stamped after; on a - * failed barrier the drained ids merge back so the checkpoint can never - * advance past unsynced bytes. Fed only while the chain is valid (see - * {@link foldCheckpointChainValid}) so tree-authority brains never grow it. - */ - private checkpointDirtyNouns = new Set() - /** @see checkpointDirtyNouns — the verb half of the accumulator. */ - private checkpointDirtyVerbs = new Set() - /** - * Whether the checkpoint chain is PROVABLY sound for this brain: true when - * a stored checkpoint exists (induction), the brain opened empty (vacuous), - * or a whole-log fold just re-applied every fact (base case). While false, - * checkpoints are never stamped and the fold bound stays 0 — the honest - * 10.0 contract, upgraded at the brain's first recovery fold. - */ - private foldCheckpointChainValid = false - /** Last stamped fold-checkpoint generation (0 = none / fold from origin). */ - private foldCheckpoint = 0 - /** - * Whether this brain's stored authority is the log — set from the stored - * artifact at open, or by {@link completeFoldCheckpointBootstrap} when an - * in-session adoption flips it. Checkpoints are only ever STAMPED under log - * authority (the artifact bounds the log fold, which only log-authority - * recovery runs); the dirty accumulator may fill slightly earlier, during - * an adoption in flight (see {@link beginFoldCheckpointBootstrap}). - */ - private authorityIsLog = false - /** * Committed generations whose record dirs exist, stored as a SORTED, DISJOINT, * ascending list of INCLUSIVE `[start, end]` intervals (a run-length set). @@ -423,21 +266,6 @@ export class GenerationStore { */ private historyBytesTotal: number | null = null - /** - * The packed tier (D1+D3): sealed segments holding folded cold - * generations. Null until {@link open} wires it (and on storage adapters - * without raw-byte primitives — the live tier then carries everything, - * exactly as before the packed tier existed). - */ - private segments: GenerationSegmentStore | null = null - - /** - * Live-tier window: generations newer than `committed - REPACK_LIVE_WINDOW` - * are never folded — the hot tail stays in the per-generation layout the - * write path owns. Matches the resident chain window's scale. - */ - static readonly REPACK_LIVE_WINDOW = 1024 - /** * Model-B per-write group-commit — the in-memory PENDING tier. * @@ -463,13 +291,7 @@ export class GenerationStore { private pendingGens: number[] = [] private readonly pendingBuffer = new Map< number, - { - nouns: Map - verbs: Map - timestamp: number - /** Engine-origin stamp for the tx-log entry (absent = user write). */ - origin?: string - } + { nouns: Map; verbs: Map; timestamp: number } >() /** Pending timer-coalesced flush handle (cleared on flush/close). */ private pendingFlushTimer: ReturnType | null = null @@ -551,50 +373,16 @@ export class GenerationStore { | null const manifest = (await this.storage.readRawObject(MANIFEST_PATH)) as GenerationManifest | null - // TORN-ARTIFACT VALIDATION (power-loss survivors): a torn manifest or - // counter can carry NaN/garbage where a generation belongs — unguarded, - // that NaN reaches BigInt() conversions at init and kills the open with - // a RangeError. A non-finite-integer generation is DISCARDED with - // narration (the conservative floor: 0 = re-derive from the record - // directories / fact log below, exactly the recovery machinery's job). - const finiteGen = (v: unknown, source: string): number => { - if (typeof v === 'number' && Number.isSafeInteger(v) && v >= 0) return v - if (v !== undefined && v !== null) { - prodLog.warn( - `[GenerationStore] ${source} carries a non-integer generation ` + - `(${String(v)}) — torn write survivor; discarding and re-deriving ` + - `from recovery (never a RangeError at open)` - ) - } - return 0 - } - this.committed = finiteGen(manifest?.generation, 'manifest') - this.horizonGen = finiteGen(manifest?.horizon, 'manifest horizon') - this.counter = Math.max(finiteGen(counterFile?.generation, 'generation counter'), this.committed) + this.committed = manifest?.generation ?? 0 + this.horizonGen = manifest?.horizon ?? 0 + this.counter = Math.max(counterFile?.generation ?? 0, this.committed) - // 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. + // Discover existing generation record directories. + const recordPaths = await this.storage.listRawObjects(GENERATIONS_PREFIX) const seenGens = new Set() - 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) - } + for (const p of recordPaths) { + const gen = parseGenerationFromPath(p) + if (gen !== null) seenGens.add(gen) } let rolledBack = 0 @@ -640,220 +428,11 @@ export class GenerationStore { // hosts no fact log (readers fall back to canonical enumeration). if (storageSupportsFactLog(this.storage)) { this.factLog = new FactLog(this.storage) - if (this.intMinter) this.factLog.setIntMinter(this.intMinter) - // LOG-AUTHORITY REPLAY (durable-at-ack's recovery half): when this - // brain's stored authority is the log, an intact fact ABOVE the - // manifest is an ACKED write whose canonical bytes may not have - // survived the crash — its fsynced fact is the ONLY durable copy. - // Truncating it would lose an acked write; instead REPLAY it into - // canonical and advance the manifest to cover it. Tree-authority - // brains keep the truncate contract (their acks never promised the - // fact was durable). Derived indexes reconcile through the normal - // drift machinery at open — same as group-commit recovery. - const authority = await readLogAuthority(this.storage) - if (authority.authority === 'log') { - this.authorityIsLog = true - // TWO REPLAY TIERS, gated by the clean-shutdown marker: - // - // (1) ABOVE-MANIFEST (always): an intact fact above the manifest is - // an acked write whose canonical bytes may not have survived — - // replay it in and advance the manifest. - // (2) WHOLE-LOG (unclean shutdown only): power loss can ALSO vaporize - // canonical bytes BELOW the manifest — live entity writes are - // tmp+rename without per-file fsync; the group-commit flush syncs - // the staging copies and the manifest, never the live tree. The - // manifest therefore over-states canonical durability across a - // power cut, and facts ≤ manifest can be the ONLY durable copy - // of acked state (measured: 299 of 301 acks lost while the log - // held every fact scan-clean). Under log authority, recovery is - // REPLAY: an unclean open folds the ENTIRE log into canonical — - // whole-entity after-images are idempotent, so re-applying - // already-intact records is byte-safe. A clean close writes the - // marker and skips all of this (zero open cost on the happy - // path); crash recovery pays one narrated log fold — LC1 and - // LC5 are the same code, a crash is just bigger lag. - const cleanShutdown = await this.readCleanShutdownMarker() - const orphans = await this.factLog.peekFactsAbove(this.committed) - const uncleanOpen = cleanShutdown === null || cleanShutdown !== this.committed - // FOLD-CHECKPOINT BOUND: a stored checkpoint G proves every entity - // whose latest fact is ≤ G has durable canonical bytes (each stamp - // followed a canonical-sync barrier), so the unclean fold only needs - // (G, head] — entities untouched since G are already safe, entities - // touched after G get their latest after-image re-applied. Absent or - // invalid checkpoint = fold from 0 (the 10.0 whole-log contract). - const checkpoint = await this.readFoldCheckpoint() - const foldBound = checkpoint ?? 0 - // Chain validity: induction (a stored stamp), vacuous truth (an empty - // brain has no bytes to assert), or — set below — the base case (a - // whole-log fold re-applies and re-syncs every entity in the log). - this.foldCheckpointChainValid = checkpoint !== null || this.committed === 0 - this.foldCheckpoint = foldBound - if (uncleanOpen) this.foldCheckpointChainValid = true - // THE FOLD STREAMS AND NARRATES. A production first boot after a live - // flip folded ~7k facts by materializing them all (GBs of decoded - // after-images, a GC storm, a starved write lane) in SILENCE — the - // operator restarted the process three times mid-fold, each restart - // making the next boot unclean again. Two laws from that day: the - // fold consumes the log one segment-batch at a time (memory = one - // segment, any log size), and it announces itself BEFORE the work - // with progress lines DURING it — an operator who can see a fold - // converging lets it finish. - const foldKind = uncleanOpen - ? foldBound > 0 - ? `BOUNDED fold above checkpoint ${foldBound}` - : '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 } - if (op.record === null) { - // A genuine tombstone (both legs absent) — the fold removes - // both legs, exactly like `writeNounRaw`/`writeVerbRaw`'s raw - // exact-restore contract. - image = { metadata: null, vector: null } - } else if ( - op.record.metadata !== null && - (op.record.vector === null || op.record.vector === undefined) - ) { - // PRESERVE-IF-ABSENT (population law, ADR-008 G1 — the fold's - // half): a metadata-only after-image must never DELETE an - // existing vector leg through the fold. `writeNounRaw`/ - // `writeVerbRaw` are exact-restore primitives — a `vector: - // null` there means "delete", which is exactly right for - // `rollBackUncommittedGeneration`'s before-image restore (a - // transaction abort legitimately un-writes a vector the failed - // transaction added). It is NOT right here: this fold replays - // AFTER-IMAGES, and re-applying an already-intact record must - // be byte-safe (this module's own invariant, see the log-authority - // comment above) — silently erasing a landed vector because one - // replayed fact's vector leg came back null is the exact defect - // that left metadata-counted, never-enumerated rows in a - // production store (confirmed root cause: the enumeration walk - // used to key on the vector leg, so a preserved-but-then-deleted - // vector made the row invisible while the ledger still counted - // it by metadata). A genuine "unvector" has its own sanctioned, - // ledger-correct path (`Brainy.unvectorNounForRootMigration`) — - // never this raw primitive, and never the fold. - const current = - op.kind === 'verb' - ? await this.storage.readVerbRaw(op.id) - : await this.storage.readNounRaw(op.id) - image = { metadata: op.record.metadata, vector: current.vector ?? null } - } else { - image = { metadata: op.record.metadata, vector: op.record.vector } - } - if (op.kind === 'verb') await this.storage.writeVerbRaw(op.id, image) - else await this.storage.writeNounRaw(op.id, image) - this.noteCheckpointDirty(op.kind, op.id) - } - replayed++ - if (replayed % 1000 === 0) { - prodLog.narrate( - `[GenerationStore] recovery fold in progress — ${replayed} fact(s) folded ` + - `in ${Date.now() - foldStartedAt}ms (at generation ${fact.generation}); ` + - `do not restart, the fold is finite` - ) - } - if (fact.generation > this.committed) { - this.committed = fact.generation - this.appendCommittedGen(fact.generation) - this.setDelta(fact.generation, { - nouns: new Set(fact.ops.filter((o) => o.kind === 'noun').map((o) => o.id)), - verbs: new Set(fact.ops.filter((o) => o.kind === 'verb').map((o) => o.id)), - timestamp: fact.timestamp, - bytes: 0 - }) - } - } - if (uncleanOpen) { - 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 ` + - `re-pays the whole fold.` - ) - for await (const batch of this.factLog.streamFactsAbove(foldBound)) { - for (const fact of batch) await replayFact(fact) - } - } else { - for (const fact of orphans) await replayFact(fact) - } - if (replayed > 0) { - if (this.counter < this.committed) this.counter = this.committed - await this.persistCounterUnlocked() - const manifest: GenerationManifest = { - version: 1, - generation: this.committed, - committedAt: new Date().toISOString(), - horizon: this.horizonGen - } - await this.storage.writeRawObject(MANIFEST_PATH, manifest) - await this.storage.syncRawObjects([MANIFEST_PATH]) - prodLog.narrate( - `[GenerationStore] log-authority recovery replayed ${replayed} fact(s) into ` + - `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 - // entity in (bound, head] — stamp the checkpoint at the new committed - // watermark so the NEXT crash folds only its own tail. This is also - // the chain's base case: the first whole-log fold of a pre-checkpoint - // brain covers every entity in the log, so its stamp is total. - if (uncleanOpen) await this.advanceFoldCheckpointUnlocked() - // The marker is consumed: any session that can write invalidates it - // at first commit (see the commit paths); a clean close re-writes it. - // A READER NEVER CONSUMES IT. The marker is the writer's own evidence - // about the writer's own process — clearing it here exists so that - // if THIS session goes on to write and then dies before its next - // clean close, the marker's absence correctly reads as unclean. A - // reader can never write, so it can never leave the store in a state - // its own crash would mis-describe; clearing the marker for it would - // only cost the store's actual writer a needless whole-log fold on - // its next open, for a generation the reader merely observed. Leave - // `_system/` exactly as found. - if (!options?.readOnly) await this.clearCleanShutdownMarker() - } await this.factLog.open(this.committed) } else { this.factLog = null } - // PACKED TIER (D1+D3): same capability gate as the fact log. Opening - // reads ONE manifest — never a listing of the packed backlog — and seeds - // committedRanges with the sealed ranges so packed generations resolve - // exactly like live ones. - if (storageSupportsFactLog(this.storage)) { - this.segments = new GenerationSegmentStore(this.storage) - await this.segments.open() - // 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; - // coalesce adjacency so range arithmetic stays interval-exact. - const merged: Array<[number, number]> = [] - for (const r of [...packedRanges, ...this.committedRanges].sort((a, b) => a[0] - b[0])) { - const last = merged[merged.length - 1] - if (last && r[0] <= last[1] + 1) last[1] = Math.max(last[1], r[1]) - else merged.push([r[0], r[1]]) - } - this.committedRanges = merged - } - this.horizonGen = Math.max(this.horizonGen, this.segments.compactedBelow() - 1) - } else { - this.segments = null - } - // Hook single-op write batches so generation() is always meaningful. // Suppressed while a transact batch executes (the batch is ONE generation). if (!options?.readOnly) { @@ -874,210 +453,6 @@ export class GenerationStore { await this.flushPendingSingleOps() this.storage.setGenerationBumpHook(undefined) await this.persistCounterNow() - // Fold-checkpoint barrier BEFORE the clean-shutdown marker: entities that - // reached the accumulator outside the pending tier (transact commits, - // aborted-write restores) get their canonical bytes synced and the stamp - // advanced, so the marker below never vouches for bytes the checkpoint - // chain hasn't proven durable. - await this.advanceFoldCheckpoint() - // Clean-shutdown marker (log-authority recovery gate): everything above - // is durable; stamp the committed generation so the next open can adopt - // instead of folding the log. Written LAST — a crash before this line is - // exactly the unclean case the marker's absence reports. - try { - await this.storage.writeRawObject(CLEAN_SHUTDOWN_PATH, { generation: this.committed }) - await this.storage.syncRawObjects([CLEAN_SHUTDOWN_PATH]) - } catch { - // A failed marker write only costs the next open a replay fold — safe. - } - } - - /** Read the clean-shutdown marker's generation, or null (absent/unreadable). */ - private async readCleanShutdownMarker(): Promise { - try { - const raw = (await this.storage.readRawObject(CLEAN_SHUTDOWN_PATH)) as { - generation?: number - } | null - return raw && Number.isSafeInteger(raw.generation) ? (raw.generation as number) : null - } catch { - return null - } - } - - /** - * Consume the clean-shutdown marker (every WRITER open; a clean close - * re-writes it). Callers must gate this on `!options.readOnly` — a reader - * never consumes the marker, see the call site in {@link open}. - */ - private async clearCleanShutdownMarker(): Promise { - try { - await this.storage.deleteRawObject(CLEAN_SHUTDOWN_PATH) - } catch { - // Absent or undeletable: the conservative outcome is a future replay. - } - } - - /** - * Read the fold checkpoint's generation, or `null` when absent, torn, or - * implausible (> committed) — every invalid shape degrades to the safe - * whole-log fold, never to a bound that could skip an acked write. - */ - private async readFoldCheckpoint(): Promise { - try { - const raw = (await this.storage.readRawObject(FOLD_CHECKPOINT_PATH)) as { - generation?: number - } | null - const gen = raw?.generation - if (!Number.isSafeInteger(gen) || (gen as number) < 0) return null - if ((gen as number) > this.committed) { - prodLog.warn( - `[GenerationStore] fold checkpoint ${gen} is ahead of the manifest ` + - `(${this.committed}) — ignoring it; recovery folds the whole log` - ) - return null - } - return gen as number - } catch { - return null - } - } - - /** - * Record that an entity's canonical live bytes were (re)written and are not - * yet covered by a checkpoint stamp. Gated on chain validity so brains - * without a sound chain (tree authority, or log authority before its first - * recovery fold) never accumulate — they keep the fold-from-0 contract. - */ - private noteCheckpointDirty(kind: 'noun' | 'verb', id: string): void { - if (!this.foldCheckpointChainValid) return - if (kind === 'verb') this.checkpointDirtyVerbs.add(id) - else this.checkpointDirtyNouns.add(id) - } - - /** - * The canonical-sync barrier + checkpoint stamp (must run under the commit - * mutex or in single-threaded open). Drains the dirty accumulator, makes - * those entities' canonical bytes durable via the adapter barrier, and only - * THEN stamps `_system/fold-checkpoint.json` at the committed watermark — - * stamp-after-data, always. On any failure the drained ids merge back and - * the stored checkpoint stays where it was: the bound can lag (a bigger - * fold later) but can never overstate durability (a lost write, outlawed). - */ - private async advanceFoldCheckpointUnlocked(): Promise { - if (!this.foldCheckpointChainValid || !this.authorityIsLog || !this.factLog) return - const nouns = [...this.checkpointDirtyNouns] - const verbs = [...this.checkpointDirtyVerbs] - const target = this.committed - if (nouns.length === 0 && verbs.length === 0 && target === this.foldCheckpoint) return - this.checkpointDirtyNouns = new Set() - this.checkpointDirtyVerbs = new Set() - try { - if (nouns.length > 0 || verbs.length > 0) { - await this.storage.syncEntityCanonical?.(nouns, verbs) - } - await this.storage.writeRawObject(FOLD_CHECKPOINT_PATH, { generation: target }) - await this.storage.syncRawObjects([FOLD_CHECKPOINT_PATH]) - this.foldCheckpoint = target - } catch (err) { - for (const id of nouns) this.checkpointDirtyNouns.add(id) - for (const id of verbs) this.checkpointDirtyVerbs.add(id) - prodLog.warn( - `[GenerationStore] fold-checkpoint barrier failed at generation ${target} ` + - `(${(err as Error).message}) — checkpoint stays at ${this.foldCheckpoint}; ` + - `recovery would fold from there (bigger, never lossy). Will retry next flush.` - ) - } - } - - /** - * @description Public, mutex-serialized fold-checkpoint advance — called by - * `close()` after the final flush so entities touched by paths that do not - * ride the pending tier (e.g. `transact()`) are covered before the - * clean-shutdown marker is written. - */ - async advanceFoldCheckpoint(): Promise { - return this.withMutex(() => this.advanceFoldCheckpointUnlocked()) - } - - /** - * @description Adoption-time chain bootstrap, phase 1 — called by - * `adoptLogAuthority()` BEFORE its oracle/backfill passes. Only a FRESH - * brain (committed === 0) may bootstrap here: with no committed - * generations the chain's assertion is vacuously true, and arming it now - * means the baseline backfill's own re-commits feed the dirty accumulator, - * so the first stamp after the flip covers them. A non-fresh flip skips - * this (returns false) — its chain starts at the brain's first recovery - * fold instead, because only a whole-log fold can prove coverage of - * entities written before the log existed. - */ - beginFoldCheckpointBootstrap(): boolean { - if (this.committed !== 0 || this.foldCheckpointChainValid) { - return this.foldCheckpointChainValid - } - this.foldCheckpointChainValid = true - this.foldCheckpoint = 0 - return true - } - - /** - * @description Adoption-time chain bootstrap, phase 2 — called after - * `flipToLogAuthority` records the flip. Opens the stamp gate; the next - * flush/close barrier writes the first checkpoint. - */ - completeFoldCheckpointBootstrap(): void { - this.authorityIsLog = true - } - - /** Whether the fold-checkpoint chain is armed (a bounded fold is possible). */ - foldCheckpointChainArmed(): boolean { - return this.foldCheckpointChainValid - } - - /** - * @description Stamp the fold checkpoint after the caller has completed a - * FULL canonical barrier (every live row's canonical bytes fsynced, paged — - * the adoption path does this right after a non-fresh flip). The stamp - * asserts total coverage, so it may ONLY be called when the barrier walked - * everything; stamp-after-data is the caller's ordering to keep. Arms the - * chain: the brain's first unclean boot folds (checkpoint, head] instead of - * the whole log — a production first boot after a live flip paid a full-log - * fold through three mid-fold restarts because the chain could previously - * only arm at a crash. - */ - async stampFoldCheckpointAfterFullBarrier(): Promise { - return this.withMutex(async () => { - if (!this.authorityIsLog || !this.factLog) { - throw new Error( - 'stampFoldCheckpointAfterFullBarrier: only a log-authority brain stamps a fold checkpoint' - ) - } - this.foldCheckpointChainValid = true - // The full barrier supersedes any accumulated partial set. - this.checkpointDirtyNouns = new Set() - this.checkpointDirtyVerbs = new Set() - const target = this.committed - await this.storage.writeRawObject(FOLD_CHECKPOINT_PATH, { generation: target }) - await this.storage.syncRawObjects([FOLD_CHECKPOINT_PATH]) - this.foldCheckpoint = target - prodLog.info( - `[GenerationStore] fold checkpoint founded at generation ${target} — ` + - `crash recovery is bounded from this moment` - ) - }) - } - - /** - * @description Adoption-time chain bootstrap, abort — called when an - * adoption attempt throws or refuses after phase 1. Disarms the chain and - * drops the accumulator so a tree-authority brain never accumulates or - * stamps. (If the chain was valid BEFORE the attempt — a stored checkpoint - * exists — it stays valid; only a phase-1 arm is undone.) - */ - abandonFoldCheckpointBootstrap(): void { - if (this.authorityIsLog) return - this.foldCheckpointChainValid = false - this.checkpointDirtyNouns = new Set() - this.checkpointDirtyVerbs = new Set() } /** @@ -1125,51 +500,6 @@ export class GenerationStore { * deltas (cache-bounded reads). * @returns Counts, bytes, generation range, and the compaction horizon. */ - /** - * @description D8 (gate-to-generation provenance): a deterministic content - * digest of the generation log THROUGH `g` — identical history ⇒ identical - * digest on any machine; any divergence (different records, different - * order, reclaimed range) ⇒ different digest. Composed from the packed - * tier's sealed-segment checksum chain (O(segments)) plus the live tier's - * per-generation delta digests (O(live window at most)). Release gates pin - * {generation, digest} and verify both at execution time. - * @param g - The generation to digest through (≤ committed). - * @returns A hex digest string, stable across reopen and repacking states - * ONLY for fully-packed prefixes — repacking changes representation, so - * the composed digest is defined over CONTENT: live-tier gens hash their - * delta + record ids, packed gens hash via frame CRCs. A gate should pin - * after a repack pass for long-term stability, or re-pin on repack. - */ - async generationDigest(g: number): Promise { - if (!Number.isInteger(g) || g < 1 || g > this.committed) { - throw new RangeError( - `generationDigest(): generation ${g} is out of range [1, ${this.committed}]` - ) - } - if (g <= this.horizonGen) { - throw new GenerationCompactedError(g, this.horizonGen) - } - let digest = 0 - const enc = new TextEncoder() - if (this.segments) { - const packed = await this.segments.digestThroughPacked(g) - if (packed !== null) digest = packed - } - // Live-tier composition: every committed gen ≤ g not covered by a sealed - // segment hashes its delta content in ascending order. - for (const gen of this.committedGensAsc()) { - if (gen > g) break - if (this.segments?.hasGeneration(gen)) continue - const delta = await this.getDelta(gen) - digest = crc32c( - enc.encode( - `${digest}:${gen}:${delta.timestamp}:${[...delta.nouns].sort().join(',')}:${[...delta.verbs].sort().join(',')}` - ) - ) - } - return digest.toString(16).padStart(8, '0') - } - async historyStats(): Promise<{ generations: number bytes: number @@ -1208,17 +538,14 @@ export class GenerationStore { try { paths = await this.storage.listRawObjects(`${GENERATIONS_PREFIX}/${gen}/prev`) } catch { - paths = [] + return [] } const records: GenerationRecord[] = [] for (const p of paths) { const record = (await this.storage.readRawObject(p)) as GenerationRecord | null if (record) records.push(record) } - if (records.length > 0) return records - // Two-tier: folded generations serve their record-set from the segment. - const packed = await this.segments?.readRecords(gen) - return packed ? (packed.map((r) => r.record) as GenerationRecord[]) : [] + return records } /** @@ -1293,37 +620,6 @@ export class GenerationStore { else this.pins.set(gen, count - 1) } - - /** - * Torn-tolerant raw read for BEFORE-IMAGE contexts: a write landing on a - * TORN record (power-loss survivor) is a HEAL — the new after-image - * replaces the unreadable bytes. The before-image is unknowable, so it - * reads as the CREATE SENTINEL ({metadata:null, vector:null}) with - * narration: history for this id restarts at this generation (an asOf - * below it resolves absent for the id — the honest statement of what the - * crash destroyed). The adapter's loud floor (error + gauge) fired at - * throw time; real storage faults still propagate. - */ - private async readRawForBeforeImage( - kind: 'noun' | 'verb', - id: string - ): Promise<{ metadata: unknown | null; vector: unknown | null }> { - try { - return kind === 'noun' - ? await this.storage.readNounRaw(id) - : await this.storage.readVerbRaw(id) - } catch (err) { - if ((err as { code?: string }).code === 'TORN_RECORD') { - prodLog.warn( - `[GenerationStore] before-image of ${kind} ${id} is TORN — the incoming ` + - `write HEALS the record; its history restarts at this generation` - ) - return { metadata: null, vector: null } - } - throw err - } - } - /** @returns Total number of live pins across all generations. */ activePinCount(): number { let total = 0 @@ -1370,9 +666,6 @@ export class GenerationStore { * @param args.execute - Runs the planned operation batch atomically. * @returns The committed generation and its commit timestamp. * @throws GenerationConflictError when the CAS expectation fails. - * @throws PendingSingleOpsUnflushedError when the pending single-op tier is - * non-empty — call `flushPendingSingleOps()` first (both `Brainy.transact()` - * and `Brainy.compactHistory()` already do). */ /** * The generation fact log, or `null` when the storage layer cannot host one. @@ -1399,8 +692,6 @@ export class GenerationStore { nouns: string[] verbs: string[] meta?: Record - /** V2 marker records riding this fact (same generation, same append). */ - records?: FactMarkerRecord[] }): Promise { const ops: FactOp[] = [] const afterRecords: GenerationRecord[] = [] @@ -1424,8 +715,7 @@ export class GenerationStore { timestamp: args.timestamp, ops, ...(args.meta ? { meta: args.meta } : {}), - ...(blobHashes.length > 0 ? { blobHashes } : {}), - ...(args.records && args.records.length > 0 ? { records: args.records } : {}) + ...(blobHashes.length > 0 ? { blobHashes } : {}) } } @@ -1438,24 +728,11 @@ export class GenerationStore { * per-record analogue of `ifAtGeneration`. A throw aborts the whole batch: * the generation reservation is returned and no staging I/O has happened. */ precommit?: (before: CommitBeforeImages) => void - /** Optional v2 marker records riding this batch's ONE commit fact (e.g. - * the deferred-embedding lifecycle markers) — same generation, same - * atomic append, same durability barrier as the batch itself, so a - * marker can never be orphaned from its write nor the write from its - * marker. Additive: omitted on every markerless path. */ - records?: FactMarkerRecord[] execute: () => Promise }): Promise<{ generation: number; timestamp: number }> { return this.withMutex(async () => { - // The generation-order guard (see assertPendingSingleOpsFlushed): a - // direct commitTransaction() call while single-ops are still pending - // would commit above them, unsorting reservedGensAsc() and corrupting - // point-in-time reads. Both sanctioned callers (Brainy.transact(), - // Brainy.compactHistory()) already flush first, so this is - // behavior-neutral on every real path. - this.assertPendingSingleOpsFlushed() // A latched history-durability failure compromises the whole generation - // chain — refuse a transact too (advancing the manifest past stuck, + // spine — refuse a transact too (advancing the manifest past stuck, // un-durable single-op generations would be inconsistent). Same loud // error; self-clears when the pending tier drains. this.assertHistoryDurable() @@ -1499,11 +776,11 @@ export class GenerationStore { // conflicting batch aborts with zero staging I/O. The maps hold the // byte-identical records the staged files are written from. for (const id of nouns) { - const prev = await this.readRawForBeforeImage('noun', id) + const prev = await this.storage.readNounRaw(id) nounBefore.set(id, { kind: 'noun', metadata: prev.metadata, vector: prev.vector }) } for (const id of verbs) { - const prev = await this.readRawForBeforeImage('verb', id) + const prev = await this.storage.readVerbRaw(id) verbBefore.set(id, { kind: 'verb', metadata: prev.metadata, vector: prev.vector }) } @@ -1573,10 +850,6 @@ export class GenerationStore { // The transaction's entire canonical footprint is now durable, so the // counter/manifest advance below can never outrun the entity bytes. await this.storage.flushWriteBarrier?.() - // THE FENCE (transact leg): verify lock ownership before the commit - // point — an aborted-by-fence transact rolls back cleanly through the - // catch below; a fenced writer must never advance counter or manifest. - await this.storage.assertWriterFenceHeld?.() faultPoint('after-execute') // Fact log (dual-write): append + fsync this generation's AFTER-IMAGE @@ -1591,15 +864,11 @@ export class GenerationStore { timestamp, nouns, verbs, - ...(args.meta ? { meta: args.meta } : {}), - ...(args.records && args.records.length > 0 ? { records: args.records } : {}) + ...(args.meta ? { meta: args.meta } : {}) }) await this.factLog.append(fact) await this.factLog.sync() } - // A crash here must cost the whole batch: the synced fact is truncated - // back at open() and the before-images are restored byte-identically. - faultPoint('transact-after-fact-sync') // -- 5. Counter + manifest rename (COMMIT POINT) ---------------------- await this.persistCounterUnlocked() @@ -1627,13 +896,6 @@ export class GenerationStore { this.historyBytesTotal += delta.bytes ?? 0 } this.extendChains(gen, nouns, verbs) - // Fold-checkpoint accounting: the write barrier above already synced - // this batch's canonical footprint on adapters that have one, but the - // accumulator entry is the belt — an adapter without a write barrier - // still gets these ids covered by the next checkpoint barrier, and a - // redundant fsync of already-durable bytes is cheap and idempotent. - for (const id of nouns) this.noteCheckpointDirty('noun', id) - for (const id of verbs) this.noteCheckpointDirty('verb', id) const logEntry: TxLogEntry = { generation: gen, timestamp, ...(args.meta && { meta: args.meta }) } await this.storage.appendTxLogLine(JSON.stringify(logEntry)) @@ -1645,13 +907,6 @@ export class GenerationStore { if (crashSimulated) { throw err } - // Fold-checkpoint accounting: an abort's rollback restores are raw - // canonical writes that never reach the transaction write barrier - // (flushWriteBarrier only runs on the commit path) — feed them so the - // next checkpoint barrier syncs the restored bytes before any stamp - // vouches for them. - for (const id of nouns) this.noteCheckpointDirty('noun', id) - for (const id of verbs) this.noteCheckpointDirty('verb', id) // The trapdoor for a batch: if rollback FAILED to fully apply, canonical // storage may be inconsistent. A batch is never adopted forward (its // other ops were rolled back — partial commit would break atomicity), so @@ -1819,24 +1074,6 @@ export class GenerationStore { touched: { nouns?: string[]; verbs?: string[] } execute: () => Promise precommit?: (before: CommitBeforeImages) => void - /** - * Optional v2 marker records riding this write's commit fact (e.g. the - * deferred-embedding lifecycle markers) — same generation, same atomic - * append, and in 'at-ack' log durability the SAME covering fsync as the - * write itself (zero extra sync). A marker can never be orphaned from - * its write nor the write from its marker. Additive: omitted on every - * markerless path. When the storage hosts no fact log the markers have - * no durable home — matching that storage's overall durability posture - * (it cannot host the log's crash guarantees either); callers own - * surfacing that honestly. - */ - records?: FactMarkerRecord[] - /** - * Engine-origin stamp (`'system:embed-landing'`, `'system:adoption-backfill'`, - * `'system:reconcile'`). Rides the tx-log entry AND the commit fact's meta, - * so both records agree about WHO committed. Absent = user write. - */ - origin?: string }): Promise<{ generation: number; timestamp: number; degraded?: string[] }> { return this.withMutex(async () => { // Refuse to accept a write whose history we cannot make durable: if the @@ -1855,12 +1092,12 @@ export class GenerationStore { // {metadata:null, vector:null} = the create sentinel. const nounBefore = new Map() for (const id of nouns) { - const prev = await this.readRawForBeforeImage('noun', id) + const prev = await this.storage.readNounRaw(id) nounBefore.set(id, { kind: 'noun', metadata: prev.metadata, vector: prev.vector }) } const verbBefore = new Map() for (const id of verbs) { - const prev = await this.readRawForBeforeImage('verb', id) + const prev = await this.storage.readVerbRaw(id) verbBefore.set(id, { kind: 'verb', metadata: prev.metadata, vector: prev.vector }) } @@ -1885,13 +1122,6 @@ export class GenerationStore { await args.execute() } catch (err) { this.inTransact = false - // Fold-checkpoint accounting: execute() ran, so canonical bytes for - // the touched ids changed — whether they now hold the new images, a - // restored rollback, or (the trapdoor) something indeterminate, the - // next checkpoint stamp must not assert their durability without a - // barrier over whatever is actually there. - for (const id of nouns) this.noteCheckpointDirty('noun', id) - for (const id of verbs) this.noteCheckpointDirty('verb', id) // A failed rollback (TransactionRollbackError) may have left canonical // storage inconsistent — the trapdoor. Reconcile against the // before-images to decide the honest response (David's ruling: @@ -1905,7 +1135,7 @@ export class GenerationStore { // incomplete for these ids until the next rebuild/repairIndex (the // egress guard prevents wrong results meanwhile). Loud, honest, // no double-write. - this.pendingBuffer.set(gen, { nouns: nounBefore, verbs: verbBefore, timestamp, ...(args.origin ? { origin: args.origin } : {}) }) + this.pendingBuffer.set(gen, { nouns: nounBefore, verbs: verbBefore, timestamp }) this.pendingGens.push(gen) this.extendChains(gen, nouns, verbs) // The adopted generation is committed — it gets its fact like any @@ -1913,14 +1143,7 @@ export class GenerationStore { // buffered history). if (this.factLog) { await this.factLog.append( - await this.buildCommitFact({ - generation: gen, - timestamp, - nouns, - verbs, - ...(args.origin ? { meta: { origin: args.origin } } : {}), - ...(args.records && args.records.length > 0 ? { records: args.records } : {}) - }) + await this.buildCommitFact({ generation: gen, timestamp, nouns, verbs }) ) } prodLog.warn( @@ -1947,99 +1170,22 @@ export class GenerationStore { throw err } this.inTransact = false - // Fold-checkpoint accounting: the live canonical write is applied — it - // must ride the next canonical-sync barrier before any stamp covers it. - for (const id of nouns) this.noteCheckpointDirty('noun', id) - for (const id of verbs) this.noteCheckpointDirty('verb', id) - // Test-only crash simulation (direct call — a throw propagates with no - // cleanup, exactly like a process death; recovery-on-open restores the - // contract). A crash here must cost only the never-returned ack: the - // live canonical write applied, but no history, fact, or generation - // record exists for it yet. - if (this.commitFaultInjector) this.commitFaultInjector('singleop-after-execute') // Buffer the pending generation + make it instantly visible to reads. - this.pendingBuffer.set(gen, { nouns: nounBefore, verbs: verbBefore, timestamp, ...(args.origin ? { origin: args.origin } : {}) }) + this.pendingBuffer.set(gen, { nouns: nounBefore, verbs: verbBefore, timestamp }) this.pendingGens.push(gen) this.extendChains(gen, nouns, verbs) // Fact log (dual-write): the acked write's AFTER-IMAGE fact, appended // now (read back warm, under the mutex — group-commit means flush-time // canonical only holds the LATEST state, so each generation's after-image - // exists only here). - // - // Durability is MODE-GOVERNED: - // - 'deferred' (default, the pre-log-authority behavior): durability - // rides the group-commit flush like the buffered history — a crash - // before the flush loses the fact AND the generation together, never - // a torn state. - // - 'at-ack' (log-authority mode): the ack awaits a covering fsync via - // the log's group-commit (many concurrent writers share ONE sync) — - // an acked write's fact survives power loss, by contract. + // exists only here). Durability rides the group-commit flush, exactly + // like the buffered before-image history: a crash before the flush loses + // the fact AND the generation together — never a torn state. if (this.factLog) { - // TWO PHASES, TWO DISTINCT COMPENSATIONS (a production adoption - // proved the difference the hard way): rewinding the counter after - // a SUCCESSFUL append re-mints the same generation and every later - // append refuses non-monotonic — the write path wedges in a refusal - // loop. The counter may only rewind when the log provably does NOT - // carry the generation. - const unbuffer = (): void => { - this.pendingBuffer.delete(gen) - const idx = this.pendingGens.lastIndexOf(gen) - if (idx !== -1) this.pendingGens.splice(idx, 1) - this.invalidateChains() - } - // Phase 1 — APPEND. Failure = the log never took the fact: full - // compensation (un-buffer + counter rewind); a rejected write must - // not commit, and the next mint may safely reuse the number. - try { - await this.factLog.append( - await this.buildCommitFact({ - generation: gen, - timestamp, - nouns, - verbs, - ...(args.origin ? { meta: { origin: args.origin } } : {}), - ...(args.records && args.records.length > 0 ? { records: args.records } : {}) - }) - ) - } catch (err) { - unbuffer() - if (this.counter === gen) this.counter = gen - 1 - throw err - } - // Phase 2 — the at-ack covering sync. Failure here means the fact - // IS in the log (append succeeded) but durability was not promised: - // try to remove it (dropAbove); only a SUCCESSFUL drop earns the - // counter rewind. If the drop itself fails (e.g. the fact was - // sealed by a racing rotation), the generation stays consumed and - // buffered — monotonicity holds, the flush path retries durability, - // and the caller still gets the loud failure. - if (this.logDurability === 'at-ack') { - try { - await this.factLog.ensureSynced() - } catch (err) { - try { - await this.factLog.dropAbove(gen - 1) - unbuffer() - if (this.counter === gen) this.counter = gen - 1 - } catch (dropErr) { - prodLog.warn( - `[GenerationStore] at-ack sync failed for generation ${gen} and the ` + - `appended fact could not be dropped (${(dropErr as Error).message}) — ` + - `the generation stays consumed and buffered; the flush path retries ` + - `durability. Never re-minting a number the log may carry.` - ) - } - throw err - } - } + await this.factLog.append( + await this.buildCommitFact({ generation: gen, timestamp, nouns, verbs }) + ) } - // Test-only crash simulation. A crash here must cost the buffered - // history + the appended fact in 'deferred' mode (open() truncates it - // back to the manifest watermark) — while under 'log' authority the - // intact fact is REPLAYED at open, never the baseline or the applied - // live write. - if (this.commitFaultInjector) this.commitFaultInjector('singleop-after-fact-append') this.schedulePendingFlush() return { generation: gen, timestamp } }) @@ -2098,11 +1244,6 @@ export class GenerationStore { private async flushPendingSingleOpsUnlocked(): Promise { return this.withMutex(async () => { if (this.pendingGens.length === 0) return - // THE FENCE: an evicted writer (force-takeover, removed lock) must fail - // HERE, before a single staged byte or manifest advance — writing on - // after eviction is how split-brain stores are made. One small read - // per flush window. - await this.storage.assertWriterFenceHeld?.() this.clearPendingFlushTimer() const gens = [...this.pendingGens].sort((a, b) => a - b) @@ -2160,14 +1301,9 @@ export class GenerationStore { const deltaPath = `${dir}/tx.json` await this.storage.writeRawObject(deltaPath, delta) stagedPaths.push(deltaPath) - logEntries.push({ generation: gen, timestamp: buf.timestamp, ...(buf.origin ? { origin: buf.origin } : {}) }) + logEntries.push({ generation: gen, timestamp: buf.timestamp }) } - // Test-only crash simulation. A crash here must cost only the window's - // HISTORY: un-fsynced record-set dirs may sit above the manifest, and - // recovery drops them WITHOUT restore — the acked live writes stay. - if (this.commitFaultInjector) this.commitFaultInjector('flush-after-staging') - // ONE fsync for the whole window — the durability-batching win. await this.storage.syncRawObjects(stagedPaths) @@ -2177,12 +1313,6 @@ export class GenerationStore { // generation without its durable fact. await this.factLog?.sync() - // Test-only crash simulation. A crash here must cost only the window's - // history and its (already fsynced) facts — open() truncates the facts - // back to the manifest watermark and drops the staged group-commit dirs - // without restore; the acked live writes stay. - if (this.commitFaultInjector) this.commitFaultInjector('flush-before-manifest') - // Test-only crash simulation: a throwing injector here leaves the staged // group-commit generation dirs on disk with NO manifest advance — the // exact "crashed mid-flush" state recovery must DROP-WITHOUT-RESTORE @@ -2228,16 +1358,6 @@ export class GenerationStore { for (const entry of logEntries) { await this.storage.appendTxLogLine(JSON.stringify(entry)) } - - // Fold-checkpoint barrier: the window's LIVE canonical bytes (the acked - // writes themselves — the staging sync above covered only their history - // copies) become durable here, and only then does the checkpoint stamp - // advance to the new committed watermark. This is what keeps crash - // recovery's log fold bounded to (checkpoint, head] instead of the - // whole log. A failure inside is absorbed by the barrier (it warns, - // retains the accumulator, and leaves the old bound standing) — history - // durability above already succeeded, so the flush itself is good. - await this.advanceFoldCheckpointUnlocked() }) } @@ -2323,37 +1443,6 @@ export class GenerationStore { } } - /** - * @description Throw if the pending single-op tier is non-empty. Called at - * the top of {@link commitTransaction} (the ONLY method that appends a - * fresh commit directly into {@link committedRanges} outside recovery) so - * the ordering invariant {@link reservedGensAsc}'s own doc comment states — - * "pending generations are always greater than every committed one" — is - * ENFORCED there rather than merely assumed. - * - * That invariant holds today only because both sanctioned callers flush the - * pending tier before committing: `Brainy.transact()` (src/brainy.ts, - * `await this.generationStore.flushPendingSingleOps()` immediately before - * its `commitTransaction()` call) and `Brainy.compactHistory()` - * (src/brainy.ts, the same flush immediately before its `compact()` call — - * `compact()` itself only ever RECLAIMS an existing committed prefix, so it - * cannot land a commit out of order and needs no guard of its own). A - * caller that reaches `commitTransaction()` by any other path — bypassing - * that flush — would commit a new generation into `committedRanges` ABOVE - * generations still sitting in `pendingGens`, breaking `reservedGensAsc`'s - * "committed-then-pending is already sorted" assumption and making - * `resolveManyAt`'s single ascending pass (and `resolveAt`'s consumers) - * return the WRONG before-image for a point-in-time read — silently, no - * compensating error. Refusing here, before any staging I/O, keeps the - * store untouched (nothing committed, nothing staged, the generation - * counter reservation unaffected) on every path that already flushes. - */ - private assertPendingSingleOpsFlushed(): void { - if (this.pendingGens.length > 0) { - throw new PendingSingleOpsUnflushedError(this.pendingGens.length) - } - } - /** Schedule a coalesced pending-tier flush (size trigger fires immediately on * the next microtask; otherwise a {@link PENDING_FLUSH_DELAY_MS} timer). Both * defer outside the current mutex section so the flush can re-acquire it. A @@ -2437,13 +1526,6 @@ export class GenerationStore { * committed-then-pending concatenation is already sorted — identical to the old * `[...committedGens, ...pendingGens]`. This is the union historical reads * resolve over so un-flushed single-ops are visible to pins/`asOf`. - * - * The "flush first" half of that invariant is ENFORCED, not just documented: - * {@link commitTransaction} — the only method that lands a fresh commit into - * {@link committedRanges} outside crash recovery — refuses via - * {@link assertPendingSingleOpsFlushed} whenever {@link pendingGens} is - * non-empty, so a committed generation can never land above a still-pending - * one and break this ordering. */ private *reservedGensAsc(): IterableIterator { yield* this.committedGensAsc() @@ -2701,15 +1783,9 @@ export class GenerationStore { if (pending) { return (kind === 'noun' ? pending.nouns : pending.verbs).get(id) ?? null } - const live = (await this.storage.readRawObject( + return (await this.storage.readRawObject( `${GENERATIONS_PREFIX}/${gen}/prev/${id}.json` )) as GenerationRecord | null - if (live) return live - // Two-tier: the packed tier serves folded generations (live-tier-wins). - if (this.segments?.hasGeneration(gen)) { - return (await this.segments.readRecord(gen, kind, id)) as GenerationRecord | null - } - return null } /** @@ -3056,21 +2132,6 @@ export class GenerationStore { `${GENERATIONS_PREFIX}/${gen}/tx.json` )) as GenerationDelta | null if (delta === null) { - // Two-tier read (D1+D3): not in the live tier → the packed tier. - // Live-tier-wins ordering (a crash mid-fold leaves a duplicate, never - // a gap), so the segment lookup runs only after the live miss. - const packed = await this.segments?.readDelta(gen) - if (packed) { - const d = packed.delta as GenerationDelta - const entry = { - nouns: new Set(d.nouns), - verbs: new Set(d.verbs), - timestamp: packed.timestamp, - bytes: d.bytes ?? 0 - } - this.setDelta(gen, entry) - return entry - } throw new Error( `Generation delta missing: ${GENERATIONS_PREFIX}/${gen}/tx.json ` + `(store corrupted or records removed outside compactHistory())` @@ -3152,107 +2213,6 @@ export class GenerationStore { * @param options - Retention caps (see {@link CompactHistoryOptions}). * @returns Count of removed record-sets and the new horizon. */ - /** - * @description The REPACKER (D1+D3+repacking): fold cold live-tier - * generations into sealed segments — re-representation, never deletion. - * Every record and delta stays readable (asOf/chains unchanged); the - * per-generation directories are deleted only AFTER their segment is - * durable (crash between = duplicate representation, resolved - * live-tier-wins by every reader; never a gap). This is the transform that - * takes a 70k-file history to tens of segment files, and the ONLY history - * transform permitted under the archival profile. - * - * Folds oldest-first, contiguous from the packed boundary, in batches, and - * stops at the live window ({@link GenerationStore.REPACK_LIVE_WINDOW}) - * or when `timeBudgetMs` is spent — an early stop is a consistent prefix; - * the next pass resumes. - */ - async repackHistory(options?: { timeBudgetMs?: number; batchGenerations?: number }): Promise<{ - foldedGenerations: number - segmentsCreated: number - }> { - if (!this.segments) return { foldedGenerations: 0, segmentsCreated: 0 } - const segments = this.segments - return this.withMutex(async () => { - const deadline = - options?.timeBudgetMs !== undefined ? Date.now() + options.timeBudgetMs : undefined - const batchSize = options?.batchGenerations ?? 512 - const coldCeiling = this.committed - GenerationStore.REPACK_LIVE_WINDOW - const packedThrough = - segments.segments().length > 0 - ? segments.segments()[segments.segments().length - 1].lastGeneration - : 0 - - // Cold, unpacked, committed generations — ascending, contiguous scan. - const eligible: number[] = [] - for (const gen of this.committedGensAsc()) { - if (gen > coldCeiling) break - if (gen <= packedThrough) continue // already packed (dup fold barred) - if (this.pendingBuffer.has(gen)) continue // un-flushed = live by definition - eligible.push(gen) - } - - let folded = 0 - let segmentsCreated = 0 - for (let i = 0; i < eligible.length; i += batchSize) { - if (deadline !== undefined && Date.now() >= deadline) break - const batch = eligible.slice(i, i + batchSize) - const foldInput: FoldGeneration[] = [] - for (const gen of batch) { - const delta = (await this.storage.readRawObject( - `${GENERATIONS_PREFIX}/${gen}/tx.json` - )) as GenerationDelta | null - if (delta === null) { - // Already folded by a prior crashed pass whose dirs were removed, - // or damage — getDelta's two-tier read decides which, loudly, - // when someone asks. Skip; never fold a generation we cannot read. - continue - } - const records: FoldGeneration['records'] = [] - for (const [kind, ids] of [ - ['noun', delta.nouns] as const, - ['verb', delta.verbs] as const - ]) { - for (const id of ids) { - const record = await this.storage.readRawObject( - `${GENERATIONS_PREFIX}/${gen}/prev/${id}.json` - ) - if (record) records.push({ kind, id, record }) - } - } - foldInput.push({ generation: gen, timestamp: delta.timestamp, delta, records }) - } - if (foldInput.length === 0) continue - // 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 - } - } - if (folded > 0) { - prodLog.info( - `[GenerationStore] repacked ${folded} cold generation(s) into ${segmentsCreated} segment(s) — history preserved, file count reduced` - ) - } - return { foldedGenerations: folded, segmentsCreated } - }) - } - async compact(options?: CompactHistoryOptions): Promise { return this.withMutex(async () => { const minPinned = this.minPinnedGeneration() @@ -3344,16 +2304,6 @@ export class GenerationStore { // Reclaimed generations leave the per-id chains stale → rebuild on next read. this.invalidateChains() this.horizonGen = Math.max(this.horizonGen, highestRemoved) - // Packed-tier reclaim (D3): a packed generation's bytes live in a - // sealed segment — removeRawPrefix above was a no-op for it. Drop - // WHOLE segments now fully below the horizon; a partially-reclaimed - // segment keeps its bytes until the boundary passes it (the frozen - // partial-segments-wait rule; logical reclamation above still holds — - // the generations left committedRanges and asOf below the horizon - // throws regardless). - if (this.segments) { - await this.segments.dropSegmentsBelow(this.horizonGen + 1) - } const manifest: GenerationManifest = { version: 1, generation: this.committed, @@ -3380,28 +2330,6 @@ export class GenerationStore { * are never reissued. * @param floorGeneration - The counter value before the restore. */ - /** - * @description Run a wholesale state replacement (the restore swap) - * EXCLUSIVELY: under the commit mutex, with the pending flush timer - * disarmed and the pending tier + fold-checkpoint accumulator discarded - * FIRST — so no background flush can write into `_system/` while the - * replacement is removing and swapping directories. Observed without this: - * a checkpoint stamp raced restore's directory removal and the swap died - * ENOTEMPTY mid-flight. The discarded in-memory state describes the store - * being replaced — `reopenAfterRestore` (which the caller runs next) - * rebuilds everything from the restored bytes. - */ - async runStateReplacement(replace: () => Promise): Promise { - return this.withMutex(async () => { - this.clearPendingFlushTimer() - this.pendingGens = [] - this.pendingBuffer.clear() - this.checkpointDirtyNouns = new Set() - this.checkpointDirtyVerbs = new Set() - await replace() - }) - } - async reopenAfterRestore(floorGeneration: number): Promise { await this.withMutex(async () => { this.deltaCache.clear() @@ -3414,27 +2342,6 @@ export class GenerationStore { this.clearPendingFlushTimer() this.pendingGens = [] this.pendingBuffer.clear() - // The fold-checkpoint accumulator described the replaced state too. - this.checkpointDirtyNouns = new Set() - this.checkpointDirtyVerbs = new Set() - this.foldCheckpointChainValid = false - this.foldCheckpoint = 0 - // A RESTORE IS AN UNCLEAN EVENT, by construction: the snapshot's files - // were just bulk-copied WITHOUT per-file fsync, so a power cut here can - // tear them — yet the snapshot may CARRY the source brain's - // clean-shutdown marker and fold checkpoint, which would together - // suppress exactly the recovery fold that cures such a tear. Delete - // both BEFORE reopening: the open below then treats the store as - // uncleanly shut, folds the restored log into canonical, barrier-syncs - // what it re-applied, and stamps a FRESH checkpoint — the restored - // state becomes durably founded at restore time instead of inheriting - // the source brain's assertions about bytes this disk never synced. - try { - await this.storage.deleteRawObject(CLEAN_SHUTDOWN_PATH) - } catch { /* absent is fine — same outcome */ } - try { - await this.storage.deleteRawObject(FOLD_CHECKPOINT_PATH) - } catch { /* absent is fine — fold from 0 */ } this.opened = false // open() re-reads counter/manifest and re-registers the bump hook. await this.open() @@ -3492,8 +2399,6 @@ export class GenerationStore { private async rollBackUncommittedGeneration(gen: number): Promise { const dir = `${GENERATIONS_PREFIX}/${gen}` const prevPaths = await this.storage.listRawObjects(`${dir}/prev`) - const restoredNouns: string[] = [] - const restoredVerbs: string[] = [] for (const recordPath of prevPaths) { const id = recordIdFromPath(recordPath) if (id === null) continue @@ -3502,20 +2407,10 @@ export class GenerationStore { const image = { metadata: record.metadata, vector: record.vector } if (record.kind === 'verb') { await this.storage.writeVerbRaw(id, image) - restoredVerbs.push(id) } else { await this.storage.writeNounRaw(id, image) - restoredNouns.push(id) } } - // Make the restores durable IMMEDIATELY (this runs at open, before the - // fold-checkpoint chain state is even read): a restored before-image - // replaces bytes a stored checkpoint may already vouch for, so it must - // reach disk with the same certainty — otherwise a power cut could let - // the rolled-back write's bytes resurrect past a bounded fold. - if (restoredNouns.length > 0 || restoredVerbs.length > 0) { - await this.storage.syncEntityCanonical?.(restoredNouns, restoredVerbs) - } await this.storage.removeRawPrefix(dir) prodLog.warn( `[GenerationStore] rolled back uncommitted generation ${gen} ` + diff --git a/src/db/logAuthority.ts b/src/db/logAuthority.ts deleted file mode 100644 index b63ae715..00000000 --- a/src/db/logAuthority.ts +++ /dev/null @@ -1,341 +0,0 @@ -/** - * @module db/logAuthority - * @description The per-brain LOG-AUTHORITY SWITCH and its verification - * oracle — the guarded adoption path for log-canonical storage. - * - * Two storage authorities exist during the adoption window: - * - `'tree'` (the default, today's behavior): the canonical record tree is - * authoritative; the generation log is a complete dual-written journal. - * - `'log'`: the generation log is authoritative for this brain; single-op - * write acks await a covering log fsync (durable-at-ack), and derived - * state treats the log as ground truth. - * - * THE SWITCH IS PER BRAIN, STORED, CHECKED AT OPEN ONLY, and ONE-DIRECTIONAL - * unless explicitly reverted by an operator. A brain flips ONLY when its - * verification oracle is green: a full replay-and-diff of the log against - * the still-authoritative tree (the read-only witness). The oracle failing - * NAMES every divergence — a brain with pre-log history (records the log - * never saw) reports them as `pre-log-record` mismatches and needs a - * baseline backfill before it can ever flip. - * - * Nothing in this module mutates data: the oracle is read-only; the flip - * writes ONE artifact. Reverting = rewriting the artifact to 'tree' (the - * tree remained authoritative-quality throughout the window by dual-write). - */ - -import type { FactScanHandle } from './factLog.js' -import { prodLog } from '../utils/logger.js' -import { createHash } from 'crypto' - -/** Storage-root-relative path of the authority switch artifact. */ -export const LOG_AUTHORITY_PATH = '_system/log-authority.json' - -/** The persisted shape of the authority switch. */ -export interface LogAuthorityRecord { - /** Which store is authoritative for this brain. */ - authority: 'tree' | 'log' - /** When the flip happened (ms epoch). Absent while authority = 'tree'. */ - flippedAt?: number - /** The oracle verdict that justified the flip (summary, not the full report). */ - oracle?: { - verifiedAt: number - generationsScanned: number - nounsChecked: number - verbsChecked: number - } - /** - * Recorded when an OPEN-TIME adoption attempt (the 10.0.0 fleet default) - * was refused — the oracle could not go green. Keeps subsequent opens - * cheap; an operator re-runs adoptLogAuthority() after resolving it. - */ - adoptRefusal?: { at: number; reason: string } -} - -/** The narrow storage surface this module needs. */ -export interface LogAuthorityStorage { - readRawObject(path: string): Promise - writeRawObject(path: string, data: unknown): Promise - syncRawObjects(paths: string[]): Promise - getNouns(opts: { - pagination: { limit: number; offset?: number; cursor?: string } - }): Promise<{ items: unknown[]; hasMore?: boolean; nextCursor?: string }> - getNounMetadata(id: string): Promise -} - -/** One divergence found by the oracle. */ -export interface OracleMismatch { - id: string - kind: 'noun' | 'verb' - reason: - | 'pre-log-record' // canonical row the log never saw — needs baseline backfill - | 'state-differs' // latest log after-image ≠ canonical bytes - | 'log-live-canonical-absent' // log says live, canonical has no record - | 'log-tombstone-canonical-present' // log says deleted, canonical still has it -} - -/** The oracle's full report. */ -export interface OracleReport { - verdict: 'green' | 'red' - generationsScanned: number - nounsChecked: number - verbsChecked: number - matched: number - mismatches: OracleMismatch[] - /** Mismatch listing is capped; the counts above are always complete. */ - mismatchListTruncated: boolean -} - -const MISMATCH_LIST_CAP = 200 - -/** Read the stored authority (absent artifact = 'tree', the safe default). */ -export async function readLogAuthority( - storage: Pick -): Promise { - const raw = (await storage - .readRawObject(LOG_AUTHORITY_PATH) - .catch(() => null)) as LogAuthorityRecord | null - if (raw && (raw.authority === 'log' || raw.authority === 'tree')) return raw - return { authority: 'tree' } -} - -/** - * Normalize a canonical noun record to its ENTITY TRUTH before diffing: - * the canonical vector-file wrapper denormalizes derived index residue - * (`connections` — HNSW graph edges; `level` — the node's random skip-list - * level) that the generation log deliberately does NOT carry (projections - * own their own rebuild paths). Digesting the residue would report false - * `state-differs` on ~any brain whose HNSW assigned a nonzero level. Both - * sides of every oracle comparison pass through this normalizer. - */ -export function nounEntityTruth(record: { - metadata: unknown - vector: unknown -}): { metadata: unknown; vector: unknown } { - const v = record.vector - if (v && typeof v === 'object' && !Array.isArray(v)) { - const { connections: _c, level: _l, ...entity } = v as Record - return { metadata: record.metadata, vector: entity } - } - return { metadata: record.metadata, vector: v } -} - -/** - * Stable content hash of a stored record for diffing — key-sorted JSON so - * property order can never fake a divergence. - */ -export function recordDigest(record: unknown): string { - const stable = (v: unknown): unknown => { - if (Array.isArray(v)) return v.map(stable) - if (v && typeof v === 'object') { - const out: Record = {} - for (const k of Object.keys(v as Record).sort()) { - out[k] = stable((v as Record)[k]) - } - return out - } - return v - } - return createHash('sha256').update(JSON.stringify(stable(record))).digest('hex') -} - -/** - * THE VERIFICATION ORACLE: replay the fact log's noun records and diff the - * final state per id against the canonical tree (the witness). Read-only; - * bounded memory (id → {tombstoned, digest} — digests, never bodies). - * - * Verdict law: 'green' iff EVERY canonical row's latest state is exactly - * reproduced by the log AND the log claims nothing canonical denies. A - * brain older than its log reports its unlogged rows as `pre-log-record` - * mismatches — the named cure is a baseline backfill, never a silent pass. - */ -export async function runLogCompletenessOracle(args: { - storage: LogAuthorityStorage - scanFacts: () => FactScanHandle | null - /** Digest the canonical record the same way the log's after-image is digested. */ - canonicalNounDigest: (id: string) => Promise - /** Digest a log after-image record's payload. */ - factRecordDigest: (record: unknown) => string - /** - * Verb legs (optional until every owner wires them): the canonical verb - * digest + the paged verb enumeration. When ABSENT, the oracle counts NO - * verbs and says so via verbsChecked = 0 — an honest partial verdict, - * never a silent full-pass claim. - */ - canonicalVerbDigest?: (id: string) => Promise - getVerbs?: (opts: { - pagination: { limit: number; offset?: number; cursor?: string } - }) => Promise<{ items: unknown[]; hasMore?: boolean; nextCursor?: string }> - /** - * Cap on the LISTED mismatches (counts are always complete). Defaults to - * the wire-friendly {@link MISMATCH_LIST_CAP}; the adoption backfill passes - * `Infinity` so ONE scan yields the ENTIRE curable set — a production - * brain with a 12.7k-row pre-log baseline once advanced only 800 rows per - * adoption call because each pass could see (and cure) at most 200. - */ - mismatchListCap?: number -}): Promise { - const listCap = args.mismatchListCap ?? MISMATCH_LIST_CAP - const report: OracleReport = { - verdict: 'red', - generationsScanned: 0, - nounsChecked: 0, - verbsChecked: 0, - matched: 0, - mismatches: [], - mismatchListTruncated: false - } - const addMismatch = (m: OracleMismatch): void => { - if (report.mismatches.length < listCap) report.mismatches.push(m) - else report.mismatchListTruncated = true - } - - // Pass 1: fold the log — latest state per noun id (digest or tombstone). - const scan = args.scanFacts() - if (!scan) { - // No fact log on this store: nothing can be verified — red, loudly. - prodLog.warn('[logAuthority] oracle: this store has no fact log — cannot verify, verdict red') - return report - } - const logState = new Map() - const verbLogState = new Map() - for await (const batch of scan.batches()) { - for (const fact of batch.facts) { - report.generationsScanned++ - for (const op of fact.ops) { - const state = - op.record === null - ? { tombstoned: true, digest: null } - : { tombstoned: false, digest: args.factRecordDigest(op.record) } - if (op.kind === 'noun') logState.set(op.id, state) - else verbLogState.set(op.id, state) - } - } - } - - // Pass 2: walk canonical (paged) and diff. - const seenCanonical = new Set() - const PAGE = 500 - let offset = 0 - let cursor: string | undefined - for (;;) { - const page = await args.storage.getNouns({ - pagination: cursor ? { limit: PAGE, cursor } : { limit: PAGE, offset } - }) - for (const item of page.items) { - const id = (item as { id: string }).id - seenCanonical.add(id) - report.nounsChecked++ - const inLog = logState.get(id) - if (!inLog) { - addMismatch({ id, kind: 'noun', reason: 'pre-log-record' }) - continue - } - if (inLog.tombstoned) { - addMismatch({ id, kind: 'noun', reason: 'log-tombstone-canonical-present' }) - continue - } - const canonicalDigest = await args.canonicalNounDigest(id) - if (canonicalDigest === null) { - addMismatch({ id, kind: 'noun', reason: 'pre-log-record' }) - continue - } - if (canonicalDigest === inLog.digest) report.matched++ - else addMismatch({ id, kind: 'noun', reason: 'state-differs' }) - } - if (!page.hasMore || page.items.length === 0) break - if (page.nextCursor) cursor = page.nextCursor - else offset += page.items.length - } - - // Pass 3: log-live ids canonical never showed us. - for (const [id, state] of logState) { - if (!state.tombstoned && !seenCanonical.has(id)) { - addMismatch({ id, kind: 'noun', reason: 'log-live-canonical-absent' }) - } - } - - // Verb passes — only when the owner wired the verb legs; otherwise the - // report says verbsChecked: 0, an honest partial scope, never a claim. - if (args.canonicalVerbDigest && args.getVerbs) { - const seenVerbs = new Set() - let vOffset = 0 - let vCursor: string | undefined - for (;;) { - const page = await args.getVerbs({ - pagination: vCursor ? { limit: PAGE, cursor: vCursor } : { limit: PAGE, offset: vOffset } - }) - for (const item of page.items) { - const id = (item as { id: string }).id - seenVerbs.add(id) - report.verbsChecked++ - const inLog = verbLogState.get(id) - if (!inLog) { - addMismatch({ id, kind: 'verb', reason: 'pre-log-record' }) - continue - } - if (inLog.tombstoned) { - addMismatch({ id, kind: 'verb', reason: 'log-tombstone-canonical-present' }) - continue - } - const canonical = await args.canonicalVerbDigest(id) - if (canonical === null) { - addMismatch({ id, kind: 'verb', reason: 'pre-log-record' }) - continue - } - if (canonical === inLog.digest) report.matched++ - else addMismatch({ id, kind: 'verb', reason: 'state-differs' }) - } - if (!page.hasMore || page.items.length === 0) break - if (page.nextCursor) vCursor = page.nextCursor - else vOffset += page.items.length - } - for (const [id, state] of verbLogState) { - if (!state.tombstoned && !seenVerbs.has(id)) { - addMismatch({ id, kind: 'verb', reason: 'log-live-canonical-absent' }) - } - } - } - - const totalMismatches = - report.mismatches.length + (report.mismatchListTruncated ? 1 : 0) - report.verdict = totalMismatches === 0 ? 'green' : 'red' - return report -} - -/** - * Flip this brain's authority to the log — REFUSES unless the supplied - * oracle report is green (the caller runs the oracle; the flip records its - * summary). Writes + fsyncs the switch artifact; the mode takes full effect - * at the NEXT open (checked-at-open-only law), except durable-at-ack which - * the owner may enable immediately. - */ -export async function flipToLogAuthority( - storage: Pick, - oracle: OracleReport -): Promise { - if (oracle.verdict !== 'green') { - throw new Error( - `log-authority flip refused: the verification oracle is RED ` + - `(${oracle.mismatches.length}${oracle.mismatchListTruncated ? '+' : ''} mismatches; ` + - `first: ${oracle.mismatches[0] ? `${oracle.mismatches[0].reason} on ${oracle.mismatches[0].id}` : 'n/a'}). ` + - `A brain flips only on green — fix the divergences (pre-log records need a baseline backfill) and re-run.` - ) - } - const record: LogAuthorityRecord = { - authority: 'log', - flippedAt: Date.now(), - oracle: { - verifiedAt: Date.now(), - generationsScanned: oracle.generationsScanned, - nounsChecked: oracle.nounsChecked, - verbsChecked: oracle.verbsChecked - } - } - await storage.writeRawObject(LOG_AUTHORITY_PATH, record) - await storage.syncRawObjects([LOG_AUTHORITY_PATH]) - prodLog.info( - `[logAuthority] this brain's storage authority is now the generation log ` + - `(oracle green over ${oracle.nounsChecked} nouns / ${oracle.generationsScanned} generations)` - ) - return record -} diff --git a/src/db/portableGraph.ts b/src/db/portableGraph.ts index f3134325..d8f57b78 100644 --- a/src/db/portableGraph.ts +++ b/src/db/portableGraph.ts @@ -27,7 +27,7 @@ import { Entity, Relation, Result } from '../types/brainy.types.js' import { NounType, VerbType } from '../types/graphTypes.js' -import { StorageAdapter, HNSWVerbWithMetadata } from '../coreTypes.js' +import { StorageAdapter } from '../coreTypes.js' import { getBrainyVersion } from '../utils/version.js' import { TxOperation } from './types.js' @@ -94,85 +94,8 @@ export interface ExportOptions { includeContent?: boolean /** Include `visibility:'system'` entities (e.g. the VFS root) (default: false). */ includeSystem?: boolean - /** - * Admit BOTH hidden visibility tiers — `'internal'` AND `'system'` — into the - * whole-brain/predicate candidate set, in EITHER `enumeration` mode (default: - * false — today's behavior is byte-identical). `includeSystem` alone only ever - * reached `'system'` for a structural selector's per-entity gate; whole-brain/ - * predicate enumeration never forwarded it into the candidate walk at all, so a - * hidden-tier row could never survive a whole-brain export regardless of any - * flag — the gap this option closes. `includeHidden: true` IMPLIES - * `includeSystem: true` (both tiers are admitted together; there is no - * "system but not internal" combination via this flag) — `includeSystem` on - * its own keeps its narrower, pre-existing meaning for back-compat. - * - * Migration-grade exports set `includeHidden: true` — a complete-canon export - * must carry every visibility tier; consumer-facing exports leave it off. - */ - includeHidden?: boolean /** Which edges to include (default: `'induced'`). */ edges?: 'induced' | 'incident' | 'none' - /** - * How the whole-brain / predicate selector (no `ids`/`collection`/`connected`/ - * `vfsPath`) resolves its candidate id set: - * - * - `'index'` (DEFAULT — unchanged behavior) — the generation-correct - * paginated `find()` walk. Fast (O(matches), not O(N)) but rides the - * metadata index as an acceleration structure: a lost/stale index posting - * can silently OMIT a canonical record, and a stale posting pointing at a - * record that no longer matches can silently produce a phantom (dropped - * later by the same predicate re-check `'canonical'` mode also runs, so - * phantoms never reach `entities` — but they ARE lost silently unless - * {@link reportIndexDrift} is set). - * - `'canonical'` — walks every live noun/verb directly off the storage - * adapter's canonical shard layout (`storage.getNouns()`/`getVerbs()` — - * the same primitive `repairIndex()`'s recount and every index-heal walk - * use), then applies the selector as a plain predicate over the walked - * records. This GUARANTEES canon-completeness — the metadata/graph - * indexes are never consulted, so their corruption cannot hide a live - * record — at the cost of an O(N) walk regardless of selector - * selectivity (unlike the index path's O(matches)). Structural selectors - * (`ids`/`collection`/`connected`/`vfsPath`) resolve their node set - * exactly as in `'index'` mode either way (they never rode the metadata - * index); `'canonical'` additionally walks relations canonically for - * EVERY selector, since a lost adjacency-index posting can hide a - * relation regardless of how the node set was produced. Requires a - * storage adapter and the CURRENT generation — throws on a historical - * `asOf()` view or a speculative `with()` overlay (see - * {@link CanonicalEnumerationUnavailableError}), because the canonical - * walk has no notion of "as of a past generation." - */ - enumeration?: 'index' | 'canonical' - /** - * Only meaningful with `enumeration:'canonical'` (ignored otherwise): ALSO - * run the `'index'` enumeration in parallel and diff it against the - * canonical ground truth, attaching the result as {@link PortableGraph.drift}. - * Never auto-heals anything — this is migration-audit evidence, reported - * loudly (`console.warn` with the counts) whenever either list is non-empty, - * never silently. Default: false. - */ - reportIndexDrift?: boolean -} - -/** - * @description Index-vs-canonical drift for one `export({ enumeration: 'canonical', - * reportIndexDrift: true })` call. Only populated for the whole-brain / predicate - * selector (structural selectors never consulted the metadata index for their node - * set, so there is nothing to diff — both lists are empty for those). - */ -export interface ExportIndexDrift { - /** - * Ids the canonical storage walk confirmed (live, selector-matching) that the - * index-based `find()` enumeration did NOT return — canonical records the - * metadata index has lost track of. - */ - canonicalOnly: string[] - /** - * Ids the index-based `find()` enumeration returned for this selector that - * canonical ground truth (the storage walk + the same predicate check) does - * NOT support — phantom index rows (stale or cross-bucket postings). - */ - indexOnly: string[] } /** Controls how a `PortableGraph` is applied on `import()`. */ @@ -228,8 +151,6 @@ export interface PortableGraph { relations: PortableGraphRelation[] blobs?: Record danglingIds?: string[] - /** Present only when `export()` was called with `reportIndexDrift: true`. */ - drift?: ExportIndexDrift stats: { entityCount: number; relationCount: number; blobCount: number; vectorDimensions?: number } } @@ -350,19 +271,11 @@ export function validatePortableGraph(data: unknown): PortableGraphValidation { /** * @description Serialize part or all of a graph (read through `reader` at its pinned * generation) into a portable `PortableGraph` document. - * - * `enumeration:'canonical'` (see {@link ExportOptions.enumeration}) requires a - * storage adapter and the current generation: it throws - * {@link CanonicalEnumerationUnavailableError} if `storage` is absent, and the - * caller (`Db.export()`) throws the same error before this runs if the view is - * historical or a speculative overlay — the canonical storage walk has no - * generation parameter, so it can only ever answer "as of right now." - * * @param reader - Generation-correct read surface (`Db` or `Brainy`). - * @param storage - Storage adapter (VFS blob bytes when `includeContent`; the - * canonical noun/verb walk when `enumeration:'canonical'`). + * @param storage - Storage adapter (used only for VFS blob bytes when `includeContent`). * @param selector - WHAT to export (omit for the whole brain). - * @param options - HOW to export (vectors / file bytes / edge policy / enumeration mode). + * @param options - HOW to export (vectors / file bytes / edge policy). + * @param dimensions - Embedding dimensionality for the manifest. */ export async function exportGraph( reader: PortableGraphReader, @@ -374,81 +287,28 @@ export async function exportGraph( includeVectors = false, includeContent = false, includeSystem = false, - includeHidden = false, - edges = 'induced', - enumeration = 'index', - reportIndexDrift = false + edges = 'induced' } = options - if (enumeration === 'canonical' && !storage) { - throw new Error( - `export(): enumeration:'canonical' requires a storage adapter, but none was supplied ` + - `to this reader. Use enumeration:'index' (the default), or export through a Db/Brainy ` + - `that carries its storage adapter.` - ) - } - const wantDrift = enumeration === 'canonical' && reportIndexDrift - // includeHidden IMPLIES includeSystem (see ExportOptions.includeHidden's JSDoc) — every - // system-tier gate below reads THIS combined value, never the raw option, so - // `includeHidden` alone is always sufficient to see system-tier rows too. - const effectiveIncludeSystem = includeSystem || includeHidden - - // 1. Resolve the node-id set (+ the index's raw candidate set, only when diffing it). - // Both `enumerateAllCanonical` and `enumerateAllIndexed` receive the SAME - // `effectiveIncludeSystem`/`includeHidden` pair below, so a drift diff can never - // contain tier-policy noise — only genuine index-vs-canonical disagreement. - const { idSet, indexCandidateIds } = await resolveSelector( - reader, - storage, - selector, - effectiveIncludeSystem, - enumeration, - wantDrift, - includeHidden - ) + // 1. Resolve the node-id set. + const idSet = await resolveSelector(reader, selector, includeSystem) // 2. Read canonical entities (reserved fields top-level), applying any predicate filter. - // Identical for both enumeration modes: 'canonical' only changes WHICH ids reach this - // loop, never how a candidate is verified — so the two modes can disagree on candidacy, - // never on what counts as a match. const usePredicate = hasPredicate(selector) const entityMap = new Map>() const entities: PortableGraphEntity[] = [] for (const id of idSet) { const e = await reader.get(id, { includeVectors }) if (!e) continue - if (!effectiveIncludeSystem && (e as any).visibility === 'system') continue + if (!includeSystem && (e as any).visibility === 'system') continue if (usePredicate && !matchesPredicate(e, selector)) continue entityMap.set(id, e) entities.push(toPortableGraphEntity(e, includeVectors)) } const keptIds = new Set(entityMap.keys()) - // 2b. Finalize the drift report now that ground truth (keptIds) is known. - let drift: ExportIndexDrift | undefined - if (wantDrift) { - const indexIds = indexCandidateIds ?? new Set() - const canonicalOnly = [...keptIds].filter((id) => !indexIds.has(id)) - const indexOnly = [...indexIds].filter((id) => !keptIds.has(id)) - drift = { canonicalOnly, indexOnly } - if (canonicalOnly.length > 0 || indexOnly.length > 0) { - console.warn( - `[Brainy] export() index drift: ${canonicalOnly.length} canonical-only id(s) ` + - `(canon-present, the index-based enumeration missed them) and ${indexOnly.length} ` + - `index-only id(s) (index-visible, canon-absent — phantom rows). ` + - `See the returned PortableGraph's 'drift' field for the exact ids. Nothing was ` + - `auto-healed — run brain.repairIndex() to reconcile the metadata index.` - ) - } - } - - // 3. Edges per policy. Canonical mode ALSO walks verbs canonically for every - // selector (not just whole-brain) — a lost adjacency-index posting can hide a - // relation regardless of how the node set was produced. - const { relations, danglingIds } = - enumeration === 'canonical' - ? await collectEdgesCanonical(storage!, keptIds, edges, effectiveIncludeSystem, includeHidden) - : await collectEdges(reader, keptIds, edges, includeHidden) + // 3. Edges per policy. + const { relations, danglingIds } = await collectEdges(reader, keptIds, edges) // 4. VFS blob bytes (only when requested). let blobs: Record | undefined @@ -470,7 +330,6 @@ export async function exportGraph( relations, ...(blobs && blobCount > 0 ? { blobs } : {}), ...(danglingIds && danglingIds.length > 0 ? { danglingIds } : {}), - ...(drift ? { drift } : {}), stats: { entityCount: entities.length, relationCount: relations.length, @@ -618,36 +477,12 @@ function hasPredicate(s: ExportSelector): boolean { ) } -/** - * @param reader - Generation-correct read surface. - * @param storage - Storage adapter (only touched when `enumeration:'canonical'` - * resolves the whole-brain/predicate branch). - * @param s - The export selector. - * @param includeSystem - The ALREADY-COMBINED `includeSystem || includeHidden` value - * (see `exportGraph`'s `effectiveIncludeSystem`) — whether `visibility:'system'` - * entities are wanted. - * @param enumeration - `'index'` (default) or `'canonical'` — see {@link ExportOptions.enumeration}. - * Only affects the whole-brain/predicate branch (the `else` below): structural - * selectors (`ids`/`collection`/`connected`/`vfsPath`) never rode the metadata - * index for their node set, so they resolve identically either way. - * @param wantIndexCandidates - When true (only meaningful with `enumeration:'canonical'` - * on the whole-brain/predicate branch), ALSO run the index-based walk and return - * its raw candidate set as `indexCandidateIds`, for {@link ExportIndexDrift}. - * @param includeHidden - Whether `visibility:'internal'` entities are ALSO wanted - * (see {@link ExportOptions.includeHidden}). Threaded to BOTH enumeration - * functions identically so a drift diff never contains tier-policy noise. - */ async function resolveSelector( reader: PortableGraphReader, - storage: StorageAdapter | undefined, s: ExportSelector, - includeSystem: boolean, - enumeration: 'index' | 'canonical', - wantIndexCandidates: boolean, - includeHidden: boolean -): Promise<{ idSet: Set; indexCandidateIds?: Set }> { + includeSystem: boolean +): Promise> { let idSet: Set - let indexCandidateIds: Set | undefined if (s.ids && s.ids.length) { idSet = new Set(s.ids) } else if (s.collection ?? s.memberOf) { @@ -656,45 +491,20 @@ async function resolveSelector( idSet = await resolveConnected(reader, s.connected) } else if (s.vfsPath) { idSet = await resolveVfsPath(reader, s.vfsPath, s.recursive ?? true, s.depth) - } else if (enumeration === 'canonical') { - idSet = await enumerateAllCanonical(storage!, includeSystem, includeHidden) - if (wantIndexCandidates) indexCandidateIds = await enumerateAllIndexed(reader, s, includeHidden) } else { - idSet = await enumerateAllIndexed(reader, s, includeHidden) + idSet = await enumerateAll(reader, s) } if (!includeSystem) idSet.delete(VFS_ROOT_ID) - return { idSet, indexCandidateIds } + return idSet } -/** - * @description Whole-brain / predicate enumeration via generation-correct - * paginated `find()`. The metadata index is an acceleration structure over - * this candidate set — see {@link enumerateAllCanonical} for the storage-level - * counterpart that never consults it. - * - * @param includeHidden - When true, forwards `includeInternal: true` AND - * `includeSystem: true` into the SAME `find()` call — `find()` supports both - * flags simultaneously (confirmed via `FindParams.includeInternal`/`includeSystem` - * and `Brainy`'s `resolveHiddenIds`/`excludedVisibilityTiers`), so ONE pass - * reaches both hidden tiers; no per-tier union pass is needed. When false - * (default), neither flag is forwarded — the pre-existing behavior, preserved - * byte-identically for back-compat (`ExportOptions.includeSystem` alone never - * reached this far; see {@link ExportOptions.includeHidden}'s JSDoc). - */ -async function enumerateAllIndexed( - reader: PortableGraphReader, - s: ExportSelector, - includeHidden = false -): Promise> { +/** Whole-brain / predicate enumeration via generation-correct paginated `find()`. */ +async function enumerateAll(reader: PortableGraphReader, s: ExportSelector): Promise> { const params: any = {} if (s.type !== undefined) params.type = s.type if (s.subtype !== undefined) params.subtype = s.subtype if (s.where !== undefined) params.where = s.where if (s.service !== undefined) params.service = s.service - if (includeHidden) { - params.includeInternal = true - params.includeSystem = true - } const ids = new Set() let offset = 0 // eslint-disable-next-line no-constant-condition @@ -707,52 +517,6 @@ async function enumerateAllIndexed( return ids } -/** - * @description Canonical (storage-level) counterpart of {@link enumerateAllIndexed}: - * walks every live noun directly off the storage adapter's canonical shard layout - * (`storage.getNouns()` — the same primitive `repairIndex()`'s recount and every - * index-heal walk use) instead of going through the metadata index. Guarantees - * canon-completeness — a lost or stale metadata-index posting cannot cause a - * canonical record to be silently missing from the returned set — at the cost of - * an O(N) walk regardless of selector selectivity (unlike the index path's - * O(matches)). Returns the RAW candidate id set; `exportGraph`'s caller applies - * `matchesPredicate` per-entity via `reader.get()` afterward, exactly as the index - * path does, so both paths share one predicate-evaluation code path and can only - * disagree on candidacy, never on what a match means. - * - * Mirrors `find()`'s hidden-tier policy given the SAME `includeSystem`/`includeHidden` - * pair (see {@link enumerateAllIndexed}) so the two enumeration modes produce - * identical id sets when the index is healthy, at ANY tier-visibility setting. - * - * @param includeSystem - The ALREADY-COMBINED `includeSystem || includeHidden` value. - * @param includeHidden - Whether `'internal'`-tier nouns are ALSO admitted. - */ -async function enumerateAllCanonical( - storage: StorageAdapter, - includeSystem = false, - includeHidden = false -): Promise> { - const ids = new Set() - let offset = 0 - let cursor: string | undefined - // eslint-disable-next-line no-constant-condition - while (true) { - const page = await storage.getNouns({ pagination: { limit: ENUM_PAGE, offset, cursor } }) - for (const item of page.items) { - if (item.visibility === 'internal' && !includeHidden) continue - if (item.visibility === 'system' && !includeSystem) continue - ids.add(item.id) - } - if (!page.hasMore || page.items.length === 0) break - if (page.nextCursor !== undefined) { - cursor = page.nextCursor - } else { - offset += ENUM_PAGE - } - } - return ids -} - async function resolveCollectionSubtree( reader: PortableGraphReader, rootId: string, @@ -914,27 +678,19 @@ function toPortableGraphRelation(r: Relation): PortableGraphRelation { return br } -/** - * @param includeHidden - When true, forwards `includeInternal`/`includeSystem` into - * every `related()` call so hidden-tier relations reach candidacy too — mirrors - * {@link enumerateAllIndexed}'s `includeHidden` handling, and preserves back-compat - * when false/omitted (the pre-existing, unconditional hidden-tier exclusion). - */ async function collectEdges( reader: PortableGraphReader, idSet: Set, - edges: 'induced' | 'incident' | 'none', - includeHidden = false + edges: 'induced' | 'incident' | 'none' ): Promise<{ relations: PortableGraphRelation[]; danglingIds?: string[] }> { if (edges === 'none') return { relations: [] } - const tierOptIn = includeHidden ? { includeInternal: true, includeSystem: true } : {} const relations: PortableGraphRelation[] = [] const dangling = new Set() const seen = new Set() for (const id of idSet) { - const rels = await reader.related({ from: id, limit: RELATION_FETCH_LIMIT, ...tierOptIn }) + const rels = await reader.related({ from: id, limit: RELATION_FETCH_LIMIT }) for (const r of rels) { if (seen.has(r.id)) continue const toIn = idSet.has(r.to) @@ -947,7 +703,7 @@ async function collectEdges( if (edges === 'incident') { for (const id of idSet) { - const rels = await reader.related({ to: id, limit: RELATION_FETCH_LIMIT, ...tierOptIn }) + const rels = await reader.related({ to: id, limit: RELATION_FETCH_LIMIT }) for (const r of rels) { if (seen.has(r.id)) continue if (!idSet.has(r.from)) { @@ -962,81 +718,6 @@ async function collectEdges( return dangling.size > 0 ? { relations, danglingIds: Array.from(dangling) } : { relations } } -/** Converts a canonical verb record (as returned by `storage.getVerbs()`) into the wire shape. */ -function hnswVerbToPortableGraphRelation(v: HNSWVerbWithMetadata): PortableGraphRelation { - const br: PortableGraphRelation = { id: v.id, from: v.sourceId, to: v.targetId, type: v.verb as string } - if (v.subtype !== undefined) br.subtype = v.subtype - if (v.visibility !== undefined && v.visibility !== 'public') br.visibility = v.visibility - if (v.weight !== undefined) br.weight = v.weight - if (v.confidence !== undefined) br.confidence = v.confidence - if (v.metadata && Object.keys(v.metadata as any).length) br.metadata = v.metadata - return br -} - -/** - * @description Canonical (storage-level) counterpart of {@link collectEdges}: - * walks every live verb directly off `storage.getVerbs()` — the same primitive - * `repairIndex()`'s recount and every index-heal walk use — instead of the graph - * adjacency index (`reader.related()`), so a lost/stale adjacency posting cannot - * cause a canonical relationship to be silently dropped from the export. Used for - * EVERY selector in `enumeration:'canonical'` mode, not just the whole-brain - * branch: relations can be blinded by adjacency-index corruption regardless of - * how `idSet` (the kept node ids) was produced. - * - * Mirrors `related()`'s default hidden-tier policy (hides `'internal'` and - * `'system'` unless `includeHidden`/`includeSystem` say otherwise) so the two - * enumeration modes produce identical relation sets when the index is healthy. - * - * @param includeSystem - Whether `'system'`-tier verbs are admitted (the - * caller passes the ALREADY-combined `includeSystem || includeHidden` value — - * see `exportGraph`'s `effectiveIncludeSystem`). - * @param includeHidden - Whether `'internal'`-tier verbs are ALSO admitted. - */ -async function collectEdgesCanonical( - storage: StorageAdapter, - idSet: Set, - edges: 'induced' | 'incident' | 'none', - includeSystem = false, - includeHidden = false -): Promise<{ relations: PortableGraphRelation[]; danglingIds?: string[] }> { - if (edges === 'none') return { relations: [] } - - const relations: PortableGraphRelation[] = [] - const dangling = new Set() - const seen = new Set() - let offset = 0 - let cursor: string | undefined - - // eslint-disable-next-line no-constant-condition - while (true) { - const page = await storage.getVerbs({ pagination: { limit: ENUM_PAGE, offset, cursor } }) - for (const v of page.items) { - if (seen.has(v.id)) continue - if (v.visibility === 'internal' && !includeHidden) continue - if (v.visibility === 'system' && !includeSystem) continue - const fromIn = idSet.has(v.sourceId) - const toIn = idSet.has(v.targetId) - if (edges === 'induced') { - if (!fromIn || !toIn) continue - } else if (!fromIn && !toIn) { - continue // 'incident': neither endpoint kept — irrelevant to this export - } - if (fromIn && !toIn) dangling.add(v.targetId) - if (toIn && !fromIn) dangling.add(v.sourceId) - seen.add(v.id) - relations.push(hnswVerbToPortableGraphRelation(v)) - } - if (!page.hasMore || page.items.length === 0) break - if (page.nextCursor !== undefined) { - cursor = page.nextCursor - } else { - offset += ENUM_PAGE - } - } - - return dangling.size > 0 ? { relations, danglingIds: Array.from(dangling) } : { relations } -} - async function collectBlobs( storage: StorageAdapter | undefined, entityMap: Map> diff --git a/src/db/types.ts b/src/db/types.ts index 56bdef11..4c8a4957 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -121,13 +121,9 @@ export interface TransactOptions { * with the batch: `max(30 000, opCount × 2 000)` — production imports on * network-attached disks measure ~2 s per operation, so a flat 30 s budget * silently capped honest bulk work at ~15 operations. A tripped budget - * rolls the whole batch back and throws a `TransactionTimeoutError` naming - * the operation it stopped at, the batch size, and the elapsed/budget - * times. That error is retryable-with-latch, never hot-retry: its - * `retryable` field says a later attempt may succeed, its - * `hotRetryUnsafe` field says an immediate identical retry re-pays the - * full cost that just timed out — callers must latch and back off, never - * loop. + * rolls the whole batch back and throws a retryable + * `TransactionTimeoutError` naming the operation it stopped at, the batch + * size, and the elapsed/budget times. */ timeoutMs?: number } @@ -412,17 +408,6 @@ export interface TxLogEntry { timestamp: number /** Transaction metadata, when supplied to `transact()`. */ meta?: Record - /** - * WHO committed. Absent = a user write (every pre-existing consumer's - * reading stays exact). Engine-originated commits stamp themselves — - * `'system:embed-landing'` (the deferred vector landing), - * `'system:adoption-backfill'` (baseline re-commits), `'system:reconcile'` - * (the attested per-id divergence door) — so activity feeds can filter on - * fact instead of collapsing near-in-time entries (a consumer refused that - * heuristic as a quiet loss, correctly; this field is the honest cure). - * The same stamp rides the commit fact's meta, so log and tx-log agree. - */ - origin?: string } // ============================================================================ @@ -450,21 +435,6 @@ 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). */ @@ -488,28 +458,6 @@ export interface GenerationStorage { /** @see beginWriteBarrier — fsync every canonical write since begin. */ flushWriteBarrier?(): Promise - /** - * OPTIONAL fold-checkpoint durability barrier: make the listed entities' - * CANONICAL live objects durable — fsync each present metadata/vector file - * AND the parent directory entry of each absent one (so a delete is as - * durable as a write). The generation store may only advance the fold - * checkpoint (`_system/fold-checkpoint.json`) after this resolves; the - * checkpoint bounds crash recovery's log fold to `(checkpoint, head]`. - * Adapters whose writes are durable per-call may leave this undefined — - * the store then treats canonical durability as immediate. - */ - syncEntityCanonical?(nouns: string[], verbs: string[]): Promise - - /** - * OPTIONAL writer fence: throw `BRAINY_WRITER_FENCED` when this instance - * no longer owns the store's writer lock (an operator force-takeover or a - * removed lock file). Called at every flush commit and transact barrier — - * one small read per commit window — so an evicted writer fails loudly on - * its next commit instead of split-braining the store. Adapters without a - * cross-process lock model omit it. - */ - assertWriterFenceHeld?(): Promise - /** Read an entity's raw stored metadata+vector objects. */ readNounRaw(id: string): Promise<{ metadata: any | null; vector: any | null }> /** Restore an entity's raw stored objects (`null` part ⇒ delete that file). */ diff --git a/src/db/whereMatcher.ts b/src/db/whereMatcher.ts index 8dab02fd..c5469209 100644 --- a/src/db/whereMatcher.ts +++ b/src/db/whereMatcher.ts @@ -61,44 +61,41 @@ export class UnsupportedWhereOperatorError extends Error { * @returns The field's value, or `undefined` when absent. */ export function resolveEntityField(entity: Entity, field: string): unknown { - // THE ONE ADDRESSING LAW (sealed 2026-08-03): `system.` reads the - // entity scalar; bare and `metadata.`-prefixed names read the user's - // metadata bag (dotted paths traverse INSIDE the bag). The old bare-name - // switch over system fields is dead — bare `createdAt` is the user's own - // field now; the engine scalar is `system.createdAt`. Plumbing (vector, - // connections, level, data, _rev) is invisible: no spelling reaches it. - if (field.startsWith('system.')) { - switch (field.slice('system.'.length)) { - case 'type': - return entity.type - case 'subtype': - return entity.subtype - case 'id': - return entity.id - case 'createdAt': - return entity.createdAt - case 'updatedAt': - return entity.updatedAt - case 'service': - return entity.service - case 'createdBy': - return entity.createdBy - case 'confidence': - return entity.confidence - case 'weight': - return entity.weight - case 'visibility': - return (entity as unknown as Record).visibility - } - // Out-of-map system spelling: parse refuses these upstream with a typed - // error; reaching here (internal callers only) reads as absent. - return undefined + switch (field) { + case 'noun': + case 'type': + return entity.type + case 'subtype': + return entity.subtype + case 'id': + return entity.id + case 'createdAt': + return entity.createdAt + case 'updatedAt': + return entity.updatedAt + case 'service': + return entity.service + case 'createdBy': + return entity.createdBy + case 'confidence': + return entity.confidence + case 'weight': + return entity.weight + case '_rev': + return entity._rev + case 'data': + return entity.data } - const path = field.startsWith('metadata.') ? field.slice('metadata.'.length) : field - const bag = (entity.metadata ?? {}) as Record - if (!path.includes('.')) return bag[path] - return resolvePath(bag, path) + if (field.includes('.')) { + // Dotted path: resolve against the whole entity first (`metadata.x`), + // then against the metadata bag (`address.city` on nested metadata). + const fromEntity = resolvePath(entity as unknown as Record, field) + if (fromEntity !== undefined) return fromEntity + return resolvePath((entity.metadata ?? {}) as Record, field) + } + + return ((entity.metadata ?? {}) as Record)[field] } /** Walk a dotted path through nested plain objects. */ diff --git a/src/embeddings/wasm/modelLoader.ts b/src/embeddings/wasm/modelLoader.ts index b39d90ea..45ffc4d3 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/@soulcraftlabs/brainy/assets/models/all-MiniLM-L6-v2' + const nmPath = './node_modules/@soulcraft/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/@soulcraftlabs/brainy/assets/ alongside your binary\n' + + ' Option 1: Keep node_modules/@soulcraft/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/@soulcraftlabs/brainy/assets/**/*"' + ' Option 3: Use --asset flag: bun build --compile --asset="./node_modules/@soulcraft/brainy/assets/**/*"' ) } @@ -190,7 +190,7 @@ async function loadNodeAssets(): Promise { if (!fs.existsSync(assetsDir)) { throw new Error( `Model assets not found: ${assetsDir}\n` + - `Ensure @soulcraftlabs/brainy is installed correctly.` + `Ensure @soulcraft/brainy is installed correctly.` ) } diff --git a/src/errors/brainyError.ts b/src/errors/brainyError.ts index 2fbdbe8d..a58236e3 100644 --- a/src/errors/brainyError.ts +++ b/src/errors/brainyError.ts @@ -405,73 +405,3 @@ export class MigrationInProgressError extends BrainyError { } } } - -/** - * THE INDEXABLE-ARRAY BOUND. An array-valued metadata field indexes one posting - * per element, so an unbounded array is an unbounded write — a 384-float - * embedding parked in the metadata bag would mint 384 postings for one row. - * The bound exists to keep that out of the index. - * - * 256 is hardcoded on purpose (the zero-config law: no knob). It sits far above - * every legitimate multi-value field the engine has seen — tags, authors, - * categories, labels, keyword lists, participant lists — and still below the - * narrowest embedding this engine will ever meet (384 dimensions, the smallest - * model it ships), so the two populations do not overlap and no caller has to - * tune it. A vector parked in metadata is refused; a long keyword list is not. - * - * It replaces a limit of 10 that was applied SILENTLY: a row whose `tags` array - * held eleven entries had that field skipped entirely and dropped out of every - * filtered search on it, with no error, no warning and no way to tell the - * difference from "no row matches". A rule this consequential is a law with a - * name and a refusal, not a `continue`. - * - * This is the ONE place the number lives. Every message, warning, doc line and - * pin derives it from here — never a literal. - */ -export const MAX_INDEXED_ARRAY_LENGTH = 256 - -/** - * A metadata field carries an array longer than {@link MAX_INDEXED_ARRAY_LENGTH}. - * - * Thrown at the WRITE door (`add` / `update` / `relate` / `updateRelation`), so - * the caller learns at the moment of writing that the field will not be - * searchable — rather than discovering it later as rows that quietly fail to - * match. Carries the field, its length and the bound so a handler can report - * or repair without parsing the message. - * - * The cure is one of: store the long array outside the indexed bag (`data` - * carries arbitrary content and is not indexed element-wise); pass an embedding - * as the first-class `vector` parameter, which is where a vector belongs; or - * shorten the field to the values that are actually queried. - */ -export class MetadataArrayTooLargeError extends BrainyError { - /** The metadata field whose array is too long (its full dotted address). */ - public readonly field: string - /** How many elements that array holds. */ - public readonly length: number - /** The bound it exceeded — {@link MAX_INDEXED_ARRAY_LENGTH}. */ - public readonly limit: number - - constructor(site: string, field: string, length: number, limit: number) { - super( - `${site}: metadata field '${field}' holds ${length} array elements, ` + - `over the ${limit}-element indexing bound. An array field indexes one ` + - `posting per element, so an unbounded array is an unbounded write. ` + - `This write is refused rather than indexed partially or skipped silently ` + - `— a skipped field drops the row out of every filtered search on '${field}' ` + - `with no way to tell that from "nothing matched". ` + - `Cures: put the long array in 'data' (stored, not indexed element-wise); ` + - `pass an embedding as the first-class 'vector' parameter; or keep only ` + - `the values you actually query in '${field}'.`, - 'VALIDATION', - false - ) - this.name = 'MetadataArrayTooLargeError' - this.field = field - this.length = length - this.limit = limit - if (Error.captureStackTrace) { - Error.captureStackTrace(this, MetadataArrayTooLargeError) - } - } -} diff --git a/src/errors/notFound.ts b/src/errors/notFound.ts index 628797a6..8eca9b2e 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 (`@soulcraftlabs/brainy`). + * Both are exported from the package root (`@soulcraft/brainy`). */ /** diff --git a/src/graph/graphAdjacencyIndex.ts b/src/graph/graphAdjacencyIndex.ts index 2c131a30..b37391aa 100644 --- a/src/graph/graphAdjacencyIndex.ts +++ b/src/graph/graphAdjacencyIndex.ts @@ -27,22 +27,6 @@ import { UnifiedCache, getGlobalCache } from '../utils/unifiedCache.js' import { prodLog } from '../utils/logger.js' import { LSMTree } from './lsm/LSMTree.js' import type { GraphIndexProvider } from '../plugin.js' -import { - computeWatermarkVerdict, - makeProjectionStamp, - readStampedWatermark, - type WatermarkVerdict, - type WatermarkVerdictResult -} from '../utils/projectionWatermark.js' - -/** - * Storage key for the graph-adjacency projection's watermark stamp — a - * sidecar record beside the artifact (the two verb-id LSM trees' persisted - * SSTables + manifests). Written LAST in - * {@link GraphAdjacencyIndex.flush} / {@link GraphAdjacencyIndex.close} so - * stamp-after-data ordering holds for every byte the stamp certifies. - */ -export const GRAPH_ADJACENCY_STAMP_KEY = '__index_graph_adjacency_watermark__' export interface GraphIndexConfig { maxIndexSize?: number // Default: 100000 @@ -128,14 +112,6 @@ export class GraphAdjacencyIndex implements GraphIndexProvider { // Initialization flag private initialized = false - // --- Watermark stamp state (see utils/projectionWatermark for the law) --- - /** Generation handed in via {@link stampWatermark}, awaiting the next flush. */ - private pendingWatermark: number | null = null - /** Last watermark durably stamped by this instance or loaded at init. */ - private stampedWatermark: number | null = null - /** The three-way verdict computed at init; null until init runs. */ - private loadVerdict: WatermarkVerdictResult | null = null - /** * Check if index is initialized and ready for use */ @@ -265,135 +241,12 @@ export class GraphAdjacencyIndex implements GraphIndexProvider { await this.populateVerbIdSetFromStorage() } - // Watermark verdict for the persisted adjacency artifact (the LSM - // SSTables just loaded) — computed and exposed only: today's rebuild / - // recovery triggers are unchanged (acting on 'catchup' — the incremental - // fold — lands with the coordinator's wiring). - await this.loadWatermarkVerdict(lsmTreeSize > 0) - // Start auto-flush timer after initialization this.startAutoFlush() this.initialized = true } - /** - * @description Record the committed generation this projection reflects. - * The stamp is NOT written here — it is written as the final storage write - * of the next {@link flush} (or {@link close}), so stamp-after-data - * ordering is a module guarantee, not a caller obligation. The coordinator - * calls this with the store's committed generation right before flushing. - * @param generation - The committed generation every flushed byte reflects. - */ - stampWatermark(generation: number): void { - this.pendingWatermark = generation - } - - /** - * @description The projection's current watermark: the stamp loaded at - * init (or the last stamp durably written by this instance). Null = - * unstamped (legacy artifact, first boot, or stamping never wired). - */ - watermark(): number | null { - return this.stampedWatermark - } - - /** - * @description The three-way adoption verdict computed at init — - * `'adopt'` (stamped == committed, zero work), `'catchup'` (stamped < - * committed; the gap from {@link watermarkGap} awaits an incremental - * fold), `'rescan'` (unstamped or stamped above committed — never - * trusted). Null until init() has run. Computed and exposed only; no - * load behavior changes ride on it yet. - */ - watermarkVerdict(): WatermarkVerdict | null { - return this.loadVerdict?.verdict ?? null - } - - /** - * @description The catch-up window `(from, to]` when the init verdict was - * `'catchup'`; null otherwise. - */ - watermarkGap(): { from: number; to: number } | null { - return this.loadVerdict?.gap ?? null - } - - /** - * @description Write the pending watermark stamp as a sidecar record — - * always called AFTER the LSM flushes it certifies completed. A stamp-write - * failure is fail-safe (unstamped/behind → rescan/catchup on next open, - * never a wrong adopt) but is said out loud and the pending stamp is - * retained for the next flush. - */ - private async writePendingStamp(): Promise { - if (this.pendingWatermark === null) return - const watermark = this.pendingWatermark - try { - await this.storage.saveMetadata(GRAPH_ADJACENCY_STAMP_KEY, { - noun: 'IndexWatermark', - ...makeProjectionStamp(watermark) - }) - this.stampedWatermark = watermark - this.pendingWatermark = null - } catch (error) { - prodLog.error( - `[GraphAdjacencyIndex] failed to write watermark stamp (generation ${watermark}) — ` + - `artifact stays behind-stamped (safe: verdicts catchup/rescan, never wrong-adopt); ` + - `retrying on next flush:`, - error - ) - } - } - - /** - * @description Read the artifact's stamp and compute the three-way verdict - * against the store's committed generation. Unstamped state on a stamped - * store verdicts `'rescan'` LOUDLY — never a silent adopt. - * - * MIGRATION COST: existing pre-stamp brains verdict `'rescan'` exactly - * once (that open re-derives via the recovery walk it already runs); the - * next flush stamps them, and every later open adopts. - * - * @param artifactPresent - Whether persisted SSTables exist at all; gates - * loud-vs-quiet on the rescan verdict so first boots don't scream. - */ - private async loadWatermarkVerdict(artifactPresent: boolean): Promise { - const committed = this.storage.committedGeneration?.() ?? null - let stamped: number | null = null - try { - const record = await this.storage.getMetadata(GRAPH_ADJACENCY_STAMP_KEY) - stamped = readStampedWatermark(record) - } catch { - // An unreadable stamp is unstamped — the fail-safe direction. - stamped = null - } - const result = computeWatermarkVerdict(stamped, committed) - this.loadVerdict = result - this.stampedWatermark = stamped - - if (result.verdict === 'rescan') { - if (artifactPresent || stamped !== null) { - prodLog.warn( - `[GraphAdjacencyIndex] watermark verdict: RESCAN — persisted adjacency is ` + - (stamped === null - ? 'unstamped (legacy pre-stamp artifact, or a crash between data and stamp)' - : `stamped at generation ${stamped}, ABOVE the store's committed generation ${committed}`) + - ` — never adopting unverifiable state` - ) - } else { - prodLog.debug( - '[GraphAdjacencyIndex] watermark verdict: rescan (no persisted artifact — first boot)' - ) - } - } else if (result.verdict === 'catchup') { - prodLog.info( - `[GraphAdjacencyIndex] watermark verdict: catchup — adjacency stamped at generation ` + - `${stamped}, store committed at ${committed}; the (${stamped}, ${committed}] window ` + - `awaits an incremental fold (verdict exposed; the fold lands with the coordinator's wiring)` - ) - } - } - /** * Populate verbIdSet from storage without full rebuild * Lighter weight than full rebuild - only loads verb IDs, not all verb data @@ -1052,17 +905,6 @@ 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 — @@ -1093,43 +935,19 @@ export class GraphAdjacencyIndex implements GraphIndexProvider { }), ]) - // STAMP-AFTER-DATA: the watermark stamp is the LAST write of the flush — - // both trees' SSTables are durable before the stamp lands. A crash - // anywhere above leaves the artifact behind-stamped or unstamped, which - // verdicts as catchup/rescan on the next open — never a wrong adopt. - await this.writePendingStamp() - const elapsed = Date.now() - startTime prodLog.debug(`GraphAdjacencyIndex: Flush completed in ${elapsed}ms`) } /** - * Stop the auto-flush interval WITHOUT writing anything. - * - * The non-writing half of {@link close}, for a shutdown that must leave the - * store byte-identical — a read-only brain's close. `close()` itself is a - * writer: it drains both LSM MemTables to SSTables and stamps the watermark, - * which is exactly right for a writer and forbidden for a reader. A reader - * still has to release this interval, though: it is the one piece of this - * index that outlives the close and could fire against a store the session no - * longer owns. - * - * @returns Nothing. + * Clean shutdown */ - stopBackgroundFlush(): void { + async close(): Promise { if (this.flushTimer) { clearInterval(this.flushTimer) this.flushTimer = undefined } - } - - /** - * Clean shutdown — drains both trees and stamps the watermark. THIS WRITES; - * a read-only brain must call {@link stopBackgroundFlush} instead. - */ - async close(): Promise { - this.stopBackgroundFlush() // Close both LSM-trees (will flush MemTables to SSTables) if (this.initialized) { @@ -1137,10 +955,6 @@ export class GraphAdjacencyIndex implements GraphIndexProvider { this.lsmTreeVerbsBySource.close(), this.lsmTreeVerbsByTarget.close(), ]) - - // Stamp-after-data on the shutdown path too: the trees' final flushes - // completed above, so a pending watermark may land now. - await this.writePendingStamp() } prodLog.info('GraphAdjacencyIndex: Shutdown complete') diff --git a/src/graph/lsm/LSMTree.ts b/src/graph/lsm/LSMTree.ts index b4f6052f..e19ec145 100644 --- a/src/graph/lsm/LSMTree.ts +++ b/src/graph/lsm/LSMTree.ts @@ -687,17 +687,6 @@ 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 8b9badc1..eb2acd71 100644 --- a/src/hnsw/hnswIndex.ts +++ b/src/hnsw/hnswIndex.ts @@ -10,28 +10,12 @@ import { Vector, VectorDocument } from '../coreTypes.js' -import { euclideanDistance, calculateDistancesBatch, isZeroNormVector } from '../utils/index.js' +import { euclideanDistance, calculateDistancesBatch } from '../utils/index.js' import type { BaseStorage } from '../storage/baseStorage.js' import { getGlobalCache, UnifiedCache } from '../utils/unifiedCache.js' import { prodLog } from '../utils/logger.js' import type { VectorIndexProvider, OpaqueIdSet, AtGenerationVectors } from '../plugin.js' import { ConnectionsCodec, compressedConnectionsKey } from './connectionsCodec.js' -import { - computeWatermarkVerdict, - makeProjectionStamp, - readStampedWatermark, - type WatermarkVerdict, - type WatermarkVerdictResult -} from '../utils/projectionWatermark.js' - -/** - * Storage key for the JS HNSW projection's watermark stamp — a sidecar - * record beside the artifact (per-node vector-index records + connection - * blobs + the entryPoint/maxLevel system record). Written LAST in - * {@link JsHnswVectorIndex.flush} so stamp-after-data ordering holds for - * every byte the stamp certifies. - */ -export const HNSW_INDEX_STAMP_KEY = '__index_hnsw_watermark__' // Default HNSW parameters const DEFAULT_CONFIG: HNSWConfig = { @@ -64,34 +48,6 @@ 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 @@ -143,14 +99,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider { private dirtyNodes: Set = new Set() // Nodes with unpersisted HNSW data private dirtySystem: boolean = false // Whether system data (entryPoint, maxLevel) needs persist - // --- Watermark stamp state (see utils/projectionWatermark for the law) --- - /** Generation handed in via {@link stampWatermark}, awaiting the next flush. */ - private pendingWatermark: number | null = null - /** Last watermark durably stamped by this instance or loaded on rebuild. */ - private stampedWatermark: number | null = null - /** The three-way verdict computed at load; null until rebuild() runs. */ - private loadVerdict: WatermarkVerdictResult | null = null - // Lazy vector storage (B2 optimization): evict the float32 vector to // storage after insert; reload on demand via getVectorSafe() + UnifiedCache. private vectorStorageMode: 'memory' | 'lazy' = 'memory' @@ -222,9 +170,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider { } if (this.dirtyNodes.size === 0 && !this.dirtySystem) { - // Nothing dirty — but a pending watermark still stamps: every byte it - // certifies is already durable, so stamp-after-data holds trivially. - await this.writePendingStamp() return 0 } @@ -294,13 +239,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider { throw new HnswFlushError(failedNodes.size, systemFailed, firstError ?? undefined) } - // STAMP-AFTER-DATA: the watermark stamp is the LAST write of the flush — - // it lands only after every dirty node and the system record persisted - // (the throw above guarantees it). A crash anywhere earlier leaves the - // artifact behind-stamped or unstamped, which verdicts as catchup/rescan - // on the next open — never a wrong adopt. - await this.writePendingStamp() - if (nodeCount > 0) { prodLog.info(`[HNSW] Flushed ${nodeCount} dirty nodes in ${duration}ms`) } @@ -308,126 +246,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider { return nodeCount } - /** - * @description Record the committed generation this projection reflects. - * The stamp is NOT written here — it is written as the final storage write - * of the next {@link flush} (stamp-after-data ordering is a module - * guarantee, not a caller obligation). The coordinator calls this with the - * store's committed generation right before flushing. - * @param generation - The committed generation every flushed byte reflects. - */ - public stampWatermark(generation: number): void { - this.pendingWatermark = generation - } - - /** - * @description The projection's current watermark: the stamp loaded at - * rebuild (or the last stamp durably written by this instance). Null = - * unstamped (legacy artifact, first boot, or stamping never wired). - */ - public watermark(): number | null { - return this.stampedWatermark - } - - /** - * @description The three-way adoption verdict computed at load — - * `'adopt'` (stamped == committed, zero work), `'catchup'` (stamped < - * committed; the gap from {@link watermarkGap} awaits an incremental - * fold), `'rescan'` (unstamped or stamped above committed — never - * trusted). Null until rebuild() has run. Computed and exposed only; no - * load behavior changes ride on it yet — today's rebuild triggers are - * unchanged. - */ - public watermarkVerdict(): WatermarkVerdict | null { - return this.loadVerdict?.verdict ?? null - } - - /** - * @description The catch-up window `(from, to]` when the load verdict was - * `'catchup'`; null otherwise. - */ - public watermarkGap(): { from: number; to: number } | null { - return this.loadVerdict?.gap ?? null - } - - /** - * @description Write the pending watermark stamp as a sidecar record — - * always called AFTER the data it certifies is durable. The stamp carries - * the vector-space identity this module can honestly assert: dimensions - * only (no embedding-model id is reachable from the index — it never sees - * the embedder). A stamp-write failure is fail-safe (unstamped/behind → - * rescan/catchup on next open, never a wrong adopt) but is said out loud - * and the pending stamp is retained for the next flush. - */ - private async writePendingStamp(): Promise { - if (this.pendingWatermark === null || !this.storage) return - const watermark = this.pendingWatermark - try { - await this.storage.saveMetadata(HNSW_INDEX_STAMP_KEY, { - noun: 'IndexWatermark', - ...makeProjectionStamp(watermark, { dimensions: this.dimension }) - }) - this.stampedWatermark = watermark - this.pendingWatermark = null - } catch (error) { - prodLog.error( - `[HNSW] failed to write watermark stamp (generation ${watermark}) — ` + - `artifact stays behind-stamped (safe: verdicts catchup/rescan, never wrong-adopt); ` + - `retrying on next flush:`, - error - ) - } - } - - /** - * @description Read the artifact's stamp and compute the three-way verdict - * against the store's committed generation. Unstamped state on a stamped - * store verdicts `'rescan'` LOUDLY — never a silent adopt. - * - * MIGRATION COST: existing pre-stamp brains verdict `'rescan'` exactly - * once (that open re-derives via the rebuild it is already running); the - * next flush stamps them, and every later open adopts. - * - * @param artifactPresent - Whether a persisted artifact exists at all (a - * system record was found); gates loud-vs-quiet on the rescan verdict so - * first boots don't scream. - */ - private async loadWatermarkVerdict(artifactPresent: boolean): Promise { - if (!this.storage) return - const committed = this.storage.committedGeneration?.() ?? null - let stamped: number | null = null - try { - const record = await this.storage.getMetadata(HNSW_INDEX_STAMP_KEY) - stamped = readStampedWatermark(record) - } catch { - // An unreadable stamp is unstamped — the fail-safe direction. - stamped = null - } - const result = computeWatermarkVerdict(stamped, committed) - this.loadVerdict = result - this.stampedWatermark = stamped - - if (result.verdict === 'rescan') { - if (artifactPresent || stamped !== null) { - prodLog.warn( - `[HNSW] watermark verdict: RESCAN — persisted index is ` + - (stamped === null - ? 'unstamped (legacy pre-stamp artifact, or a crash between data and stamp)' - : `stamped at generation ${stamped}, ABOVE the store's committed generation ${committed}`) + - ` — never adopting unverifiable state` - ) - } else { - prodLog.debug('[HNSW] watermark verdict: rescan (no persisted artifact — first boot)') - } - } else if (result.verdict === 'catchup') { - prodLog.info( - `[HNSW] watermark verdict: catchup — index stamped at generation ${stamped}, ` + - `store committed at ${committed}; the (${stamped}, ${committed}] window awaits ` + - `an incremental fold (verdict exposed; the fold lands with the coordinator's wiring)` - ) - } - } - /** * @description Persist one node's connections. When the connections codec is * wired AND the storage adapter exposes `saveBinaryBlob`, the per-level @@ -587,15 +405,8 @@ export class JsHnswVectorIndex implements VectorIndexProvider { /** * Add a vector to the index - * - * @param generation - Brainy's commit generation for this write (contract - * parity with `VectorIndexProvider.addItem`). This JS index serves "now" - * only — no per-record delta log, no natural slot — so the value is - * accepted and ignored; a native provider stamps its durable records - * with it. The JS twin adopts stamping with the watermark train. */ - public async addItem(item: VectorDocument, generation?: bigint): Promise { - void generation // Contract parity — the JS index keeps no per-write log. + public async addItem(item: VectorDocument): Promise { // Check if item is defined if (!item) { throw new Error('Item is undefined or null') @@ -608,15 +419,6 @@ 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 @@ -684,90 +486,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider { return id } - // Wire the node into the graph: greedy descent + per-level linking. - // Extracted to linkNode so updateItem's in-place relink runs the SAME - // insertion linking (one implementation, never a diverging copy). - await this.linkNode(noun, entryPoint) - - // Update max level and entry point if needed - if (nounLevel > this.maxLevel) { - this.maxLevel = nounLevel - this.entryPointId = id - } - - // Add noun to the index - this.nouns.set(id, noun) - - // Track high-level nodes for O(1) entry point selection - if (nounLevel >= 2 && nounLevel <= this.MAX_TRACKED_LEVELS) { - if (!this.highLevelNodes.has(nounLevel)) { - this.highLevelNodes.set(nounLevel, new Set()) - } - this.highLevelNodes.get(nounLevel)!.add(id) - } - - // Lazy vector eviction (B2: graph-only memory after insert) - // After graph construction completes, evict the full vector from memory. - // Future searches will load vectors on-demand via getVectorSafe() + UnifiedCache. - if (this.vectorStorageMode === 'lazy' && this.storage) { - noun.vector = [] // Release float32 vector from memory - } - - // Persist HNSW graph data to storage - // Respect persistMode setting - if (this.storage && this.persistMode === 'immediate') { - // IMMEDIATE MODE: Original behavior - persist new entity and system data. - // Goes through the per-node helper so the compressed-blob branch fires - // identically here vs. the deferred-flush + neighbor-update paths. - await this.persistNodeConnections(id, noun).catch((error) => { - console.error(`Failed to persist HNSW data for ${id}:`, error) - }) - - // Persist system data (entry point and max level) - await this.storage.saveHNSWSystem({ - entryPointId: this.entryPointId, - maxLevel: this.maxLevel - }).catch((error) => { - console.error('Failed to persist HNSW system data:', error) - }) - } else if (this.persistMode === 'deferred') { - // DEFERRED MODE: Track dirty nodes for later batch persistence - this.dirtyNodes.add(id) - this.dirtySystem = true - } - - return id - } - - /** - * @description The insertion LINKING phase shared by {@link addItem} and - * {@link updateItem}: greedy-descend from `entryPoint` through the levels - * above `noun.level`, then at each level from `min(noun.level, maxLevel)` - * down to 0 find `efConstruction` candidates, select the M nearest, and - * create bidirectional edges — maintaining the reverse-adjacency index via - * {@link addIncoming} and re-pruning any neighbor pushed over M. - * - * Persistence follows the caller's mode exactly as the historical inline - * addItem code did: `'immediate'` persists each touched neighbor's - * connections concurrently (batched by `maxConcurrentNeighborWrites`); - * `'deferred'` marks each touched neighbor dirty for the next flush. - * - * Does NOT touch index membership (`this.nouns`), the entry point, or - * `maxLevel` — the caller owns that bookkeeping: addItem inserts a NEW node - * afterwards and may raise maxLevel; updateItem relinks an EXISTING node in - * place whose level was already counted, so nothing may change. `noun.vector` - * must be the live in-memory vector at call time; both callers guarantee it - * (lazy-mode eviction happens only after linking completes). - * - * A `neighborId === noun.id` candidate is skipped defensively: during - * updateItem the node is already IN `this.nouns` (visibility-atomicity — - * unlike addItem, which links before inserting), and a self-edge must never - * be creatable no matter what the traversal surfaces. - */ - private async linkNode(noun: HNSWNoun, entryPoint: HNSWNoun): Promise { - const { id, vector } = noun - const nounLevel = noun.level - let currObj = entryPoint // Calculate distance to entry point (handles lazy loading + sync fast path) @@ -829,10 +547,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider { }> = [] for (const [neighborId, _] of neighbors) { - if (neighborId === id) { - // Never self-link (see method JSDoc — reachable only via updateItem) - continue - } const neighbor = this.nouns.get(neighborId) if (!neighbor) { // Skip neighbors that don't exist (expected during rapid additions/deletions) @@ -916,7 +630,7 @@ export class JsHnswVectorIndex implements VectorIndexProvider { const nearestNoun = this.nouns.get(nearestId) if (!nearestNoun) { console.error( - `Nearest noun with ID ${nearestId} not found in linkNode` + `Nearest noun with ID ${nearestId} not found in addItem` ) // Keep the current object as is } else { @@ -925,185 +639,55 @@ export class JsHnswVectorIndex implements VectorIndexProvider { } } } - } - /** - * @description Atomically replace an item's vector IN PLACE — the row is - * NEVER absent from the index during an update. The historical shape staged - * a remove followed by an add as two separately-awaited transaction - * operations; between them the row was in NEITHER index — dark to semantic - * recall while perfectly visible to metadata reads (observed as seconds-long - * production flicker in a downstream deployment). Mandate: a row that - * exists must never be invisible to a read path, even transiently. - * - * Behavior: - * - id not in the index → delegates to {@link addItem} (plain insert). - * - SAME vector (element-wise equal) → pure no-op. This is the production - * flicker shape: a type-only update re-indexes an UNCHANGED vector, so the - * old remove+add did pure damage. (In lazy vector-storage mode the - * comparison baseline is whatever {@link getVectorSafe} serves — the - * cache, or the persisted record; if the caller already rewrote the - * record with the new vector before calling in, equality may report "no - * change" and skip the relink. Query correctness is unaffected either - * way — distances always use the live vector — the graph edges just keep - * their pre-update geometry, which HNSW tolerates by construction.) - * - DIFFERENT vector → the node never leaves `this.nouns`: - * 1. `node.vector` is swapped SYNCHRONOUSLY first (and the shared vector - * cache updated in the same tick), so from that point every query sees - * the node with correct distances; - * 2. its old edges are unlinked via the same reverse-adjacency walk - * removeItem uses ({@link unlinkNodeEdges}) — the node stays in the - * map and KEEPS its level; - * 3. the insertion linking re-runs at the node's EXISTING level - * ({@link linkNode}). Entry-point cases: if the node IS the entry - * point it REMAINS the entry point (still valid — same id, same - * level); the relink traversal then starts from another node via - * {@link resolveRelinkStart}, because the node's own edges were just - * cleared and a traversal starting AT it would find nothing and link - * nothing — stranding the whole graph behind an edgeless entry point. - * maxLevel never regresses: the node keeps its level and its - * membership, so the remove-side relevel bookkeeping never runs. - * - * Persistence mirrors {@link addItem}'s tail for the node itself plus the - * in-neighbors whose connection sets changed during the unlink: - * `'immediate'` persists their connections now; `'deferred'` marks them - * dirty for the next flush. The system record (entry point + maxLevel) is - * NOT rewritten — an in-place update changes neither. - * - * @param generation - Brainy's commit generation for this write (contract - * parity with the feature-detected `updateItem` provider capability). - * Accepted and ignored — the JS index keeps no per-write log. - */ - public async updateItem(item: VectorDocument, generation?: bigint): Promise { - void generation // Contract parity — the JS index keeps no per-write log. - if (!item) { - throw new Error('Item is undefined or null') - } - const { id, vector } = item - if (!vector) { - throw new Error('Vector is undefined or null') + // Update max level and entry point if needed + if (nounLevel > this.maxLevel) { + this.maxLevel = nounLevel + this.entryPointId = id } - const node = this.nouns.get(id) - if (!node) { - // Absent → plain insert. - await this.addItem(item) - return - } + // Add noun to the index + this.nouns.set(id, noun) - // 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) { - throw new Error( - `Vector dimension mismatch: expected ${this.dimension}, got ${vector.length}` - ) - } - - // Fast path: element-wise-equal vector → NOTHING to do (the production - // flicker shape — a type-only update re-indexing an unchanged vector). - // getVectorSafe handles the lazy-evicted case (loads from cache/storage). - const current = await this.getVectorSafe(node) - if (current.length === vector.length) { - let same = true - for (let i = 0; i < vector.length; i++) { - if (current[i] !== vector[i]) { - same = false - break - } + // Track high-level nodes for O(1) entry point selection + if (nounLevel >= 2 && nounLevel <= this.MAX_TRACKED_LEVELS) { + if (!this.highLevelNodes.has(nounLevel)) { + this.highLevelNodes.set(nounLevel, new Set()) } - if (same) return + this.highLevelNodes.get(nounLevel)!.add(id) } - // (1) Visibility-atomic swap: from this synchronous assignment on, every - // query sees the node with correct distances. The shared vector cache is - // updated in the same tick so the lazy-mode read path can never serve the - // stale vector either. - node.vector = vector - this.unifiedCache.set(`hnsw:vector:${id}`, vector, 'vectors', vector.length * 4, 50) - - // (2) Unlink the old edges — the node stays in the map, keeps its level. - const touchedReferrers = await this.unlinkNodeEdges(node) - node.connections = new Map() - for (let level = 0; level <= node.level; level++) { - node.connections.set(level, new Set()) - } - // The node's own reverse entry is rebuilt by the relink below. - this.incoming?.delete(id) - - // (3) Relink at the node's EXISTING level (see JSDoc for the entry-point - // reasoning). A single-node index has nothing to link to — trivially done. - const start = this.resolveRelinkStart(id) - if (start) { - await this.linkNode(node, start) + // Lazy vector eviction (B2: graph-only memory after insert) + // After graph construction completes, evict the full vector from memory. + // Future searches will load vectors on-demand via getVectorSafe() + UnifiedCache. + if (this.vectorStorageMode === 'lazy' && this.storage) { + noun.vector = [] // Release float32 vector from memory } - // Persistence — addItem's tail, minus the system record (entry point and - // maxLevel are untouched by an in-place update). Unlink-touched referrers - // are included so the persisted graph converges on the live one instead of - // keeping their pre-update edge sets forever. + // Persist HNSW graph data to storage + // Respect persistMode setting if (this.storage && this.persistMode === 'immediate') { - await this.persistNodeConnections(id, node).catch((error) => { + // IMMEDIATE MODE: Original behavior - persist new entity and system data. + // Goes through the per-node helper so the compressed-blob branch fires + // identically here vs. the deferred-flush + neighbor-update paths. + await this.persistNodeConnections(id, noun).catch((error) => { console.error(`Failed to persist HNSW data for ${id}:`, error) }) - for (const refId of touchedReferrers) { - const ref = this.nouns.get(refId) - if (!ref) continue - await this.persistNodeConnections(refId, ref).catch((error) => { - console.error(`Failed to persist HNSW data for ${refId}:`, error) - }) - } + + // Persist system data (entry point and max level) + await this.storage.saveHNSWSystem({ + entryPointId: this.entryPointId, + maxLevel: this.maxLevel + }).catch((error) => { + console.error('Failed to persist HNSW system data:', error) + }) } else if (this.persistMode === 'deferred') { + // DEFERRED MODE: Track dirty nodes for later batch persistence this.dirtyNodes.add(id) - for (const refId of touchedReferrers) { - this.dirtyNodes.add(refId) - } + this.dirtySystem = true } - // Lazy vector eviction — same contract as addItem: after graph work - // completes the float32 vector leaves memory; reads serve from the - // (just-updated) cache or the persisted record. - if (this.vectorStorageMode === 'lazy' && this.storage) { - node.vector = [] - } - } - - /** - * @description Pick the traversal start for an in-place relink - * ({@link updateItem} step 3): the current entry point — unless that IS the - * node being relinked. Its edges were just unlinked, so a traversal - * starting there would see an empty neighborhood and produce zero links, - * stranding the graph behind an edgeless entry point. In that case (or when - * the entry point is missing/stale) fall back to the best OTHER node: - * highest tracked level first (the same O(1) heuristic as - * {@link recoverEntryPointO1}), then any other node. Returns null when the - * node is the only one in the index — nothing to link to, trivially valid. - */ - private resolveRelinkStart(excludeId: string): HNSWNoun | null { - if (this.entryPointId && this.entryPointId !== excludeId) { - const entry = this.nouns.get(this.entryPointId) - if (entry) return entry - } - for (let level = this.MAX_TRACKED_LEVELS; level >= 2; level--) { - const nodesAtLevel = this.highLevelNodes.get(level) - if (!nodesAtLevel) continue - for (const nodeId of nodesAtLevel) { - if (nodeId !== excludeId) { - const candidate = this.nouns.get(nodeId) - if (candidate) return candidate - } - } - } - for (const [nodeId, candidate] of this.nouns) { - if (nodeId !== excludeId) return candidate - } - return null + return id } /** @@ -1364,34 +948,20 @@ export class JsHnswVectorIndex implements VectorIndexProvider { } /** - * @description Unlink every graph edge touching `noun`, in BOTH directions, - * WITHOUT removing the node from `this.nouns` — the unlink walk shared by - * {@link removeItem} (which then drops the node) and {@link updateItem} - * (which relinks the node in place, so it must never leave the map and - * KEEPS its level). - * - * Reverse-adjacency lets us touch ONLY the nodes that actually reference - * `noun.id` (its in-neighbors) rather than scanning the whole corpus — - * turning a delete from O(N) into O(in-degree) and a bulk delete from O(N²) - * into O(N·degree). Each referrer set is snapshotted because - * pruneConnections mutates the index. Outgoing edges are unhooked from each - * target's reverse set so no stale referrer survives. - * - * `incoming[noun.id]` itself is intentionally NOT maintained edge-by-edge - * inside the walk — both callers dispose of it wholesale afterwards - * (removeItem deletes it with the node; updateItem clears it and lets the - * relink rebuild it). - * - * @returns The ids of in-neighbors whose connection sets were modified - * (they dropped their edge to `noun` and may have been re-pruned), so a - * caller that persists per-node connections (updateItem) can mark them - * dirty / persist them. removeItem ignores the return — its persistence - * story lives in the caller's delete path, unchanged. + * Remove an item from the index */ - private async unlinkNodeEdges(noun: HNSWNoun): Promise> { - const id = noun.id - const touchedReferrers = new Set() + public async removeItem(id: string): Promise { + if (!this.nouns.has(id)) { + return false + } + + const noun = this.nouns.get(id)! + + // Reverse-adjacency lets us touch ONLY the nodes that actually reference `id` + // (its in-neighbors) rather than scanning the whole corpus — turning a delete + // from O(N) into O(in-degree) and a bulk delete from O(N²) into O(N·degree). + // Snapshot each referrer set because pruneConnections mutates the index. const incoming = this.ensureIncoming() const referrers = incoming.get(id) if (referrers) { @@ -1399,11 +969,11 @@ export class JsHnswVectorIndex implements VectorIndexProvider { for (const refId of Array.from(refSet)) { const ref = this.nouns.get(refId) if (ref && ref.connections.has(level)) { - // Drop the forward edge ref → id, then re-prune ref so the graph - // stays navigable. + // Drop the forward edge ref → id, then re-prune ref so the graph stays + // navigable. (id's own reverse entry is dropped wholesale below, so we + // intentionally do not maintain incoming[id] inside this loop.) ref.connections.get(level)!.delete(id) await this.pruneConnections(ref, level) - touchedReferrers.add(refId) } } } @@ -1417,32 +987,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider { } } - return touchedReferrers - } - - /** - * Remove an item from the index - * - * @param generation - Brainy's commit generation for this removal (contract - * parity with `VectorIndexProvider.removeItem`). Accepted and ignored — - * this JS index removes immediately; a native provider records the - * tombstone at this generation. - */ - public async removeItem(id: string, generation?: bigint): Promise { - void generation // Contract parity — the JS index keeps no per-write log. - if (!this.nouns.has(id)) { - return false - } - - - const noun = this.nouns.get(id)! - - // Unlink every edge touching the node (shared with updateItem's in-place - // relink — see unlinkNodeEdges). The returned touched-referrer set is - // ignored here: removeItem's persistence story lives in the caller's - // delete path, unchanged. - await this.unlinkNodeEdges(noun) - // Remove the noun + its reverse-index entry. this.nouns.delete(id) this.incoming?.delete(id) @@ -1599,15 +1143,7 @@ export class JsHnswVectorIndex implements VectorIndexProvider { } const loaded = await this.storage.getNounVector(noun.id) - // `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) { + if (!loaded) { throw new Error(`Vector not found for noun ${noun.id}`) } @@ -1769,11 +1305,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider { this.maxLevel = systemData.maxLevel } - // Step 2b: Watermark verdict for the persisted artifact — computed and - // exposed only (today's rebuild flow is unchanged; this rebuild IS the - // re-derive a 'rescan' verdict asks for). - await this.loadWatermarkVerdict(systemData !== null) - // Step 3: Determine preloading strategy (adaptive caching) // Check if vectors should be preloaded at init or loaded on-demand const stats = await this.storage.getStatistics() @@ -1817,56 +1348,9 @@ 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) @@ -1914,10 +1398,7 @@ export class JsHnswVectorIndex implements VectorIndexProvider { options.onProgress(loadedCount, totalCount) } - prodLog.info( - `HNSW: Loaded ${loadedCount.toLocaleString()} nodes (${storageType})` + - (skippedUnvectored > 0 ? ` — ${skippedUnvectored.toLocaleString()} unvectored row(s) skipped` : '') - ) + prodLog.info(`HNSW: Loaded ${loadedCount.toLocaleString()} nodes (${storageType})`) } // Step 5: CRITICAL - Recover entry point if missing) diff --git a/src/import/ImportCoordinator.ts b/src/import/ImportCoordinator.ts index dd145045..1e1316b7 100644 --- a/src/import/ImportCoordinator.ts +++ b/src/import/ImportCoordinator.ts @@ -22,6 +22,7 @@ import { SmartYAMLImporter } from '../importers/SmartYAMLImporter.js' import { SmartDOCXImporter } from '../importers/SmartDOCXImporter.js' import { VFSStructureGenerator } from '../importers/VFSStructureGenerator.js' import { NounType, VerbType } from '../types/graphTypes.js' +import { splitNounMetadataRecord, splitVerbMetadataRecord } from '../types/reservedFields.js' import { v4 as uuidv4 } from '../universal/uuid.js' import * as fs from 'fs' import * as path from 'path' @@ -870,18 +871,35 @@ export class ImportCoordinator { } /** - * Normalize an extractor/consumer metadata bag for spreading — the - * field-addressing law: the bag is the user's, VERBATIM. No name is - * reserved anymore ('confidence', 'subtype', 'type', … in a source bag - * import as ordinary user fields); the old reserved-key strip was data - * loss under the law and is gone. A forged 'system.'-prefixed key still - * refuses loudly at the write door (`rejectForgedSystemKeys`). + * Strip Brainy-reserved entity keys out of an extractor-supplied metadata bag. + * + * Extractors (and consumer `customMetadata`) can carry reserved keys + * (`confidence`, `subtype`, `weight`, …) inside `metadata`. Brainy 8.0's + * default `reservedFieldPolicy` is `'throw'`, so spreading such a bag into + * `add({ metadata })` would reject the whole import. The import pipeline owns + * the correct write path: user-mutable reserved values are passed as dedicated + * `AddParams` params (see the call sites), so here we simply drop the reserved + * half of the bag and keep only the custom fields that belong in `metadata`. + * * @param bag - The extractor/consumer metadata bag (may be undefined). - * @returns The bag itself, or `{}` for non-object inputs. + * @returns The custom-only metadata (reserved keys removed). */ - private bagVerbatim(bag: Record | undefined | null): Record { + private stripReservedFromBag(bag: Record | undefined | null): Record { if (!bag || typeof bag !== 'object') return {} - return bag + return splitNounMetadataRecord(bag).custom + } + + /** + * Relationship mirror of {@link stripReservedFromBag} — strips reserved verb + * keys (`verb`, `confidence`, `weight`, `subtype`, …) out of an edge metadata + * bag so it carries only custom fields. Reserved values that have a dedicated + * `RelateParams` param are passed there by the call site instead. + * @param bag - The extractor/consumer edge metadata bag (may be undefined). + * @returns The custom-only edge metadata (reserved keys removed). + */ + private stripReservedFromRelationBag(bag: Record | undefined | null): Record { + if (!bag || typeof bag !== 'object') return {} + return splitVerbMetadataRecord(bag).custom } /** @@ -999,7 +1017,7 @@ export class ImportCoordinator { importedAt: trackingContext.importedAt, importFormat: trackingContext.importFormat, importSource: trackingContext.importSource, - ...this.bagVerbatim(trackingContext.customMetadata) + ...this.stripReservedFromBag(trackingContext.customMetadata) }) } }) @@ -1027,11 +1045,13 @@ export class ImportCoordinator { data: entity.description || entity.name, type: entity.type, subtype: entity.subtype ?? options.defaultSubtype ?? 'imported', - // Engine confidence rides its dedicated param; the bag below is - // the user's verbatim (no name is reserved — field-addressing law). + // `confidence` is a reserved field — pass it as the dedicated param, + // never inside the metadata bag (8.0 reservedFieldPolicy defaults to 'throw'). confidence: entity.confidence, metadata: { - ...this.bagVerbatim(entity.metadata), + // Extractor/consumer bags may smuggle reserved keys — strip them so + // the bag carries only custom fields. + ...this.stripReservedFromBag(entity.metadata), name: entity.name, vfsPath: vfsFile?.path, importedFrom: 'import-coordinator', @@ -1044,7 +1064,7 @@ export class ImportCoordinator { importSource: trackingContext.importSource, sourceRow: row.rowNumber, sourceSheet: row.sheet, - ...this.bagVerbatim(trackingContext.customMetadata) + ...this.stripReservedFromBag(trackingContext.customMetadata) }) } } @@ -1125,7 +1145,7 @@ export class ImportCoordinator { importIds: [trackingContext.importId], projectId: trackingContext.projectId, importFormat: trackingContext.importFormat, - ...this.bagVerbatim(trackingContext.customMetadata) + ...this.stripReservedFromRelationBag(trackingContext.customMetadata) }) } } @@ -1160,7 +1180,7 @@ export class ImportCoordinator { confidence: entity.confidence, metadata: { // Strip any reserved keys an extractor smuggled into the bag. - ...this.bagVerbatim(entity.metadata), + ...this.stripReservedFromBag(entity.metadata), name: entity.name, vfsPath: vfsFile?.path, importedFrom: 'import-coordinator', @@ -1174,7 +1194,7 @@ export class ImportCoordinator { importSource: trackingContext.importSource, sourceRow: row.rowNumber, sourceSheet: row.sheet, - ...this.bagVerbatim(trackingContext.customMetadata) + ...this.stripReservedFromBag(trackingContext.customMetadata) }) } }) @@ -1214,7 +1234,7 @@ export class ImportCoordinator { importIds: [trackingContext.importId], projectId: trackingContext.projectId, importFormat: trackingContext.importFormat, - ...this.bagVerbatim(trackingContext.customMetadata) + ...this.stripReservedFromRelationBag(trackingContext.customMetadata) }) } }) @@ -1269,7 +1289,7 @@ export class ImportCoordinator { projectId: trackingContext.projectId, importedAt: trackingContext.importedAt, importFormat: trackingContext.importFormat, - ...this.bagVerbatim(trackingContext.customMetadata) + ...this.stripReservedFromBag(trackingContext.customMetadata) }) } }) @@ -1299,7 +1319,7 @@ export class ImportCoordinator { projectId: trackingContext.projectId, importedAt: trackingContext.importedAt, importFormat: trackingContext.importFormat, - ...this.bagVerbatim(trackingContext.customMetadata) + ...this.stripReservedFromRelationBag(trackingContext.customMetadata) }) } }) @@ -1402,7 +1422,7 @@ export class ImportCoordinator { ...(typeof (rel as any).confidence === 'number' && { confidence: (rel as any).confidence }), ...(typeof (rel as any).weight === 'number' && { weight: (rel as any).weight }), metadata: { - ...this.bagVerbatim(rel.metadata), + ...this.stripReservedFromRelationBag(rel.metadata), relationshipType: 'semantic', // Distinguish from VFS/provenance inferredType: verbType !== rel.type, // Track if type was enhanced originalType: rel.type diff --git a/src/index.ts b/src/index.ts index 673e1e6f..00adc191 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,11 +31,6 @@ export type { DiagnosticsResult } from './brainy.js' // brain.warm() — eager index/storage readiness report (per-surface honest // outcome + timing). See the WarmReport JSDoc in brainy.ts. export type { WarmReport, WarmOutcome } from './brainy.js' -// brain.maintenanceDebt() — per-surface passthrough of each active -// provider's self-reported background maintenance debt. See the -// MaintenanceDebtReport JSDoc in brainy.ts and ProviderMaintenanceDebt in -// plugin.ts for the measure-only-what-you-track contract. -export type { MaintenanceDebtReport, MaintenanceDebtOutcome } from './brainy.js' export type { GraphAuditReport, GraphAuditDiscrepancy @@ -80,31 +75,16 @@ export type { AggregationOp, TimeWindowGranularity, GroupByDimension, - AggregationProvider, - RepairReport, - RepairFamilyReport, + AggregationProvider } from './types/brainy.types.js' -// Read-barrier contract (waitForIndexed): the leg names, the options, and -// the typed timeout error (a value export — consumers catch it by instanceof) -export type { - IndexedProjectionPath, - WaitForIndexedOptions -} from './types/brainy.types.js' -export { WaitForIndexedTimeoutError } from './types/brainy.types.js' - // Reserved-field contract — the canonical list of Brainy-owned field names // that may never appear inside a `metadata` bag (see docs/concepts/consistency-model.md) export { RESERVED_ENTITY_FIELDS, RESERVED_RELATION_FIELDS, splitNounMetadataRecord, - splitVerbMetadataRecord, - buildNounMetadataRecord, - buildVerbMetadataRecord, - isNestedBagRecord, - METADATA_RECORD_FORMAT_KEY, - NESTED_BAG_FORMAT + splitVerbMetadataRecord } from './types/reservedFields.js' export type { ReservedEntityField, @@ -121,25 +101,6 @@ export type { // Export Aggregation Engine export { AggregationIndex, AggregateMaterializer, bucketTimestamp, parseBucketRange } from './aggregation/index.js' -// THE ONE FIELD-ADDRESSING LAW (sealed 2026-08-03) — the arming surface both -// engines' conformance suites detect: bare names = user metadata, system.* = -// the ten ruled scalars, plumbing invisible, refusals typed with the fix in -// the message. See docs/concepts/field-addressing.md. -export { - FIELD_ADDRESSING_CAPABILITY, - SYSTEM_ENTITY_SCALARS, - SYSTEM_RELATION_SCALARS, - PLUMBING_FIELDS, - parseFieldAddress, - readEntityFieldAddress, - readRelationFieldAddress, - buildUnresolvableMessage, - InvalidFieldAddressError, - UnresolvableFieldError, - UnsupportedFindOptionError -} from './db/fieldAddressing.js' -export type { FieldAddress, FieldAddressKind } from './db/fieldAddressing.js' - // Export Neural Import (AI data understanding) export { NeuralImport } from './neural/neuralImport.js' export type { @@ -184,7 +145,6 @@ 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' @@ -203,7 +163,7 @@ export { EntityNotFoundError, RelationNotFoundError } from './errors/notFound.js // Base error + typed migration-lock error — thrown by any data-plane call while a // brain runs its one-time 7.x→8.0 upgrade; catch to answer HTTP 503 + Retry-After. -export { BrainyError, MigrationInProgressError, GraphIndexNotReadyError, MetadataIndexNotReadyError, VectorIndexNotReadyError, ProtectedArtifactError, DerivedArtifactMissingError, MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from './errors/brainyError.js' +export { BrainyError, MigrationInProgressError, GraphIndexNotReadyError, MetadataIndexNotReadyError, VectorIndexNotReadyError, ProtectedArtifactError, DerivedArtifactMissingError } from './errors/brainyError.js' export type { BrainyErrorType } from './errors/brainyError.js' // ============= 8.0 Db API — generational MVCC ============= @@ -220,7 +180,6 @@ export type { PortableGraphRelation, ExportSelector, ExportOptions, - ExportIndexDrift, ImportOptions, ImportResult, PortableGraphValidation @@ -230,9 +189,7 @@ export { SpeculativeOverlayError, GenerationCompactedError, StoreInconsistentError, - PendingFlushDurabilityError, - CanonicalEnumerationUnavailableError, - PendingSingleOpsUnflushedError + PendingFlushDurabilityError } from './db/errors.js' export type { UnreconciledRecord } from './db/errors.js' export type { @@ -259,7 +216,6 @@ export type { CommitFact, FactOp, FactScanBatch, - SCANFACTS_FIRST_BATCH_MS, FactScanHandle } from './db/factLog.js' // The generalized family stamp — which source generation a projection @@ -271,15 +227,6 @@ export type { FamilyStamp, StampMembers, StampVerdict } from './db/familyStamp.j export { isVersionedIndexProvider } from './plugin.js' export type { VersionedIndexProvider } from './plugin.js' export type { ProviderInvariantReport, InvariantResult, InvariantHeal } from './plugin.js' -// The named, synchronous, O(1) health-report contract (the read gate's ONLY -// source of truth for "can I serve right now") — see HealthReport's -// derivation laws in plugin.ts. -export type { HealthReport, LedgerInvariantResult, InvariantSource } from './plugin.js' -// Optional provider self-report of outstanding background maintenance work -// (compaction, deferred writes, etc.) — the payload type for -// brain.maintenanceDebt(). See the measure-only-what-you-track contract on -// ProviderMaintenanceDebt in plugin.ts. -export type { ProviderMaintenanceDebt } from './plugin.js' // Optional native graph-acceleration engine (cor 3.0) — the published provider // contract + its columnar wire types. Brainy feature-detects an implementation // and falls back to its pure-TS adjacency when absent. @@ -370,15 +317,6 @@ export { MemoryStorage, createStorage } // FileSystemStorage is exported separately to avoid browser build issues. export { FileSystemStorage } from './storage/adapters/fileSystemStorage.js' -// Torn-record surface: a stored file that EXISTS but cannot be decoded throws -// a typed, catchable error on entity reads (never a silent "not found"), and -// every encounter is counted on a per-process gauge. -export { - TornRecordError, - isTornRecordError, - getTornRecordGauge -} from './storage/tornRecordError.js' - // Export types import type { Vector, @@ -391,10 +329,7 @@ import type { HNSWVerb, HNSWConfig, StorageAdapter, - DerivedFamilyDeclaration, - // The canonical count ledger a storage adapter maintains (counted + ALL-visibility - // scalars per family, the coverage-ledger denominators) — see StorageAdapter.getCanonicalCounts. - CanonicalCounts + DerivedFamilyDeclaration } from './coreTypes.js' // Export vector index implementation (the JS HNSW path) diff --git a/src/indexes/columnStore/ColumnStore.ts b/src/indexes/columnStore/ColumnStore.ts index 6bff86d4..d33c05c4 100644 --- a/src/indexes/columnStore/ColumnStore.ts +++ b/src/indexes/columnStore/ColumnStore.ts @@ -23,10 +23,7 @@ import type { ColumnStoreProvider, SegmentMeta } from './types.js' import { ValueType, DEFAULT_FLUSH_THRESHOLD, - FLAG_MULTI_VALUE, - POSTING_KINDS, - KIND_PATH_SEGMENT, - type PostingKind + FLAG_MULTI_VALUE } from './types.js' import { ColumnTailBuffer } from './ColumnTailBuffer.js' import { ColumnManifest } from './ColumnManifest.js' @@ -34,7 +31,6 @@ import { ColumnSegmentCursor, TailBufferCursor, type CursorEntry } from './Colum import { writeSegmentToBuffer, readSegmentFromBuffer } from './ColumnSegmentFormat.js' import { RoaringBitmap32 } from '../../utils/roaring/index.js' import { compareCodePoints } from '../../utils/collation.js' -import { prodLog } from '../../utils/logger.js' /** * Configuration for the ColumnStore. @@ -55,89 +51,10 @@ interface HeapEntry { value: number | string entityIntId: number cursorIndex: number - /** - * Rank of the posting kind this entry came from, from {@link POSTING_KINDS}. - * A mixed-kind field has no natural total order, so the merge orders by kind - * first and by value within a kind. - */ - kindRank: number /** Iterator for the cursor — call next() to advance */ iterator: Generator } -/** - * One physical posting column: a (field, kind) pair and the key every internal - * map and every storage path uses for it. - */ -interface KindColumn { - /** The field as the query language names it. */ - field: string - /** The kind of value this column holds. */ - kind: PostingKind - /** - * Internal map / storage key. The field's PRIMARY kind uses the bare field - * name — the historical layout — and every other kind uses - * `//`. - */ - key: string -} - -/** - * The KIND a value indexes under — its JavaScript `typeof` class, not its - * storage encoding. - * - * Anything that is not a number, string or boolean indexes as a string, which - * is the `String(value)` treatment those values already received. `null` and - * `undefined` never reach here: `addEntity` skips them, and their absence is - * what the `exists` / `missing` operators read. - * - * @param value - The value about to be indexed or queried - * @returns The posting kind that owns this value - */ -function kindOfValue(value: unknown): PostingKind { - const t = typeof value - if (t === 'number') return 'number' - if (t === 'boolean') return 'boolean' - return 'string' -} - -/** - * The segment encoding a fresh column of this kind starts with. - * - * Only the number kind has a choice: an integer column starts as i64 and - * widens to f64 the first time a non-integer arrives - * ({@link ColumnTailBuffer.promoteToFloat}). - */ -function initialValueTypeFor(kind: PostingKind, firstValue: unknown): ValueType { - switch (kind) { - case 'boolean': - return ValueType.Boolean - case 'string': - return ValueType.String - case 'number': - return Number.isInteger(firstValue) ? ValueType.Number : ValueType.Float - } -} - -/** - * The kind a column of this encoding holds — the inverse of - * {@link initialValueTypeFor}, used to read a kind back off a manifest written - * before typed postings existed. - */ -function kindOfValueType(valueType: ValueType): PostingKind { - switch (valueType) { - case ValueType.Boolean: - return 'boolean' - case ValueType.String: - return 'string' - case ValueType.Number: - case ValueType.Float: - return 'number' - default: - throw new Error(`Unknown ValueType: ${valueType}`) - } -} - /** * Unified column store coordinator. * @@ -203,19 +120,9 @@ export class ColumnStore implements ColumnStoreProvider { */ private deletedEntities: Map = new Map() - /** Segment encoding per COLUMN key (not per field — a field has one per kind). */ + /** Known field value types (inferred from first write). */ private fieldTypes: Map = new Map() - /** - * Every posting column a field owns: field → kind → column key. - * - * This is the map that ends the first-writer type freeze. A field's first - * kind takes the bare field name as its column key, keeping the historical - * on-disk layout; each later kind takes its own column beside it. Nothing is - * coerced across kinds and nothing is dropped for being the wrong type. - */ - private fieldColumns: Map> = new Map() - /** Whether init() has completed. */ private initialized = false @@ -232,128 +139,6 @@ export class ColumnStore implements ColumnStoreProvider { this.l0CompactionTrigger = config?.l0CompactionTrigger ?? 4 } - // ========================================================================= - // Posting columns: (field, kind) → one physical column - // ========================================================================= - - /** - * Storage / map key for a (field, kind) column. - * - * `primary` is the kind that owns the bare field name. It is whichever kind - * the field saw first, which for an index written before typed postings is - * simply the kind of its single manifest — so the historical layout is - * preserved rather than migrated. - */ - private static columnKeyFor(field: string, kind: PostingKind, primary: PostingKind | null): string { - return primary === null || kind === primary - ? field - : `${field}/${KIND_PATH_SEGMENT}/${kind}` - } - - /** - * Split a discovered manifest path back into its (field, kind) column, or - * `null` when the path names a field's primary column rather than a kind - * column. `/k/` is the only shape that reads as a kind column, - * and only for a `` this version knows. - */ - private static parseKindColumnKey(key: string): { field: string; kind: PostingKind } | null { - const marker = `/${KIND_PATH_SEGMENT}/` - const at = key.lastIndexOf(marker) - if (at <= 0) return null - const kind = key.slice(at + marker.length) - if (!POSTING_KINDS.includes(kind as PostingKind)) return null - return { field: key.slice(0, at), kind: kind as PostingKind } - } - - /** Record a discovered or freshly created column against its field. */ - private registerColumn(field: string, kind: PostingKind, key: string): void { - let byKind = this.fieldColumns.get(field) - if (!byKind) { - byKind = new Map() - this.fieldColumns.set(field, byKind) - } - const existing = byKind.get(kind) - if (existing !== undefined && existing !== key) { - // Two columns claiming one (field, kind) means the layout on disk is not - // one this writer could have produced. Serving it would silently answer - // from half the postings, so say which two and stop. - throw new Error( - `ColumnStore: field '${field}' has two '${kind}' posting columns on ` + - `disk ('${existing}' and '${key}'). The column index layout is ` + - `inconsistent — rebuild/repair the metadata index rather than ` + - `serving from one half of it.` - ) - } - byKind.set(kind, key) - } - - /** The column key for this (field, kind), or `null` if the field has no such kind. */ - private columnKey(field: string, kind: PostingKind): string | null { - return this.fieldColumns.get(field)?.get(kind) ?? null - } - - /** - * The column key for this (field, kind), creating the registration if the - * field has not seen this kind before. Write path only. - */ - private ensureColumnKey(field: string, kind: PostingKind): string { - const byKind = this.fieldColumns.get(field) - const existing = byKind?.get(kind) - if (existing !== undefined) return existing - - // The primary kind is the one already holding the bare field name, if any. - let primary: PostingKind | null = null - if (byKind) { - for (const [k, key] of byKind) { - if (key === field) { primary = k; break } - } - } - const key = ColumnStore.columnKeyFor(field, kind, primary) - this.registerColumn(field, kind, key) - return key - } - - /** - * Every posting column this field owns, in {@link POSTING_KINDS} order. - * - * Read doors that are not about one particular value — an unbounded range - * used as an "any value present" probe, distinct values, sorting — fan out - * over all of them. - */ - private columnsForField(field: string): KindColumn[] { - const byKind = this.fieldColumns.get(field) - if (!byKind) return [] - const out: KindColumn[] = [] - for (const kind of POSTING_KINDS) { - const key = byKind.get(kind) - if (key !== undefined) out.push({ field, kind, key }) - } - return out - } - - /** - * Which value kinds this field actually holds, in {@link POSTING_KINDS} - * order — the honest answer to "what type is this field?". - * - * A field that carries both `'electronics'` and `5` reports - * `['number', 'string']`, not whichever of them was written first. - * - * @param field - Field name - * @returns Every kind with at least one posting, or `[]` for an unknown field - */ - getFieldKinds(field: string): PostingKind[] { - return this.columnsForField(field) - .filter((c) => this.columnHasData(c.key)) - .map((c) => c.kind) - } - - /** Does this physical column hold any postings (persisted or buffered)? */ - private columnHasData(key: string): boolean { - const manifest = this.manifests.get(key) - const buffer = this.tailBuffers.get(key) - return (manifest !== undefined && !manifest.isEmpty()) || (buffer !== undefined && buffer.size > 0) - } - /** * Initialize the column store: discover existing field manifests. */ @@ -371,23 +156,11 @@ export class ColumnStore implements ColumnStoreProvider { }).listObjectsUnderPath(this.basePath + '/') for (const path of paths) { if (path.endsWith('/MANIFEST.json')) { - // The discovered name is a COLUMN key: either a bare field (that - // field's primary kind, which is every column an index written - // before typed postings has) or `/k/` for a second - // kind that arrived on a field later. - const columnKey = path.replace(this.basePath + '/', '').replace('/MANIFEST.json', '') - const manifest = new ColumnManifest(columnKey, this.basePath) + const fieldName = path.replace(this.basePath + '/', '').replace('/MANIFEST.json', '') + const manifest = new ColumnManifest(fieldName, this.basePath) await manifest.load(storage) - this.manifests.set(columnKey, manifest) - this.fieldTypes.set(columnKey, manifest.valueType) - - const parsed = ColumnStore.parseKindColumnKey(columnKey) - if (parsed) { - this.registerColumn(parsed.field, parsed.kind, columnKey) - } else { - this.registerColumn(columnKey, kindOfValueType(manifest.valueType), columnKey) - } - const fieldName = columnKey + this.manifests.set(fieldName, manifest) + this.fieldTypes.set(fieldName, manifest.valueType) // Load global deleted bitmap if it exists. Raw blob preferred // (2.4.0 #4 cortex-shared format); legacy envelope fallback for @@ -490,43 +263,26 @@ export class ColumnStore implements ColumnStoreProvider { /** * Point filter: find entities where field equals value. * - * The QUERY VALUE'S OWN KIND picks the posting column, and only that column - * is read. `where {category: 5}` answers from the number postings and - * `where {category: '5'}` from the string postings — neither borrows the - * other's rows, because a row written with the number `5` is not a row whose - * category is the text `'5'`. - * - * A field that has never seen this kind matches nothing, which is the true - * answer rather than a coerced one. - * - * Searches all segments + tail buffer of that column, returns the union as a - * roaring bitmap. Excludes globally deleted entities. + * Searches all segments + tail buffer, returns union as roaring bitmap. + * Excludes globally deleted entities. */ async filter(field: string, value: unknown): Promise { const result = new RoaringBitmap32() - const columnKey = this.columnKey(field, kindOfValue(value)) - if (columnKey === null) return result - - // The query value takes the column's encoding — a boolean queried against - // a boolean column has to become the 1/0 the column stores. - const encoded = this.normalizeValue(value, this.fieldTypes.get(columnKey) ?? ValueType.String) - if (encoded === undefined) return result - - const deleted = this.deletedEntities.get(columnKey) + const deleted = this.deletedEntities.get(field) // Search segments - const cursors = await this.getSegmentCursors(columnKey) + const cursors = await this.getSegmentCursors(field) for (const cursor of cursors) { - const ids = cursor.getEntityIdsForValue(encoded) + const ids = cursor.getEntityIdsForValue(value as number | string) for (const id of ids) { if (!deleted || !deleted.has(id)) result.add(id) } } // Search tail buffer - const tailCursor = this.getTailBufferCursor(columnKey) + const tailCursor = this.getTailBufferCursor(field) if (tailCursor) { - const ids = tailCursor.getEntityIdsForValue(encoded) + const ids = tailCursor.getEntityIdsForValue(value as number | string) for (const id of ids) { if (!deleted || !deleted.has(id)) result.add(id) } @@ -535,62 +291,6 @@ export class ColumnStore implements ColumnStoreProvider { return result } - /** - * Read this column's value for each of `entityIntIds` — the per-id read - * behind `find({ fields })`. - * - * Every other read door here answers "which entities have this value". A - * projection asks the opposite — "what value does this entity have" — and - * without it a projection has to go to the canonical record for a field the - * column is already holding. - * - * The column is walked ONCE and the wanted ids are picked out as they pass, - * so the cost is O(column) per field rather than O(ids x column). Later - * sources win: the tail buffer holds writes newer than any segment, and - * within the segments a later one supersedes an earlier, exactly as `filter` - * treats them. - * - * Values are EXACT — this store keeps raw values, not the bucketed form the - * sparse index uses for range queries — which is what makes it safe to - * project from. Deleted entities are skipped; an id with no value in this - * column is simply absent from the result. - * - * @param field - Field name to read. - * @param entityIntIds - Entity integer ids to read values for. - * @returns `entityIntId -> value` for the ids this column holds. - */ - async valuesForIds( - field: string, - entityIntIds: Iterable - ): Promise> { - const wanted = new Set(entityIntIds) - const out = new Map() - if (wanted.size === 0 || !this.hasField(field)) return out - - // Every kind the field holds is read, in POSTING_KINDS order — a value an - // entity wrote as a string is still that entity's value for this field. - for (const column of this.columnsForField(field)) { - const deleted = this.deletedEntities.get(column.key) - const take = (entry: { value: number | string; entityIntId: number }): void => { - if (!wanted.has(entry.entityIntId)) return - if (deleted && deleted.has(entry.entityIntId)) return - out.set(entry.entityIntId, entry.value) - } - - // Segments oldest -> newest, then the tail: a later write overwrites an - // earlier one for the same id. - const cursors = await this.getSegmentCursors(column.key) - for (const cursor of cursors) { - for (const entry of cursor.iterateForward()) take(entry) - } - const tailCursor = this.getTailBufferCursor(column.key) - if (tailCursor) { - for (const entry of tailCursor.iterateForward()) take(entry) - } - } - return out - } - /** * Range filter: find entities where field is within the bounds. * @@ -610,59 +310,41 @@ export class ColumnStore implements ColumnStoreProvider { includeMax: boolean = true ): Promise { const result = new RoaringBitmap32() + const cursors = await this.getSegmentCursors(field) const hasMin = min !== undefined && min !== null const hasMax = max !== undefined && max !== null - // The BOUNDS pick the column: numeric bounds read the numeric postings, - // string bounds the string postings. An unbounded call is not a range at - // all — it is the "has any value here" probe behind `exists` — so it fans - // out over every kind the field holds. - const columns: KindColumn[] = hasMin - ? this.columnsForKind(field, kindOfValue(min)) - : hasMax - ? this.columnsForKind(field, kindOfValue(max)) - : this.columnsForField(field) + for (const cursor of cursors) { + const lo = hasMin ? min as number | string : cursor.minValue + const hi = hasMax ? max as number | string : cursor.maxValue + if (lo === undefined || hi === undefined) continue + // Exclusivity applies only to an explicitly provided bound. A bound taken + // from the segment's own min/max is a real stored value and must stay + // inclusive, or the segment's boundary entities would be wrongly dropped. + const ids = cursor.getEntityIdsInRange( + lo, + hi, + hasMin ? includeMin : true, + hasMax ? includeMax : true + ) + for (const id of ids) result.add(id) + } - for (const column of columns) { - const cursors = await this.getSegmentCursors(column.key) - for (const cursor of cursors) { - const lo = hasMin ? min as number | string : cursor.minValue - const hi = hasMax ? max as number | string : cursor.maxValue - if (lo === undefined || hi === undefined) continue - // Exclusivity applies only to an explicitly provided bound. A bound taken - // from the segment's own min/max is a real stored value and must stay - // inclusive, or the segment's boundary entities would be wrongly dropped. - const ids = cursor.getEntityIdsInRange( - lo, - hi, - hasMin ? includeMin : true, - hasMax ? includeMax : true - ) - for (const id of ids) result.add(id) - } - - // Tail buffer range: linear scan (tail is small) - const tailCursor = this.getTailBufferCursor(column.key) - if (tailCursor) { - for (const entry of tailCursor.iterateForward()) { - const v = entry.value as any - const loOk = !hasMin || (includeMin ? v >= (min as any) : v > (min as any)) - const hiOk = !hasMax || (includeMax ? v <= (max as any) : v < (max as any)) - if (loOk && hiOk) result.add(entry.entityIntId) - } + // Tail buffer range: linear scan (tail is small) + const tailCursor = this.getTailBufferCursor(field) + if (tailCursor) { + for (const entry of tailCursor.iterateForward()) { + const v = entry.value as any + const loOk = !hasMin || (includeMin ? v >= (min as any) : v > (min as any)) + const hiOk = !hasMax || (includeMax ? v <= (max as any) : v < (max as any)) + if (loOk && hiOk) result.add(entry.entityIntId) } } return result } - /** The single column for this (field, kind), as a list, or empty if absent. */ - private columnsForKind(field: string, kind: PostingKind): KindColumn[] { - const key = this.columnKey(field, kind) - return key === null ? [] : [{ field, kind, key }] - } - /** * Sort top-K: return K entity int IDs in sorted order (u64-safe BigInt). * @@ -693,21 +375,18 @@ export class ColumnStore implements ColumnStoreProvider { */ async getFilterValues(field: string): Promise { const valueSet = new Set() + const cursors = await this.getSegmentCursors(field) - for (const column of this.columnsForField(field)) { - const cursors = await this.getSegmentCursors(column.key) - - for (const cursor of cursors) { - for (const entry of cursor.iterateForward()) { - valueSet.add(String(entry.value)) - } + for (const cursor of cursors) { + for (const entry of cursor.iterateForward()) { + valueSet.add(String(entry.value)) } + } - const tailCursor = this.getTailBufferCursor(column.key) - if (tailCursor) { - for (const entry of tailCursor.iterateForward()) { - valueSet.add(String(entry.value)) - } + const tailCursor = this.getTailBufferCursor(field) + if (tailCursor) { + for (const entry of tailCursor.iterateForward()) { + valueSet.add(String(entry.value)) } } @@ -718,7 +397,9 @@ export class ColumnStore implements ColumnStoreProvider { * Check if a field has any indexed data. */ hasField(field: string): boolean { - return this.columnsForField(field).some((c) => this.columnHasData(c.key)) + const manifest = this.manifests.get(field) + const buffer = this.tailBuffers.get(field) + return (manifest !== undefined && !manifest.isEmpty()) || (buffer !== undefined && buffer.size > 0) } /** @@ -728,11 +409,12 @@ export class ColumnStore implements ColumnStoreProvider { * store will actually serve queries from. */ getIndexedFields(): string[] { - // Names FIELDS, not columns: a field carrying two kinds is one name here, - // the same name a caller queries with. const fields = new Set() - for (const [field] of this.fieldColumns) { - if (this.hasField(field)) fields.add(field) + for (const [field, manifest] of this.manifests) { + if (!manifest.isEmpty()) fields.add(field) + } + for (const [field, buffer] of this.tailBuffers) { + if (buffer.size > 0) fields.add(field) } return Array.from(fields).sort() } @@ -747,16 +429,12 @@ export class ColumnStore implements ColumnStoreProvider { getFieldSizeSummary(): Array<{ field: string; segmentCount: number; tailSize: number }> { const summary: Array<{ field: string; segmentCount: number; tailSize: number }> = [] for (const field of this.getIndexedFields()) { - // Summed across the field's kind columns — the caller asked about a - // field, and a field's size is all of the postings under its name. - let segmentCount = 0 - let tailSize = 0 - for (const column of this.columnsForField(field)) { - const manifest = this.manifests.get(column.key) - const buffer = this.tailBuffers.get(column.key) - if (manifest && !manifest.isEmpty()) segmentCount += manifest.getAllSegments().length - if (buffer) tailSize += buffer.size - } + const manifest = this.manifests.get(field) + const buffer = this.tailBuffers.get(field) + const segmentCount = manifest && !manifest.isEmpty() + ? manifest.getAllSegments().length + : 0 + const tailSize = buffer ? buffer.size : 0 summary.push({ field, segmentCount, tailSize }) } return summary @@ -784,8 +462,6 @@ export class ColumnStore implements ColumnStoreProvider { this.segmentCache.clear() this.manifests.clear() this.deletedEntities.clear() - this.fieldColumns.clear() - this.fieldTypes.clear() this.initialized = false } @@ -794,64 +470,32 @@ export class ColumnStore implements ColumnStoreProvider { // ========================================================================= /** - * Push a single value to the posting column for its (field, KIND). - * - * The value's own kind picks the column — a string goes to the field's - * string postings, a number to its number postings — so a field carrying - * `'electronics'` and `5` keeps both, each answerable by an equality filter - * of its own kind. Under the first-writer type freeze this method replaced, - * the first value's type became the field's type and every later value of - * another kind was coerced to it or, when coercion failed, dropped with no - * error at all. - * - * Creates the column's buffer and manifest on its first value. + * Push a single value to a field's tail buffer. + * Creates the buffer and manifest if first write to this field. + * Infers ValueType from the first value seen. */ private pushToBuffer(field: string, value: unknown, entityIntId: number, isMultiValue: boolean): void { - const kind = kindOfValue(value) - const columnKey = this.ensureColumnKey(field, kind) - - let buffer = this.tailBuffers.get(columnKey) + let buffer = this.tailBuffers.get(field) if (!buffer) { - // A reopened column takes its encoding from its manifest — an integer - // column that widened to f64 in an earlier session stays widened. - const valueType = - this.manifests.get(columnKey)?.valueType ?? initialValueTypeFor(kind, value) - buffer = new ColumnTailBuffer(columnKey, valueType, this.flushThreshold) - this.tailBuffers.set(columnKey, buffer) - this.fieldTypes.set(columnKey, valueType) + const valueType = this.inferValueType(value) + buffer = new ColumnTailBuffer(field, valueType, this.flushThreshold) + this.tailBuffers.set(field, buffer) + this.fieldTypes.set(field, valueType) // Ensure manifest exists - if (!this.manifests.has(columnKey)) { - const manifest = new ColumnManifest(columnKey, this.basePath) + if (!this.manifests.has(field)) { + const manifest = new ColumnManifest(field, this.basePath) manifest.valueType = valueType manifest.multiValue = isMultiValue - this.manifests.set(columnKey, manifest) + this.manifests.set(field, manifest) } } - // An integer column widens the first time a non-integer number arrives, so - // the value is stored as itself instead of rounded to the nearest integer. - if (kind === 'number' && buffer.valueType === ValueType.Number && !Number.isInteger(value)) { - buffer.promoteToFloat() - this.fieldTypes.set(columnKey, ValueType.Float) - const manifest = this.manifests.get(columnKey) - if (manifest) manifest.valueType = ValueType.Float - } - + // Normalize value to the column type const normalizedValue = this.normalizeValue(value, buffer.valueType) - if (normalizedValue === undefined) { - // Unreachable by construction: the column was chosen BY this value's - // kind, so the encoding always accepts it. Reaching here would mean a - // value had been silently dropped from the index — the exact failure - // typed postings exist to end — so it is an error, never a skip. - throw new Error( - `ColumnStore: field '${field}' rejected a ${kind} value for its own ` + - `${ValueType[buffer.valueType]} posting column. The value would have ` + - `been dropped from the index while the row stayed readable by id — ` + - `this is a kind-routing bug, not a value the caller may ignore.` - ) + if (normalizedValue !== undefined) { + buffer.add(normalizedValue, entityIntId) } - buffer.add(normalizedValue, entityIntId) } /** @@ -968,31 +612,6 @@ export class ColumnStore implements ColumnStoreProvider { /** * Get all segment cursors for a field, loading from storage if needed. */ - /** - * Per-field quarantine ledger for torn segments (power-loss survivors: - * manifest-listed but unloadable). A quarantined segment is skipped with - * per-doubling narration and the field serves its REMAINING segments as a - * DEGRADED-ANNOUNCED result — never a raw throw killing the query, never - * a silent drop. Cleared when a heal/rebuild rewrites the field. - */ - private readonly segmentQuarantine = new Map() - - /** Torn-segment quarantine entries for a field (observability + heal input). */ - quarantinedSegments(field: string): Array<{ segment: string; error: string; hits: number }> { - const out: Array<{ segment: string; error: string; hits: number }> = [] - // Across every kind column of the field — a torn segment in the string - // postings is this field's torn segment as much as one in the numbers. - for (const column of this.columnsForField(field)) { - const prefix = `${column.key}:` - for (const [key, q] of this.segmentQuarantine) { - if (key.startsWith(prefix)) { - out.push({ segment: key.slice(prefix.length), error: q.error, hits: q.hits }) - } - } - } - return out - } - private async getSegmentCursors(field: string): Promise { const manifest = this.manifests.get(field) if (!manifest) return [] @@ -1003,38 +622,11 @@ export class ColumnStore implements ColumnStoreProvider { let cursor = this.segmentCache.get(cacheKey) if (!cursor) { - const quarantined = this.segmentQuarantine.get(cacheKey) - if (quarantined) { - // Already-quarantined torn segment: skip, count, narrate per doubling. - quarantined.hits++ - if ((quarantined.hits & (quarantined.hits - 1)) === 0) { - prodLog.warn( - `[ColumnStore] field '${field}' serving DEGRADED: torn segment ${seg.id} ` + - `quarantined (${quarantined.error}) — ${quarantined.hits} queries served ` + - `without it; heal/rebuild the metadata index to restore` - ) - } - continue - } - try { - cursor = await this.loadSegmentCursor(field, seg) - } catch (err) { - if (err instanceof ColumnSegmentLoadError) { - // POWER-LOSS SURVIVOR: a manifest-listed segment whose bytes are - // torn/absent. Quarantine at DISCOVERY and serve the remaining - // segments degraded-announced — a raw throw here killed every - // query on the field forever; a silent skip hid the loss. The - // quarantine is the middle: loud once, counted always, healable. - this.segmentQuarantine.set(cacheKey, { error: (err as Error).message, hits: 1 }) - prodLog.error( - `[ColumnStore] torn segment QUARANTINED at discovery: field '${field}' ` + - `segment ${seg.id} — ${(err as Error).message}. The field serves its ` + - `remaining segments DEGRADED until a heal/rebuild rewrites it.` - ) - continue - } - throw err // real storage faults propagate — never absorbed - } + // loadSegmentCursor either returns a cursor or THROWS — a corrupt / + // missing manifest-listed segment raises ColumnSegmentLoadError and a + // real storage fault propagates, so a listed segment is never silently + // dropped from the result set. + cursor = await this.loadSegmentCursor(field, seg) this.segmentCache.set(cacheKey, cursor) } @@ -1160,22 +752,17 @@ export class ColumnStore implements ColumnStoreProvider { k: number, filterBitmap: RoaringBitmap32 | null ): Promise { - // Collect cursors across EVERY kind the field holds. A single-kind field — - // nearly all of them — merges exactly the cursors it always did. + // Collect all cursors (segments + tail buffer) + const segCursors = await this.getSegmentCursors(field) + const tailCursor = this.getTailBufferCursor(field) + + // Create iterators for each cursor in the specified direction const iterators: Generator[] = [] - const iteratorKindRank: number[] = [] - for (const column of this.columnsForField(field)) { - const kindRank = POSTING_KINDS.indexOf(column.kind) - const segCursors = await this.getSegmentCursors(column.key) - for (const cursor of segCursors) { - iterators.push(order === 'asc' ? cursor.iterateForward() : cursor.iterateBackward()) - iteratorKindRank.push(kindRank) - } - const tailCursor = this.getTailBufferCursor(column.key) - if (tailCursor) { - iterators.push(order === 'asc' ? tailCursor.iterateForward() : tailCursor.iterateBackward()) - iteratorKindRank.push(kindRank) - } + for (const cursor of segCursors) { + iterators.push(order === 'asc' ? cursor.iterateForward() : cursor.iterateBackward()) + } + if (tailCursor) { + iterators.push(order === 'asc' ? tailCursor.iterateForward() : tailCursor.iterateBackward()) } if (iterators.length === 0) return [] @@ -1189,21 +776,16 @@ export class ColumnStore implements ColumnStoreProvider { value: next.value.value, entityIntId: next.value.entityIntId, cursorIndex: i, - kindRank: iteratorKindRank[i], iterator: iterators[i] }) } } - // Heapify. A number and a string have no ordering between them, so a - // mixed-kind field orders by KIND first (POSTING_KINDS order) and by value - // within a kind — one defined total order instead of a comparison whose - // answer depends on which value happened to be on the left. + // Heapify + const isString = (this.fieldTypes.get(field) ?? ValueType.Number) === ValueType.String const compare = (a: HeapEntry, b: HeapEntry): number => { let cmp: number - if (a.kindRank !== b.kindRank) { - cmp = a.kindRank - b.kindRank - } else if (POSTING_KINDS[a.kindRank] === 'string') { + if (isString) { cmp = compareCodePoints(String(a.value), String(b.value)) } else { cmp = (a.value as number) - (b.value as number) @@ -1235,7 +817,6 @@ export class ColumnStore implements ColumnStoreProvider { value: next.value.value, entityIntId: next.value.entityIntId, cursorIndex: top.cursorIndex, - kindRank: top.kindRank, iterator: top.iterator } } @@ -1243,11 +824,8 @@ export class ColumnStore implements ColumnStoreProvider { this.heapDown(heap, 0, compare) } - // Apply global deleted check, filter, and dedup. The deleted bitmap is - // per COLUMN, and the entry came from the column its kind names. - const deleted = this.deletedEntities.get( - this.columnKey(field, POSTING_KINDS[top.kindRank]) ?? field - ) + // Apply global deleted check, filter, and dedup + const deleted = this.deletedEntities.get(field) if (deleted && deleted.has(top.entityIntId)) continue if (seen.has(top.entityIntId)) continue if (filterBitmap && !filterBitmap.has(top.entityIntId)) continue @@ -1289,31 +867,35 @@ export class ColumnStore implements ColumnStoreProvider { } /** - * Encode a value for the column its own kind selected. - * - * This does NOT convert between kinds. It used to: a string reaching a - * numeric column was run through `Number(value)`, and a number reaching a - * numeric column was run through `Math.round`, so `'electronics'` became - * `NaN` and vanished while `4.5` became `5` and answered the wrong query. - * Kind routing removes the need for either — the only work left is picking - * the encoding the column already committed to. - * - * @returns The encoded value, or `undefined` if the value does not belong in - * this column at all — which the caller treats as a routing bug and - * raises, never as a value to skip. + * Infer ValueType from a JavaScript value. + */ + private inferValueType(value: unknown): ValueType { + if (typeof value === 'boolean') return ValueType.Boolean + if (typeof value === 'number') { + return Number.isInteger(value) ? ValueType.Number : ValueType.Float + } + return ValueType.String + } + + /** + * Normalize a JavaScript value to the column's ValueType. */ private normalizeValue(value: unknown, type: ValueType): number | string | undefined { switch (type) { case ValueType.Number: - // Integer column. Non-integers widen it to Float before reaching here. - return typeof value === 'number' && Number.isInteger(value) ? value : undefined + if (typeof value === 'number') return Math.round(value) + if (typeof value === 'string') { const n = Number(value); return isNaN(n) ? undefined : Math.round(n) } + if (typeof value === 'boolean') return value ? 1 : 0 + return undefined case ValueType.Float: - return typeof value === 'number' ? value : undefined + if (typeof value === 'number') return value + if (typeof value === 'string') { const n = Number(value); return isNaN(n) ? undefined : n } + return undefined case ValueType.Boolean: - return typeof value === 'boolean' ? (value ? 1 : 0) : undefined + if (typeof value === 'boolean') return value ? 1 : 0 + if (typeof value === 'number') return value ? 1 : 0 + return undefined case ValueType.String: - // The string kind is also where objects and bigints land, exactly as - // they always did. return String(value) default: return undefined diff --git a/src/indexes/columnStore/ColumnTailBuffer.ts b/src/indexes/columnStore/ColumnTailBuffer.ts index e730f884..c5874ac2 100644 --- a/src/indexes/columnStore/ColumnTailBuffer.ts +++ b/src/indexes/columnStore/ColumnTailBuffer.ts @@ -55,12 +55,8 @@ export class ColumnTailBuffer { /** Field name this buffer is for. */ readonly fieldName: string - /** - * Value type determines sort comparator and segment encoding. - * - * Widened in place by {@link promoteToFloat} — never otherwise reassigned. - */ - valueType: ValueType + /** Value type determines sort comparator. */ + readonly valueType: ValueType /** Flush threshold. */ readonly threshold: number @@ -85,38 +81,6 @@ export class ColumnTailBuffer { this.threshold = threshold } - /** - * Widen an integer column to floating point, losslessly and in place. - * - * The number posting kind holds every JavaScript number, but a segment picks - * ONE encoding: i64 for integers, f64 for the rest. A column that has only - * ever seen integers is written as i64; the first non-integer to arrive - * widens it here, so that value is stored as itself instead of being rounded - * to the nearest integer with no error — the rounding that made `4.5` and - * `5.5` both answer `where {score: 5}` and neither answer its own value. - * - * Widening is lossless in both directions it has to be: every value already - * buffered is an integer, and every integer is exactly representable as f64. - * Segments already on disk keep their own i64 encoding in their own headers - * and keep decoding by it — only segments written from here on are f64. - * - * @throws Error if called on a column that is not an integer column — the - * only legal widening is Number → Float, and any other request is a bug in - * the caller's kind routing rather than something to absorb quietly. - */ - promoteToFloat(): void { - if (this.valueType === ValueType.Float) return - if (this.valueType !== ValueType.Number) { - throw new Error( - `ColumnTailBuffer '${this.fieldName}': cannot widen a ` + - `${ValueType[this.valueType]} column to Float — only an integer ` + - `(Number) column widens, and this call means a value reached the ` + - `wrong kind's column` - ) - } - this.valueType = ValueType.Float - } - /** * Add a (value, entityIntId) entry to the buffer. * diff --git a/src/indexes/columnStore/types.ts b/src/indexes/columnStore/types.ts index ee949bd0..71dd99a0 100644 --- a/src/indexes/columnStore/types.ts +++ b/src/indexes/columnStore/types.ts @@ -58,53 +58,6 @@ export enum ValueType { Boolean = 3 } -/** - * The KIND of a value, as the query language sees it. - * - * A kind is a JavaScript `typeof` class, not a storage encoding: `5` and `5.5` - * are one kind (`'number'`) held in one posting column, even though they need - * different segment encodings (i64 vs f64 — see {@link ValueType}). - * - * A field holds ONE POSTING COLUMN PER KIND, so `category` may carry string - * values and number values at the same time and answer equality on each. This - * replaces the first-writer type freeze, under which the first value's type - * became the field's type and every later value of another kind was coerced — - * or, when coercion failed (`Number('electronics')`), dropped from the index - * with no error: the row stayed readable by id and by vector but vanished from - * every equality filter on that field. - * - * Kinds do not coerce into one another at query time either: `where {c: 5}` - * matches rows written with the NUMBER `5`, and `where {c: '5'}` matches rows - * written with the STRING `'5'`. Neither ever matches the other. - * - * Values that are none of these three (objects, bigints) index as strings — - * the same `String(value)` treatment they received before. - */ -export type PostingKind = 'number' | 'string' | 'boolean' - -/** - * Every posting kind, in the order that defines cross-kind sort position. - * - * A mixed-kind field has no natural total order — a number does not compare - * with a string — so `sortTopK` orders by KIND first (numbers, then strings, - * then booleans) and by value within a kind. A single-kind field, which is - * nearly every field, sorts exactly as it always did. - */ -export const POSTING_KINDS: readonly PostingKind[] = ['number', 'string', 'boolean'] - -/** - * Path segment marking a field's NON-PRIMARY kind columns on disk. - * - * The first kind a field ever sees keeps the historical layout — - * `//MANIFEST.json` and `//L0-NNNNNN` — so every - * index written before typed postings opens unchanged, and the byte-for-byte - * interchange with the native column store is untouched for the single-kind - * fields that are nearly all of them. A second kind arriving on the same field - * gets its own column at `//k//…` rather than overwriting or - * being coerced into the first. - */ -export const KIND_PATH_SEGMENT = 'k' - // --------------------------------------------------------------------------- // Segment header and footer // --------------------------------------------------------------------------- @@ -314,19 +267,6 @@ export interface ColumnStoreProvider { */ hasField(field: string): boolean - /** - * Which value KINDS this field actually holds, in {@link POSTING_KINDS} - * order — the honest answer to "what type is this field?" for a field that - * carries more than one. - * - * OPTIONAL so an implementation written against the pre-typed-postings - * contract still satisfies this interface; feature-detect before calling. - * - * @param field - Field name - * @returns Every kind with at least one posting, or `[]` for an unknown field - */ - getFieldKinds?(field: string): PostingKind[] - /** * Flush all in-memory tail buffers to L0 segments on disk. * Saves all manifests. diff --git a/src/integrations/index.ts b/src/integrations/index.ts index 6a6734d9..757a9fe5 100644 --- a/src/integrations/index.ts +++ b/src/integrations/index.ts @@ -9,7 +9,7 @@ * * @example Enable integrations (recommended) * ```typescript - * import { Brainy } from '@soulcraftlabs/brainy' + * import { Brainy } from '@soulcraft/brainy' * * const brain = new Brainy({ integrations: true }) * await brain.init() diff --git a/src/mcp/README.md b/src/mcp/README.md index 092534a1..c69a3b24 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 '@soulcraftlabs/brainy' +import { Brainy, BrainyMCPAdapter, MCPAugmentationToolset } from '@soulcraft/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 '@soulcraftlabs/brainy' +import { Brainy, BrainyMCPService } from '@soulcraft/brainy' // Create a Brainy instance const brainyData = new Brainy() diff --git a/src/migration/MigrationRunner.ts b/src/migration/MigrationRunner.ts index 6a8a34bd..d2e251a2 100644 --- a/src/migration/MigrationRunner.ts +++ b/src/migration/MigrationRunner.ts @@ -9,67 +9,6 @@ import type { BaseStorage } from '../storage/baseStorage.js' import type { NounMetadata, VerbMetadata } from '../coreTypes.js' import type { Migration, MigrationState, MigrationPreview, MigrationResult, MigrateOptions, MigrationError } from './types.js' import { MIGRATIONS } from './migrations.js' -import { - splitNounMetadataRecord, - splitVerbMetadataRecord, - buildNounMetadataRecord, - buildVerbMetadataRecord, - RESERVED_ENTITY_FIELDS, - RESERVED_RELATION_FIELDS -} from '../types/reservedFields.js' - -const RESERVED_NOUN_SET: ReadonlySet = new Set(RESERVED_ENTITY_FIELDS) -const RESERVED_VERB_SET: ReadonlySet = new Set(RESERVED_RELATION_FIELDS) - -/** - * Normalize a stored record (either era: legacy flat OR v2 nested-bag) into - * THE transform view — the one shape every migration transform receives: - * engine fields top-level, the user's metadata bag nested under `metadata`. - * Transforms never see the storage era; a migration written today works on - * a brain of any age. - */ -function toTransformView( - record: Record, - kind: 'noun' | 'verb' -): Record { - const { reserved, custom } = - kind === 'noun' ? splitNounMetadataRecord(record) : splitVerbMetadataRecord(record) - return { ...reserved, metadata: { ...custom } } -} - -/** - * Convert a transform's returned view back into a stamped v2 stored record. - * LOUD CONTRACT: user fields belong inside `.metadata` — a stray top-level - * key that is not an engine field is a migration bug under the - * field-addressing law (pre-law transforms wrote user fields flat), and it - * refuses with the fix in the message rather than silently dropping or - * silently storing it as an engine key. - */ -function fromTransformView( - view: Record, - kind: 'noun' | 'verb' -): Record { - const reservedSet = kind === 'noun' ? RESERVED_NOUN_SET : RESERVED_VERB_SET - const engine: Record = {} - for (const [key, value] of Object.entries(view)) { - if (key === 'metadata') continue - if (!reservedSet.has(key)) { - throw new Error( - `migration transform returned a top-level key '${key}' that is not an ` + - `engine field — under the field-addressing law user fields live inside ` + - `.metadata (return { ...view, metadata: { ...view.metadata, ${key}: … } }).` - ) - } - engine[key] = value - } - const bag = - view.metadata && typeof view.metadata === 'object' && !Array.isArray(view.metadata) - ? (view.metadata as Record) - : {} - return kind === 'noun' - ? buildNounMetadataRecord(engine, bag) - : buildVerbMetadataRecord(engine, bag) -} const MIGRATION_STATE_KEY = '__migration_state__' const PREVIEW_SAMPLE_SIZE = 5 @@ -186,16 +125,14 @@ export class MigrationRunner { const entityMeta = metadataBatch.get(entity.id) if (!entityMeta) continue - // Transforms see THE view (engine fields + nested user bag), - // never the raw storage era. - const view = toTransformView(entityMeta as Record, 'noun') - const result = this.applyTransforms(view, nounMigrations) + const metadata = entityMeta as Record + const result = this.applyTransforms(metadata, nounMigrations) if (result !== null) { affectedEntities++ if (sampleChanges.length < PREVIEW_SAMPLE_SIZE) { sampleChanges.push({ id: entity.id, - before: view, + before: { ...metadata }, after: result }) } @@ -220,14 +157,14 @@ export class MigrationRunner { const verbMeta = await this.storage.getVerbMetadata(verb.id) if (!verbMeta) continue - const view = toTransformView(verbMeta as Record, 'verb') - const result = this.applyTransforms(view, verbMigrations) + const metadata = verbMeta as Record + const result = this.applyTransforms(metadata, verbMigrations) if (result !== null) { affectedEntities++ if (sampleChanges.length < PREVIEW_SAMPLE_SIZE) { sampleChanges.push({ id: verb.id, - before: view, + before: { ...metadata }, after: result }) } @@ -352,16 +289,9 @@ export class MigrationRunner { if (!entityMeta) continue try { - const transformed = migration.transform( - toTransformView(entityMeta as Record, 'noun') - ) + const transformed = migration.transform(entityMeta as Record) if (transformed !== null) { - // Re-stamp as a v2 record (also upgrades legacy records touched - // by a migration onto the nested-bag shape). - await this.storage.saveNounMetadata( - entity.id, - fromTransformView(transformed, 'noun') as NounMetadata - ) + await this.storage.saveNounMetadata(entity.id, transformed as NounMetadata) modified++ } } catch (err) { @@ -427,14 +357,9 @@ export class MigrationRunner { if (!metadata) continue try { - const transformed = migration.transform( - toTransformView(metadata as Record, 'verb') - ) + const transformed = migration.transform(metadata as Record) if (transformed !== null) { - await this.storage.saveVerbMetadata( - verb.id, - fromTransformView(transformed, 'verb') as VerbMetadata - ) + await this.storage.saveVerbMetadata(verb.id, transformed as VerbMetadata) modified++ } } catch (err) { diff --git a/src/migration/types.ts b/src/migration/types.ts index a63e40b9..2dcc1d1a 100644 --- a/src/migration/types.ts +++ b/src/migration/types.ts @@ -14,19 +14,7 @@ export interface Migration { description: string /** Which entity types this migration applies to */ applies: 'nouns' | 'verbs' | 'both' - /** - * Return the transformed record view, or null if no change needed. - * - * THE VIEW CONTRACT (field-addressing law): the transform receives ONE - * normalized shape regardless of how old the stored record is — engine - * fields top-level (`noun`/`verb`, `subtype`, `confidence`, `weight`, - * timestamps, `_rev`, …) and the USER's metadata bag nested under - * `metadata` (where every name is the user's, engine spellings included). - * Return the same shape: user-field changes go inside `.metadata`; a - * stray non-engine top-level key in the returned object refuses loudly - * (it is the pre-law flat habit, and silently guessing its namespace - * would corrupt data). - */ + /** Return transformed metadata, or null if no change needed */ transform: (metadata: Record) => Record | null } diff --git a/src/neural/embeddedPatterns.ts b/src/neural/embeddedPatterns.ts index 92e3057a..c15447e7 100644 --- a/src/neural/embeddedPatterns.ts +++ b/src/neural/embeddedPatterns.ts @@ -2,7 +2,7 @@ * 🧠 BRAINY EMBEDDED PATTERNS * * AUTO-GENERATED - DO NOT EDIT - * Generated: 2026-08-27T09:18:45-07:00 + * Generated: 2026-07-02T21:43:26.976Z * Patterns: 220 * Coverage: 94-98% of all queries * diff --git a/src/neural/embeddedTypeEmbeddings.ts b/src/neural/embeddedTypeEmbeddings.ts index f4cdd632..b5f3546b 100644 --- a/src/neural/embeddedTypeEmbeddings.ts +++ b/src/neural/embeddedTypeEmbeddings.ts @@ -2,7 +2,7 @@ * 🧠 BRAINY EMBEDDED TYPE EMBEDDINGS * * AUTO-GENERATED - DO NOT EDIT - * Generated: 2026-08-27T09:18:45-07:00 + * Generated: 2026-02-09T16:59:48.867Z * Noun Types: 42 * Verb Types: 127 * @@ -19,7 +19,7 @@ export const TYPE_METADATA = { verbTypes: 127, totalTypes: 169, embeddingDimensions: 384, - generatedAt: "2026-08-27T09:18:45-07:00", + generatedAt: "2026-02-09T16:59:48.867Z", sizeBytes: { embeddings: 259584, base64: 346112 diff --git a/src/neural/neuralImport.ts b/src/neural/neuralImport.ts index ed240a3f..c8d19eb5 100644 --- a/src/neural/neuralImport.ts +++ b/src/neural/neuralImport.ts @@ -7,6 +7,7 @@ import { Brainy } from '../brainy.js' import { NounType, VerbType } from '../types/graphTypes.js' +import { splitNounMetadataRecord, splitVerbMetadataRecord } from '../types/reservedFields.js' import * as fs from '../universal/fs.js' import * as path from '../universal/path.js' // @ts-ignore @@ -802,14 +803,12 @@ export class NeuralImport { data: this.extractMainText(entity.originalData), type: entity.nounType as NounType, subtype: entity.subtype ?? options.defaultSubtype ?? 'extracted', - // Engine confidence rides its dedicated param; the source object - // imports as the user's bag VERBATIM — no name is reserved - // (field-addressing law). + // `confidence` is a reserved field — dedicated param, not metadata + // (8.0 reservedFieldPolicy defaults to 'throw'). confidence: entity.confidence, metadata: { - ...(typeof entity.originalData === 'object' && entity.originalData !== null - ? entity.originalData - : {}), + // Strip any reserved keys the source data smuggled into the bag. + ...splitNounMetadataRecord(entity.originalData).custom, id: entity.suggestedId } }) @@ -823,13 +822,11 @@ export class NeuralImport { type: relationship.verbType as VerbType, subtype: relationship.subtype ?? options.defaultSubtype ?? 'extracted', weight: relationship.weight, - confidence: relationship.confidence, // engine confidence — dedicated param + confidence: relationship.confidence, // reserved field — dedicated param, not metadata metadata: { context: relationship.context, - // The edge bag imports verbatim — no name is reserved. - ...(typeof relationship.metadata === 'object' && relationship.metadata !== null - ? relationship.metadata - : {}) + // Strip any reserved keys smuggled into the edge metadata bag. + ...splitVerbMetadataRecord(relationship.metadata).custom } }) } diff --git a/src/plugin.ts b/src/plugin.ts index 23a8c883..02639955 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -9,7 +9,6 @@ * registered manually via `brain.use()` — there is no implicit detection. */ -import { prodLog } from './utils/logger.js' import type { StorageAdapter, Vector, @@ -22,7 +21,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: `@soulcraftlabs/brainy/plugin`. +// provider surface from one stable entrypoint: `@soulcraft/brainy/plugin`. export type { ColumnStoreProvider } from './indexes/columnStore/types.js' export type { AggregationProvider, @@ -41,7 +40,7 @@ export interface BrainyPlugin { name: string /** - * Optional semver range of `@soulcraftlabs/brainy` this plugin supports + * Optional semver range of `@soulcraft/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 @@ -172,97 +171,6 @@ export interface ProviderInvariantReport { durationMs: number } -/** - * @description Where a {@link LedgerInvariantResult} verdict came from: - * - `'ledger'` — decided from an exact, durable ledger (a real count, not a sample). - * - `'deep'` — decided by a full/expensive scan (the `validateInvariants()` diagnostic path only). - * - `'unledgered'` — this family has no ledger yet; the verdict is UNKNOWN, never healthy and never broken. - */ -export type InvariantSource = 'ledger' | 'deep' | 'unledgered' - -/** - * @description One invariant verdict inside a {@link HealthReport}. Extends - * {@link InvariantResult} with the provenance of the verdict ({@link InvariantSource}) - * and, for a failing set-membership invariant, an exact count plus a capped sample - * of the diverging ids — a VERDICT, never a dump. `sample` MUST be capped at 16 ids; - * `count` is the exact number even when `sample` is truncated. - */ -export interface LedgerInvariantResult extends InvariantResult { - /** Provenance of this verdict — see {@link InvariantSource}. */ - source: InvariantSource - /** Exact count of diverging/missing items plus a capped (≤16 ids) sample. Present only on a failing set-membership invariant. */ - missing?: { count: number; sample: string[] } -} - -/** - * @description The NAMED, SYNCHRONOUS, O(1) health report a provider exposes via - * {@link MetadataIndexProvider.healthReport} / {@link GraphIndexProvider.healthReport} / - * {@link VectorIndexProvider.healthReport}. This is the read gate's ONLY source of - * truth for "can I serve right now" — it replaces sampled self-probes and the - * unnamed `isReady()` latch with an exact, ledger-derived verdict. - * - * Derivation laws (a provider MUST honor these; brainy's read gate assumes them): - * - `healthy` = every VERIFIED invariant in {@link invariants} holds. An invariant - * whose family is named in {@link unledgered} is NEVER counted toward `healthy` - * either way — it is unknown, not passing. - * - `serving` = no verified invariant in {@link invariants} FAILS with `heal: 'rebuild'`. - * A failure with `heal: 'repair'` or `heal: 'none'` is degraded-but-serving — - * `serving` stays `true`. Only a `'rebuild'`-grade failure makes `serving` `false`. - * - `validateInvariants()` remains the async DEEP diagnostic (full scans allowed, - * `source: 'deep'` results); `healthReport()` MUST be synchronous, O(1) from - * exact ledgers/counters, and MUST NOT throw for a well-formed provider — a - * provider that cannot produce a safe verdict reports it as a failing invariant, - * it does not throw (a throw is read by the gate as a CONTRACT VIOLATION, not as - * "unknown"). - */ -export interface HealthReport extends ProviderInvariantReport { - /** - * Monotonic per provider: bumps on every ledger mutation and every rebuild - * boundary. Consumers (the read gate's narration dedup, external callers) may - * cache a verdict per generation. - */ - generation: number - /** Each checked invariant, with provenance — see {@link LedgerInvariantResult}. */ - invariants: LedgerInvariantResult[] - /** - * Families with no ledger yet. NAMED here so an operator can see what is not - * yet tracked — NEVER counted as healthy (they are not verified) and NEVER - * counted as broken (there is nothing to fail). - */ - unledgered: string[] -} - -/** - * @description A provider's self-report of its own outstanding background - * maintenance work (compaction, deferred writes, a build-new→verify→swap in - * flight, etc.) — the observability seam so an operator sees a grind coming - * (rising pending bytes/items, a stalled pass) instead of discovering it as a - * CPU storm or a timeout under transaction budget pressure. Every field is - * OPTIONAL and every field is a MEASUREMENT: a provider reports ONLY what it - * actually tracks, never an estimate dressed up as a fact. Absence of the - * {@link VectorIndexProvider.maintenanceDebt} / - * {@link GraphIndexProvider.maintenanceDebt} / - * {@link MetadataIndexProvider.maintenanceDebt} hook itself means the - * provider does not track debt at all — brainy reports that surface - * `'unavailable'` rather than inventing zeros. Brainy performs NO threshold - * checks, NO polling, and NO JS-side estimation over this payload — it is a - * pure passthrough via {@link Brainy.maintenanceDebt}; the provider owns the - * numbers and the operator owns the policy (what threshold matters, what to - * do about it). - */ -export interface ProviderMaintenanceDebt { - /** Bytes of outstanding/unmerged work, if the provider measures it (e.g. unflushed writes, unmerged segments). */ - pendingBytes?: number - /** Count of outstanding items (records, segments, nodes) awaiting the provider's background pass. */ - pendingItems?: number - /** Epoch millis when the provider's last maintenance pass finished, if it tracks one. */ - lastPassCompletedAt?: number - /** How the last pass ended, if the provider tracks pass outcomes. */ - lastPassOutcome?: 'completed' | 'partial' | 'failed' - /** `true` if the provider's own measurements show debt trending down (making progress); `false` if flat or growing; omitted if the provider can't tell. */ - converging?: boolean -} - /** * The `'metadataIndex'` provider — a drop-in for `MetadataIndexManager`. * Brainy calls this surface via `this.metadataIndex.*` (see `brainy.ts`) and @@ -273,34 +181,6 @@ export interface MetadataIndexProvider { flush(): Promise rebuild(): Promise - /** - * @description OPTIONAL. Eagerly load/fault-in backing storage (e.g. mmap - * pretouch, full sparse-index hydration) so first queries run at - * steady-state cost. Optional; absence means the provider demand-loads. - * Mirrors {@link GraphIndexProvider.warm} / the vector provider's `warm?()` - * (`src/plugin.ts` VectorIndexProvider). Distinct from `init()`: `init` is - * required and runs once automatically during brain startup; `warm` is a - * separate, explicit step a caller opts into via `brain.warm()` (or - * `warmOnOpen`) to pre-pay demand-load cost `init` left lazy. Idempotent — - * calling it more than once must be safe and cheap on a brain that is - * already warm. A provider that already loads everything eagerly in - * `init()` may implement `warm` as a no-op or omit it — `brain.warm()` - * falls back to the built-in JS manager's `hydrateAll()` duck-type when - * absent, and to an honest `'unavailable'` when neither exists. - */ - warm?(): Promise - - /** - * @description OPTIONAL self-reported {@link ProviderMaintenanceDebt} — - * the observability seam so an operator sees outstanding background - * maintenance work (e.g. unmerged postings) BEFORE it grinds a transaction - * into a budget-busting op. Absence means this provider does not track - * debt; `brain.maintenanceDebt()` reports this surface `'unavailable'` - * rather than guessing. See {@link ProviderMaintenanceDebt} for the - * measure-only-what-you-track contract. - */ - maintenanceDebt?(): Promise - /** * @description OPTIONAL honest durability signal (readiness contract, * mirrors `isReady?()` on the graph and vector providers). `true` ⇔ the @@ -327,20 +207,6 @@ export interface MetadataIndexProvider { */ validateInvariants?(): Promise - /** - * @description OPTIONAL. The named, SYNCHRONOUS, O(1) health verdict this - * provider derives from its own exact ledgers — see {@link HealthReport} for - * the full derivation laws. MUST NOT perform I/O and MUST NOT throw for a - * well-formed provider (brainy treats a throw as a CONTRACT VIOLATION, never - * as "unknown"). When present, brainy's read gate (`assessProviderHealth()`) - * reads THIS instead of `isReady()` / size heuristics: `serving` decides - * whether reads may proceed; a `false` refuses the read loudly rather than - * triggering a rebuild. Absent → the gate falls back to `isReady?()` / the - * size heuristic (this train's JS built-in providers stay on that interim - * path). - */ - healthReport?(): HealthReport - /** * @description OPTIONAL. A native provider returns true from the moment its * `init()` detects a large epoch-drift until its background @@ -352,35 +218,8 @@ export interface MetadataIndexProvider { */ isMigrating?(): boolean - /** - * @description Index one entity's metadata. - * @param id - The entity's UUID. - * @param entityOrMetadata - Entity structure or plain metadata bag. - * @param skipFlush - Transactional atomicity: defer the flush to the commit seam. - * @param deferWrites - Batch mode: buffer postings for a later flush. - * @param generation - OPTIONAL (additive) — Brainy's commit generation for - * this write: the SAME u64 counter {@link GraphIndexProvider.addVerb} - * carries, resolved at operation-execute time. A provider with per-record - * delta logs stamps it onto the durable record so its watermark - * ("this projection reflects generation N") is derivable from real data — - * never a literal 0. `undefined` means the caller genuinely has no commit - * generation for this write (rebuild-from-canonical scans, bootstrap - * writes before generation stamping activates); a provider must treat - * that as "unstamped", not as generation 0. The built-in JS manager - * accepts and ignores it (single live view, no per-record log). - */ - addToIndex(id: string, entityOrMetadata: any, skipFlush?: boolean, deferWrites?: boolean, generation?: bigint): Promise - /** - * @description Remove one entity from the index. - * @param id - The entity's UUID. - * @param metadata - The entity's metadata (targets exact postings; absent → full scan). - * @param generation - OPTIONAL (additive) — Brainy's commit generation for - * this removal, same contract as {@link MetadataIndexProvider.addToIndex}: - * a provider with per-record delta logs records the tombstone at this - * generation (so as-of reads before it still see the entity); the JS - * manager removes immediately and ignores it. - */ - removeFromIndex(id: string, metadata?: any, generation?: bigint): Promise + addToIndex(id: string, entityOrMetadata: any, skipFlush?: boolean, deferWrites?: boolean): Promise + removeFromIndex(id: string, metadata?: any): Promise getIds(field: string, value: any): Promise /** @@ -411,129 +250,7 @@ export interface MetadataIndexProvider { * @returns The matching id universe as an opaque set. */ getIdSetForFilter?(filter: any): Promise - /** - * @description OPTIONAL: evaluate `filter` over `ids` ONLY and return the - * survivors in the caller's order — the door a graph-first - * `find({ connected, where })` walks. The neighbour set is the universe there, - * so the filter must cost O(|ids|) membership checks, never a whole-store - * materialization. A native index answers from its roaring filter result - * (membership by entity int); the reference index answers from its own - * `getIdsForFilter`, so the two doors can never disagree. Absent → Brainy - * intersects `getIdsForFilter`'s answer with `ids` itself (correct, O(store)). - * @param filter - The same filter shape accepted by `getIdsForFilter`. - * @param ids - The candidate ids (canonical). The answer is a subsequence. - */ - filterIdsWithin?(filter: any, ids: readonly string[]): Promise - /** - * @description OPTIONAL: plan and execute a WHOLE `find()` — the graph - * traversal, the metadata filter, the ordering and the page — and answer the - * page's ids, or `null` for a shape this index does not plan. - * - * The doors above each serve one stage, so a `find()` that consults three of - * them crosses into the index three times and marshals a result set at every - * crossing. An index that can decide the stage ORDER itself does the whole - * thing in one call and materializes ids only for the page — a filter - * matching a hundred thousand rows then builds twenty-five id strings instead - * of a hundred thousand. - * - * The contract this door must keep, because Brainy cannot check it: - * - * - **The same answer.** Identical rows, in identical order, to what the - * stage doors would have produced for the same params. This door changes - * which code runs, never what the answer is. - * - **The law of the stages** (`find({ connected })` is graph-first): the - * neighbour set is the candidate universe, the filter is evaluated over - * those ids only, `orderBy` sorts the whole candidate set, and the page is - * cut LAST. - * - **`null` before work, not instead of an answer.** A shape the index does - * not plan must be handed back BEFORE any evaluation, so Brainy serves it - * through the stage doors exactly as it always has. Returning `null` after - * partial work, or an empty page for a shape it could not evaluate, is a - * silent wrong answer. - * - **`emptyAt` names the stage** that produced an empty page — `'graph'`, - * `'filter'`, `'visibility'` or `'none'` — so Brainy can apply its serving - * law to the right index. An empty answer from an index that is not - * serving must refuse loudly, and Brainy can only re-verify what it is told. - * - * Absent → every `find()` is served by the stage doors, which is Brainy's - * own behaviour and the ordering oracle for any implementation of this one. - * @param params - The find params, already normalized by `find()` - * (natural-language parsed, `connected` anchors resolved to canonical ids, - * an empty `where` dropped). - * @param hiddenIds - Ids this read must not return. The contract is the ANSWER, not the - * mechanism: a provider may subtract this set before paging, or derive the - * same exclusion from the params' visibility tiers itself — either way the - * page must equal the engine's own answer with none of these ids in it. - * @param graphIndex - The active graph provider, for a `connected` plan. - * @returns The page's ids plus the stage that emptied it, or `null`. - */ - planFindPage?( - params: any, - hiddenIds: readonly string[], - graphIndex: unknown - ): Promise<{ ids: string[]; emptyAt: 'graph' | 'filter' | 'visibility' | 'none' } | null> getIdsForTextQuery(query: string): Promise> - /** - * @description OPTIONAL: score `query` over `ids` ONLY — the text-leg twin of - * {@link filterIdsWithin}, and the door a hybrid `find({ query, where })` - * walks. The metadata filter's universe is the candidate set there, so the - * text leg must cost O(|ids|) membership checks and marshal at most `|ids|` - * rows, never the whole posting list of every query word. A native index - * intersects its own postings with the candidate set (membership by entity - * int) before any string crosses the boundary; the reference index answers - * from its own `getIdsForTextQuery`, so the two doors can never disagree. - * Absent → Brainy intersects `getIdsForTextQuery`'s answer with `ids` itself - * (correct, and still hydrate-last, but it marshals the whole answer). - * - * The answer keeps `getIdsForTextQuery`'s contract: `{ id, matchCount }` - * sorted by `matchCount` descending, ties in the order the whole-store answer - * would have produced. Only rows in `ids` may appear. - * @param query - The same text query accepted by `getIdsForTextQuery`. - * @param ids - The candidate ids (canonical). The answer is a subset. - */ - getIdsForTextQueryWithin?( - query: string, - ids: readonly string[] - ): Promise> - /** - * @description OPTIONAL: read named SCALAR fields for many ids at once, from - * the index's own value storage, WITHOUT touching the canonical record. - * - * This is the door behind `find/get/related({ fields })`. A list view that - * needs a title and a slug currently hydrates the whole record for every row - * — document bodies included — and then discards almost all of it. Serving - * the named scalars from the index turns that into an index read. - * - * ## The contract, and the one rule that makes it safe - * - * **Return only what you can serve EXACTLY, and say what you served.** The - * answer is a per-id map of the fields this index actually resolved; the - * caller diffs it against what was requested and reads the canonical record - * for the remainder. An implementation must therefore OMIT a field rather - * than approximate it — and omission costs only a record read, while a wrong - * value is a wrong answer nobody can see. - * - * That rule is not hypothetical. This engine's own index buckets - * `system.createdAt` and `system.updatedAt` to the minute for range queries, - * so it cannot serve them exactly and omits them. An engine whose column - * store holds raw values can serve the same fields — so the two answer - * differently in COST and identically in CONTENT, which is the only - * difference a projection door is allowed to have. - * - * A field absent from an entity is simply absent from that entity's map. It - * is never an error, and never a `null` standing in for one: absent and - * present-and-null are different answers. - * - * @param ids - Canonical entity ids to read. - * @param fields - Index KEYS (bare = user metadata, `system.*` = engine - * scalar), already address-resolved by the caller. - * @returns `id → { field: value }` for the fields this index served exactly. - * Ids with nothing to serve may be omitted entirely. - */ - getScalarsForIds?( - ids: readonly string[], - fields: readonly string[] - ): Promise>> getSortedIdsForFilter(filter: any, orderBy: string, order?: 'asc' | 'desc', topK?: number): Promise getFilterValues(field: string): Promise getFilterFields(): Promise @@ -592,14 +309,7 @@ export interface MetadataIndexProvider { * the ceiling on the JS path), so `Number(bigint)` narrowing is lossless. */ getIdMapper(): { - /** - * Resolve-or-mint the entity's int. `generation` is OPTIONAL (additive): - * Brainy's commit generation current at mint time, so a mapper with - * per-record delta logs stamps the assignment record with a real - * watermark instead of a literal 0. Ignored when the uuid is already - * assigned (assignments are append-only) and by the JS mapper. - */ - getOrAssign(uuid: string, generation?: bigint): number + getOrAssign(uuid: string): number getInt(uuid: string): number | undefined getUuid(intId: number): string | undefined } @@ -659,20 +369,6 @@ export interface GraphIndexProvider { */ validateInvariants?(): Promise - /** - * @description OPTIONAL. The named, SYNCHRONOUS, O(1) health verdict this - * provider derives from its own exact ledgers — see {@link HealthReport} for - * the full derivation laws. MUST NOT perform I/O and MUST NOT throw for a - * well-formed provider (brainy treats a throw as a CONTRACT VIOLATION, never - * as "unknown"). When present, brainy's read gate (`assessProviderHealth()`) - * reads THIS instead of `isReady()` / size heuristics: `serving` decides - * whether reads may proceed; a `false` refuses the read loudly rather than - * triggering a rebuild. Absent → the gate falls back to `isReady?()` / the - * size heuristic (this train's JS built-in providers stay on that interim - * path). - */ - healthReport?(): HealthReport - /** * @description OPTIONAL eager cold-load. Called once during brain init — AFTER * the metadata provider's `init()` (so the id-mapper is hydrated; a native int @@ -699,18 +395,6 @@ export interface GraphIndexProvider { */ warm?(): Promise - /** - * @description OPTIONAL self-reported {@link ProviderMaintenanceDebt} — - * the observability seam so an operator sees outstanding background - * maintenance work (e.g. a build-new→verify→swap in flight, unmerged - * adjacency segments) BEFORE it grinds a transaction into a - * budget-busting op. Absence means this provider does not track debt; - * `brain.maintenanceDebt()` reports this surface `'unavailable'` rather - * than guessing. See {@link ProviderMaintenanceDebt} for the - * measure-only-what-you-track contract. - */ - maintenanceDebt?(): Promise - /** * @description OPTIONAL. A native provider returns true from the moment its * `init()` detects a large epoch-drift until its background @@ -1297,33 +981,8 @@ export interface VectorIndexProvider { */ readonly name: string - /** - * @description Insert one vector. - * @param item - The vector document (`id` + `vector`). - * @param generation - OPTIONAL (additive) — Brainy's commit generation for - * this write: the SAME u64 counter the graph provider's - * `addVerb(..., generation)` carries (and that `search`'s as-of - * `options.generation` reads back), resolved at operation-execute time. - * A provider with per-record delta logs / segment stamps records it so - * its watermark reflects real data — never a literal 0. `undefined` = - * the caller has no commit generation (rebuild-from-canonical, the - * at-generation materializer's ephemeral reader); treat as "unstamped", - * not generation 0. The built-in JS index accepts and ignores it (it - * serves "now" only). The feature-detected `updateItem` capability (see - * `src/transaction/operations/IndexOperations.ts`) carries the same - * optional trailing generation. - */ - addItem(item: VectorDocument, generation?: bigint): Promise - /** - * @description Remove one vector by id. - * @param id - The entity's UUID. - * @param generation - OPTIONAL (additive) — Brainy's commit generation for - * this removal, same contract as {@link VectorIndexProvider.addItem}: a - * provider with durable delete records stamps the tombstone at this - * generation (as-of reads before it still see the vector); the JS index - * removes immediately and ignores it. - */ - removeItem(id: string, generation?: bigint): Promise + addItem(item: VectorDocument): Promise + removeItem(id: string): Promise search( queryVector: Vector, k?: number, @@ -1398,18 +1057,6 @@ export interface VectorIndexProvider { */ warm?(): Promise - /** - * @description OPTIONAL self-reported {@link ProviderMaintenanceDebt} — - * the observability seam so an operator sees outstanding background - * maintenance work (e.g. unflushed writes, a pending rebuild) BEFORE it - * grinds a transaction into a budget-busting op. Absence means this - * provider does not track debt; `brain.maintenanceDebt()` reports this - * surface `'unavailable'` rather than guessing. See - * {@link ProviderMaintenanceDebt} for the measure-only-what-you-track - * contract. - */ - maintenanceDebt?(): Promise - /** * @description OPTIONAL honest durability signal (readiness contract, * mirrors {@link GraphIndexProvider.isReady}). `true` ⇔ the persisted @@ -1436,20 +1083,6 @@ export interface VectorIndexProvider { */ validateInvariants?(): Promise - /** - * @description OPTIONAL. The named, SYNCHRONOUS, O(1) health verdict this - * provider derives from its own exact ledgers — see {@link HealthReport} for - * the full derivation laws. MUST NOT perform I/O and MUST NOT throw for a - * well-formed provider (brainy treats a throw as a CONTRACT VIOLATION, never - * as "unknown"). When present, brainy's read gate (`assessProviderHealth()`) - * reads THIS instead of `isReady()` / size heuristics: `serving` decides - * whether reads may proceed; a `false` refuses the read loudly rather than - * triggering a rebuild. Absent → the gate falls back to `isReady?()` / the - * size heuristic (this train's JS built-in providers stay on that interim - * path). - */ - healthReport?(): HealthReport - /** * @description OPTIONAL. A native provider returns true from the moment its * `init()` detects a large epoch-drift until its background @@ -1483,29 +1116,10 @@ export interface EntityIdMapperProvider { * stays compatible — `restore()` falls back to `init()` when this is absent. */ rebuild?(): Promise - /** - * @description Resolve-or-mint the entity's interned int (append-only: - * once assigned, a uuid's int never changes and is never recycled). - * @param uuid - The entity's UUID. - * @param generation - OPTIONAL (additive) — Brainy's commit generation - * current at mint time (the same u64 counter the graph/metadata write - * surfaces carry). A mapper with per-record delta logs stamps the - * assignment record with this real watermark instead of a literal 0. - * Ignored when the uuid is already assigned, and by the JS mapper - * (which keeps no per-record log). - */ - getOrAssign(uuid: string, generation?: bigint): number + getOrAssign(uuid: string): number getUuid(intId: number): string | undefined getInt(uuid: string): number | undefined - /** - * @description Remove the uuid's mapping (the int stays reserved). - * @param uuid - The entity's UUID. - * @param generation - OPTIONAL (additive) — Brainy's commit generation for - * this removal: a mapper with a per-key version chain tombstones the - * mapping at this generation (as-of reads before it still resolve); - * the JS mapper removes immediately and ignores it. - */ - remove(uuid: string, generation?: bigint): boolean + remove(uuid: string): boolean flush(): Promise clear(): Promise getAllIntIds(): number[] @@ -1697,13 +1311,9 @@ export class PluginRegistry { this.activated.add(name) activated.push(name) } else { - // Documented graceful decline (activate() → false). Surface it on the - // ALWAYS-ON channel: `silent: true` patches console, and a declined - // accelerator warned into a patched console is a silent degrade to the - // default engines — the exact invisible-fallback class this registry - // exists to prevent (a production storm ran the WASM engine for 90s - // behind one suppressed warn). - prodLog.warn( + // Documented graceful decline (activate() → false). Surface it loudly so + // a silent degrade to the default engine never goes unnoticed. + console.warn( `[brainy] Plugin "${name}" declined activation (activate() returned false); ` + `the default engine is in use for its providers.` ) diff --git a/src/reprojection/factLogSource.ts b/src/reprojection/factLogSource.ts deleted file mode 100644 index 796fb343..00000000 --- a/src/reprojection/factLogSource.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * @module reprojection/factLogSource - * @description The production {@link FactSource}: adapts the database's - * committed-fact scan to the reprojection engine's `scan(from, limit)` - * window contract. - * - * DEPENDENCY-CLEAN BY DESIGN: this module never imports the database class. - * It wraps a host-owned scan callback `(from, limit) => Promise` - * injected at construction, so the host wires itself in one line — either by - * handing {@link FactLogSource} a callback built on its own scan API, or via - * {@link factSourceFromHost}, which builds that callback from any object - * structurally exposing `scanFacts` (the batch-handle shape the fact log - * serves). - * - * CONTRACT ENFORCEMENT — loud, never quiet: every `scan` return is checked - * (≤ limit facts, strictly ascending generations, all strictly above `from`); - * a violating callback throws instead of silently corrupting a fold. A host - * with NO fact log throws too — reporting "caught up" against an unscannable - * store would be a silent lie. - */ - -import type { CommitFact } from '../db/factLog.js' -import type { FactSource } from './reprojectionEngine.js' - -/** - * The host-owned scan callback: return up to `limit` committed facts with - * generation strictly greater than `from`, in ascending generation order; - * empty means caught up to the head as of the call. - */ -export type FactScanCallback = (from: number, limit: number) => Promise - -/** - * The minimal structural surface of a fact-scanning host — matches the - * database's `scanFacts` shape without importing it. `scanFacts` returns a - * handle whose `batches()` yields ordered, non-empty fact batches, or `null` - * when the store hosts no fact log. - */ -export interface FactScanHost { - scanFacts(options?: { fromGeneration?: number; batchSize?: number }): { - batches: () => AsyncGenerator<{ facts: CommitFact[] }> - } | null -} - -/** - * The production {@link FactSource}: wraps an injected scan callback and - * enforces the window contract on every return. - * - * COST NOTE: each `scan` call is stateless (a fresh window above the caller's - * watermark), which is exactly what resumable, crash-tolerant folds need — - * at the price of the host re-opening its scan per call. Fine for - * budget-capped maintenance; not a hot-path read primitive. - */ -export class FactLogSource implements FactSource { - private readonly scanCallback: FactScanCallback - - /** @param scanCallback - The host-owned scan (see {@link FactScanCallback}). */ - constructor(scanCallback: FactScanCallback) { - if (typeof scanCallback !== 'function') { - throw new Error('FactLogSource: a scan callback (from, limit) => Promise is required') - } - this.scanCallback = scanCallback - } - - /** - * Fetch up to `limit` committed facts strictly above generation `from`, - * verifying the callback honored the window contract. - * @param from - Exclusive lower bound generation (≥ 0 integer). - * @param limit - Maximum facts to return (≥ 1 integer). - */ - async scan(from: number, limit: number): Promise { - if (!Number.isInteger(from) || from < 0) { - throw new Error(`FactLogSource.scan: 'from' must be a non-negative integer (got ${from})`) - } - if (!Number.isInteger(limit) || limit < 1) { - throw new Error(`FactLogSource.scan: 'limit' must be a positive integer (got ${limit})`) - } - const facts = await this.scanCallback(from, limit) - if (!Array.isArray(facts)) { - throw new Error('FactLogSource.scan: the scan callback must resolve to an array of facts') - } - if (facts.length > limit) { - throw new Error( - `FactLogSource.scan: the scan callback returned ${facts.length} facts for limit ${limit} — ` + - `contract violation; refusing to fold an oversized window` - ) - } - let prev = from - for (const fact of facts) { - const g = fact?.generation - if (typeof g !== 'number' || !Number.isFinite(g) || g <= prev) { - throw new Error( - `FactLogSource.scan: the scan callback violated the window contract — generation ` + - `${String(g)} is not strictly ascending above ${prev} (from=${from}); refusing to fold` - ) - } - prev = g - } - return facts - } -} - -/** - * Build the production source from any host structurally exposing - * `scanFacts` — the one-line wiring for the database side: - * - * ```ts - * const source = factSourceFromHost(brain) - * ``` - * - * Each `scan(from, limit)` opens `scanFacts({ fromGeneration: from + 1, - * batchSize: limit })` (the engine's `from` is exclusive; `scanFacts` bounds - * are inclusive) and returns the FIRST batch, closing the handle — short - * batches at segment boundaries are legal under the source contract (only - * EMPTY means caught up). A host with no fact log throws loudly. - * - * @param host - Any object with the `scanFacts` batch-handle shape. - */ -export function factSourceFromHost(host: FactScanHost): FactLogSource { - if (!host || typeof host.scanFacts !== 'function') { - throw new Error('factSourceFromHost: the host must expose scanFacts(options)') - } - return new FactLogSource(async (from, limit) => { - const scan = host.scanFacts({ fromGeneration: from + 1, batchSize: limit }) - if (scan === null) { - throw new Error( - 'reprojection: this store hosts no fact log — reprojection folds committed facts, ' + - 'and reporting a caught-up fold against an unscannable store would be a silent lie' - ) - } - const iterator = scan.batches() - try { - const first = await iterator.next() - return first.done ? [] : first.value.facts - } finally { - // Close the abandoned generator so its cleanup (timers) runs. - if (typeof iterator.return === 'function') { - await iterator.return(undefined) - } - } - }) -} diff --git a/src/reprojection/reprojectionEngine.ts b/src/reprojection/reprojectionEngine.ts deleted file mode 100644 index 1465b1f6..00000000 --- a/src/reprojection/reprojectionEngine.ts +++ /dev/null @@ -1,648 +0,0 @@ -/** - * @module reprojection/reprojectionEngine - * @description The pure-TS reprojection engine — the ONE machinery for - * rebuilding, healing, and migrating persisted projections from the committed - * fact log on the JS side. It is the TypeScript twin of the native engine's - * reprojection core: the same frozen contract (names AND semantics), so a - * single shared conformance suite runs against both implementations and - * TS-only deployments green the same rows without native code. - * - * THE AVAILABILITY LAW — maintenance never holds the doors: - * - * - Work proceeds in INSTALLMENTS of at most {@link MAX_INSTALLMENT_MS} (50ms) - * of wall time each. Between installments the loop awaits a REAL macrotask - * boundary (never a busy loop, never a bare microtask), so foreground I/O - * and timers always interleave with a running fold. - * - Foreground door traffic announces itself via {@link DoorSignal.bump}. An - * in-flight {@link ReprojectionEngine.advance} yields at the next - * installment boundary and returns `{ status: 'preempted' }` — the doors - * never wait for maintenance to finish. - * - Budgets are honored: `advance` stops once `budgetMs` is spent and reports - * exactly how far it got; a later call RESUMES from the adapter's own - * watermark. Nothing ever refolds from zero because a budget ran out. - * - * WATERMARK DISCIPLINE — the engine NEVER writes stamps. Each adapter's - * `applyBatch` owns its own durability and its own stamp (stamp-after-data, - * the law stated in src/utils/projectionWatermark.ts); the engine only READS - * `watermark()` to decide the next scan window. Delivery is therefore - * at-least-once: an adapter that crashed between data and stamp is re-served - * the same facts on resume and MUST apply idempotently. - * - * THE FOUR ANSWER CLASSES of an advance: `'caught-up'` (folded to the head of - * the requested window, ledger clean), `'preempted'` (a door bumped), - * `'budget-exhausted'` (time ran out mid-stream), and `'quarantined'` (folded - * to the head, but this family's quarantine ledger is non-empty — one or more - * poison facts are being skipped and reads touching them are suspect). - */ - -import type { CommitFact } from '../db/factLog.js' -import { prodLog } from '../utils/logger.js' - -/** - * The hard ceiling on one installment of fold work, in wall-clock ms. An - * advance loop that has run this long without yielding closes the installment - * and awaits a macrotask boundary so foreground traffic interleaves. Frozen by - * the shared contract — both engines install the same ceiling. - */ -export const MAX_INSTALLMENT_MS = 50 - -/** Default facts-per-batch pulled from the {@link FactSource} per step. */ -export const DEFAULT_REPROJECTION_BATCH_SIZE = 256 - -/** - * One registered projection family: a named consumer that folds committed - * facts into its own persisted artifact and stamps its own watermark. - * - * OWNERSHIP: the adapter owns durability AND the stamp. `applyBatch` must - * persist its data first and stamp `upTo` after (stamp-after-data), and must - * tolerate at-least-once delivery — on resume after a crash between data and - * stamp, the same facts arrive again. - */ -export interface ProjectionAdapter { - /** Unique family name — the registry key; one adapter serves a family at a time. */ - family: string - /** - * The highest generation this projection's persisted state reflects, or - * `null` when the projection is unbuilt/unstamped. The engine reads this to - * open the next scan window; it never writes it. - */ - watermark(): number | null - /** - * Fold `facts` (ascending generations, all strictly above the current - * watermark) into the projection, then stamp `watermark = upTo`. - * - * `facts` MAY be empty while `upTo` is above the current watermark: that is - * a pure watermark advance past quarantined generations — the adapter must - * still stamp, or the fold cannot make progress past the poison. - * - * FAILURE CONTRACT: throw a {@link ProjectionApplyError} to name exactly one - * poison fact (the engine quarantines it and continues). ANY other throw - * aborts the advance loudly — an unknown failure is never treated as a - * poison record. - */ - applyBatch(facts: CommitFact[], upTo: number): Promise - /** - * Destroy this adapter's persisted artifact(s). The engine calls this on - * the LOSING adapter after a successful {@link ReprojectionEngine.swap}, - * and on a partially-built replacement whose build aborted. - */ - discard(): Promise -} - -/** - * The committed-fact scan the engine folds from. `from` is an EXCLUSIVE lower - * bound generation; the source returns at most `limit` facts in ascending - * generation order, and an empty array means caught up to the head as of this - * call. Short non-empty returns are legal (e.g. a segment boundary) — only - * empty means done. - */ -export interface FactSource { - scan(from: number, limit: number): Promise -} - -/** - * The foreground-preemption signal. Door traffic (foreground reads/writes) - * calls {@link DoorSignal.bump}; an in-flight `advance` observes the bump at - * its next installment boundary, yields a macrotask, and returns - * `{ status: 'preempted' }`. Bumps are edge-triggered per advance: only bumps - * that arrive AFTER an advance began preempt it. - */ -export class DoorSignal { - private count = 0 - - /** Announce foreground door traffic — an in-flight advance will yield. */ - bump(): void { - this.count++ - } - - /** - * The current bump epoch — the engine snapshots this at advance entry and - * compares at installment boundaries. - * @internal - */ - epoch(): number { - return this.count - } -} - -/** - * The TYPED poison-record failure an adapter throws from `applyBatch` to name - * exactly one unfoldable fact. The engine quarantines that generation for - * that family (skips it, ledgers it, narrates per-doubling) and keeps - * folding. Any OTHER throw from `applyBatch` aborts the advance loudly. - */ -export class ProjectionApplyError extends Error { - /** The generation of the fact that cannot be applied. */ - readonly generation: number - /** Optional index of the offending record within the fact's ops. */ - readonly recordIndex?: number - /** The underlying failure. */ - override readonly cause: unknown - - /** - * @param args - `generation` names the poison fact; `recordIndex` - * optionally narrows to one record inside it; `cause` carries the - * underlying failure. - */ - constructor(args: { generation: number; recordIndex?: number; cause: unknown }) { - super( - `projection apply failed at generation ${args.generation}` + - (args.recordIndex !== undefined ? ` (record ${args.recordIndex})` : '') - ) - this.name = 'ProjectionApplyError' - this.generation = args.generation - if (args.recordIndex !== undefined) this.recordIndex = args.recordIndex - this.cause = args.cause - } -} - -/** - * The TYPED single-flight refusal: a second concurrent - * {@link ReprojectionEngine.swap} on a family whose replacement is still - * building. The caller retries after the in-flight swap settles. - */ -export class SwapInFlightError extends Error { - /** The family whose swap is already in flight. */ - readonly family: string - - /** @param family - The family whose swap is already in flight. */ - constructor(family: string) { - super( - `reprojection: a swap is already in flight for family '${family}' — ` + - `swaps are single-flight per family; retry after the current build settles` - ) - this.name = 'SwapInFlightError' - this.family = family - } -} - -/** One quarantined fact in a family's ledger. */ -export interface QuarantineEntry { - /** The generation being skipped for this family. */ - generation: number - /** The typed apply failure that condemned it. */ - error: ProjectionApplyError - /** Wall-clock ms when it was quarantined (diagnostic). */ - at: number -} - -/** How an advance ended — the four answer classes (see the module header). */ -export type AdvanceStatus = 'caught-up' | 'preempted' | 'budget-exhausted' | 'quarantined' - -/** The result of one advance over one family. */ -export interface AdvanceResult { - /** The answer class. */ - status: AdvanceStatus - /** The family's watermark as stamped by its own adapter, after this advance. */ - watermark: number | null - /** - * Facts delivered in SUCCESSFUL `applyBatch` calls during this advance. - * At-least-once delivery means retried facts (after a quarantine or a - * resume) count again; this is delivered work, not distinct generations. - */ - applied: number -} - -/** The result of a completed {@link ReprojectionEngine.swap}. */ -export interface SwapResult { - /** The NEW adapter's watermark at the flip (parity with the head). */ - watermark: number | null - /** Facts delivered to the replacement during its beside-build. */ - applied: number -} - -/** Constructor options for {@link ReprojectionEngine}. */ -export interface ReprojectionEngineOptions { - /** The committed-fact scan every family folds from. */ - source: FactSource - /** The preemption signal; a fresh one is created when omitted. */ - doorSignal?: DoorSignal - /** - * Installment ceiling in ms, `(0, MAX_INSTALLMENT_MS]`. Out-of-range values - * throw — the 50ms law is a ceiling, never a suggestion. - */ - installmentMs?: number - /** Facts per {@link FactSource.scan} pull (default {@link DEFAULT_REPROJECTION_BATCH_SIZE}). */ - batchSize?: number -} - -/** The fold-side state shared by a serving family and a swap's beside-build. */ -interface FoldState { - adapter: ProjectionAdapter - /** The quarantine ledger, in condemnation order. */ - quarantine: QuarantineEntry[] - /** Generations filtered out of every batch served to this adapter. */ - skip: Set - /** Next ledger size that triggers a narration (1, 2, 4, 8, …). */ - nextWarnAt: number -} - -/** A registered family: fold state plus the single-flight swap latch. */ -interface FamilyState extends FoldState { - swapInFlight: boolean -} - -/** One real macrotask boundary — foreground I/O and timers run before resume. */ -function yieldToDoors(): Promise { - return new Promise((resolve) => { - if (typeof setImmediate === 'function') { - setImmediate(resolve) - } else { - setTimeout(resolve, 0) - } - }) -} - -/** - * The reprojection engine: registry of projection families, budget-capped - * yielding advances, round-robin `advanceAll`, atomic build-beside `swap`, - * and the per-family quarantine ledger. Pure TS, no storage dependencies — - * everything durable lives behind the injected {@link FactSource} and the - * registered {@link ProjectionAdapter}s. - */ -export class ReprojectionEngine { - /** The preemption signal foreground door traffic bumps. */ - readonly doorSignal: DoorSignal - - private readonly source: FactSource - private readonly installmentMs: number - private readonly batchSize: number - private readonly registry = new Map() - /** Rotates the family that leads each `advanceAll`, so repeated tiny-budget calls stay fair. */ - private roundRobinCursor = 0 - - /** @param options - See {@link ReprojectionEngineOptions}. */ - constructor(options: ReprojectionEngineOptions) { - if (!options || typeof options.source?.scan !== 'function') { - throw new Error('reprojection: a FactSource with scan(from, limit) is required') - } - const installmentMs = options.installmentMs ?? MAX_INSTALLMENT_MS - if (!(installmentMs > 0) || installmentMs > MAX_INSTALLMENT_MS) { - throw new Error( - `reprojection: installmentMs must be in (0, ${MAX_INSTALLMENT_MS}] — ` + - `${installmentMs} would let maintenance hold the doors` - ) - } - const batchSize = options.batchSize ?? DEFAULT_REPROJECTION_BATCH_SIZE - if (!Number.isInteger(batchSize) || batchSize < 1) { - throw new Error(`reprojection: batchSize must be a positive integer (got ${batchSize})`) - } - this.source = options.source - this.doorSignal = options.doorSignal ?? new DoorSignal() - this.installmentMs = installmentMs - this.batchSize = batchSize - } - - /** - * Register a projection family. Refuses a duplicate family loudly — the - * sanctioned way to replace a serving adapter is {@link swap}, never - * re-registration. - * @param adapter - The adapter that will serve this family. - */ - register(adapter: ProjectionAdapter): void { - if (!adapter || typeof adapter.family !== 'string' || adapter.family.length === 0) { - throw new Error('reprojection: adapter.family must be a non-empty string') - } - if (this.registry.has(adapter.family)) { - throw new Error( - `reprojection: family '${adapter.family}' is already registered — ` + - `replace a serving adapter via swap(), never by re-registering` - ) - } - this.registry.set(adapter.family, { - adapter, - quarantine: [], - skip: new Set(), - nextWarnAt: 1, - swapInFlight: false - }) - } - - /** - * The adapter currently serving `family` (observability — e.g. asserting - * the old adapter still serves during a swap's beside-build), or undefined - * when the family is not registered. - * @param family - The family name. - */ - getAdapter(family: string): ProjectionAdapter | undefined { - return this.registry.get(family)?.adapter - } - - /** - * This family's quarantine ledger (a defensive copy, condemnation order). - * Non-empty means one or more generations are being skipped for this - * family — the projection owner should refuse reads the skipped facts - * would have affected. - * @param family - The family name (must be registered). - */ - quarantined(family: string): QuarantineEntry[] { - return [...this.mustGet(family).quarantine] - } - - /** - * Advance one family toward the head of the fact log (or toward `upTo`), - * in installments, under a wall-clock budget, preemptible by the door - * signal. Always makes at least ONE step of progress before any budget - * check, so a zero budget still advances. - * - * @param family - The registered family to advance. - * @param options - `budgetMs` caps this call's wall time (≥ 0); `upTo` - * optionally caps the fold at a generation (inclusive). - * @returns The answer class with the adapter-stamped watermark and the - * count of facts delivered in successful applyBatch calls. - */ - async advance(family: string, options: { budgetMs: number; upTo?: number }): Promise { - const state = this.mustGet(family) - const budgetMs = options?.budgetMs - if (typeof budgetMs !== 'number' || !(budgetMs >= 0)) { - throw new Error(`reprojection: advance('${family}') requires budgetMs >= 0 (got ${budgetMs})`) - } - const start = Date.now() - const entryEpoch = this.doorSignal.epoch() - let installmentStart = start - let applied = 0 - - for (;;) { - const stepResult = await this.step(state, options.upTo) - applied += stepResult.applied - if (stepResult.done) { - return this.completed(state, applied) - } - // A bump ends the current installment immediately: yield a macrotask so - // the foreground work runs, then answer 'preempted'. - if (this.doorSignal.epoch() !== entryEpoch) { - await yieldToDoors() - return { status: 'preempted', watermark: state.adapter.watermark(), applied } - } - const t = Date.now() - if (t - start >= budgetMs) { - return { status: 'budget-exhausted', watermark: state.adapter.watermark(), applied } - } - if (t - installmentStart >= this.installmentMs) { - await yieldToDoors() - installmentStart = Date.now() - } - } - } - - /** - * Advance EVERY registered family toward the head under one shared budget, - * round-robin at batch granularity — one batch per family per turn — so no - * family starves behind another's backlog. The leading family rotates - * across calls, keeping repeated tiny-budget calls fair too. - * - * @param options - `budgetMs` caps this call's total wall time (≥ 0). - * @returns Per-family results. Families still mid-stream when the budget - * ran out (or a door bumped) report `'budget-exhausted'` (or - * `'preempted'`) at their current watermark. - */ - async advanceAll(options: { budgetMs: number }): Promise> { - const budgetMs = options?.budgetMs - if (typeof budgetMs !== 'number' || !(budgetMs >= 0)) { - throw new Error(`reprojection: advanceAll requires budgetMs >= 0 (got ${budgetMs})`) - } - const start = Date.now() - const entryEpoch = this.doorSignal.epoch() - let installmentStart = start - - const all = [...this.registry.values()] - const results: Record = {} - const appliedBy = new Map() - if (all.length === 0) return results - - // Rotate the leader across calls (fairness across repeated small budgets). - const offset = this.roundRobinCursor % all.length - this.roundRobinCursor = (this.roundRobinCursor + 1) % all.length - let queue = [...all.slice(offset), ...all.slice(0, offset)] - for (const s of queue) appliedBy.set(s.adapter.family, 0) - - const finish = ( - status: 'preempted' | 'budget-exhausted', - remaining: FamilyState[] - ): Record => { - for (const s of remaining) { - results[s.adapter.family] = { - status, - watermark: s.adapter.watermark(), - applied: appliedBy.get(s.adapter.family) ?? 0 - } - } - return results - } - - while (queue.length > 0) { - const survivors: FamilyState[] = [] - for (let i = 0; i < queue.length; i++) { - const s = queue[i] - const fam = s.adapter.family - const stepResult = await this.step(s, undefined) - appliedBy.set(fam, (appliedBy.get(fam) ?? 0) + stepResult.applied) - if (stepResult.done) { - results[fam] = this.completed(s, appliedBy.get(fam) ?? 0) - } else { - survivors.push(s) - } - const remaining = [...survivors, ...queue.slice(i + 1)] - if (this.doorSignal.epoch() !== entryEpoch) { - await yieldToDoors() - return finish('preempted', remaining) - } - const t = Date.now() - if (t - start >= budgetMs && remaining.length > 0) { - return finish('budget-exhausted', remaining) - } - if (t - installmentStart >= this.installmentMs) { - await yieldToDoors() - installmentStart = Date.now() - } - } - queue = survivors - } - return results - } - - /** - * Replace a family's adapter by BUILD-BESIDE: the old adapter keeps serving - * (stays registered, its watermark untouched) while the replacement folds - * from its own watermark (null/0 for a fresh build) to parity with the head - * of the fact log. The flip is ATOMIC — a single registry pointer swap with - * no await between the parity check and the assignment — and the losing - * adapter's `discard()` is called after the flip. - * - * SINGLE-FLIGHT: a second concurrent swap on the same family throws a - * typed {@link SwapInFlightError}. The build yields at installment - * boundaries like any fold (doors interleave), but it is never - * preemption-aborted — a swap under steady foreground traffic still - * completes. - * - * On a build failure the partially-built replacement is discarded - * (best-effort, narrated if that also fails) and the error propagates; the - * old adapter keeps serving untouched. - * - * @param family - The registered family to replace. - * @param buildAdapter - Factory for the replacement adapter (same family). - * @returns The new adapter's watermark at the flip and the facts delivered - * during the build. - */ - async swap(family: string, buildAdapter: () => Promise): Promise { - const state = this.mustGet(family) - if (state.swapInFlight) throw new SwapInFlightError(family) - state.swapInFlight = true - try { - const next = await buildAdapter() - if (!next || next.family !== family) { - throw new Error( - `reprojection: swap('${family}') built an adapter for family ` + - `'${next?.family}' — the replacement must serve the same family` - ) - } - const build: FoldState = { adapter: next, quarantine: [], skip: new Set(), nextWarnAt: 1 } - let applied = 0 - let installmentStart = Date.now() - let stalledDoneAt: number | null = null - - try { - for (;;) { - const stepResult = await this.step(build, undefined) - applied += stepResult.applied - if (stepResult.applied > 0) stalledDoneAt = null - if (stepResult.done) { - // Parity: the build just saw an empty scan (caught up to the head - // as of that call). The serving adapter can never be beyond the - // head, so newWm >= oldWm holds — verified loudly, never assumed. - const oldWm = state.adapter.watermark() ?? 0 - const newWm = next.watermark() ?? 0 - if (newWm >= oldWm) break - if (stalledDoneAt === newWm) { - throw new Error( - `reprojection: swap('${family}') build is caught up to the head at ` + - `generation ${newWm} but the serving adapter claims watermark ${oldWm} — ` + - `the serving stamp is beyond the fact log; refusing to flip` - ) - } - // The head moved past our scan (a concurrent fold advanced the - // serving adapter) — keep folding to the new head. - stalledDoneAt = newWm - } - if (Date.now() - installmentStart >= this.installmentMs) { - await yieldToDoors() - installmentStart = Date.now() - } - } - } catch (err) { - await next.discard().catch((cleanupErr) => { - prodLog.warn( - `reprojection: swap('${family}') build failed AND the failed build's discard() ` + - `also failed — its artifact may be orphaned`, - cleanupErr - ) - }) - throw err - } - - // THE FLIP — atomic by construction: no await between the parity check - // above and this pointer swap; readers see the old adapter until this - // line and the new one from it. - const losing = state.adapter - state.adapter = next - state.quarantine = build.quarantine - state.skip = build.skip - state.nextWarnAt = build.nextWarnAt - - try { - await losing.discard() - } catch (discardErr) { - // The flip already happened and the new adapter serves; the only loss - // is the loser's orphaned artifact — said out loud, never rethrown as - // a false swap failure. - prodLog.warn( - `reprojection: swap('${family}') completed but the losing adapter's discard() ` + - `failed — its artifact may be orphaned`, - discardErr - ) - } - return { watermark: next.watermark(), applied } - } finally { - state.swapInFlight = false - } - } - - /** One fold step: scan a batch above the watermark, filter quarantined generations, apply. */ - private async step(state: FoldState, upTo: number | undefined): Promise<{ done: boolean; applied: number }> { - const from = state.adapter.watermark() ?? 0 - if (upTo !== undefined && from >= upTo) return { done: true, applied: 0 } - let facts = await this.source.scan(from, this.batchSize) - if (facts.length === 0) return { done: true, applied: 0 } - if (upTo !== undefined) { - facts = facts.filter((f) => f.generation <= upTo) - if (facts.length === 0) return { done: true, applied: 0 } - } - const batchUpTo = facts[facts.length - 1].generation - const toApply = state.skip.size > 0 ? facts.filter((f) => !state.skip.has(f.generation)) : facts - try { - await state.adapter.applyBatch(toApply, batchUpTo) - } catch (err) { - if (err instanceof ProjectionApplyError) { - this.recordQuarantine(state, err) - return { done: false, applied: 0 } - } - throw err // unknown failure ≠ poison record — abort the advance loudly - } - // Anti-spin guard: a successful applyBatch that never advances the stamp - // would re-serve the same window forever. Refuse loudly instead. - const after = state.adapter.watermark() ?? 0 - if (after <= from) { - throw new Error( - `reprojection: family '${state.adapter.family}' applyBatch succeeded up to ` + - `generation ${batchUpTo} but the watermark did not advance past ${from} — ` + - `the adapter is not stamping; refusing to spin` - ) - } - return { done: false, applied: toApply.length } - } - - /** Ledger a typed apply failure, skip its generation, narrate per-doubling. */ - private recordQuarantine(state: FoldState, err: ProjectionApplyError): void { - if (!Number.isFinite(err.generation)) { - throw new Error( - `reprojection: family '${state.adapter.family}' threw ProjectionApplyError with a ` + - `non-finite generation (${err.generation}) — cannot quarantine; aborting the advance` - ) - } - if (state.skip.has(err.generation)) { - throw new Error( - `reprojection: family '${state.adapter.family}' threw ProjectionApplyError for ` + - `generation ${err.generation}, which is ALREADY quarantined and was not in the ` + - `batch — the adapter is misreporting; aborting the advance` - ) - } - state.skip.add(err.generation) - state.quarantine.push({ generation: err.generation, error: err, at: Date.now() }) - const n = state.quarantine.length - if (n === state.nextWarnAt) { - state.nextWarnAt *= 2 - prodLog.warn( - `reprojection: family '${state.adapter.family}' quarantined generation ` + - `${err.generation} (${n} quarantined total) — the fact is skipped for this family ` + - `and ledgered; reads it would have affected should be refused by the owner`, - err.cause - ) - } - } - - /** A window completed: 'caught-up' with a clean ledger, 'quarantined' otherwise. */ - private completed(state: FoldState, applied: number): AdvanceResult { - return { - status: state.quarantine.length > 0 ? 'quarantined' : 'caught-up', - watermark: state.adapter.watermark(), - applied - } - } - - /** The registered family state, or a loud refusal. */ - private mustGet(family: string): FamilyState { - const state = this.registry.get(family) - if (!state) throw new Error(`reprojection: family '${family}' is not registered`) - return state - } -} diff --git a/src/storage/adapters/baseStorageAdapter.ts b/src/storage/adapters/baseStorageAdapter.ts index a90adb93..a76d22df 100644 --- a/src/storage/adapters/baseStorageAdapter.ts +++ b/src/storage/adapters/baseStorageAdapter.ts @@ -12,8 +12,7 @@ import { HNSWNounWithMetadata, HNSWVerbWithMetadata, NounMetadata, - VerbMetadata, - CanonicalCounts, + VerbMetadata } from '../../coreTypes.js' import { StorageBatchConfig } from '../baseStorage.js' import { extractFieldNamesFromJson, mapToStandardField } from '../../utils/fieldNameTracking.js' @@ -1029,55 +1028,6 @@ export abstract class BaseStorageAdapter implements StorageAdapter { // Universal count tracking - O(1) operations protected totalNounCount = 0 protected totalVerbCount = 0 - /** - * The ALL-visibility canonical scalars — every noun / verb the unfiltered - * storage walk yields, system and internal tiers included. These are the - * denominators a derived-index provider's coverage ledger subtracts from - * (`posted === all` is the whole-store coverage verdict); the user-facing - * `totalNounCount` / `totalVerbCount` skip hidden tiers by design and can - * never serve as a ledger denominator. Maintained on the write path - * (every new record +1, every proven delete −1), persisted beside the - * counted scalars, recomputed by the sanctioned recount. Never clamped. - */ - protected totalNounCountAll = 0 - protected totalVerbCountAll = 0 - /** - * The count of canonical nouns holding a REAL (non-empty) vector — the - * vector-side mirror of `totalNounCountAll` and the coverage denominator a - * vector index's node-count ledger is measured against. A deferred-embed - * noun (`add({ deferEmbedding: true })`) counts only once its vector - * LANDS (the `system:embed-landing` commit) — its canonical record exists - * (already counted in `totalNounCountAll`) with an empty vector until - * then. Maintained on the write path (a fresh insert whose vector is - * non-empty +1, a deferred embed's landing +1, a PROVEN delete of a - * vectored noun −1), persisted beside the other ALL scalars, recomputed by - * the sanctioned recount. Shares `allCountsSuspect` — no separate flag. - */ - protected totalVectoredNounCount = 0 - /** - * `true` when a delete could not prove whether the record existed (no - * canonical read, no caller-provided prior) — the ALL scalar may be off by - * the unprovable deletes since. Loud, persisted, and cleared only by the - * sanctioned recount; a consumer reading the scalar as a ledger denominator - * must treat a suspect scalar as unverified, never as exact. Also covers - * `totalVectoredNounCount` — a delete whose vector-presence fact was - * unknowable marks this SAME flag rather than minting a second one. - */ - protected allCountsSuspect = false - /** One narration per session for the suspect transition (never per delete). */ - private allCountsSuspectNarrated = false - /** - * Which rule produced the ALL scalars currently in memory. `'identity-record'` - * means one counted entity per metadata content leg — the honest rule: a - * bare id-directory (a ghost or scar left by a partial-delete defect, no - * content leg) counts zero. Set by the one-time derivation and by the - * sanctioned recount, alongside `allCountsSuspect = false`; left `undefined` - * when a loaded counts.json carries the ALL scalars but no stamp — the - * legacy container-rule derivation, which forces `allCountsSuspect = true` - * at load instead. A filesystem concern: `MemoryStorage` has no counts.json - * and never sets this. - */ - protected allCountsDerivedBy?: 'identity-record' protected entityCounts: Map = new Map() // type -> count protected verbCounts: Map = new Map() // verb type -> count protected countCache: Map = new Map() @@ -1089,10 +1039,6 @@ export abstract class BaseStorageAdapter implements StorageAdapter { // Counts changed since the last persist? Drives the write-through flush. protected pendingCountPersist = false - /** The one persist running right now, if any (single-flight law — see flushCounts). */ - private countPersistInFlight: Promise | null = null - /** The one trailing persist a burst has queued behind the in-flight one. */ - private countPersistTrailing: Promise | null = null /** * Get total noun count - O(1) operation @@ -1110,82 +1056,6 @@ export abstract class BaseStorageAdapter implements StorageAdapter { return this.totalVerbCount } - /** - * The canonical count ledger — O(1), no I/O. `counted` is the user-facing - * scalar (public/internal tiers, what `getNounCount()` returns); `all` is - * the ALL-visibility scalar every unfiltered storage walk is measured - * against (the coverage-ledger denominator for derived-index providers); - * `vectors.all` is the vectored-noun scalar — the coverage denominator for - * a vector index's node-count ledger specifically. - * `suspect` is `true` when an unprovable delete has made `all` (any - * family, including `vectors`) unverified since the last sanctioned - * recount (`rebuildTypeCounts`). - * @returns All scalars per family plus the suspect flag. - */ - async getCanonicalCounts(): Promise { - return { - nouns: { counted: this.totalNounCount, all: this.totalNounCountAll }, - verbs: { counted: this.totalVerbCount, all: this.totalVerbCountAll }, - vectors: { all: this.totalVectoredNounCount }, - suspect: this.allCountsSuspect - } - } - - /** - * Mark the ALL scalars unverified after a delete that could not prove the - * record existed. Narrates ONCE per session (the flag is what persists); - * the sanctioned recount clears it. - * @param family - Which family's delete was unprovable. - * @param id - The id whose existence could not be established. - */ - protected markAllCountsSuspect(family: 'noun' | 'verb' | 'noun-vector', id: string): void { - this.allCountsSuspect = true - if (!this.allCountsSuspectNarrated) { - this.allCountsSuspectNarrated = true - console.warn( - `[Storage] ${family} delete of ${id} could not prove the record existed ` + - `(no canonical read, no prior record) — the ALL-visibility count ledger is ` + - `SUSPECT until brain.repairIndex() recounts. Further unprovable deletes ` + - `this session are counted silently under the same flag.` - ) - } - } - - /** - * OPTIONAL narrow ledger hook (see {@link StorageAdapter.noteVectorLanded}): - * record a deferred-embed noun's FIRST real vector landing. The caller - * (the deferred-embed worker) proves this is a genuine landing — not a - * re-embed of an already-vectored row — by observing its own pre-embed - * read's vector was empty, at no added storage cost. - * @param id - The noun whose vector just landed (retained for a future - * narration seam; the count itself needs no id-keyed state). - */ - async noteVectorLanded(id: string): Promise { - void id - this.totalVectoredNounCount++ - this.scheduleCountPersist().catch(() => { - // Ignore persist errors — the in-memory count is authoritative; a later op retries. - }) - } - - /** - * OPTIONAL narrow ledger hook (see {@link StorageAdapter.noteVectorUnlanded}): - * the mirror of {@link noteVectorLanded} — record a noun's vector was just - * REMOVED (rewritten to the unvectored `[]` shape). Never below zero: a - * caller that (incorrectly) fires this for a noun already unvectored would - * otherwise drive the ledger negative — clamped defensively, matching the - * delete path's `if (this.totalVectoredNounCount > 0)` guard. - * @param id - The noun whose vector was just removed (retained for a - * future narration seam; the count itself needs no id-keyed state). - */ - async noteVectorUnlanded(id: string): Promise { - void id - if (this.totalVectoredNounCount > 0) this.totalVectoredNounCount-- - this.scheduleCountPersist().catch(() => { - // Ignore persist errors — the in-memory count is authoritative; a later op retries. - }) - } - /** * Increment count for entity type - O(1) operation. * Concurrency is handled by the process-global mutex @@ -1345,46 +1215,15 @@ export abstract class BaseStorageAdapter implements StorageAdapter { return } - // SINGLE-FLIGHT, COALESCED. Counts are write-through on every change, so - // a burst of writes used to launch one persist per change, all in flight - // together. Two of them inside the same millisecond shared the atomic - // writer's temp path (`.tmp--`): both wrote it, the first rename - // consumed it, the second rename found nothing — ENOENT, ~1,500 times a - // day on a busy production brain, with a full ledger write per change - // behind it. Now exactly one persist runs at a time; requests that arrive - // while it runs collapse into ONE trailing persist that carries the final - // state. A burst of N changes costs at most two writes and never races - // itself. - if (this.countPersistInFlight) { - // The in-flight write may have already serialised a stale snapshot — - // ask for one more pass after it, and let every caller in this burst - // await that same pass. - if (!this.countPersistTrailing) { - this.countPersistTrailing = this.countPersistInFlight - .catch(() => undefined) - .then(() => { - this.countPersistTrailing = null - return this.flushCounts() - }) - } - return this.countPersistTrailing + try { + // Persist to storage (implemented by subclass) + await this.persistCounts() + this.pendingCountPersist = false + } catch (error) { + console.error('CRITICAL: Failed to flush counts to storage:', error) + // Keep pending flag set so we retry on next operation + throw error } - - this.countPersistInFlight = (async () => { - try { - // Persist to storage (implemented by subclass) - this.pendingCountPersist = false - await this.persistCounts() - } catch (error) { - // Keep the flag set so the next operation retries. - this.pendingCountPersist = true - console.error('CRITICAL: Failed to flush counts to storage:', error) - throw error - } finally { - this.countPersistInFlight = null - } - })() - return this.countPersistInFlight } /** diff --git a/src/storage/adapters/fileSystemStorage.ts b/src/storage/adapters/fileSystemStorage.ts index 87b6406f..5eb4785a 100644 --- a/src/storage/adapters/fileSystemStorage.ts +++ b/src/storage/adapters/fileSystemStorage.ts @@ -14,18 +14,10 @@ import { StorageBatchConfig, SYSTEM_DIR, STATISTICS_KEY, - WriterLockInfo, - WriterCloseRecord + WriterLockInfo } 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, - registerTornRecordEncounter -} from '../tornRecordError.js' // Node.js modules - dynamically imported to avoid issues in browser environments let fs: any @@ -100,30 +92,7 @@ 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' - /** - * 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_HEARTBEAT_MS = 10_000 private static readonly WRITER_STALE_THRESHOLD_MS = 60_000 private writerLockHeartbeat?: NodeJS.Timeout private writerLockInfo?: WriterLockInfo @@ -136,13 +105,6 @@ 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 @@ -151,16 +113,9 @@ 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 @@ -279,54 +234,38 @@ export class FileSystemStorage extends BaseStorage { // Finish any restore interrupted by a crash (resume the staged swap, or // discard an uncommitted staging area) BEFORE counts/derived state load, - // so the rest of startup sees the completed store. ORDER-DEPENDENT: - // `swapStagedRestoreIn()` reads `fs.readdir(rootDir)` and then - // removes/renames rootDir's own TOP-LEVEL entries to place the staged - // copy — racing that against the directory-creation batch below (which - // also touches rootDir's children) could see a half-created directory - // mid-swap or a mkdir racing a concurrent rm/rename on the same path. - // Stays strictly sequential, never folded into the OPEN-PATH batch. + // so the rest of startup sees the completed store. await this.completeInterruptedRestore() - // OPEN-PATH FIX: the remaining bootstrap directories are mutually - // independent — each is its own subtree under rootDir, and - // `fs.mkdir(dir, { recursive: true })` creates every intermediate - // segment of ITS OWN path in one call, so it never depends on any - // sibling here existing first. Nothing between here and - // `initializeCounts()` reads any of them, so batching collapses what - // was up to 8 sequential mkdir round-trips (each a real syscall+await) - // into one wave — this is what serialized an N-writer restart storm on - // filesystem I/O it never structurally needed. `initializeCounts()` - // right after DOES depend on `systemDir` (which the batch creates), so - // it stays outside, awaited only once every directory has landed. - await Promise.all([ - // Create the nouns directory if it doesn't exist - this.ensureDirectoryExists(this.nounsDir), - // Create the verbs directory if it doesn't exist - this.ensureDirectoryExists(this.verbsDir), - // Create the metadata directory if it doesn't exist - this.ensureDirectoryExists(this.metadataDir), - // Create the noun metadata directory if it doesn't exist - this.ensureDirectoryExists(this.nounMetadataDir), - // Create the verb metadata directory if it doesn't exist - this.ensureDirectoryExists(this.verbMetadataDir), - // Create both directories for backward compatibility - this.ensureDirectoryExists(this.systemDir), - // Only create legacy directory if it exists (don't create new legacy - // dirs) — a read-then-maybe-write, but on its own subtree, so it's - // still independent of every other entry in this batch. - (async () => { - if (await this.directoryExists(this.indexDir)) { - await this.ensureDirectoryExists(this.indexDir) - } - })(), - // Create the locks directory if it doesn't exist - this.ensureDirectoryExists(this.lockDir), - // Create the binary blobs directory if it doesn't exist - this.ensureDirectoryExists(this.blobsDir) - ]) + // Create the nouns directory if it doesn't exist + await this.ensureDirectoryExists(this.nounsDir) - // Initialize count management — depends on systemDir, created above. + // Create the verbs directory if it doesn't exist + await this.ensureDirectoryExists(this.verbsDir) + + // Create the metadata directory if it doesn't exist + await this.ensureDirectoryExists(this.metadataDir) + + // Create the noun metadata directory if it doesn't exist + await this.ensureDirectoryExists(this.nounMetadataDir) + + // Create the verb metadata directory if it doesn't exist + await this.ensureDirectoryExists(this.verbMetadataDir) + + // Create both directories for backward compatibility + await this.ensureDirectoryExists(this.systemDir) + // Only create legacy directory if it exists (don't create new legacy dirs) + if (await this.directoryExists(this.indexDir)) { + await this.ensureDirectoryExists(this.indexDir) + } + + // Create the locks directory if it doesn't exist + await this.ensureDirectoryExists(this.lockDir) + + // Create the binary blobs directory if it doesn't exist + await this.ensureDirectoryExists(this.blobsDir) + + // Initialize count management this.countsFilePath = path.join(this.systemDir, 'counts.json') await this.initializeCounts() @@ -471,22 +410,8 @@ export class FileSystemStorage extends BaseStorage { /** * Primitive operation: Read object from path * All metadata operations use this internally via base class routing + * Enhanced error handling for corrupted metadata files (Bug #3 mitigation) * Supports reading both compressed (.gz) and uncompressed files for backward compatibility - * - * Read contract (loud errors, never quiet losses): - * - Genuine absence (ENOENT on every variant) → `null`. Only a missing file - * is "not found". - * - TORN record (a file EXISTS but its bytes cannot be decoded — invalid - * JSON, truncated/garbled gzip) → the encounter is registered (production - * ERROR log + per-process gauge) and a typed {@link TornRecordError} is - * thrown. Corruption must NEVER read as absence: callers that can degrade - * (manifest recovery, rebuildable statistics) catch the typed error at - * their sites; entity reads surface it. - * Legacy dual-format exception: when the `.gz` variant is torn but the - * uncompressed fallback decodes, the recovered object is returned — AFTER - * the torn `.gz` was logged and counted (loud recovery, not a silent skip). - * - Real storage fault (EIO/EACCES/EMFILE/…) → propagates as itself; a - * fault is neither absence nor corruption and must not be reshaped. */ protected async readObjectFromPath(pathStr: string): Promise { await this.ensureInitialized() @@ -494,10 +419,7 @@ export class FileSystemStorage extends BaseStorage { const fullPath = path.join(this.rootDir, pathStr) const compressedPath = `${fullPath}.gz` - // Try reading compressed file first (if compression is enabled or file exists). - // A torn .gz is remembered so the uncompressed fallback can either recover - // (legacy dual-format installs) or surface the corruption typed. - let tornCompressed: TornRecordError | null = null + // Try reading compressed file first (if compression is enabled or file exists) try { const compressedData = await fs.promises.readFile(compressedPath) const decompressed = await new Promise((resolve, reject) => { @@ -508,16 +430,9 @@ export class FileSystemStorage extends BaseStorage { }) return JSON.parse(decompressed.toString('utf-8')) } catch (error: any) { - if (error.code === 'ENOENT') { - // No compressed variant — fall through to the uncompressed path. - } else if (isUnparseablePayloadError(error)) { - // The .gz EXISTS but cannot be decoded (zlib Z_* error or JSON - // SyntaxError after gunzip): torn record. Register NOW (log + gauge), - // then attempt the uncompressed fallback as a recovery read. - tornCompressed = registerTornRecordEncounter(`${pathStr}.gz`, error) - } else { - // Real storage fault on an existing .gz (EIO/EACCES/…): propagate. - throw error + // If compressed file doesn't exist, fall back to uncompressed + if (error.code !== 'ENOENT') { + console.warn(`Failed to read compressed file ${compressedPath}:`, error) } } @@ -527,26 +442,24 @@ export class FileSystemStorage extends BaseStorage { return JSON.parse(data) } catch (error: any) { if (error.code === 'ENOENT') { - // No uncompressed file. If the .gz variant existed but was torn, the - // object EXISTS and is unreadable — that must surface typed, never as - // "absent". Otherwise this is genuine absence. - if (tornCompressed !== null) { - throw tornCompressed - } return null } - // The file EXISTS but its content cannot be parsed: torn record. - // Register (production ERROR + gauge) and throw typed — a corrupt row - // must be distinguishable from a missing row, or nothing ever heals it. - if (isUnparseablePayloadError(error)) { - throw registerTornRecordEncounter(pathStr, error) + // Enhanced error handling for corrupted JSON files (race condition from Bug #3) + if (error instanceof SyntaxError || error.name === 'SyntaxError') { + console.warn( + `⚠️ Corrupted metadata file detected: ${pathStr}\n` + + ` This may be caused by concurrent writes during import.\n` + + ` Gracefully skipping this entry. File may be repaired on next write.` + ) + return null } // A real storage fault (EIO/EACCES/EMFILE/…) is NOT "object absent". The - // ENOENT branch (above) already returns null; a genuine fault reaching - // here must propagate loudly rather than masquerade as a missing object - // — which would corrupt reads and drive needless rebuilds. + // ENOENT branch (above) already returns null, and the corrupted-JSON + // branch (above) is a deliberate concurrent-write tolerance; a genuine + // fault reaching here must propagate loudly rather than masquerade as a + // missing object — which would corrupt reads and drive needless rebuilds. throw error } } @@ -642,20 +555,6 @@ export class FileSystemStorage extends BaseStorage { * automatically. Returns the pruned container ids so the caller can recompute * counts. */ - /** - * @description Whether an id directory's file legs include the metadata - * CONTENT leg (`metadata.json` or its `.json.gz` variant) — the single - * test that decides whether an `entities////` container is - * a live entity or a ghost/scar orphan left by the pre-8.3.1 partial-delete - * defect (see {@link pruneOrphanedEntities}). Shared by the orphan prune - * and {@link scanCanonicalEntities} so the two agree by construction — one - * counted entity per identity record, never per bare container. - * @param legs - File names in one `entities////` directory. - */ - private hasMetadataContentLeg(legs: string[]): boolean { - return legs.some((f) => f.startsWith('metadata.json')) - } - public async pruneOrphanedEntities(): Promise<{ nouns: string[]; verbs: string[] }> { await this.ensureInitialized() const pruned: { nouns: string[]; verbs: string[] } = { nouns: [], verbs: [] } @@ -695,7 +594,7 @@ export class FileSystemStorage extends BaseStorage { } // A live entity has its metadata content leg. No content leg → a // vector-only ghost or an empty scar → prune the whole container. - if (this.hasMetadataContentLeg(legs)) continue + if (legs.some((f) => f.startsWith('metadata.json'))) continue await fs.promises.rm(idAbs, { recursive: true, force: true }) pruned[kind].push(entry.name) console.warn( @@ -709,30 +608,6 @@ 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 @@ -893,7 +768,6 @@ export class FileSystemStorage extends BaseStorage { for (const objectPath of paths) { const fullPath = path.join(this.rootDir, objectPath) - let synced = false for (const candidate of [`${fullPath}.gz`, fullPath]) { let handle: any try { @@ -908,14 +782,8 @@ export class FileSystemStorage extends BaseStorage { await handle.close() } parentDirs.add(path.dirname(fullPath)) - synced = true break } - // An absent path is a state too: fsync the parent directory so a - // completed unlink is durable (a delete must survive power loss as - // surely as a write — otherwise a bounded log fold could let a - // tombstoned record resurrect from a lost directory update). - if (!synced) parentDirs.add(path.dirname(fullPath)) } for (const dir of parentDirs) { @@ -1917,67 +1785,18 @@ export class FileSystemStorage extends BaseStorage { const now = new Date().toISOString() const existing = await this.readWriterLock() - // TORN-LOCK RECOVERY: power loss can legally leave the lock file - // present but EMPTY/unparseable (the claim's non-atomic write died - // mid-flight). readWriterLock() reports it as null — but the O_EXCL - // claim below would EEXIST forever, a PERMANENT lockout no staleness - // check can clear (staleness needs a parsed PID). A torn lock is - // stale BY DEFINITION: no live holder has one (a holder either - // completed its write or is dead). Unlink loudly and re-loop; a - // racer that rewrites a VALID lock first simply wins the next read. - if (existing === null) { - try { - await fs.promises.access(lockFile) - console.warn( - `[brainy] Writer lock at ${lockFile} exists but is unreadable/unparseable ` + - `(torn write from a previous power loss) — treating as stale and removing.` - ) - try { - await fs.promises.unlink(lockFile) - } catch (unlinkErr: any) { - if (unlinkErr.code !== 'ENOENT') throw unlinkErr - } - } catch (accessErr: any) { - if (accessErr.code !== 'ENOENT') throw accessErr - // Absent: the normal fresh-claim path below. - } - } - - // 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 — - // unless the record proves the previous instance already let go, in - // which case there is nothing to warn about. + // other beyond what their callers already see. Warn and take over. if (existing.pid === myPid && existing.hostname === hostname && !options?.force) { - 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.` - ) - } + 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, @@ -1987,18 +1806,11 @@ 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 } - // 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))) + const stale = !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. @@ -2009,16 +1821,8 @@ export class FileSystemStorage extends BaseStorage { options?.force ? `[brainy] Force-overwriting writer lock for ${this.rootDir} ` + `(was held by PID ${existing.pid} on ${existing.hostname}).` - : 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).` + : `[brainy] Overwriting stale writer lock for ${this.rootDir} ` + + `(PID ${existing.pid} on ${existing.hostname} appears dead).` ) // Takeover: verify the file still holds the lock we judged (a live // successor may have claimed meanwhile), then remove it and fall @@ -2052,32 +1856,16 @@ export class FileSystemStorage extends BaseStorage { rootDir: this.rootDir } - // The atomic claim: write the FULL contents to a temp file, then - // hard-link it into place — link(2) fails EEXIST if the target exists, - // and the lock file appears with its complete JSON in one atomic step. - // (The previous claim was writeFile with O_EXCL, whose open→write→close - // is NOT atomic: a concurrent opener could read the file in its empty - // window, judge it torn, unlink a LIVE claim, and take the lock — two - // live writers. The link claim leaves no empty window to misread.) - const claimTmp = `${lockFile}.claim-${myPid}-${Date.now()}` + // The atomic claim: create-exclusive, so exactly ONE racer wins. try { - await fs.promises.writeFile(claimTmp, JSON.stringify(info, null, 2)) - await fs.promises.link(claimTmp, lockFile) + await fs.promises.writeFile(lockFile, JSON.stringify(info, null, 2), { flag: 'wx' }) } catch (err: any) { if (err.code === 'EEXIST') { continue // someone else claimed between our read and create — re-evaluate } throw err - } finally { - 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 } @@ -2120,51 +1908,6 @@ export class FileSystemStorage extends BaseStorage { } } - /** - * THE FENCE: verify this instance still owns the writer lock before a - * commit barrier proceeds. An evicted writer (an operator's - * `{ force: true }` takeover, or an operator deleting the lock file) must - * fail LOUDLY on its next flush instead of writing on unaware — the - * unfenced evicted writer was half of a production split-brain (each - * writer flushing its own internally-consistent id-mapper snapshot, - * alternating the store between two truths). One small file read per - * flush window, never per record. No-op when this instance holds no - * writer lock (read-only opens, in-memory stores). - * - * @throws `BRAINY_WRITER_FENCED` when the lock is gone or held by another. - */ - public override async assertWriterFenceHeld(): Promise { - if (!this.writerLockInfo) return - const current = await this.readWriterLock() - // Ownership is PER-PROCESS: pid + hostname, deliberately NOT startedAt. - // The documented same-process re-open path ("warn and take over" — two - // instances in one Node process, the server-restart test pattern) - // rewrites the lock with a fresh startedAt; fencing the first instance - // on that mismatch latched its background flushes dead while its own - // process held the lock (caught by the plant's integration lane, twice). - // startedAt adds nothing against pid recycling either: a recycled pid's - // victim is a DEAD process — it runs no fence checks. - if ( - current && - current.pid === this.writerLockInfo.pid && - current.hostname === this.writerLockInfo.hostname - ) { - return - } - const err = new Error( - `Writer fence lost for ${this.rootDir}: this process (PID ${this.writerLockInfo.pid}) ` + - `no longer holds the writer lock — ` + - (current - ? `it is now held by PID ${current.pid} on ${current.hostname} (since ${current.startedAt}).` - : `the lock file is gone (released or removed by an operator).`) + - `\nThis instance refuses to commit further writes: a fenced-out writer continuing to ` + - `flush is how split-brain stores are made. Close this instance; if the takeover was a ` + - `mistake, close the successor and re-open.` - ) as Error & { code: string } - err.code = 'BRAINY_WRITER_FENCED' - throw err - } - /** The consumer-facing BRAINY_WRITER_LOCKED error, holder details attached. */ private writerLockedError(existing: WriterLockInfo): Error { const err = new Error( @@ -2201,27 +1944,13 @@ 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() - const ours = - current === null || - (current.pid === released.pid && current.hostname === released.hostname) - if (current && ours) { + if (current && current.pid === this.writerLockInfo.pid && current.hostname === this.writerLockInfo.hostname) { 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) @@ -2231,97 +1960,6 @@ 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) @@ -2358,25 +1996,18 @@ export class FileSystemStorage extends BaseStorage { /** * Determine whether an existing writer lock is stale (safe to overwrite). - * Same hostname and DEAD PID → stale. That is the whole rule: a LIVE - * process is never auto-evicted, however old its heartbeat — a >60s - * event-loop stall (debugger pause, GC, heavy sync work) is a slow writer, - * not a dead one, and heartbeat-age eviction of live writers was the - * dominant mechanism behind a production split-brain (two live unaware - * writers alternating a store's id-mapper between two truths). A holder - * that LOOKS alive but is truly wedged is the operator's call via - * `{ force: true }` — and the fence check on every flush - * ({@link assertWriterFenceHeld}) guarantees a forced-out holder fails - * loudly instead of writing on. Different hostname → cannot prove - * anything, treat as live. The heartbeat remains for OBSERVABILITY (the - * lock error names it so an operator can judge staleness themselves). + * Same hostname and (dead PID OR heartbeat older than threshold) → stale. + * Different hostname → cannot prove stale, treat as live. */ private async isWriterLockStale(lock: WriterLockInfo): Promise { const os = await import('node:os') if (lock.hostname !== os.hostname()) { return false } - return !this.isPidAlive(lock.pid) + const heartbeatAge = Date.now() - new Date(lock.lastHeartbeat).getTime() + const pidAlive = this.isPidAlive(lock.pid) + if (!pidAlive) return true + return heartbeatAge > FileSystemStorage.WRITER_STALE_THRESHOLD_MS } /** @@ -2400,130 +2031,44 @@ export class FileSystemStorage extends BaseStorage { * Atomic write via temp-file-then-rename so concurrent readers never see a * half-written lock JSON. Reused by writer-lock writes + heartbeat. */ - /** Monotonic per-process sequence so two atomic writes never share a temp path. */ - private static atomicWriteSeq = 0 - private async writeFileAtomic(filePath: string, contents: string): Promise { - // pid + timestamp alone collided: two writers of the same target inside - // one millisecond shared this path, and the loser's rename found the - // winner had already moved it (ENOENT). The sequence makes every call's - // temp path its own. - const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${++FileSystemStorage.atomicWriteSeq}` + const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}` await fs.promises.writeFile(tmp, contents) await fs.promises.rename(tmp, filePath) } /** * Start watching for cross-process flush requests. Called by Brainy.init() - * 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. + * 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. */ public override startFlushRequestWatcher(onRequest: () => Promise): void { - // 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 + if (this.flushWatcherInterval) return // already watching this.flushWatcherOnRequest = onRequest const reqDir = path.join(this.lockDir, FileSystemStorage.FLUSH_REQUEST_DIR) const ackDir = path.join(this.lockDir, FileSystemStorage.FLUSH_RESPONSE_DIR) - const sweep = (): void => { - if (this.flushWatcherInFlight) return // skip overlapping sweep + // 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 this.flushWatcherInFlight = true this.processFlushRequests(reqDir, ackDir).finally(() => { this.flushWatcherInFlight = false }) - } - - // 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) + }, 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 @@ -2890,82 +2435,6 @@ export class FileSystemStorage extends BaseStorage { this.totalNounCount = counts.totalNounCount || 0 this.totalVerbCount = counts.totalVerbCount || 0 - // The ALL-visibility scalars (ledger denominators). A counts.json - // written before they existed carries neither key: derive both ONCE - // from the canonical id tree (an id-directory listing — O(ids), no - // record reads), persist, and never scan again. Absent keys are a - // legacy file, not a zero — a zero here would make every provider's - // coverage ledger read "over-posted" on a populated store. - let needsPersist = false - if ( - typeof counts.totalNounCountAll === 'number' && - typeof counts.totalVerbCountAll === 'number' - ) { - this.totalNounCountAll = counts.totalNounCountAll - this.totalVerbCountAll = counts.totalVerbCountAll - if (counts.allCountsDerivedBy === 'identity-record') { - // Derived (or recounted) under the honest rule — one counted - // entity per metadata content leg. Trust the persisted suspect - // flag as-is; an unprovable delete since may still have set it. - this.allCountsDerivedBy = 'identity-record' - this.allCountsSuspect = counts.allCountsSuspect === true - } else { - // The ALL scalars exist but predate the identity-record stamp — - // they were derived under the legacy rule that counted one - // entity per id DIRECTORY, so orphaned ghost/scar containers (a - // pre-8.3.1 partial-delete defect — see pruneOrphanedEntities()) - // were counted as entities too. O(1) field read, NEVER a walk - // here: force suspect and name it loudly. A sanctioned recount - // (repairIndex) restores exact denominators and clears this. - this.allCountsDerivedBy = undefined - this.allCountsSuspect = true - needsPersist = true - prodLog.narrate( - '[FileSystemStorage] canonical count ledger was derived under the legacy ' + - '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 { - // 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 - // counts.json can carry `totalNounCountAll`/`totalVerbCountAll` but - // still predate THIS key). Unlike the ALL scalars, presence cannot be - // decided from the id-directory listing alone: a deferred-embed - // noun's `vectors.json` EXISTS with an empty `vector: []` until its - // embed lands, so this derivation reads every noun's `vectors.json` - // ONCE (O(nouns) reads, not O(ids) listing) — honest, one-time cost. - if (typeof counts.totalVectoredNounCount === 'number') { - this.totalVectoredNounCount = counts.totalVectoredNounCount - } else { - // 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() - } - // Also populate the cache for backward compatibility this.countCache.set('nouns_count', { count: this.totalNounCount, @@ -2989,22 +2458,6 @@ 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 @@ -3017,17 +2470,6 @@ export class FileSystemStorage extends BaseStorage { this.totalNounCount = nouns.count const verbs = await this.scanCanonicalEntities('verbs') this.totalVerbCount = verbs.count - // The id-tree scan counts every tier — it IS the ALL-visibility ledger. - this.totalNounCountAll = nouns.count - this.totalVerbCountAll = verbs.count - this.allCountsSuspect = false - this.allCountsDerivedBy = 'identity-record' - // Vectored-noun scalar: presence needs each noun's vectors.json CONTENT - // (a deferred-embed noun's file exists but holds an empty vector until - // its embed lands), so this is a full O(nouns) content scan — see - // scanVectoredNounCount()'s JSDoc for the cost note. Paid once, here, - // alongside the rest of this from-disk recovery. - this.totalVectoredNounCount = await this.scanVectoredNounCount() // Sample some entities for the type distribution (don't read all). // Read the metadata files DIRECTLY with fs — this runs inside init(), @@ -3052,11 +2494,6 @@ 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) } @@ -3064,132 +2501,11 @@ export class FileSystemStorage extends BaseStorage { /** * Walk the canonical `entities//<2-hex-shard>//` tree, counting - * one entity per id directory that holds the metadata CONTENT leg - * (`metadata.json` or its `.json.gz` variant — see - * {@link hasMetadataContentLeg}). A bare container — a ghost (a stale - * `vectors.json` left with no metadata leg) or a scar (an empty directory), - * both artifacts of the pre-8.3.1 partial-delete defect — counts ZERO: the - * identity record IS the population (ADR-008 G1), never the directory. - * This is the ONE-TIME legacy derivation walk (see callers); a prior - * version of this scan counted every id directory regardless of content, - * over-counting any store carrying orphaned containers — see - * `allCountsDerivedBy` for how a counts.json derived under that old rule is - * marked suspect on load. Returns up to 100 sampled *counted* entity - * directories (absolute paths) — nouns feed the type-distribution estimate - * above. An absent tree (fresh store) counts zero. + * one entity per id directory (the layout `getNounVectorPath`/`getNouns` + * use). Returns up to 100 sampled entity 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[] }> { @@ -3205,21 +2521,9 @@ export class FileSystemStorage extends BaseStorage { const ids = await fs.promises.readdir(shardPath, { withFileTypes: true }) for (const entry of ids) { if (!entry.isDirectory()) continue - const idAbs = path.join(shardPath, entry.name) - let legs: string[] - try { - legs = await fs.promises.readdir(idAbs) - } catch (error: any) { - if (error?.code === 'ENOENT') continue - throw error - } - // No metadata content leg → a ghost or scar container → not an - // entity. Same test pruneOrphanedEntities() uses, so the two agree - // by construction. - if (!this.hasMetadataContentLeg(legs)) continue count++ if (sampleDirs.length < SAMPLE_MAX) { - sampleDirs.push(idAbs) + sampleDirs.push(path.join(shardPath, entry.name)) } } } @@ -3251,72 +2555,6 @@ export class FileSystemStorage extends BaseStorage { } } - /** - * Read one canonical noun's `vectors.json` (or `.json.gz`) directly with fs - * — the vector-side mirror of {@link readEntityMetadataRaw}, same - * reentrancy reason (bypasses `getNoun()`'s `ensureInitialized()`). - * @param entityDir - Absolute `entities/nouns//` directory. - * @returns The parsed vector record, or null when absent/unreadable. - */ - private async readEntityVectorRaw(entityDir: string): Promise { - const base = path.join(entityDir, 'vectors.json') - try { - return JSON.parse(await fs.promises.readFile(base, 'utf-8')) - } catch { - // fall through to the compressed variant - } - try { - const gz = await fs.promises.readFile(`${base}.gz`) - return JSON.parse(zlib.gunzipSync(gz).toString('utf-8')) - } catch { - return null - } - } - - /** - * 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') - let vectored = 0 - try { - const shards = await fs.promises.readdir(base, { withFileTypes: true }) - for (const shard of shards) { - if (!shard.isDirectory() || !/^[0-9a-f]{2}$/i.test(shard.name)) continue - const shardPath = path.join(base, shard.name) - const ids = await fs.promises.readdir(shardPath, { withFileTypes: true }) - 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 && - !isZeroNormVector(record.vector) - ) { - vectored++ - } - } - } - } catch (error: any) { - if (error?.code !== 'ENOENT') throw error - } - return vectored - } - /** * Persist counts to filesystem storage */ @@ -3329,34 +2567,13 @@ export class FileSystemStorage extends BaseStorage { verbCounts: Object.fromEntries(this.verbCounts), totalNounCount: this.totalNounCount, totalVerbCount: this.totalVerbCount, - // ALL-visibility ledger scalars (+ the suspect flag) — absent in files - // written before the ledger existed; initializeCounts() derives them once. - totalNounCountAll: this.totalNounCountAll, - totalVerbCountAll: this.totalVerbCountAll, - // Vectored-noun ledger scalar — absent in files written before it - // existed; initializeCounts() derives it once (a content scan, see - // scanVectoredNounCount()'s JSDoc). - totalVectoredNounCount: this.totalVectoredNounCount, - allCountsSuspect: this.allCountsSuspect, - // Derivation-rule stamp for the ALL scalars above — 'identity-record' - // when they were counted one-per-metadata-content-leg (the honest - // rule); omitted (JSON.stringify drops `undefined`) when the current - // in-memory scalars came from a legacy container-rule counts.json - // that hasn't been through a sanctioned recount yet, so a future load - // keeps naming them suspect rather than trusting an unproven value. - allCountsDerivedBy: this.allCountsDerivedBy, lastUpdated: new Date().toISOString() } - // 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)) + await fs.promises.writeFile( + this.countsFilePath, + JSON.stringify(counts, null, 2) + ) } catch (error) { console.error('Error persisting counts:', error) } diff --git a/src/storage/adapters/memoryStorage.ts b/src/storage/adapters/memoryStorage.ts index ab2a52d3..bab9d4d9 100644 --- a/src/storage/adapters/memoryStorage.ts +++ b/src/storage/adapters/memoryStorage.ts @@ -133,11 +133,6 @@ export class MemoryStorage extends BaseStorage { */ protected async deleteObjectFromPath(path: string): Promise { this.objectStore.delete(path) - // Filesystem parity: on disk, objects and raw BYTE files are both just - // files — unlink removes whichever exists. Without this, deleteRawObject - // on a raw-bytes path (fact-log/generation segments) silently no-ops on - // memory storage: the delete "succeeds" and the bytes remain. - this.rawBytesStore.delete(path) } /** @@ -520,12 +515,6 @@ export class MemoryStorage extends BaseStorage { let totalNouns = 0 let totalVerbs = 0 - // Vectored-noun scalar: unlike the bare presence check above, this needs - // the vectors.json RECORD'S content — a deferred-embed noun's record - // exists with an empty `vector: []` until its embed lands. In-memory this - // is a free field access (no I/O), unlike the filesystem adapter's - // per-noun disk read. - let totalVectoredNouns = 0 // Scan all paths in objectStore for (const path of this.objectStore.keys()) { @@ -534,10 +523,6 @@ export class MemoryStorage extends BaseStorage { if (nounMatch) { // Type is in metadata, not path - just count total totalNouns++ - const record = this.objectStore.get(path) as { vector?: unknown } | undefined - if (Array.isArray(record?.vector) && record.vector.length > 0) { - totalVectoredNouns++ - } } // Count verbs (entities/verbs/{shard}/{id}/vectors.json) @@ -550,11 +535,6 @@ export class MemoryStorage extends BaseStorage { this.totalNounCount = totalNouns this.totalVerbCount = totalVerbs - // A scan of every canonical record IS the ALL-visibility count. - this.totalNounCountAll = totalNouns - this.totalVerbCountAll = totalVerbs - this.totalVectoredNounCount = totalVectoredNouns - this.allCountsSuspect = false } /** diff --git a/src/storage/baseStorage.ts b/src/storage/baseStorage.ts index a1cc2e35..6daf09c0 100644 --- a/src/storage/baseStorage.ts +++ b/src/storage/baseStorage.ts @@ -32,13 +32,11 @@ import { BlobStorage, type BlobStoreAdapter } from './blobStorage.js' import { unwrapBinaryData } from './binaryDataCodec.js' import { prodLog } from '../utils/logger.js' import { isAbsentError } from '../utils/errorClassification.js' -import { isTornRecordError } from './tornRecordError.js' import { BrainyError, ProtectedArtifactError, DerivedArtifactMissingError } from '../errors/brainyError.js' import { MetadataWriteBuffer } from '../utils/metadataWriteBuffer.js' import { splitNounMetadataRecord, - splitVerbMetadataRecord, - isNestedBagRecord + splitVerbMetadataRecord } from '../types/reservedFields.js' /** @@ -125,36 +123,6 @@ 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 @@ -233,40 +201,6 @@ function idFromVectorPath(path: string): string { return lastSlash >= 0 ? withoutSuffix.slice(lastSlash + 1) : withoutSuffix } -/** - * @description Extract the entity id embedded in a metadata path - * (`entities/{nouns|verbs}/{shard}/{id}/metadata.json`) — the IDENTITY-RECORD - * mirror of {@link idFromVectorPath}. The cursored noun/verb walks key their - * population on this file (ADR-008 G1: the metadata record IS the population; - * the vector leg is optional), so walk ordering and cursor resume derive the - * id from THIS path, never the vector path — a row with metadata and no - * vector file must still be listed, ordered, and resumable. - * @param path - A metadata path (full or prefix-relative; must end with `/metadata.json`). - * @returns The entity id (the path segment immediately before `/metadata.json`). - */ -function idFromMetadataPath(path: string): string { - const withoutSuffix = path.replace(/\/metadata\.json$/, '') - const lastSlash = withoutSuffix.lastIndexOf('/') - return lastSlash >= 0 ? withoutSuffix.slice(lastSlash + 1) : withoutSuffix -} - -/** - * @description The sanctioned UNVECTORED shape for a noun hydrated during - * enumeration when its identity record (metadata.json) exists but its vector - * leg (vectors.json) does not — a fold-born metadata-only after-image, or any - * row genuinely without a vector yet. Mirrors the shape - * `unvectorNounForRootMigration` (src/brainy.ts) writes for the sanctioned - * unvector path (`{ vector: [], connections: new Map(), level: 0 }`), so a - * walk-yielded unvectored row is byte-shape-identical to one produced by that - * migration. Callers already handle `vector: []` as first-class - * (validateAddParams exempts it; index gates key on `length > 0`). - * @param id - The noun id. - * @returns A structurally-valid, vector-empty `HNSWNoun`. - */ -function unvectoredNoun(id: string): HNSWNoun { - return { id, vector: [], connections: new Map>(), level: 0 } -} - /** * Get ID-first path for verb metadata * No type parameter needed - direct O(1) lookup by ID @@ -448,10 +382,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { // identical to the unknown-key fallback these keys hit // before being listed here — this only kills the // per-boot "Unknown key format" warning) - id.startsWith('graph-lsm-') || // Graph-LSM store manifests written through storage by - // an active native graph provider — same - // warn-then-route fallback as above; listing the family - // silences the per-boot warning on provider-backed brains isSingletonSystemKey(id) // Known singletons (e.g. brainy:entityIdMapper) hit the // same warn-then-route fallback without this — the // routing below already handles them identically @@ -676,18 +606,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { return null } - /** - * THE FENCE: verify this instance still owns its writer lock before a - * commit barrier proceeds; throw `BRAINY_WRITER_FENCED` if evicted. The - * default is a no-op — adapters without a cross-process lock model (memory, - * per-request cloud stores) have no eviction to fence against. The - * filesystem adapter overrides this; the generation store calls it at - * every flush commit and transact barrier. - */ - public async assertWriterFenceHeld(): Promise { - // No-op by default — no lock model, nothing to be evicted from. - } - /** * Start watching for cross-process flush requests. The writer Brainy * instance calls this so that out-of-process inspectors can ask for a @@ -751,11 +669,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { // — hash verification must run on the original content bytes. return unwrapBinaryData(data) } catch (error) { - // A TORN blob object (exists but undecodable) must not read as - // "blob absent" — that would misdiagnose disk corruption as a - // missing blob. This is an IDENTITY read (a caller asked for THIS - // key): propagate the typed error to the blob layer. - if (isTornRecordError(error)) throw error return undefined } }, @@ -850,20 +763,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { if (m) hashes.add(m[1]) } - // Recovery-path read: a TORN object here maps to "not usable" (null) BY - // DESIGN — the adapter has already logged + counted the encounter, and - // treating a torn `_cas/` copy as absent lets the re-copy from `_cow/` - // OVERWRITE the corrupt file with the good original (the heal), while a - // torn `_cow/` original is reported via `incomplete`. Real faults propagate. - const readOrNullIfTorn = async (p: string): Promise => { - try { - return await this.readObjectFromPath(p) - } catch (error) { - if (isTornRecordError(error)) return null - throw error - } - } - let adopted = 0 let alreadyPresent = 0 let incomplete = 0 @@ -871,15 +770,15 @@ export abstract class BaseStorage extends BaseStorageAdapter { // A blob counts as present only when BOTH its bytes and its metadata // already live in `_cas/`. A half-adopted blob (bytes without meta — the // exact "Blob metadata not found" state) is re-adopted. - const casBlob = await readOrNullIfTorn(`_cas/blob:${hash}`) - const casMeta = await readOrNullIfTorn(`_cas/blob-meta:${hash}`) + const casBlob = await this.readObjectFromPath(`_cas/blob:${hash}`) + const casMeta = await this.readObjectFromPath(`_cas/blob-meta:${hash}`) if (casBlob !== null && casMeta !== null) { alreadyPresent++ continue } - const cowBlob = await readOrNullIfTorn(`_cow/blob:${hash}`) - const cowMeta = await readOrNullIfTorn(`_cow/blob-meta:${hash}`) + const cowBlob = await this.readObjectFromPath(`_cow/blob:${hash}`) + const cowMeta = await this.readObjectFromPath(`_cow/blob-meta:${hash}`) if (cowBlob === null || cowMeta === null) { // Can't register a blob the store can't fully describe — report it so an // operator investigates rather than silently half-adopting. @@ -1110,14 +1009,8 @@ export abstract class BaseStorage extends BaseStorageAdapter { const hashes: string[] = [] for (const record of records) { if (record.kind !== 'noun') continue - // The VFS blob pointer (`storage: {type:'blob', hash}`) is a USER-bag - // field: in a v2 nested-bag record it lives inside `metadata`, in a - // legacy flat record it sits at the top level — read shape-aware. - const raw = record.metadata as Record | null - const bag = isNestedBagRecord(raw) - ? (raw!.metadata as Record) - : raw - const storage = (bag as { storage?: { type?: string; hash?: unknown } } | null)?.storage + const storage = (record.metadata as { storage?: { type?: string; hash?: unknown } } | null) + ?.storage if (storage?.type === 'blob' && typeof storage.hash === 'string') { hashes.push(storage.hash) } @@ -1230,28 +1123,12 @@ export abstract class BaseStorage extends BaseStorageAdapter { * cache (record-layer files are written through * {@link BaseStorage.writeRawObject} only). * - * TORN-record contract (deliberate, loud-by-design): this surface serves - * SYSTEM ARTIFACTS — manifests with recovery paths, markers whose verdict - * machinery treats "unreadable" as rescan, generation/transaction records - * whose recovery is built for absent artifacts. For these readers a torn - * file maps to their existing absent-artifact degrade, so a typed - * torn-record error from the adapter is caught here and returned as `null` - * — AFTER the adapter has already logged a production ERROR and counted - * the per-process torn-record gauge (never silent). Entity reads do NOT go - * through this surface; they use the canonical read paths, which propagate - * the typed error. Real storage faults (EIO/EACCES/…) still propagate. - * * @param path - Storage-root-relative object path (e.g. `_system/manifest.json`). - * @returns The parsed object, or `null` if absent (or torn — logged + counted). + * @returns The parsed object, or `null` if absent. */ public async readRawObject(path: string): Promise { await this.ensureInitialized() - try { - return await this.readObjectFromPath(path) - } catch (error) { - if (isTornRecordError(error)) return null - throw error - } + return this.readObjectFromPath(path) } /** @@ -1437,29 +1314,6 @@ 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 @@ -1489,29 +1343,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { void paths } - /** - * Fold-checkpoint durability barrier: make the listed entities' canonical - * live objects durable. Maps each id to its canonical metadata + vector - * paths and delegates to {@link BaseStorage.syncRawObjects}, whose - * filesystem override fsyncs present files (and their rename directory - * entries) and the parent directory of absent ones — so deletes are as - * durable as writes. The generation store advances the fold checkpoint - * only after this resolves (stamp-after-data). - * - * @param nouns - Entity ids whose canonical objects must be durable. - * @param verbs - Relationship ids whose canonical objects must be durable. - */ - public async syncEntityCanonical(nouns: string[], verbs: string[]): Promise { - const paths: string[] = [] - for (const id of nouns) { - paths.push(getNounMetadataPath(id), getNounVectorPath(id)) - } - for (const id of verbs) { - paths.push(getVerbMetadataPath(id), getVerbVectorPath(id)) - } - if (paths.length > 0) await this.syncRawObjects(paths) - } - /** * Read an entity's raw stored objects — the exact bytes at its canonical * metadata + vector paths (write-cache coherent). Used by the generation @@ -1540,18 +1371,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { * rollups are derived state with their own rebuild paths * (`rebuildTypeCounts()` / `rebuildSubtypeCounts()`). * - * EXACT-RESTORE PRIMITIVE — `vector: null` DELETES the vector leg, on - * purpose: `GenerationStore.rollBackUncommittedGeneration()` depends on - * this to legitimately un-write a vector a failed transaction added. This - * is deliberately NOT "preserve if absent" — a caller replaying an - * AFTER-IMAGE (the recovery fold, `GenerationStore`'s `replayFact`) must - * apply preserve-if-absent itself BEFORE calling this, by reading the - * current vector and carrying it forward when the after-image's own - * vector leg is null/undefined but its metadata is not (see `replayFact` - * for the implementation and full rationale). A caller that genuinely - * wants to unvector a row uses the sanctioned, ledger-correct path - * (`Brainy.unvectorNounForRootMigration`) — never this primitive. - * * @param id - The entity id. * @param record - Raw stored objects as returned by {@link BaseStorage.readNounRaw}. */ @@ -1588,9 +1407,7 @@ export abstract class BaseStorage extends BaseStorageAdapter { /** * Restore a relationship's raw stored objects byte-for-byte (verb-side - * mirror of {@link BaseStorage.writeNounRaw}; same bookkeeping caveats, - * same EXACT-RESTORE contract — `vector: null` deletes, on purpose; the - * fold's preserve-if-absent logic lives at its call site, not here). + * mirror of {@link BaseStorage.writeNounRaw}; same bookkeeping caveats). * * @param id - The relationship id. * @param record - Raw stored objects as returned by {@link BaseStorage.readVerbRaw}. @@ -1863,10 +1680,8 @@ export abstract class BaseStorage extends BaseStorageAdapter { /** * Delete a noun from storage - * @param hadVector - OPTIONAL vectored-noun ledger hint, forwarded to - * {@link deleteNounMetadata} unchanged — see its JSDoc. */ - public async deleteNoun(id: string, priorMetadata?: NounMetadata | null, hadVector?: boolean): Promise { + public async deleteNoun(id: string, priorMetadata?: NounMetadata | null): Promise { await this.ensureInitialized() // FULL removal (live-HEAD hygiene): remove BOTH canonical legs AND the @@ -1883,7 +1698,7 @@ export abstract class BaseStorage extends BaseStorageAdapter { // LONGER wrapped in a blind catch that masked faults as "file didn't exist". // `priorMetadata` (the caller's pre-delete read) keeps the decrement honest // even when the canonical read inside returns null (replace race / ghost). - await this.deleteNounMetadata(id, priorMetadata, hadVector) + await this.deleteNounMetadata(id, priorMetadata) // Remove the now-empty entity container (a no-op for key/prefix stores). await this.removeCanonicalContainer(getNounVectorPath(id)) @@ -2284,18 +2099,9 @@ export abstract class BaseStorage extends BaseStorageAdapter { // Stable within-shard order (by noun id) so offset windows and cursor resume // are deterministic; ids come from the path so skipped nouns are never read. - // - // IDENTITY-KEYED WALK (population law, ADR-008 G1): the metadata record - // (not the vector) IS the population — a noun with metadata and no vector - // file (a fold-born after-image, see writeNounRaw's preserve-if-absent - // contract) must still enumerate. Keying on metadata.json here means the - // ledger recount (rebuildTypeCounts' `allNouns`, also metadata.json-keyed) - // and this walk agree on population by construction. Ordering is - // unaffected for a healthy store: every vectored noun has both legs, so - // the id set and sort order are identical to the old vectors.json keying. const entries = nounFiles - .filter((p) => p.includes('/metadata.json')) - .map((p) => ({ path: p, id: idFromMetadataPath(p) })) + .filter((p) => p.includes('/vectors.json')) + .map((p) => ({ path: p, id: idFromVectorPath(p) })) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) // Resume: in the cursor's own shard, skip up to AND INCLUDING the cursor @@ -2320,36 +2126,16 @@ export abstract class BaseStorage extends BaseStorageAdapter { ) { const batch = toHydrate.slice(i, i + BaseStorage.HYDRATE_CONCURRENCY) const hydrated = await Promise.all( - batch.map(async ({ path: metadataPath, id }) => { + batch.map(async ({ path: nounPath }) => { try { - const metadata = await this.readCanonicalObject(metadataPath) + const noun = await this.readCanonicalObject(nounPath) + if (!noun) return null + const deserialized = this.deserializeNoun(noun) + const metadata = await this.getNounMetadata(deserialized.id) if (!metadata) return null - // The vector leg is OPTIONAL (population law): a metadata-only - // row hydrates with the sanctioned unvectored shape rather than - // being dropped from the walk. A fault reading the vector leg - // is treated the same as absence — best-effort, matching the - // canonical recount's tolerance for an unreadable vectors.json - // (rebuildTypeCounts) — a vector-leg problem never hides an - // otherwise-good identity record. - let deserialized: HNSWNoun - try { - const vectorRecord = await this.readCanonicalObject(getNounVectorPath(id)) - deserialized = vectorRecord ? this.deserializeNoun(vectorRecord) : unvectoredNoun(id) - } catch { - deserialized = unvectoredNoun(id) - } return { deserialized, metadata } } catch (error) { - // A TORN record must surface typed — a paginated read that - // silently skips a corrupt row hides data loss from the caller. - // Torn record inside an ENUMERATION/RECOVERY walk: the adapter already - // narrated + counted it (TornRecordError registers at creation); the - // walk's job is to HEAL PAST it — skip the victim, serve the rest. - // Identity point-reads (get-by-id) still throw typed upstream. - if (isTornRecordError(error)) { /* skip torn victim; loud floor already fired */ } - // Skip nouns whose IDENTITY record fails to load (the metadata - // read above) — that is the one leg this walk cannot proceed - // without. + // Skip nouns that fail to load return null } }) @@ -2378,12 +2164,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { } } } catch (error) { - // A TORN record propagates (typed) — only shard-listing absence is skippable. - // Torn record inside an ENUMERATION/RECOVERY walk: the adapter already - // narrated + counted it (TornRecordError registers at creation); the - // walk's job is to HEAL PAST it — skip the victim, serve the rest. - // Identity point-reads (get-by-id) still throw typed upstream. - if (isTornRecordError(error)) { /* skip torn victim; loud floor already fired */ } // Skip shards that have no data } } @@ -2398,14 +2178,11 @@ export abstract class BaseStorage extends BaseStorageAdapter { // totalCount must be the TRUE dataset total, not this peeked page. For the // unfiltered case the authoritative total is the O(1) counter maintained on - // every add/delete (rehydrated on init) — the ALL-visibility scalar, because - // this walk is unfiltered by tier (system/internal records are in `collected`). - // Never clamped: `Math.max(scalar, scanned)` could only ever move the scalar - // UP, so an inflated counter could never correct itself and a divergence was - // hidden instead of reported. A scalar that disagrees with the walk is the - // canonical-count-ledger invariant's job, healed by the sanctioned recount. - // A filtered scan has no cheap exact total, so it keeps the collected length. - const totalCount = filter ? collected.length : this.totalNounCountAll + // every add/delete (rehydrated on init); `Math.max` guards a stale counter. A + // filtered scan has no cheap exact total, so it keeps the collected length. + const totalCount = filter + ? collected.length + : Math.max(this.totalNounCount, collected.length) // nextCursor = the (shard, id) of the last RETURNED noun, so the next call // resumes immediately after it (works for both cursor and offset callers). @@ -2470,14 +2247,9 @@ export abstract class BaseStorage extends BaseStorageAdapter { const shardDir = `entities/nouns/${shardHex}` try { const nounFiles = await this.listCanonicalObjects(shardDir) - // IDENTITY-KEYED WALK (population law, ADR-008 G1) — see the matching - // comment in getNounsWithPagination: metadata.json is the population; - // the vector leg is optional, so a metadata-only row must still be - // listed (and here, for the unfiltered case, needs ZERO reads either - // way — the id comes straight from the path). const entries = nounFiles - .filter((p) => p.includes('/metadata.json')) - .map((p) => idFromMetadataPath(p)) + .filter((p) => p.includes('/vectors.json')) + .map((p) => idFromVectorPath(p)) .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) const toWalk = cursor && shard === cursor.shard ? entries.filter((id) => id > cursor.id) : entries @@ -2500,13 +2272,7 @@ export abstract class BaseStorage extends BaseStorageAdapter { batch.map(async (id) => { try { return { id, metadata: await this.getNounMetadata(id) } - } catch (error) { - // A TORN record must surface typed, never as a skipped id. - // Torn record inside an ENUMERATION/RECOVERY walk: the adapter already - // narrated + counted it (TornRecordError registers at creation); the - // walk's job is to HEAL PAST it — skip the victim, serve the rest. - // Identity point-reads (get-by-id) still throw typed upstream. - if (isTornRecordError(error)) { /* skip torn victim; loud floor already fired */ } + } catch { return null } }) @@ -2528,12 +2294,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { } } } catch (error) { - // A TORN record propagates (typed) — only shard-listing absence is skippable. - // Torn record inside an ENUMERATION/RECOVERY walk: the adapter already - // narrated + counted it (TornRecordError registers at creation); the - // walk's job is to HEAL PAST it — skip the victim, serve the rest. - // Identity point-reads (get-by-id) still throw typed upstream. - if (isTornRecordError(error)) { /* skip torn victim; loud floor already fired */ } // Skip shards with no data } } @@ -2542,8 +2302,7 @@ export abstract class BaseStorage extends BaseStorageAdapter { const pagePairs = collected.slice(windowStart, windowStart + limit) const ids = pagePairs.map((p) => p.id) const hasMore = collected.length > windowStart + limit - // ALL-visibility scalar, unclamped — same law as getNouns() above. - const totalCount = filter ? collected.length : this.totalNounCountAll + const totalCount = filter ? collected.length : Math.max(this.totalNounCount, collected.length) let nextCursor: string | undefined = undefined if (hasMore && pagePairs.length > 0) { @@ -2688,79 +2447,23 @@ export abstract class BaseStorage extends BaseStorageAdapter { // Stable within-shard order (by verb id) so offset windows and cursor resume // are deterministic and consistent across calls. Ids come from the path, so // verbs skipped by the cursor are never read. - // - // IDENTITY-KEYED WALK (population law, ADR-008 G1) — the noun mirror of - // this comment in getNounsWithPagination applies here too: metadata.json - // is the population; keying on it here means this walk and the ledger - // recount (rebuildTypeCounts' `allVerbs`, already metadata.json-keyed) - // agree on population by construction. Unchanged for a healthy store — - // `relate()` always writes both legs of a verb in the same commit, so - // the id set and order match the old vectors.json keying exactly; this - // only additionally surfaces a fold-born metadata-only row (see - // writeVerbRaw's preserve-if-absent contract). const entries = verbFiles - .filter((p) => p.includes('/metadata.json')) - .map((p) => ({ path: p, id: idFromMetadataPath(p) })) + .filter((p) => p.includes('/vectors.json')) + .map((p) => ({ path: p, id: idFromVectorPath(p) })) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - for (const { path: metadataPath, id: verbId } of entries) { + for (const { path: verbPath, id: verbId } of entries) { if (collected.length >= peekCount) break // Resume: in the cursor's own shard, skip up to AND INCLUDING the cursor id // (later shards are processed in full). No read for skipped verbs. if (cursor && shard === cursor.shard && verbId <= cursor.id) continue try { - // Identity leg first — required. A verb this walk cannot read - // metadata for cannot be hydrated at all (same as before). - const metadata = await this.readCanonicalObject(metadataPath) - if (!metadata) continue + const rawVerb = await this.readCanonicalObject(verbPath) + if (!rawVerb) continue - // The vector leg is the verb's STRUCTURAL core (verb/sourceId/ - // targetId live there — see coreTypes.ts HNSWVerb), unlike a - // noun's vector, which is pure embedding data. `relate()` always - // writes both legs atomically and verbs have no deferred-embed - // path, so a healthy store's verbs always have both. A vector-leg - // absence here can only be a fold-born after-image (see - // writeVerbRaw's preserve-if-absent contract) — and unlike a - // noun, this walk cannot safely FABRICATE sourceId/targetId to - // synthesize a structurally-valid verb (an empty-string endpoint - // would silently create a phantom edge — worse than omission). - // If the metadata record happens to carry its own sourceId/ - // targetId (never true for current production writes, but not - // disallowed — e.g. a future schema or a repair tool could - // populate them), reconstruct from those; otherwise this row is - // loudly skipped — counted by the ledger, but not returned as an - // item, until a repair can supply the missing endpoints. - const rawVerb = await this.readCanonicalObject(getVerbVectorPath(verbId)) - let verb: HNSWVerb - if (rawVerb) { - verb = this.deserializeVerb(rawVerb) - } else { - const metaSourceId = (metadata as Record).sourceId - const metaTargetId = (metadata as Record).targetId - const metaVerbType = (metadata as Record).verb - if ( - typeof metaSourceId === 'string' && metaSourceId.length > 0 && - typeof metaTargetId === 'string' && metaTargetId.length > 0 && - typeof metaVerbType === 'string' && metaVerbType.length > 0 - ) { - verb = { - id: verbId, - vector: [], - connections: new Map>(), - verb: metaVerbType as VerbType, - sourceId: metaSourceId, - targetId: metaTargetId - } - } else { - prodLog.error( - `[BaseStorage] getVerbsWithPagination: verb ${verbId} has a metadata ` + - `record but no vector leg and no recoverable sourceId/targetId — ` + - `skipping (counted by the ledger, not yielded; needs repair).` - ) - continue - } - } + // Deserialize connections Map from JSON storage format + const verb = this.deserializeVerb(rawVerb) // Apply type filter if (filterVerbTypes && !filterVerbTypes.has(verb.verb)) { @@ -2777,6 +2480,9 @@ export abstract class BaseStorage extends BaseStorageAdapter { continue } + // Load metadata + const metadata = await this.getVerbMetadata(verb.id) + // Apply subtype filter (requires metadata — checked AFTER load) if (filterSubtypes) { const subtype = metadata?.subtype as string | undefined @@ -2798,23 +2504,10 @@ export abstract class BaseStorage extends BaseStorageAdapter { // reserved fields top-level, ONLY custom fields in `metadata`. collected.push({ verb: this.hydrateVerbWithMetadata(verb, metadata), shard }) } catch (error) { - // A TORN record must surface typed — a paginated read that - // silently skips a corrupt row hides data loss from the caller. - // Torn record inside an ENUMERATION/RECOVERY walk: the adapter already - // narrated + counted it (TornRecordError registers at creation); the - // walk's job is to HEAL PAST it — skip the victim, serve the rest. - // Identity point-reads (get-by-id) still throw typed upstream. - if (isTornRecordError(error)) { /* skip torn victim; loud floor already fired */ } // Skip verbs that fail to load } } } catch (error) { - // A TORN record propagates (typed) — only shard-listing absence is skippable. - // Torn record inside an ENUMERATION/RECOVERY walk: the adapter already - // narrated + counted it (TornRecordError registers at creation); the - // walk's job is to HEAL PAST it — skip the victim, serve the rest. - // Identity point-reads (get-by-id) still throw typed upstream. - if (isTornRecordError(error)) { /* skip torn victim; loud floor already fired */ } // Skip shards that have no data } } @@ -2828,13 +2521,13 @@ export abstract class BaseStorage extends BaseStorageAdapter { const hasMore = collected.length > windowStart + limit // totalCount must be the TRUE dataset total, not this peeked page. For the - // unfiltered scan the authoritative total is the O(1) ALL-visibility counter - // (`totalVerbCountAll`: isNew-gated, EVERY tier, rehydrated on init) — the walk - // itself is unfiltered by tier, so the user-facing `totalVerbCount` (which skips - // system/internal edges) would undercount it on every store with a VFS. Never - // clamped (see getNouns): a divergence is reported, not hidden. A filtered scan - // has no cheap exact total, so it keeps the collected length (a lower bound). - const totalCount = filter ? collected.length : this.totalVerbCountAll + // unfiltered scan the authoritative total is the O(1) `totalVerbCount` counter + // (isNew-gated, visibility-filtered, rehydrated on init); `Math.max` guards a + // stale counter from under-reporting. A filtered scan has no cheap exact total, + // so it keeps the collected length (a lower bound). + const totalCount = filter + ? collected.length + : Math.max(this.totalVerbCount, collected.length) // nextCursor encodes the (shard, id) of the LAST RETURNED verb so the next call // resumes immediately after it — for both cursor and offset callers (an offset @@ -2942,33 +2635,19 @@ export abstract class BaseStorage extends BaseStorageAdapter { !options.filter.service && !options.filter.metadata ) { - const sourceIds = Array.isArray(options.filter.sourceId) - ? options.filter.sourceId - : [options.filter.sourceId] + const sourceId = Array.isArray(options.filter.sourceId) + ? options.filter.sourceId[0] + : options.filter.sourceId - // EVERY requested verb type is honoured — an array used to collapse to - // its first element here, silently dropping the rest of the ask. - const verbTypes = new Set( - Array.isArray(options.filter.verbType) - ? options.filter.verbType - : [options.filter.verbType] - ) + const verbType = Array.isArray(options.filter.verbType) + ? options.filter.verbType[0] + : options.filter.verbType - // Get verbs by source (union over every requested source), filter by the - // requested type SET (O(1) graph lookup + O(n) type filter), then apply - // the subtype / visibility metadata filters on the candidate set. - const bySource: HNSWVerbWithMetadata[] = [] - const seenVerbIds = new Set() - for (const oneSource of sourceIds) { - for (const v of await this.getVerbsBySource_internal(oneSource)) { - if (!seenVerbIds.has(v.id)) { - seenVerbIds.add(v.id) - bySource.push(v) - } - } - } + // Get verbs by source, then filter by type (O(1) graph lookup + O(n) type filter), + // then apply the subtype / visibility metadata filters on the candidate set. + const verbsBySource = await this.getVerbsBySource_internal(sourceId) const filteredVerbs = this.applyVerbMetadataFilters( - bySource.filter(v => verbTypes.has(v.verb)), + verbsBySource.filter(v => v.verb === verbType), options.filter ) @@ -2999,22 +2678,16 @@ export abstract class BaseStorage extends BaseStorageAdapter { !options.filter.service && !options.filter.metadata ) { - // EVERY requested source is honoured — an array used to collapse to - // its first element here, silently dropping the rest of the ask. - const onlySourceIds = Array.isArray(options.filter.sourceId) - ? options.filter.sourceId - : [options.filter.sourceId] - const sourceUnion: HNSWVerbWithMetadata[] = [] - const seenSourceVerbIds = new Set() - for (const oneSource of onlySourceIds) { - for (const v of await this.getVerbsBySource_internal(oneSource)) { - if (!seenSourceVerbIds.has(v.id)) { - seenSourceVerbIds.add(v.id) - sourceUnion.push(v) - } - } - } - const verbsBySource = this.applyVerbMetadataFilters(sourceUnion, options.filter) + const sourceId = Array.isArray(options.filter.sourceId) + ? options.filter.sourceId[0] + : options.filter.sourceId + + // Get verbs by source directly (hydrated with metadata), then apply the + // subtype / visibility metadata filters on the O(degree) candidate set. + const verbsBySource = this.applyVerbMetadataFilters( + await this.getVerbsBySource_internal(sourceId), + options.filter + ) // Apply pagination const paginatedVerbs = verbsBySource.slice(offset, offset + limit) @@ -3043,22 +2716,16 @@ export abstract class BaseStorage extends BaseStorageAdapter { !options.filter.service && !options.filter.metadata ) { - // EVERY requested target is honoured — an array used to collapse to - // its first element here, silently dropping the rest of the ask. - const onlyTargetIds = Array.isArray(options.filter.targetId) - ? options.filter.targetId - : [options.filter.targetId] - const targetUnion: HNSWVerbWithMetadata[] = [] - const seenTargetVerbIds = new Set() - for (const oneTarget of onlyTargetIds) { - for (const v of await this.getVerbsByTarget_internal(oneTarget)) { - if (!seenTargetVerbIds.has(v.id)) { - seenTargetVerbIds.add(v.id) - targetUnion.push(v) - } - } - } - const verbsByTarget = this.applyVerbMetadataFilters(targetUnion, options.filter) + const targetId = Array.isArray(options.filter.targetId) + ? options.filter.targetId[0] + : options.filter.targetId + + // Get verbs by target directly (hydrated with metadata), then apply the + // subtype / visibility metadata filters on the O(degree) candidate set. + const verbsByTarget = this.applyVerbMetadataFilters( + await this.getVerbsByTarget_internal(targetId), + options.filter + ) // Apply pagination const paginatedVerbs = verbsByTarget.slice(offset, offset + limit) @@ -3087,25 +2754,16 @@ export abstract class BaseStorage extends BaseStorageAdapter { !options.filter.service && !options.filter.metadata ) { - // EVERY requested verb type is honoured — an array used to collapse to - // its first element here, silently dropping the rest of the ask. - const verbTypes = Array.isArray(options.filter.verbType) - ? options.filter.verbType - : [options.filter.verbType] + const verbType = Array.isArray(options.filter.verbType) + ? options.filter.verbType[0] + : options.filter.verbType - // Get verbs by each requested type (hydrated with metadata), deduped by - // id, then apply the subtype / visibility metadata filters on the set. - const byType: HNSWVerbWithMetadata[] = [] - const seenTypeVerbIds = new Set() - for (const oneType of verbTypes) { - for (const v of await this.getVerbsByType_internal(oneType)) { - if (!seenTypeVerbIds.has(v.id)) { - seenTypeVerbIds.add(v.id) - byType.push(v) - } - } - } - const verbsByType = this.applyVerbMetadataFilters(byType, options.filter) + // Get verbs by type directly (hydrated with metadata), then apply the + // subtype / visibility metadata filters on the candidate set. + const verbsByType = this.applyVerbMetadataFilters( + await this.getVerbsByType_internal(verbType), + options.filter + ) // Apply pagination const paginatedVerbs = verbsByType.slice(offset, offset + limit) @@ -3610,12 +3268,11 @@ export abstract class BaseStorage extends BaseStorageAdapter { /** * Save noun metadata to storage (now typed) * Routes to correct sharded location based on UUID - * @param hasVector - See {@link StorageAdapter.saveNounMetadata}'s JSDoc. */ - public async saveNounMetadata(id: string, metadata: NounMetadata, hasVector?: boolean): Promise { + public async saveNounMetadata(id: string, metadata: NounMetadata): Promise { // Validate noun type in metadata - storage boundary protection validateNounType(metadata.noun) - return this.saveNounMetadata_internal(id, metadata, hasVector) + return this.saveNounMetadata_internal(id, metadata) } /** @@ -3626,24 +3283,16 @@ export abstract class BaseStorage extends BaseStorageAdapter { * This ensures counts are updated AFTER metadata exists, fixing the race condition * where storage adapters tried to read metadata before it was saved. * - * @param hasVector - See {@link StorageAdapter.saveNounMetadata}'s JSDoc. * @protected */ - protected async saveNounMetadata_internal(id: string, metadata: NounMetadata, hasVector?: boolean): Promise { + protected async saveNounMetadata_internal(id: string, metadata: NounMetadata): Promise { await this.ensureInitialized() // ID-first path - no type needed! const path = getNounMetadataPath(id) // Determine if this is a new entity by checking if metadata already exists - // Torn-tolerant: a WRITE landing on a torn record HEALS it — the read - // here only classifies new-vs-update and captures the prior subtype; - // a torn prior reads as "no previous" (fresh write) with the adapter's - // loud floor already fired. Never let corruption block its own cure. - const existingMetadata = await this.readCanonicalObject(path).catch((err) => { - if ((err as { code?: string }).code === 'TORN_RECORD') return null - throw err - }) + const existingMetadata = await this.readCanonicalObject(path) const isNew = !existingMetadata // Save the metadata (write-cache coherent canonical write) @@ -3679,27 +3328,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { const wasCounted = isNew ? false : isCountedVisibility(existingMetadata?.visibility) const isCounted = isCountedVisibility(newVisibility) - // ALL-visibility ledger: every NEW canonical record is +1 regardless of tier - // (the unfiltered walk yields it, so the denominator must count it). The - // counted branch below persists for public/internal records; a hidden new - // record persists here so the ALL scalar never lags the tree. - if (isNew) { - this.totalNounCountAll++ - // Vectored-noun ledger: rides the SAME isNew gate (once per id, at - // creation) — this seam is metadata-write-driven and never re-runs on - // the HNSW neighbor-link re-saves that hit saveNoun_internal, so it - // cannot double-count. A deferred-embed insert passes hasVector=false - // (or omits it); its vector lands later via noteVectorLanded(). - if (hasVector) { - this.totalVectoredNounCount++ - } - if (!(metadata.noun && isCounted)) { - this.scheduleCountPersist().catch(() => { - // Ignore persist errors — the in-memory count is authoritative; a later op retries. - }) - } - } - // CRITICAL FIX: Increment count for new entities // This runs AFTER metadata is saved, guaranteeing type information is available // Uses synchronous increment since storage operations are already serialized @@ -4030,22 +3658,8 @@ export abstract class BaseStorage extends BaseStorageAdapter { ) for (const result of chunkResults) { - if (result.status === 'fulfilled') { - if (result.value.data !== null) { - results.set(result.value.path, result.value.data) - } - } else if (isTornRecordError(result.reason)) { - // A torn record inside a SET-SHAPED read (batch hydration behind - // find/sort pages and recovery walks): the adapter narrated + - // counted at throw time; the batch HEALS PAST the victim and - // serves the remaining rows — one crash casualty must not kill - // every query that pages over its shard (and init-time recovery - // walks ride this exact path). Identity point-reads still throw. - continue - } else { - // A REAL storage fault (EIO-class) is not a torn victim — - // propagate loudly, never absorb. - throw result.reason + if (result.status === 'fulfilled' && result.value.data !== null) { + results.set(result.value.path, result.value.data) } } } @@ -4088,18 +3702,8 @@ export abstract class BaseStorage extends BaseStorageAdapter { * the skip permanently inflated the persisted totals (adds counted, paired * removals not decremented), and `Math.max(totalNounCount, scanned)` made * the inflation unfixable by any disk cleanup. - * @param hadVector - OPTIONAL vectored-noun ledger hint — see - * {@link StorageAdapter.deleteNounMetadata}'s JSDoc. This method never - * reads `vectors.json` to answer the question itself (a canonical read - * the delete path must never add); a caller that cannot supply the fact - * for free leaves it `undefined`, and the ledger goes SUSPECT rather - * than guessing. */ - public async deleteNounMetadata( - id: string, - priorRecord?: NounMetadata | null, - hadVector?: boolean - ): Promise { + public async deleteNounMetadata(id: string, priorRecord?: NounMetadata | null): Promise { await this.ensureInitialized() // Direct O(1) delete with ID-first path. Read the canonical record BEFORE @@ -4113,29 +3717,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { await this.deleteCanonicalObject(path) const record = read ?? priorRecord - // ALL-visibility ledger: a PROVEN delete (the record was read, or the caller - // carried its prior image) is −1 regardless of tier. A delete that can prove - // nothing never guesses — it marks the ledger suspect (loud, persisted) and the - // sanctioned recount restores exactness. - if (record) { - if (this.totalNounCountAll > 0) this.totalNounCountAll-- - else this.markAllCountsSuspect('noun', id) - } else { - this.markAllCountsSuspect('noun', id) - } - - // Vectored-noun ledger: a KNOWN vector fact decrements (or no-ops); - // an UNKNOWN one goes suspect rather than guessing — see @param hadVector. - if (hadVector === true) { - if (this.totalVectoredNounCount > 0) this.totalVectoredNounCount-- - else this.markAllCountsSuspect('noun-vector', id) - } else if (hadVector === undefined) { - this.markAllCountsSuspect('noun-vector', id) - } - this.scheduleCountPersist().catch(() => { - // Ignore persist errors — the in-memory count is authoritative; a later op retries. - }) - const priorType = record?.noun as NounType | undefined // 8.0 visibility: an internal/system entity was never added to `nounCountsByType` // (gated in `saveNounMetadata_internal()`), so it must not be decremented here either. @@ -4214,14 +3795,7 @@ export abstract class BaseStorage extends BaseStorageAdapter { const path = getVerbMetadataPath(id) // Determine if this is a new verb by checking if metadata already exists - // Torn-tolerant: a WRITE landing on a torn record HEALS it — the read - // here only classifies new-vs-update and captures the prior subtype; - // a torn prior reads as "no previous" (fresh write) with the adapter's - // loud floor already fired. Never let corruption block its own cure. - const existingMetadata = await this.readCanonicalObject(path).catch((err) => { - if ((err as { code?: string }).code === 'TORN_RECORD') return null - throw err - }) + const existingMetadata = await this.readCanonicalObject(path) const isNew = !existingMetadata // Save the metadata (write-cache coherent canonical write) @@ -4269,9 +3843,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { // Fixes Bug #2: Count synchronization failure during relate() and import() // 8.0: skip the user-facing total for internal/system edges (counts.json + getVerbCount()). if (isNew) { - // ALL-visibility ledger: every new edge is +1 regardless of tier (the - // unfiltered walk yields VFS/system edges too; the denominator must count them). - this.totalVerbCountAll++ if (isCounted) { this.incrementVerbCount(verbType) } else { @@ -4333,18 +3904,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { await this.deleteCanonicalObject(path) const record = read ?? priorRecord - // ALL-visibility ledger: proven delete −1 regardless of tier; an unprovable - // delete marks the ledger suspect instead of guessing (see deleteNounMetadata). - if (record) { - if (this.totalVerbCountAll > 0) this.totalVerbCountAll-- - else this.markAllCountsSuspect('verb', id) - } else { - this.markAllCountsSuspect('verb', id) - } - this.scheduleCountPersist().catch(() => { - // Ignore persist errors — in-memory count is authoritative; a later op retries. - }) - const priorVerb = record?.verb as VerbType | undefined // Symmetric count decrement (previously OMITTED — verb deletes touched neither the // scalar total nor the per-type bucket, so both inflated permanently). A COUNTED @@ -4790,22 +4349,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { // walk, every counter rollup rebuilt and persisted from it. const countedNouns = new Map() const countedVerbs = new Map() - // ALL-visibility scalars: one per canonical record the walk yields, every - // tier, readable or not — the same population the unfiltered getNouns()/ - // getVerbs() walks enumerate, so `totalCount` and this recount agree by - // construction. - let allNouns = 0 - let allVerbs = 0 - // Vectored-noun scalar: unlike `allNouns` (decided from the metadata.json - // LISTING alone), presence cannot be decided from the vectors.json - // listing alone — a deferred-embed noun's vectors.json EXISTS with an - // empty `vector: []` until its embed lands, so the file's CONTENT must be - // read. This walk already lists every path per shard (including - // vectors.json entries — `listCanonicalObjects` yields both legs), so - // reading them here costs one EXTRA read per noun beyond the metadata.json - // read above (doubling this walk's per-noun I/O) — honest cost, paid only - // by this diagnostic/repair recount, never on the hot path. - let allVectoredNouns = 0 // Scan noun shards for (let shard = 0; shard < 256; shard++) { @@ -4816,20 +4359,7 @@ export abstract class BaseStorage extends BaseStorageAdapter { const paths = await this.listCanonicalObjects(shardDir) for (const path of paths) { - if (path.includes('/vectors.json')) { - try { - const vectorRecord = await this.readCanonicalObject(path) - if (vectorRecord && Array.isArray(vectorRecord.vector) && vectorRecord.vector.length > 0) { - allVectoredNouns++ - } - } catch (error) { - // Skip vector records that fail to load — best-effort ground truth, - // same as the metadata read below. - } - continue - } if (!path.includes('/metadata.json')) continue - allNouns++ try { const metadata = await this.readCanonicalObject(path) @@ -4862,7 +4392,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { for (const path of paths) { if (!path.includes('/metadata.json')) continue - allVerbs++ try { const metadata = await this.readCanonicalObject(path) @@ -4899,30 +4428,10 @@ export abstract class BaseStorage extends BaseStorageAdapter { this.verbCounts = countedVerbs this.totalNounCount = totalNouns this.totalVerbCount = totalVerbs - // The ALL scalars are exact again and the suspect flag clears — this walk - // IS the proof an unprovable delete could not give. - const nounsAllBefore = this.totalNounCountAll - const verbsAllBefore = this.totalVerbCountAll - const vectoredBefore = this.totalVectoredNounCount - this.totalNounCountAll = allNouns - this.totalVerbCountAll = allVerbs - this.totalVectoredNounCount = allVectoredNouns - this.allCountsSuspect = false - // This walk counts one entity per metadata.json record (never per bare - // container) — the identity-record rule. Stamp it so a future load - // trusts these scalars instead of naming them suspect at open. - this.allCountsDerivedBy = 'identity-record' this.countCache.clear() await this.persistCounts() - prodLog.info( - `[BaseStorage] Rebuilt counts: ${totalNouns} nouns, ${totalVerbs} verbs (user-facing); ` + - `ALL-visibility ledger ${allNouns} nouns / ${allVerbs} verbs / ${allVectoredNouns} vectored nouns` + - (nounsAllBefore !== allNouns || verbsAllBefore !== allVerbs || vectoredBefore !== allVectoredNouns - ? ` (corrected from ${nounsAllBefore} / ${verbsAllBefore} / ${vectoredBefore})` - : ' (unchanged)') + - ` — scalar + per-type persisted` - ) + prodLog.info(`[BaseStorage] Rebuilt counts: ${totalNouns} nouns, ${totalVerbs} verbs (scalar + per-type persisted)`) } /** @@ -5116,23 +4625,10 @@ export abstract class BaseStorage extends BaseStorageAdapter { } } } catch (error) { - // A TORN record must surface typed — an enumeration that silently - // skips a corrupt row hides data loss from the caller. - // Torn record inside an ENUMERATION/RECOVERY walk: the adapter already - // narrated + counted it (TornRecordError registers at creation); the - // walk's job is to HEAL PAST it — skip the victim, serve the rest. - // Identity point-reads (get-by-id) still throw typed upstream. - if (isTornRecordError(error)) { /* skip torn victim; loud floor already fired */ } // Skip nouns that fail to load } } } catch (error) { - // A TORN record propagates (typed) — only shard-listing absence is skippable. - // Torn record inside an ENUMERATION/RECOVERY walk: the adapter already - // narrated + counted it (TornRecordError registers at creation); the - // walk's job is to HEAL PAST it — skip the victim, serve the rest. - // Identity point-reads (get-by-id) still throw typed upstream. - if (isTornRecordError(error)) { /* skip torn victim; loud floor already fired */ } // Skip shards that have no data } } @@ -5318,24 +4814,11 @@ export abstract class BaseStorage extends BaseStorageAdapter { results.push(this.hydrateVerbWithMetadata(verb, metadata)) } } catch (error) { - // A TORN record must surface typed — an enumeration that silently - // skips a corrupt row hides data loss from the caller. - // Torn record inside an ENUMERATION/RECOVERY walk: the adapter already - // narrated + counted it (TornRecordError registers at creation); the - // walk's job is to HEAL PAST it — skip the victim, serve the rest. - // Identity point-reads (get-by-id) still throw typed upstream. - if (isTornRecordError(error)) { /* skip torn victim; loud floor already fired */ } // Skip verbs that fail to load prodLog.debug(`[BaseStorage] Failed to load verb from ${verbPath}:`, error) } } } catch (error) { - // A TORN record propagates (typed) — only shard-listing absence is skippable. - // Torn record inside an ENUMERATION/RECOVERY walk: the adapter already - // narrated + counted it (TornRecordError registers at creation); the - // walk's job is to HEAL PAST it — skip the victim, serve the rest. - // Identity point-reads (get-by-id) still throw typed upstream. - if (isTornRecordError(error)) { /* skip torn victim; loud floor already fired */ } // Skip shards that have no data } } @@ -5451,13 +4934,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { sourceVerbs.push(hydratedVerb) } } catch (error) { - // A TORN record propagates (typed) — batch hydration must not - // silently drop a corrupt row. Only shard-listing absence is skippable. - // Torn record inside an ENUMERATION/RECOVERY walk: the adapter already - // narrated + counted it (TornRecordError registers at creation); the - // walk's job is to HEAL PAST it — skip the victim, serve the rest. - // Identity point-reads (get-by-id) still throw typed upstream. - if (isTornRecordError(error)) { /* skip torn victim; loud floor already fired */ } // Skip shards that have no data } } @@ -5543,23 +5019,10 @@ export abstract class BaseStorage extends BaseStorageAdapter { results.push(this.hydrateVerbWithMetadata(verb, metadata)) } } catch (error) { - // A TORN record must surface typed — an enumeration that silently - // skips a corrupt row hides data loss from the caller. - // Torn record inside an ENUMERATION/RECOVERY walk: the adapter already - // narrated + counted it (TornRecordError registers at creation); the - // walk's job is to HEAL PAST it — skip the victim, serve the rest. - // Identity point-reads (get-by-id) still throw typed upstream. - if (isTornRecordError(error)) { /* skip torn victim; loud floor already fired */ } // Skip verbs that fail to load } } } catch (error) { - // A TORN record propagates (typed) — only shard-listing absence is skippable. - // Torn record inside an ENUMERATION/RECOVERY walk: the adapter already - // narrated + counted it (TornRecordError registers at creation); the - // walk's job is to HEAL PAST it — skip the victim, serve the rest. - // Identity point-reads (get-by-id) still throw typed upstream. - if (isTornRecordError(error)) { /* skip torn victim; loud floor already fired */ } // Skip shards that have no data } } @@ -5604,23 +5067,10 @@ export abstract class BaseStorage extends BaseStorageAdapter { ) ) } catch (error) { - // A TORN record must surface typed — an enumeration that silently - // skips a corrupt row hides data loss from the caller. - // Torn record inside an ENUMERATION/RECOVERY walk: the adapter already - // narrated + counted it (TornRecordError registers at creation); the - // walk's job is to HEAL PAST it — skip the victim, serve the rest. - // Identity point-reads (get-by-id) still throw typed upstream. - if (isTornRecordError(error)) { /* skip torn victim; loud floor already fired */ } // Skip verbs that fail to load } } } catch (error) { - // A TORN record propagates (typed) — only shard-listing absence is skippable. - // Torn record inside an ENUMERATION/RECOVERY walk: the adapter already - // narrated + counted it (TornRecordError registers at creation); the - // walk's job is to HEAL PAST it — skip the victim, serve the rest. - // Identity point-reads (get-by-id) still throw typed upstream. - if (isTornRecordError(error)) { /* skip torn victim; loud floor already fired */ } // Skip shards that have no data } } diff --git a/src/storage/brainFormat.ts b/src/storage/brainFormat.ts index 6ef913d4..2e6488e9 100644 --- a/src/storage/brainFormat.ts +++ b/src/storage/brainFormat.ts @@ -69,15 +69,7 @@ export const BRAIN_FORMAT_PATH = '_system/brain-format.json' * (the 8.0 GA baseline). An on-disk `indexEpoch` that differs from this — or an * absent marker — triggers a full derived-index rebuild on open. */ -// Epoch 3 (2026-08-03, the namespace-law pair): the index key format split -// the two namespaces — user fields keep bare flattened keys, the ten system -// scalars moved to literal 'system.' keys (the legacy 'noun' column -// spelling died with them). Every brain rebuilds its derived indexes from -// canonical at first open onto the frozen keys. -// Epoch 2 (2026-08-03, same day, the interim pair): user metadata fields -// named `level` became indexable on both engines; poisoned multi-valued -// `level` columns healed through the rebuild. -export const EXPECTED_INDEX_EPOCH = 3 +export const EXPECTED_INDEX_EPOCH = 1 /** * @description The data-layer format string this build writes and runs as. diff --git a/src/storage/storageFactory.ts b/src/storage/storageFactory.ts index 46c67f44..64b18dc1 100644 --- a/src/storage/storageFactory.ts +++ b/src/storage/storageFactory.ts @@ -154,21 +154,6 @@ export function resolveFilesystemRoot( ) { throwRemovedStorageKey('fileSystemStorage.path') } - // A nested `config` object carrying a path-shaped key is the same hazard in - // a shape nobody ever supported: it used to fall through SILENTLY to the - // shared default root — every instance writing one directory while its - // caller believed each had its own. (Found live: an integration test's - // brains shared one store across a whole single-process run and a health - // probe refused on the foreign edges it sampled.) Loud, with the rename. - const nested = (config as Record).config - if (nested && typeof nested === 'object') { - const pathish = ['path', 'baseDir', 'rootDirectory', 'rootDir', 'dir', 'directory'] - const hit = pathish.find( - (k) => typeof (nested as Record)[k] === 'string' && - ((nested as Record)[k] as string).length > 0 - ) - if (hit) throwRemovedStorageKey(`config.${hit}`) - } // 3. Zero-config default. A `type: 'filesystem'` with no path lands here // intentionally ("persist, default location"). diff --git a/src/storage/tornRecordError.ts b/src/storage/tornRecordError.ts deleted file mode 100644 index e3248f80..00000000 --- a/src/storage/tornRecordError.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * @module storage/tornRecordError - * @description Typed surface for TORN records — files that EXIST in storage but - * cannot be decoded (invalid JSON, truncated/garbled gzip). A torn record is - * disk corruption, not absence: reading it as `null` ("not found") makes the - * consumer unable to distinguish "never existed" from "exists but unreadable", - * so nothing ever heals it. Mandate: loud errors, never quiet losses. - * - * Contract implemented across the storage layer: - * - Genuine absence (ENOENT) still reads as clean `null` — no error, no noise. - * - A torn record ALWAYS registers here (error log + per-process gauge), then: - * - entity read paths (get/getBatch/pagination/enumeration hydration) throw - * {@link TornRecordError} to the caller — a row is never silently dropped; - * - system-artifact read paths whose machinery is designed for - * absent-artifact degradation (manifests with recovery paths, markers - * whose verdict is "rescan", rebuildable statistics) map torn → their - * existing degrade AFTER the encounter is logged and counted. - */ - -import { prodLog } from '../utils/logger.js' - -/** - * @description Thrown when a stored object EXISTS but cannot be decoded — - * corrupt/torn bytes on disk (invalid JSON, undecodable gzip). Deliberately - * distinct from absence: `readObjectFromPath` returns `null` only for ENOENT. - * Catchable by type (`instanceof`), by `name === 'TornRecordError'`, or by - * `code === 'TORN_RECORD'` (cross-realm safe; never matches `isAbsentError`). - */ -export class TornRecordError extends Error { - /** Stable machine-checkable discriminator (errno-style). */ - public readonly code = 'TORN_RECORD' - /** Storage-root-relative path of the torn object. */ - public readonly path: string - /** The underlying decode failure (SyntaxError, zlib error, …). */ - public override readonly cause: unknown - - /** - * @param path - Storage-root-relative path of the torn object. - * @param cause - The underlying decode failure. - */ - constructor(path: string, cause: unknown) { - const causeMessage = - cause instanceof Error ? cause.message : String(cause) - super( - `Torn record at '${path}': file exists but cannot be decoded (${causeMessage}). ` + - `This is storage corruption, not absence — the record was not silently skipped.` - ) - this.name = 'TornRecordError' - this.path = path - this.cause = cause - } -} - -/** - * @description True IFF `e` is a torn-record error — matches by `instanceof` - * first, then by `name`/`code` so errors crossing module-duplication or realm - * boundaries are still recognized. - * @param e - The caught value. - * @returns Whether `e` denotes an existing-but-undecodable stored object. - */ -export function isTornRecordError(e: unknown): e is TornRecordError { - if (e instanceof TornRecordError) return true - if (e === null || typeof e !== 'object') return false - const { name, code } = e as { name?: unknown; code?: unknown } - return name === 'TornRecordError' || code === 'TORN_RECORD' -} - -/** - * @description True IFF `e` is a payload-decode failure — the file's BYTES were - * read fine but could not be turned back into an object: `SyntaxError` from - * `JSON.parse`, or a zlib error (`Z_DATA_ERROR`, `Z_BUF_ERROR`, …) from gunzip. - * Distinguishes "torn record" from real I/O faults (EIO/EACCES/…), which must - * propagate as themselves. - * @param e - The caught value. - * @returns Whether the error means "bytes present, content undecodable". - */ -export function isUnparseablePayloadError(e: unknown): boolean { - if (e === null || typeof e !== 'object') return false - if (e instanceof SyntaxError) return true - const { name, code } = e as { name?: unknown; code?: unknown } - if (name === 'SyntaxError') return true - return typeof code === 'string' && code.startsWith('Z_') -} - -/** Per-process torn-record gauge state (module-scoped; see the accessors). */ -let tornRecordCount = 0 -let lastTornRecordPath: string | null = null - -/** - * @description Register a torn-record encounter: logs a production ERROR - * naming the path, increments the per-process gauge, and returns the typed - * error for the caller to throw (or to map into a documented loud degrade). - * EVERY torn encounter goes through here, whatever the caller decides — - * the floor is: never silent. - * @param path - Storage-root-relative path of the torn object. - * @param cause - The underlying decode failure. - * @returns The constructed {@link TornRecordError}. - */ -export function registerTornRecordEncounter( - path: string, - cause: unknown -): TornRecordError { - tornRecordCount++ - lastTornRecordPath = path - const error = new TornRecordError(path, cause) - prodLog.error( - `[Storage] TORN RECORD #${tornRecordCount}: '${path}' exists but cannot be decoded — ` + - `corrupt or partially written bytes. Cause: ${ - cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause) - }` - ) - return error -} - -/** - * @description Read the per-process torn-record gauge: how many torn records - * this process has encountered and the most recent path. Observability seam — - * lets operators and tests confirm that corruption was seen, not swallowed. - * @returns The current gauge snapshot. - */ -export function getTornRecordGauge(): { count: number; lastPath: string | null } { - return { count: tornRecordCount, lastPath: lastTornRecordPath } -} - -/** - * @description Reset the per-process torn-record gauge to zero. Test seam only - * (the gauge is process-lifetime state); production code never resets it. - */ -export function resetTornRecordGauge(): void { - tornRecordCount = 0 - lastTornRecordPath = null -} diff --git a/src/transaction/Transaction.ts b/src/transaction/Transaction.ts index ede65e83..79b53006 100644 --- a/src/transaction/Transaction.ts +++ b/src/transaction/Transaction.ts @@ -62,10 +62,9 @@ const DEFAULT_BUDGET_FLOOR_MS = 30_000 * NEXT operation may start (see {@link Transaction.execute}), never whether * already-completed work is rolled back after the fact. A trip mid-batch * still rolls back every operation applied so far, atomically, and throws a - * fully-labeled `TransactionTimeoutError` — retryable-with-latch, never - * hot-retry (see its `retryable` and `hotRetryUnsafe` fields) — that - * zero-loss guarantee doesn't change; only the point at which the clock - * stops mattering does (at the last operation, not one check later). + * retryable, fully-labeled TransactionTimeoutError — that zero-loss guarantee + * doesn't change; only the point at which the clock stops mattering does (at + * the last operation, not one check later). * * @param opCount - Number of operations in the batch. * @param override - A full override for this call; wins over everything else. diff --git a/src/transaction/TransactionManager.ts b/src/transaction/TransactionManager.ts index 5abf48ba..0f13b6a3 100644 --- a/src/transaction/TransactionManager.ts +++ b/src/transaction/TransactionManager.ts @@ -19,6 +19,7 @@ import { Transaction } from './Transaction.js' import { TransactionFunction, + TransactionResult, TransactionOptions } from './types.js' import { TransactionError } from './errors.js' @@ -104,6 +105,34 @@ export class TransactionManager { } } + /** + * Execute a transaction and return detailed result + */ + async executeTransactionWithResult( + fn: TransactionFunction, + options?: TransactionOptions + ): Promise> { + const startTime = Date.now() + const transaction = new Transaction(options) + + try { + const value = await fn(transaction) + await transaction.execute() + + const executionTimeMs = Date.now() - startTime + + return { + value, + operationCount: transaction.getOperationCount(), + executionTimeMs + } + + } catch (error) { + // Transaction failed + throw error + } + } + /** * Get transaction statistics */ diff --git a/src/transaction/errors.ts b/src/transaction/errors.ts index d8382a4b..c270d0ed 100644 --- a/src/transaction/errors.ts +++ b/src/transaction/errors.ts @@ -73,47 +73,14 @@ export class InvalidTransactionStateError extends TransactionError { /** * Error for transaction timeout - * - * Machine-readable no-hot-retry contract: {@link retryable} and - * {@link hotRetryUnsafe} are both always `true` on this class — they exist - * so a caller can branch on the *shape* of the error instead of parsing - * message text. Read them together: the operation may eventually succeed, - * but never by looping on it immediately. - * - * `context` (inherited from {@link TransactionError}) carries the caller's - * backoff inputs — see the field docs below. */ export class TransactionTimeoutError extends TransactionError { - /** - * The failed operation MAY succeed on a later attempt — once the - * underlying slowness resolves (e.g. a cold page cache warms up) or the - * budget is deliberately raised (`transactionBudgetFloorMs`, or a larger - * `timeoutMs` override on the batch). This is a statement about eventual - * retryability, not a license to retry now — see {@link hotRetryUnsafe}. - */ - public readonly retryable = true - - /** - * An immediate, identical retry re-pays the FULL cost of the work that - * just timed out — it does not resume partway. Looping on this error - * (hot-retrying) repeats that full cost every attempt and can cascade - * into a CPU/resource storm on the caller's side. Callers MUST latch: on - * this error, record `{ at: Date.now(), error }`, surface one loud - * failure to their own caller, and hold a cooldown window before any - * re-attempt (clearing the latch only on success). Never retry this error - * in a tight loop. - */ - public readonly hotRetryUnsafe = true - constructor( timeoutMs: number, operationIndex: number, telemetry?: { - /** Milliseconds elapsed in the transaction when the budget tripped. */ elapsedMs?: number - /** Total number of operations in the batch that timed out. */ totalOperations?: number - /** Name of the operation the batch was about to start when it tripped, if named. */ operationName?: string } ) { @@ -126,16 +93,8 @@ export class TransactionTimeoutError extends TransactionError { telemetry?.elapsedMs !== undefined ? `${telemetry.elapsedMs}ms elapsed, ` : '' super( `Transaction timed out at operation ${progress}${name} — ${elapsed}budget ${timeoutMs}ms. ` + - `The batch rolled back atomically; retryable after the underlying slowness resolves or ` + - `the budget is raised, but hot-retry-unsafe — latch and back off, never loop.`, - { - // Caller backoff inputs — all present on every instance: - /** Configured budget (ms) that was exceeded. */ - timeoutMs, - /** Index of the operation the batch was about to start when it tripped. */ - operationIndex, - ...telemetry - } + `The batch rolled back atomically; retry with a higher timeoutMs or a smaller batch.`, + { timeoutMs, operationIndex, ...telemetry } ) this.name = 'TransactionTimeoutError' } diff --git a/src/transaction/operations/IndexOperations.ts b/src/transaction/operations/IndexOperations.ts index 0142dc54..d130bb3f 100644 --- a/src/transaction/operations/IndexOperations.ts +++ b/src/transaction/operations/IndexOperations.ts @@ -13,9 +13,6 @@ import type { VectorIndexProvider, GraphIndexProvider } from '../../plugin.js' import type { MetadataIndexManager } from '../../utils/metadataIndex.js' import type { GraphVerb } from '../../coreTypes.js' import type { Operation, RollbackAction } from '../types.js' -import { isZeroNormVector } from '../../utils/distance.js' -import { jsonSafeIndexMetadata } from '../../utils/jsonSafeIndexMetadata.js' -import { prodLog } from '../../utils/logger.js' /** * Backend identity stamped into an operation's emitted `name` string (e.g. @@ -59,77 +56,32 @@ function resolveVectorProviderId(index: VectorIndexProvider): string { * or timing trace see which engine actually ran, never a fossil name from * whichever engine happened to be active when this op class was written. * - * Generation: `generationFn` is resolved at execute time (not construction) so - * the write is stamped at the transaction's in-flight commit generation — - * which the generation store only assigns once the batch begins executing. - * The same generation is reused for the rollback removal, so an add and its - * undo reference one watermark in a provider's per-record delta log (the - * exact pattern the graph operations established). - * * Rollback strategy: * - Remove item from index */ export class AddToVectorIndexOperation implements Operation { readonly name: string - /** - * @param index - The vector-index provider (JS HNSW or native). - * @param id - The entity's UUID. - * @param vector - The vector to index. - * @param generationFn - OPTIONAL: resolves the commit generation to stamp - * this write at, evaluated when the operation executes (see class note). - * Absent -> the provider receives no generation (undefined), never a - * fabricated 0. - */ constructor( private readonly index: VectorIndexProvider, private readonly id: string, - private readonly vector: number[], - private readonly generationFn?: () => bigint | undefined + private readonly vector: number[] ) { this.name = `AddToVectorIndex(${resolveVectorProviderId(index)})` } async execute(): Promise { - // THE ZERO-NORM LAW (the live provider-write seam's belt): a zero-norm - // vector is not a vector — it never crosses an engine boundary. This - // engine's own cosine distance treats an all-zero vector safely (a - // zero-norm operand always scores MAXIMUM distance, see - // {@link 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 darkens real results. - // The canonical write already landed (SaveNoun/SaveNounMetadata - // operations are staged ahead of this one in every caller) — only the - // INDEX INSERT is refused here, loudly, never a throw. A length-0 - // vector is the unrelated "unvectored" shape and is skipped silently - // (the same contract callers already rely on for deferred embeds). - if (this.vector.length === 0) { - return async () => {} - } - if (isZeroNormVector(this.vector)) { - prodLog.warn( - `[vector-index] refusing to index a zero-norm vector for entity ${this.id} — ` + - `a zero-norm vector is not a vector and never crosses an engine boundary ` + - `(the canonical write is unaffected; only the vector-index insert is skipped)` - ) - return async () => {} - } - // Check if item already exists (for rollback decision) const existed = await this.itemExists(this.id) - // Stamp this write at the in-flight commit generation; reuse it for the - // rollback so add + undo reference the same watermark. - const generation = this.generationFn?.() - // Add to index - await this.index.addItem({ id: this.id, vector: this.vector }, generation) + await this.index.addItem({ id: this.id, vector: this.vector }) // Return rollback action return async () => { if (!existed) { // Remove newly added item - await this.index.removeItem(this.id, generation) + await this.index.removeItem(this.id) } // If item existed before, we don't rollback (update is OK) // This prevents index corruption from removing pre-existing items @@ -179,181 +131,22 @@ export class AddToVectorIndexOperation implements Operation { export class RemoveFromVectorIndexOperation implements Operation { readonly name: string - /** - * @param index - The vector-index provider (JS HNSW or native). - * @param id - The entity's UUID. - * @param vector - The removed vector (required for rollback re-add). - * @param generationFn - Resolves the commit generation for this removal, - * evaluated when the operation executes; reused for the rollback re-add - * so the round trip references one watermark. - */ constructor( private readonly index: VectorIndexProvider, private readonly id: string, - private readonly vector: number[], // Required for rollback - private readonly generationFn?: () => bigint | undefined + private readonly vector: number[] // Required for rollback ) { this.name = `RemoveFromVectorIndex(${resolveVectorProviderId(index)})` } async execute(): Promise { - // Resolve the removal generation once; reuse it for the rollback re-add. - const generation = this.generationFn?.() - // Remove from index - await this.index.removeItem(this.id, generation) + await this.index.removeItem(this.id) // Return rollback action return async () => { // Re-add item with original vector - await this.index.addItem({ id: this.id, vector: this.vector }, generation) - } - } -} - -/** - * Replace an item's vector in the vector index as ONE atomic transaction leg — - * the row is never absent from vector search during an update. - * - * Backend-neutral: see {@link AddToVectorIndexOperation} — `index` may be the - * JS HNSW fallback or a native acceleration provider; the emitted `name` - * stamps the active backend. - * - * Why this op exists: update flows historically staged a - * {@link RemoveFromVectorIndexOperation} followed by an - * {@link AddToVectorIndexOperation} as two separately-awaited operations. - * Between them the row was in NEITHER index — dark to semantic recall while - * perfectly visible to metadata reads (a transient-invisibility window that - * stretched to seconds in a production deployment). The structural cure is a - * single leg that never removes without simultaneously re-inserting. - * - * Execution strategy (feature-detected, in preference order): - * 1. Provider exposes `updateItem` → ONE in-place call. The provider swaps - * the vector without the row ever leaving its index, and an element-wise - * UNCHANGED vector (the type-only-update production shape) is a pure - * no-op on its side. - * 2. Provider without `updateItem` (a native provider that has not shipped - * it yet) → `removeItem` + `addItem` executed ADJACENT within this single - * op. Still strictly better than the historical pair: no other transaction - * operation can interleave between the two calls. This is a temporary - * seam — the native side of the pair is expected to ship its own - * `updateItem` so path 1 applies everywhere; when it does, this fallback - * becomes dead code that costs nothing. - * - * Rollback strategy (mirrors the execute branch that ran): - * - `updateItem` path → `updateItem` back to `oldVector`. - * - Fallback path → `removeItem` + `addItem` back to `oldVector`. - * - * Rollback semantics when the item did not exist at execute time: this op's - * contract is that the caller read the entity and its CURRENT vector - * (`oldVector`) before staging — update flows only stage it for existing - * rows. If the item was somehow absent, execute() inserts it (`updateItem` - * delegates to add; the fallback's remove is a no-op before its add), and - * rollback restores `oldVector` rather than removing — the same posture as - * {@link RemoveFromVectorIndexOperation}'s unconditional re-add: by - * constructing the op with `oldVector` the caller DECLARED the before-state, - * and rollback reconstructs that declared state instead of silently deciding - * the row should vanish. - */ -export class ReplaceInVectorIndexOperation implements Operation { - readonly name: string - - /** - * @param index - The vector-index provider (JS HNSW or native). - * @param id - The entity's UUID. - * @param oldVector - The pre-update vector (required for rollback). - * @param newVector - The replacement vector. - * @param generationFn - Resolves the commit generation to stamp this write - * at, evaluated when the operation executes and reused across both - * execute branches AND the rollback — one watermark for the whole - * replace round trip. - */ - constructor( - private readonly index: VectorIndexProvider, - private readonly id: string, - private readonly oldVector: number[], // Required for rollback - private readonly newVector: number[], - private readonly generationFn?: () => bigint | undefined - ) { - this.name = `ReplaceInVectorIndex(${resolveVectorProviderId(index)})` - } - - async execute(): Promise { - // Feature-detect the in-place capability — optional on the provider - // contract, like `getItem`/`setPersistMode` (Brainy's JS HNSW index - // ships it; a native provider may not have yet). The capability carries - // the same optional trailing generation as the required write surface. - const index = this.index as VectorIndexProvider & { - updateItem?: (item: { id: string; vector: number[] }, generation?: bigint) => Promise - } - - // One commit generation for the whole replace (both branches + rollback). - const generation = this.generationFn?.() - - // THE ZERO-NORM LAW (see AddToVectorIndexOperation's matching JSDoc): a - // real all-zero replacement vector must never land in the index — refuse - // loudly, canonical write unaffected. The row must not be left stale - // either: if it was genuinely indexed under `oldVector`, remove it - // rather than pretend the old vector still describes the row. A - // length-0 `newVector` (the unrelated "unvectored" shape) is handled the - // same way, silently — no caller today reaches this with an empty - // replacement (update() rejects a dimension-mismatched empty vector), - // but the seam stays consistent in case one ever legitimately does. - if (isZeroNormVector(this.newVector) || this.newVector.length === 0) { - const wasIndexed = this.oldVector.length > 0 && !isZeroNormVector(this.oldVector) - if (isZeroNormVector(this.newVector)) { - prodLog.warn( - `[vector-index] refusing to replace with a zero-norm vector for entity ${this.id} — ` + - `a zero-norm vector is not a vector and never crosses an engine boundary ` + - `(the canonical write is unaffected; the row is removed from the vector index instead)` - ) - } - if (wasIndexed) { - await this.index.removeItem(this.id, generation) - } - return async () => { - // Restore the declared before-state. - if (wasIndexed) { - await this.index.addItem({ id: this.id, vector: this.oldVector }, generation) - } - } - } - - if (typeof index.updateItem === 'function') { - // Atomic path: one in-place call, the row never leaves the index. - await index.updateItem({ id: this.id, vector: this.newVector }, generation) - - return async () => { - // Restore the declared before-state in place (see class JSDoc for - // 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) - } - } - } - - // Fallback seam: remove+add ADJACENT within this single op — no other - // transaction operation can interleave between them (see class JSDoc). - await this.index.removeItem(this.id, generation) - await this.index.addItem({ id: this.id, vector: this.newVector }, generation) - - return async () => { - // updateItem-style restore via the same adjacent pair, back to the - // 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) - if (this.oldVector.length > 0) { - await this.index.addItem({ id: this.id, vector: this.oldVector }, generation) - } + await this.index.addItem({ id: this.id, vector: this.vector }) } } } @@ -361,51 +154,26 @@ export class ReplaceInVectorIndexOperation implements Operation { /** * Add to metadata index with rollback support * - * Generation: `generationFn` is resolved at execute time (not construction) — - * see {@link AddToVectorIndexOperation}'s class note; the same generation is - * reused for the rollback removal so add + undo reference one watermark in a - * provider's per-record delta log. - * * Rollback strategy: * - Remove item from index */ export class AddToMetadataIndexOperation implements Operation { readonly name = 'AddToMetadataIndex' - /** - * @param index - The metadata-index manager (JS baseline or a registered provider). - * @param id - The entity's UUID. - * @param entity - Entity or metadata structure to index. - * @param generationFn - Resolves the commit generation to stamp this write - * at, evaluated when the operation executes. - */ constructor( private readonly index: MetadataIndexManager, private readonly id: string, - private readonly entity: any, // Entity or metadata structure - private readonly generationFn?: () => bigint | undefined + private readonly entity: any // Entity or metadata structure ) {} async execute(): Promise { - // Stamp this write at the in-flight commit generation; reuse it for the - // rollback so add + undo reference the same watermark. - const generation = this.generationFn?.() - - // The JSON-safe view is taken HERE, per crossing, never at construction: - // the entity reference this op holds can be mutated between plan and - // execute (a graph op's execute-time endpoint-int resolution mirrors - // BigInts onto a shared verb object) — see jsonSafeIndexMetadata's - // module doc. - await this.index.addToIndex( - this.id, jsonSafeIndexMetadata(this.entity), true, false, generation - ) + // Add to metadata index (skipFlush=true for transaction atomicity) + await this.index.addToIndex(this.id, this.entity, true) // Return rollback action return async () => { // Remove from metadata index - await this.index.removeFromIndex( - this.id, jsonSafeIndexMetadata(this.entity), generation - ) + await this.index.removeFromIndex(this.id, this.entity) } } } @@ -413,49 +181,26 @@ export class AddToMetadataIndexOperation implements Operation { /** * Remove from metadata index with rollback support * - * Generation: resolved at execute time and reused for the rollback re-add — - * one watermark for the removal round trip (see - * {@link AddToMetadataIndexOperation}). - * * Rollback strategy: * - Re-add item to index with original metadata */ export class RemoveFromMetadataIndexOperation implements Operation { readonly name = 'RemoveFromMetadataIndex' - /** - * @param index - The metadata-index manager (JS baseline or a registered provider). - * @param id - The entity's UUID. - * @param entity - The entity/metadata being removed (required for rollback). - * @param generationFn - Resolves the commit generation for this removal, - * evaluated when the operation executes. - */ constructor( private readonly index: MetadataIndexManager, private readonly id: string, - private readonly entity: any, // Required for rollback - private readonly generationFn?: () => bigint | undefined + private readonly entity: any // Required for rollback ) {} async execute(): Promise { - // Resolve the removal generation once; reuse it for the rollback re-add. - const generation = this.generationFn?.() - - // Sanitized per crossing, never at construction — transact()'s delete - // legs hand this op the SAME verb object the graph-retraction op's - // execute-time endpoint resolution mutates (BigInt sourceInt/targetInt), - // so a plan-time view aliases the pollution. See jsonSafeIndexMetadata's - // module doc. - await this.index.removeFromIndex( - this.id, jsonSafeIndexMetadata(this.entity), generation - ) + // Remove from metadata index + await this.index.removeFromIndex(this.id, this.entity) // Return rollback action return async () => { // Re-add with original metadata (skipFlush=true) - await this.index.addToIndex( - this.id, jsonSafeIndexMetadata(this.entity), true, false, generation - ) + await this.index.addToIndex(this.id, this.entity, true) } } } @@ -524,7 +269,7 @@ export class AddToGraphIndexOperation implements Operation { // Stamp this edge at the in-flight commit generation; reuse it for the // rollback so add + undo reference the same watermark. Endpoint ints // resolve HERE — after any same-batch adds have applied. - const generation = this.generationFn?.() + const generation = this.generationFn() const { sourceInt, targetInt } = resolveEndpointInts(this.endpointInts) const verbInt = await this.index.addVerb(this.verb, sourceInt, targetInt, generation) this.onVerbInt?.(verbInt) @@ -573,7 +318,7 @@ export class RemoveFromGraphIndexOperation implements Operation { // Resolve the removal generation once; reuse it for the rollback re-add. // Endpoint ints resolve HERE (after any same-batch adds applied) and are // captured for the rollback, whose re-add must use the same mappings. - const generation = this.generationFn?.() + const generation = this.generationFn() const { sourceInt, targetInt } = resolveEndpointInts(this.endpointInts) await this.index.removeVerb(this.verb.id, generation) @@ -597,20 +342,13 @@ export class BatchAddToVectorIndexOperation implements Operation { private operations: AddToVectorIndexOperation[] - /** - * @param index - The vector-index provider (JS HNSW or native). - * @param items - The vectors to index. - * @param generationFn - Resolves the commit generation shared by every item - * in the batch, evaluated when the operations execute. - */ constructor( index: VectorIndexProvider, - items: Array<{ id: string; vector: number[] }>, - generationFn?: () => bigint | undefined + items: Array<{ id: string; vector: number[] }> ) { this.name = `BatchAddToVectorIndex(${resolveVectorProviderId(index)})` this.operations = items.map( - item => new AddToVectorIndexOperation(index, item.id, item.vector, generationFn) + item => new AddToVectorIndexOperation(index, item.id, item.vector) ) } @@ -645,19 +383,12 @@ export class BatchAddToMetadataIndexOperation implements Operation { private operations: AddToMetadataIndexOperation[] - /** - * @param index - The metadata-index manager (JS baseline or a registered provider). - * @param items - The entities to index. - * @param generationFn - Resolves the commit generation shared by every item - * in the batch, evaluated when the operations execute. - */ constructor( index: MetadataIndexManager, - items: Array<{ id: string; entity: any }>, - generationFn?: () => bigint | undefined + items: Array<{ id: string; entity: any }> ) { this.operations = items.map( - item => new AddToMetadataIndexOperation(index, item.id, item.entity, generationFn) + item => new AddToMetadataIndexOperation(index, item.id, item.entity) ) } diff --git a/src/transaction/operations/StorageOperations.ts b/src/transaction/operations/StorageOperations.ts index 8b2ebffe..316f1ac0 100644 --- a/src/transaction/operations/StorageOperations.ts +++ b/src/transaction/operations/StorageOperations.ts @@ -12,7 +12,6 @@ import type { StorageAdapter, HNSWNoun, HNSWVerb, NounMetadata, VerbMetadata } from '../../coreTypes.js' import type { Operation, RollbackAction } from '../types.js' -import { prodLog } from '../../utils/logger.js' /** * Save noun metadata with rollback support @@ -21,30 +20,6 @@ import { prodLog } from '../../utils/logger.js' * - If metadata existed: Restore previous metadata * - If metadata was new: Delete metadata */ - -/** - * Torn-tolerant previous-state read for ROLLBACK CAPTURE: a write or delete - * landing on a TORN record (power-loss survivor) HEALS it — the incoming - * bytes replace (or remove) the unreadable ones, and the rollback target is - * the create sentinel (null). The adapter's loud floor (error + gauge) - * already fired at throw time; this narrates the heal and proceeds. Real - * storage faults still propagate. - */ -async function tornHealsToNull(read: Promise, what: string): Promise { - try { - return await read - } catch (err) { - if ((err as { code?: string }).code === 'TORN_RECORD') { - prodLog.warn( - `[StorageOperations] previous ${what} is TORN — the incoming operation ` + - `heals it; rollback target is the create sentinel` - ) - return null - } - throw err - } -} - export class SaveNounMetadataOperation implements Operation { readonly name = 'SaveNounMetadata' @@ -52,25 +27,17 @@ export class SaveNounMetadataOperation implements Operation { private readonly storage: StorageAdapter, private readonly id: string, private readonly metadata: NounMetadata, - private readonly isNew: boolean = false, - /** - * OPTIONAL vectored-noun ledger hint: `true` when this write's paired - * vector (the SAME insert's `vector` local) is real/non-empty — see - * {@link StorageAdapter.saveNounMetadata}'s JSDoc for the isNew-gated, - * double-count-proof seam this rides. Default `false`: a deferred-embed - * insert (or any caller that doesn't know) never counts here. - */ - private readonly hasVector: boolean = false + private readonly isNew: boolean = false ) {} async execute(): Promise { // Skip read for new entities — nothing to rollback to (saves 1 storage round-trip) const previousMetadata = this.isNew ? null - : await tornHealsToNull(this.storage.getNounMetadata(this.id), 'noun metadata') + : await this.storage.getNounMetadata(this.id) // Save new metadata - await this.storage.saveNounMetadata(this.id, this.metadata, this.hasVector) + await this.storage.saveNounMetadata(this.id, this.metadata) // Return rollback action return async () => { @@ -78,10 +45,8 @@ export class SaveNounMetadataOperation implements Operation { // Restore previous metadata await this.storage.saveNounMetadata(this.id, previousMetadata) } else { - // Delete newly created metadata. `this.hasVector` is the SAME fact - // this operation's own execute() used to (maybe) count the vectored - // ledger — reversing with it on rollback needs no new read. - await this.storage.deleteNounMetadata(this.id, undefined, this.hasVector) + // Delete newly created metadata + await this.storage.deleteNounMetadata(this.id) } } } @@ -110,29 +75,10 @@ export class SaveNounOperation implements Operation { // Skip read for new entities — nothing to rollback to (saves 1 storage round-trip) const previousNoun = this.isNew ? null - : await tornHealsToNull(this.storage.getNoun(this.noun.id), 'noun record') + : await this.storage.getNoun(this.noun.id) - // PRESERVE stored graph state on updates. Callers stage this op with - // placeholder adjacency ({connections: empty, level: 0}) because the - // vector index owns those values and persists them at flush. Codec-era - // records (2.4.0+) carry an empty connections field by design (adjacency - // lives in a separate compressed blob — the placeholder is harmless), but - // LEGACY pre-codec records store adjacency INLINE: writing the - // placeholder over one stamped out its stored connections, leaving a - // crash window (until the next flush) where a reload found the node - // unreachable. Stale adjacency in that window is tolerable — HNSW - // self-corrects at the reindex flush; EMPTY adjacency is silent recall - // loss. The read above is already paid for rollback; preservation is free. - const toSave: HNSWNoun = - previousNoun && this.noun.connections.size === 0 - ? { - ...this.noun, - connections: previousNoun.connections || this.noun.connections, - level: previousNoun.level ?? this.noun.level - } - : this.noun - - await this.storage.saveNoun(toSave) + // Save new noun + await this.storage.saveNoun(this.noun) // Return rollback action return async () => { @@ -150,9 +96,7 @@ export class SaveNounOperation implements Operation { // Note: Not all adapters implement deleteNoun // This is acceptable - metadata deletion makes entity invisible if ('deleteNoun' in this.storage && typeof this.storage.deleteNoun === 'function') { - // `this.noun.vector` is the SAME record just written — the - // vectored-noun ledger fact is free (no added read) and exact. - await this.storage.deleteNoun(this.noun.id, undefined, this.noun.vector.length > 0) + await this.storage.deleteNoun(this.noun.id) } } } @@ -199,32 +143,22 @@ export class DeleteNounMetadataOperation implements Operation { // Capture the FULL before-image (both legs) so the undo restores the whole // entity — a metadata-only rollback would leave the vector leg unrestored. // A null metadata read falls back to the caller's pre-delete read. - const previousNoun = await tornHealsToNull(this.storage.getNoun(this.id), 'noun record') - const previousMetadata = - (await tornHealsToNull(this.storage.getNounMetadata(this.id), 'noun metadata')) ?? - this.priorMetadata ?? - null + const previousNoun = await this.storage.getNoun(this.id) + const previousMetadata = (await this.storage.getNounMetadata(this.id)) ?? this.priorMetadata ?? null if (!previousNoun && !previousMetadata) { // Nothing to delete - no rollback needed return async () => {} } - // Vectored-noun ledger fact: `previousNoun` is already read above for the - // before-image capture — no added read. `undefined` (noun genuinely - // absent, metadata-only ghost) is passed through honestly; the storage - // layer marks the ledger suspect rather than guessing. - const hadVector = previousNoun ? previousNoun.vector.length > 0 : undefined - // Full removal: both canonical legs + the entity container + count decrement // (the prior record keeps the decrement honest on a null canonical read). - await this.storage.deleteNoun(this.id, previousMetadata, hadVector) + await this.storage.deleteNoun(this.id, previousMetadata) // Return rollback action return async () => { // Restore the vector leg, then the metadata leg through the count-aware - // save so deleteNoun()'s decrement is reversed (hadVector's mirror: - // re-increments the vectored ledger iff the restored vector is real). + // save so deleteNoun()'s decrement is reversed. if (previousNoun) { await this.storage.saveNoun({ id: previousNoun.id, @@ -234,7 +168,7 @@ export class DeleteNounMetadataOperation implements Operation { }) } if (previousMetadata) { - await this.storage.saveNounMetadata(this.id, previousMetadata, hadVector === true) + await this.storage.saveNounMetadata(this.id, previousMetadata) } } } @@ -258,7 +192,7 @@ export class SaveVerbMetadataOperation implements Operation { async execute(): Promise { // Get existing metadata (for rollback) - const previousMetadata = await tornHealsToNull(this.storage.getVerbMetadata(this.id), 'verb metadata') + const previousMetadata = await this.storage.getVerbMetadata(this.id) // Save new metadata await this.storage.saveVerbMetadata(this.id, this.metadata) @@ -294,7 +228,7 @@ export class SaveVerbOperation implements Operation { async execute(): Promise { // Get existing verb (for rollback) - const previousVerb = await tornHealsToNull(this.storage.getVerb(this.verb.id), 'verb record') + const previousVerb = await this.storage.getVerb(this.verb.id) // Save new verb await this.storage.saveVerb(this.verb) @@ -338,7 +272,7 @@ export class DeleteVerbMetadataOperation implements Operation { async execute(): Promise { // Get metadata before deletion (for rollback) - const previousMetadata = await tornHealsToNull(this.storage.getVerbMetadata(this.id), 'verb metadata') + const previousMetadata = await this.storage.getVerbMetadata(this.id) if (!previousMetadata) { // Nothing to delete - no rollback needed diff --git a/src/transaction/operations/index.ts b/src/transaction/operations/index.ts index 32a69a21..c5548e70 100644 --- a/src/transaction/operations/index.ts +++ b/src/transaction/operations/index.ts @@ -23,7 +23,6 @@ export { export { AddToVectorIndexOperation, RemoveFromVectorIndexOperation, - ReplaceInVectorIndexOperation, AddToMetadataIndexOperation, RemoveFromMetadataIndexOperation, AddToGraphIndexOperation, diff --git a/src/transaction/types.ts b/src/transaction/types.ts index 6cbc56ca..9a3a2eaa 100644 --- a/src/transaction/types.ts +++ b/src/transaction/types.ts @@ -66,6 +66,26 @@ export interface TransactionContext { */ export type TransactionFunction = (ctx: TransactionContext) => Promise +/** + * Transaction execution result + */ +export interface TransactionResult { + /** + * Result value from user function + */ + value: T + + /** + * Number of operations executed + */ + operationCount: number + + /** + * Execution time in milliseconds + */ + executionTimeMs: number +} + /** * Transaction execution options */ diff --git a/src/types/brainy.types.ts b/src/types/brainy.types.ts index b99f0261..8bede8d3 100644 --- a/src/types/brainy.types.ts +++ b/src/types/brainy.types.ts @@ -215,7 +215,7 @@ export interface ScoreExplanation { * * @example * ```ts - * declare module '@soulcraftlabs/brainy' { + * declare module '@soulcraft/brainy' { * interface SubtypeRegistry { * // For NounType.Person, subtype 'employee': * 'person:employee': { employeeId: string; department: string } @@ -320,38 +320,21 @@ export interface AddParams { */ visibility?: 'public' | 'internal' /** - * Structured queryable fields — indexed by MetadataIndex, used in `where` - * filters, `orderBy`, and aggregation. + * Structured queryable fields — indexed by MetadataIndex, used in `where` filters. * - * THE FIELD-ADDRESSING LAW: every name here is YOURS. There are no - * reserved metadata names — `confidence`, `type`, `id`, `level`, `data`, - * `content`, … are ordinary user fields that index, filter, sort, and - * aggregate like any other, and survive faithfully across restarts and - * rebuilds. Engine scalars are set only via their dedicated params - * (`confidence`, `weight`, `subtype`, …) and are queried explicitly as - * `system.` (`where: { 'system.confidence': … }`). The ONE illegal - * spelling is a key starting `'system.'` — the engine's explicit address - * namespace cannot be forged; such a write refuses with a typed error. + * Reserved entity fields (`RESERVED_ENTITY_FIELDS` — `noun`, `subtype`, `visibility`, + * `createdAt`, `updatedAt`, `confidence`, `weight`, `service`, `data`, `createdBy`, + * `_rev`) may NOT appear here — they have dedicated top-level params and the type makes + * a literal reserved key a compile error. Untyped (JavaScript) callers that pass one + * anyway are normalized at write time: user-settable fields remap to their top-level + * param (top-level wins when both are supplied), system-managed fields are dropped with + * a one-shot warning. */ metadata?: EntityMetadataInput /** Custom entity ID. When omitted, a time-ordered UUID v7 is generated; a supplied natural-key string is normalized to a stable UUID v5. */ id?: string /** Pre-computed embedding vector (skips auto-embedding when provided) */ vector?: Vector - /** - * DEFER THE EMBEDDING (MT5, the deferred-embedding worker): the write - * acknowledges at durability — data + metadata persisted, a durable - * pending-embed marker written — and the embedding + vector-index insert - * run on the engine's single-flight background worker. HONEST SEMANTICS: - * the row is findable by id/metadata/path IMMEDIATELY; vector/semantic - * search sees it when the background embed completes (eventual vector - * index — `getIndexStatus().pendingEmbeds` counts the backlog, and - * `awaitPendingEmbeds()` is the barrier). CRASH-SAFE: markers persist - * before the ack and are recovered at the next open — a crash can DELAY - * a vector, never lose one. Refused (typed) together with `vector` — - * a supplied vector has nothing to defer. - */ - deferEmbedding?: boolean /** Multi-tenancy service identifier */ service?: string /** Type classification confidence (0-1) */ @@ -393,15 +376,6 @@ export interface AddParams { export interface UpdateParams { id: string // Entity to update data?: any // New content to re-embed - /** - * Defer the re-embedding of new `data` (see `AddParams.deferEmbedding`). - * The write acks at durability; the OLD vector keeps serving semantic - * search — stale-but-present, never absent (the flicker law) — until the - * background worker embeds the new content and swaps it in atomically. - * `data` reads return the NEW content immediately. Refused (typed) with - * an explicit `vector`. - */ - deferEmbedding?: boolean type?: NounType // Change type subtype?: string // Change subtype (set to '' or null-equivalent via dedicated unset is future work) /** @@ -412,11 +386,12 @@ export interface UpdateParams { */ visibility?: EntityVisibility /** - * Metadata fields to merge (or replace when `merge: false`). Every name is - * the user's (the field-addressing law) — a patch field named `confidence` - * updates YOUR field of that name, never the engine scalar (use the - * dedicated `confidence` param for that). Keys spelled `'system.…'` refuse - * with a typed error (namespace forgery). + * Metadata fields to merge (or replace when `merge: false`). Reserved entity + * fields (`RESERVED_ENTITY_FIELDS`) may NOT appear here — `confidence` / + * `weight` / `subtype` / `visibility` have dedicated params on this call, and the rest + * are system-managed. A literal reserved key is a compile error; untyped callers + * are normalized at write time (remap user-settable, drop system-managed + * with a one-shot warning). */ metadata?: EntityMetadataPatch merge?: boolean // Merge or replace metadata (default: true) @@ -469,11 +444,11 @@ export interface RelateParams { /** Content for the relationship (optional — overrides auto-computed vector) */ data?: any /** - * Structured queryable fields on the edge. Every name is the user's (the - * field-addressing law) — `verb`, `confidence`, `weight`, … in this bag are - * ordinary user fields; engine scalars ride their dedicated params and are - * addressed as `system.`. Keys spelled `'system.…'` refuse with a - * typed error (namespace forgery). + * Structured queryable fields on the edge. Reserved relationship fields + * (`RESERVED_RELATION_FIELDS` — `verb`, `subtype`, `visibility`, `createdAt`, + * `updatedAt`, `confidence`, `weight`, `service`, `data`, `createdBy`, `_rev`) may NOT + * appear here — they have dedicated params. A literal reserved key is a + * compile error; untyped callers are normalized at write time. */ metadata?: RelationMetadataInput /** Create reverse edge too (default: false) */ @@ -503,9 +478,10 @@ export interface UpdateRelationParams { confidence?: number // New confidence (0-1) data?: any // New content /** - * Metadata fields to merge (or replace when `merge: false`). Every name is - * the user's (the field-addressing law); engine scalars ride their - * dedicated params. Keys spelled `'system.…'` refuse with a typed error. + * Metadata fields to merge (or replace when `merge: false`). Reserved + * relationship fields (`RESERVED_RELATION_FIELDS`) may NOT appear here — + * a literal reserved key is a compile error; untyped callers are + * normalized at write time. */ metadata?: RelationMetadataPatch merge?: boolean // Merge or replace metadata @@ -522,72 +498,8 @@ export interface UpdateRelationParams { * - **Graph:** `connected` for relationship traversal (via GraphAdjacencyIndex) * * See also: [Query Operators](../../docs/QUERY_OPERATORS.md) for all `where` operators. - * - * @remarks - * **Field-addressing law.** Governs every query-surface field name — `where` - * and `orderBy` on this interface, plus `AggregateSource.where` and - * `AggregateDefinition.groupBy` in the aggregation engine: - * - * 1. A bare name (e.g. `'level'`, `'rank'`, `'score'`) always means the - * caller's own metadata field — it reads `entity.metadata.`. There - * is no fallback to an engine-internal field of the same name and no - * priority resolution between the two; metadata wins unconditionally. - * 2. `system.` reaches an engine scalar, explicitly, and only for - * these ten: `id`, `type`, `subtype`, `createdAt`, `updatedAt`, - * `confidence`, `weight`, `visibility`, `service`, `createdBy`. - * 3. `vector`, `connections`, `level` (the engine-internal node field — a - * different thing from a user metadata field also named `level`), - * `data`, and `_rev` are invisible plumbing: neither spelling can - * address them from a query surface. - * 4. `metadata.` is the explicit spelling of the bare form and means - * exactly the same thing as rule 1. - * 5. A name that matches none of the above — most often a bare name that - * collides with one of the ten system-scalar names in rule 2 — REFUSES - * with a typed {@link UnresolvableFieldError} naming both candidates, - * e.g. `no metadata field 'createdAt' — did you mean system.createdAt or - * metadata.createdAt?`. The same loud-refusal principle covers whole - * options: the previously accepted-and-silently-ignored `cursor`, - * `includeRelations`, and `writeOnly` now throw - * {@link UnsupportedFindOptionError} instead of doing nothing. - * 6. **Ordering contract** (identical on the pure-JS engine and the native - * accelerator): rows missing or `null` on the `orderBy` field sort LAST - * in BOTH `asc` and `desc` order and are never dropped from the result; - * ties break by `id` ascending. - * - * Migration note: a call site written against the old rule — e.g. - * `orderBy: 'createdAt'` or `where: { visibility: 'internal' }` meaning the - * engine scalar — now refuses instead of silently reading the wrong field. - * The thrown error names the exact fix (`system.createdAt`). A loud - * refusal with the fix in hand beats a silent behavior flip. */ export interface FindParams { - /** - * **Field projection** — return only these fields on each row, instead of the - * whole record. - * - * A list view that shows a title and a slug does not need the document body, - * yet without a projection every row hydrates its full record and throws - * almost all of it away. Naming the fields lets them be served from the index - * itself: a scalar the index holds exactly is read from the index, and the - * canonical record is opened ONLY when a requested field cannot be. - * - * Field names follow the one addressing law: a bare name is the user's - * metadata (`'title'`), and `system.*` is an engine scalar - * (`'system.createdAt'`). - * - * - **Absent** ⇒ the full record, exactly as before. - * - A requested field the entity does not carry is simply **absent** from the - * row. It is never an error — a projection asks "give me these if you have - * them", so an optional field must not turn a list into a failure. - * - Every returned row carries `id` (and, on `find`, `score`) regardless: a - * row you cannot identify is not a row. - * - * @example - * // A list page: two user fields and one engine scalar, no document bodies. - * await brain.find({ where: { kind: 'post' }, fields: ['title', 'slug', 'system.createdAt'], limit: 50 }) - */ - fields?: readonly string[] - // Vector Intelligence /** Natural language or semantic search query (embedded and matched via HNSW + text index) */ query?: string @@ -604,18 +516,7 @@ export interface FindParams { * `{ exists: true }`, `{ missing: true }`) use `where: { subtype: { …operators… } }`. */ subtype?: string | string[] - /** - * Metadata filters using BFO operators (e.g., `{ year: { greaterThan: 2020 } }`). - * Field names follow the field-addressing law — see the `@remarks` on - * {@link FindParams}: a bare key is always the caller's metadata field; - * an engine scalar needs the explicit `system.` form. - * - * @example - * ```typescript - * await brain.find({ where: { level: { greaterThan: 5 } } }) // metadata.level - * await brain.find({ where: { 'system.visibility': 'internal' } }) // engine scalar - * ``` - */ + /** Metadata filters using BFO operators (e.g., `{ year: { greaterThan: 2020 } }`) */ where?: Partial // Visibility @@ -647,49 +548,13 @@ export interface FindParams { // Control options limit?: number // Max results (default: 10) offset?: number // Skip N results - /** - * @deprecated Not implemented. Passing `cursor` throws - * {@link UnsupportedFindOptionError} — it used to be accepted and - * silently ignored, which masked that no cursor pagination ever ran. Use - * `offset` / `limit` until cursor pagination ships. - */ cursor?: string // Cursor-based pagination // Sorting - /** - * Field to sort by. Follows the field-addressing law (see the `@remarks` - * on {@link FindParams}): a bare name (`'level'`, `'rank'`, `'score'`, …) - * always sorts by that metadata field; the ten engine scalars sort only - * via the explicit `system.` form (e.g. `'system.createdAt'`); a - * name that resolves to neither throws {@link UnresolvableFieldError} - * naming the fix. - * - * Ordering contract (identical on the pure-JS engine and the native - * accelerator): rows missing or `null` on this field sort LAST in BOTH - * `asc` and `desc` order and are never dropped from the result; ties - * break by `id` ascending. - * - * @example - * ```typescript - * await brain.find({ orderBy: 'level', order: 'desc' }) // metadata.level - * await brain.find({ orderBy: 'system.createdAt', order: 'desc' }) // engine scalar - * ``` - */ - orderBy?: string - /** - * Sort direction: `'asc'` (default) or `'desc'`. Per the ordering - * contract on `orderBy`, rows missing/`null` on the sorted field sort - * LAST in both directions — `order` never moves them to the front. - */ + orderBy?: string // Field to sort by (e.g., 'createdAt', 'title', 'metadata.priority') order?: 'asc' | 'desc' // Sort direction: 'asc' (default) or 'desc' // Advanced options - /** - * @deprecated Not implemented. Passing `includeRelations` throws - * {@link UnsupportedFindOptionError} — it used to be accepted and - * silently ignored, so no relationships were ever attached. Fetch - * relationships separately via `brain.related()`. - */ includeRelations?: boolean // Include entity relationships excludeVFS?: boolean // Exclude VFS entities from results (default: false - VFS included) service?: string // Multi-tenancy filter @@ -722,11 +587,6 @@ export interface FindParams { } // Performance options - /** - * @deprecated Not implemented. Passing `writeOnly` throws - * {@link UnsupportedFindOptionError} — it used to be accepted and - * silently ignored, so validation was never actually skipped. - */ writeOnly?: boolean // Skip validation for high-speed ingestion // Aggregation @@ -816,12 +676,6 @@ export interface SimilarParams { * Added string ID shorthand syntax */ export interface RelatedParams { - // NOTE: `fields` is deliberately NOT offered here. A Relation carries `from` - // and `to` as IDS and hydrates no entity record, so there is nothing for a - // projection to trim — the param would be decorative. Projecting the - // ENDPOINTS would be a new capability (related() hydrating entities), not a - // projection of an existing one, and it belongs in its own decision. - /** * Filter by source entity ID * @@ -1225,47 +1079,6 @@ export interface RelateManyParams { /** * Batch result */ -/** - * One family's row in a {@link RepairReport} — what repairIndex() checked, - * what it healed, and why anything was skipped. The receipts venue's graph - * trust program asked for: a repair that cannot show its work is a repair - * nobody can trust. - */ -export interface RepairFamilyReport { - family: string - /** The family was actually examined (false = skipped; see `skipped`/`reason`). */ - checked: boolean - /** Items re-posted / corrected in place — the incremental heal count. */ - healed: number - /** - * What the check found missing or divergent, when it can name it: an exact - * count plus a capped sample of ids (never the whole list — a report is a - * verdict, not a dump). Absent when the family has nothing to name. - */ - missing?: { count: number; sample: string[] } - /** A full generational rebuild of this family ran (as opposed to an incremental heal). */ - rebuilt?: boolean - detail?: string - /** Why the family was not checked (`checked: false`). */ - 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(). */ -export interface RepairReport { - families: RepairFamilyReport[] - healedTotal: number - durationMs: number -} - export interface BatchResult { successful: T[] // Successfully processed items failed: Array<{ // Failed items with errors @@ -1447,33 +1260,6 @@ export interface ImportResult { * */ export interface GetOptions { - /** - * **Field projection** — return only these fields on each row, instead of the - * whole record. - * - * A list view that shows a title and a slug does not need the document body, - * yet without a projection every row hydrates its full record and throws - * almost all of it away. Naming the fields lets them be served from the index - * itself: a scalar the index holds exactly is read from the index, and the - * canonical record is opened ONLY when a requested field cannot be. - * - * Field names follow the one addressing law: a bare name is the user's - * metadata (`'title'`), and `system.*` is an engine scalar - * (`'system.createdAt'`). - * - * - **Absent** ⇒ the full record, exactly as before. - * - A requested field the entity does not carry is simply **absent** from the - * row. It is never an error — a projection asks "give me these if you have - * them", so an optional field must not turn a list into a failure. - * - Every returned row carries `id` (and, on `find`, `score`) regardless: a - * row you cannot identify is not a row. - * - * @example - * // A list page: two user fields and one engine scalar, no document bodies. - * await brain.find({ where: { kind: 'post' }, fields: ['title', 'slug', 'system.createdAt'], limit: 50 }) - */ - fields?: readonly string[] - /** * Include 384-dimensional vector embeddings in the response * @@ -1550,10 +1336,7 @@ export type GroupByDimension = export interface AggregateSource { /** Filter by entity type(s) */ type?: NounType | NounType[] - /** - * Metadata filter — same syntax and field-addressing law as find()'s - * `where` (see the `@remarks` on {@link FindParams}). - */ + /** Metadata filter (same syntax as find({ where })) */ where?: Record /** Multi-tenancy service filter */ service?: string @@ -1567,11 +1350,7 @@ export interface AggregateDefinition { name: string /** Which entities contribute to this aggregate */ source: AggregateSource - /** - * Dimensions to group by — field names follow the same field-addressing - * law as find()'s `where` / `orderBy` (see the `@remarks` on - * {@link FindParams}). - */ + /** Dimensions to group by */ groupBy: GroupByDimension[] /** Named metrics to compute */ metrics: Record @@ -1630,25 +1409,16 @@ export interface AggregateGroupState { export interface AggregateQueryParams { /** Name of the aggregate to query */ name: string - /** - * Filter aggregate groups by their key values — same field-addressing - * law as find() (see the `@remarks` on {@link FindParams}). - */ + /** Filter aggregate groups by their key values */ where?: Record /** * Filter groups by their computed METRIC values (SQL HAVING). Same BFO operators as * `where`, but applied to the derived metric results plus `count`, e.g. * `{ revenue: { greaterThan: 1000 } }`. Evaluated per group (O(groups), independent of - * entity count), before sort/pagination. Metric names and `count` are looked up - * directly, not field-addressed; a group-KEY field used here follows the same - * field-addressing law as find() (see the `@remarks` on {@link FindParams}). + * entity count), before sort/pagination. */ having?: Record - /** - * Sort by metric name (a key from `metrics`, looked up directly) or by a - * group key field — a group key field follows the same field-addressing - * law as find()'s `orderBy` (see the `@remarks` on {@link FindParams}). - */ + /** Sort by metric name or group key field */ orderBy?: string /** Sort direction */ order?: 'asc' | 'desc' @@ -1715,15 +1485,6 @@ export interface AggregationProvider { /** Serialize internal state for persistence (called during flush) */ serializeState?(): string - - /** - * Bake the committed generation into the provider's own state envelope - * before {@link serializeState} (called during flush, immediately prior). - * Lets a native-side reopen verify the envelope's honesty independently of - * the host's wrapper stamp. Optional — providers without it rely on the - * host wrapper's `sourceGeneration` alone. - */ - noteSourceGeneration?(generation: number): void } // ============= Configuration ============= @@ -1883,16 +1644,10 @@ export interface BrainyConfig { | StorageAdapter /** - * RE-MEANT (the health-gate contract): `init()` (open) always verifies the - * durable generation of every derived index, and a needed rebuild ALWAYS - * runs at open — it is never deferred to the first read, regardless of - * dataset size or this flag. There is no first-query lazy-build path - * anymore: a read that finds a provider not serving throws a typed - * `*NotReadyError` rather than building anything (see - * `assessProviderHealth` / the read gate in `brainy.ts`). Setting this - * `true` no longer defers index construction to the first query — it has - * no effect on WHEN a needed rebuild runs. Full manual control over - * rebuilds remains available via `repairIndex({ rebuild: [...] })`. + * Disable the automatic index rebuild check during `init()`. By default + * Brainy auto-decides from dataset size: small datasets rebuild missing + * indexes inline, large datasets rebuild lazily on first query. Set `true` + * only when an operator wants full manual control via `repairIndex()`. */ disableAutoRebuild?: boolean @@ -1903,10 +1658,8 @@ export interface BrainyConfig { * **start** — never whether already-completed work gets rolled back after * the fact (a single-op write can never time out post-hoc: it either runs * or it commits). A trip mid-batch still rolls back every applied operation - * atomically and throws a `TransactionTimeoutError` that is - * retryable-with-latch, never hot-retry (see its `retryable` and - * `hotRetryUnsafe` fields); only the floor of the formula is configurable - * here. + * atomically and throws a retryable `TransactionTimeoutError`; only the + * floor of the formula is configurable here. * * Raise this when a cold store's first writes after a restart legitimately * take longer than 30s per operation (e.g. page-cache-cold canonical writes @@ -2050,32 +1803,25 @@ export interface BrainyConfig { reservedQueryMemory?: number // Memory reserved for queries in bytes (e.g., 1073741824 = 1GB) /** - * Controls whether `init()` starts a BACKGROUND warm of the WASM embedding - * engine. + * Controls when the WASM embedding engine is initialized. * - * **Adaptive default (8.0, background since the open-path fix):** when - * omitted, `init()` STARTS a background warm of the engine whenever the - * WASM embedder is the *active* one — i.e. no native `'embeddings'` - * provider is registered — and this instance is a writer (not - * `mode: 'reader'`) running outside unit tests. The WASM module (≈93MB with - * the embedded model) takes 90-140s to compile on throttled CPUs — but - * `init()` never awaits that compile. It only starts it, so N concurrent - * opens no longer serialize on the one process-global engine singleton. - * The first `embed()` call then waits for whichever finishes first: the - * background warm (if still running) or its own fresh init (if the warm - * never started, e.g. `eagerEmbeddings: false`) — both paths converge on - * the SAME shared promise inside the engine singleton, so the vector is - * always correct; only the timing of who pays the wait differs. + * **Adaptive default (8.0):** when omitted, the engine eagerly initializes + * during `init()` whenever the WASM embedder is the *active* one — i.e. no + * native `'embeddings'` provider is registered — and this instance is a + * writer (not `mode: 'reader'`) running outside unit tests. The WASM module + * (≈93MB with the embedded model) takes 90-140s to compile on throttled + * CPUs, so paying that during boot rather than on the first `embed()`-driven + * call is the right default for a single-process server. * * The adaptive path skips itself automatically when a native embeddings * provider owns embeddings, in reader-mode (readers query existing vectors * and never embed), and in unit-test mode (kept fast via the mock embedder). * - * - `true` — force the background warm to start during `init()` (the - * adaptive default already does this for the active-embedder writer - * case; set it explicitly to be unambiguous). - * - `false` — no warm at all. Fully lazy: the first `embed()` call pays the - * full cold-compile cost inline, on whichever request triggers it. + * - `true` — force eager init during `init()` (the adaptive default already + * does this for the active-embedder writer case; set it explicitly to be + * unambiguous). + * - `false` — explicit override to force lazy init (first `embed()` call) + * even when this instance is the active embedder. */ eagerEmbeddings?: boolean @@ -2175,60 +1921,31 @@ export interface BrainyConfig { force?: boolean /** - * THE ENGINE OWNS ITS FLUSH CADENCE (the persistence policy — - * SELF-ENGINE-LIFECYCLE-SPRINT, David-directed: "why do we need manual - * flushes at all?"). Under `'auto'` (the DEFAULT) the engine schedules - * single-flight background flushes itself — triggered by write count, - * elapsed time, and idle — so callers NEVER call `flush()` in a hot path - * (a production consumer's 829 per-write flushes convoyed into 45–66s - * write walls; the cadence belongs to the layer that can see dirty-node - * counts and IO pressure). `flush()` remains public as an awaitable - * durability BARRIER for the rare "must be on disk before I proceed" - * moment — calling it is never wrong, just no longer necessary. + * How write paths react when an untyped (JavaScript) caller smuggles a + * Brainy-reserved field (`RESERVED_ENTITY_FIELDS` / `RESERVED_RELATION_FIELDS` + * — `confidence`, `weight`, `subtype`, `visibility`, `service`, `createdBy`, + * `noun`/`verb`, `data`, `createdAt`, `updatedAt`, `_rev`) **inside the + * `metadata` bag** of `add()` / `update()` / `relate()` / `updateRelation()` + * (and their `transact()` / `with()` mirrors). TypeScript callers can't write + * these shapes at all — the compile-time guard on the metadata param types + * (`NoReservedEntityKeys` / `NoReservedRelationKeys`) rejects a literal + * reserved key — so this policy only governs untyped callers that slip one + * past the compiler. * - * RECOVERY SEMANTICS (the documented promise): canonical records are - * durable per-write, independent of this policy — a crash between - * background flushes loses NO data. What a flush persists is DERIVED - * state (index postings, deferred HNSW nodes, counters, aggregation - * stamps); after a crash, derived state converges at the next open from - * canonical records (epoch machinery + incremental aggregation catch-up), - * paying a bounded catch-up cost proportional to the un-flushed window — - * never data loss. + * - `'throw'` (**default, 8.0**): a reserved key in the bag throws a clear + * `Error` naming the offending key(s) and the correct write path. No silent + * remap, no data loss, no surprise. This is the 8.0 "no silent failures" + * contract. + * - `'warn'`: legacy remapping with a loud, one-shot (per key, per process) + * warning for EVERY reserved key found — user-mutable fields are remapped to + * their dedicated top-level param (top-level wins when both are supplied), + * system-managed fields are dropped. Use while migrating untyped call sites. + * - `'remap'`: the pre-8.0 silent remapping, no warning. Last-resort + * compatibility hatch for code that intentionally relies on the bag path. * - * `'manual'` restores the pre-9.1 behavior: the engine never flushes on - * its own (except at `close()`); the caller owns the cadence. + * @default 'throw' */ - /** - * Storage-authority posture at open (10.0.0+ fleet default: `'adopt'`). - * - * `'adopt'` — a brain with NO stored authority artifact adopts LOG - * AUTHORITY at open, oracle-gated: the verification oracle replays the - * generation log against stored truth; curable divergences (pre-log - * rows, witness drift) are baseline-backfilled; the brain flips ONLY on - * a green verdict and writes the durable per-brain switch. On green, - * writes become durable-at-ack (group-committed log fsync covers every - * ack). A brain whose oracle cannot go green STAYS tree-authoritative, - * says so loudly, and records the refusal — never a silent half-state. - * - * `'defer'` — the explicit opt-out: no automatic adoption; the brain - * stays tree-authoritative until `adoptLogAuthority()` is called. The - * pre-10 behavior, documented for operators who stage their own flips. - * - * A STORED artifact always wins over this setting (checked-at-open law): - * an already-flipped brain stays flipped; an explicitly-recorded tree - * posture is honored until an operator re-runs adoption. - */ - logAuthority?: 'adopt' | 'defer' - - persistence?: { - policy?: 'auto' | 'manual' - /** Background flush after this many committed writes (default 512). */ - flushEveryWrites?: number - /** Background flush when this much time has passed since the last flush, checked at write time (default 30_000). */ - flushIntervalMs?: number - /** Background flush after the store goes quiet for this long with dirty state (default 2_000). */ - flushOnIdleMs?: number - } + reservedFieldPolicy?: 'throw' | 'warn' | 'remap' } // ============= Neural API Types ============= @@ -2389,79 +2106,6 @@ export interface Highlight { contentCategory?: ContentCategory } -// ============= Read barrier (waitForIndexed) ============= - -/** - * One projection leg of the read barrier (`brain.waitForIndexed(path)`) — a - * derived view of the committed data that queries are served from: - * - * - `'semantic'` — the vector index (deferred embeds land here asynchronously) - * - `'metadata'` — the field/filter index behind `find({ where })` - * - `'graph'` — the relationship adjacency index - * - `'aggregation'` — the incremental aggregate states - */ -export type IndexedProjectionPath = 'semantic' | 'metadata' | 'graph' | 'aggregation' - -/** - * Options for `brain.waitForIndexed()`. - */ -export interface WaitForIndexedOptions { - /** - * Resolve as soon as the projection has caught up to this committed - * generation (rather than the current head). Today the pending-embed set - * carries no generation stamps, so the refinement is conservative: an - * empty backlog resolves immediately (the watermark is at the head, hence - * ≥ any committed generation); a non-empty backlog waits for the full - * drain — a SUPERSET of the requested wait, never a partial one. - */ - generation?: number - - /** - * Upper bound on the wait in milliseconds. On expiry the promise REJECTS - * with {@link WaitForIndexedTimeoutError} (typed: the leg + the - * still-pending count) — never a silent partial wait. - */ - timeoutMs?: number -} - -/** - * The typed rejection of `brain.waitForIndexed(path, { timeoutMs })` on - * expiry. Carries the projection leg (`path`; `'all'` for the no-argument - * barrier) and the deferred-embed backlog size at the moment the timer fired - * (`pendingEmbeds` — the same number as - * `getIndexStatus().projections.semantic.pendingEmbeds`), so a caller can - * log an honest gauge and retry instead of guessing. A timeout means the - * projection has NOT caught up — nothing was skipped, nothing partially - * waited. - */ -export class WaitForIndexedTimeoutError extends Error { - /** The projection leg that had not caught up (`'all'` = the no-arg barrier). */ - public readonly path: IndexedProjectionPath | 'all' - - /** The expired timeout, in milliseconds. */ - public readonly timeoutMs: number - - /** Deferred embeds still pending when the timer fired — the live value of - * `getIndexStatus().projections.semantic.pendingEmbeds`. */ - public readonly pendingEmbeds: number - - constructor(path: IndexedProjectionPath | 'all', timeoutMs: number, pendingEmbeds: number) { - super( - `waitForIndexed(${path === 'all' ? '' : `'${path}'`}) timed out after ${timeoutMs}ms — ` + - `${pendingEmbeds} deferred embed${pendingEmbeds === 1 ? '' : 's'} still pending; the projection has ` + - `NOT caught up. Check getIndexStatus().projections.semantic.pendingEmbeds, then retry with a ` + - `larger timeoutMs or use awaitPendingEmbeds() for an unbounded drain.` - ) - this.name = 'WaitForIndexedTimeoutError' - this.path = path - this.timeoutMs = timeoutMs - this.pendingEmbeds = pendingEmbeds - if (Error.captureStackTrace) { - Error.captureStackTrace(this, WaitForIndexedTimeoutError) - } - } -} - // ============= Export all types ============= export * from './graphTypes.js' // Re-export NounType, VerbType, etc. \ No newline at end of file diff --git a/src/types/reservedFields.ts b/src/types/reservedFields.ts index ce2108f8..a0606e1d 100644 --- a/src/types/reservedFields.ts +++ b/src/types/reservedFields.ts @@ -1,54 +1,35 @@ /** * @module types/reservedFields - * @description The stored-record layout contract — ONE place that defines - * which keys of a persisted metadata record belong to the ENGINE (top-level - * entity/relationship fields) and how the USER's metadata bag is kept apart - * from them, faithfully, across flush / reopen / rebuild / time travel. + * @description The canonical reserved-field contract — ONE place that defines + * which keys belong to Brainy (top-level entity/relationship fields) and may + * therefore never live inside a `metadata` bag. * - * THE FIELD-ADDRESSING LAW (ruled 2026-08-03, VENUE-BRAINY-ORDERBY-NOOP): - * data is either in main space — where developers can use ANY name, and it - * all works with every database function — or it is in `system.*`. There are - * NO reserved user-facing metadata names anymore: `confidence`, `type`, - * `level`, `data`, `id`, `content` … inside a metadata bag are ordinary user - * fields. The only refused write is a user metadata key literally starting - * with `'system.'` (namespace forgery — see `rejectForgedSystemKeys`). + * Three layers enforce the contract, all driven by the constants below: * - * That law makes name-based storage discrimination unsound for NEW records - * (a user field named `confidence` may now legally sit beside the engine's - * confidence scalar), so persisted metadata records carry the user bag - * NESTED, shape-discriminated by a format stamp: + * 1. **Compile time** — `AddParams.metadata`, `UpdateParams.metadata`, + * `RelateParams.metadata` and `UpdateRelationParams.metadata` are typed so + * a literal reserved key is a TypeScript error (see + * {@link EntityMetadataInput} / {@link RelationMetadataInput}). + * 2. **Write time** — for untyped (JavaScript) callers that smuggle a + * reserved key past the compiler anyway, every write path normalizes the + * bag: user-mutable fields are remapped to their dedicated top-level + * param (top-level wins when both are supplied) and system-managed fields + * are dropped with a one-shot warning naming the correct write path. + * 3. **Read time** — every read path splits the stored flat record through + * {@link splitNounMetadataRecord} / {@link splitVerbMetadataRecord}, so a + * reserved field is surfaced ONLY at top level and `entity.metadata` / + * `relation.metadata` contain ONLY custom fields, always — live reads, + * batch reads, and historical (`asOf`) reads alike. * - * - **v2 (nested-bag)** — `{ …engine fields…, [METADATA_RECORD_FORMAT_KEY]: - * NESTED_BAG_FORMAT, metadata: { …user bag, verbatim… } }`. Built ONLY by - * {@link buildNounMetadataRecord} / {@link buildVerbMetadataRecord}; the - * engine half and the user bag can never collide because they never share - * a level. - * - **legacy (flat)** — engine fields and user fields mixed at one level, - * discriminated BY NAME through the RESERVED_* lists. Sound for legacy - * records precisely because the pre-law write door REFUSED user metadata - * carrying those names — a flat key matching a reserved name IS the - * engine's value in any record the old door admitted. - * - * {@link splitNounMetadataRecord} / {@link splitVerbMetadataRecord} read - * BOTH shapes (stamp first, name split as the legacy fallback) and are the - * single read-side choke point for live, batch, AND historical (`asOf`) - * reads — the generation store snapshots whole records, so time travel - * rides the same split. - * - * The RESERVED_* lists therefore no longer describe a user-facing ban — they - * describe the ENGINE HALF of the stored record layout (and drive the legacy - * split). The write-door remap machinery and the compile-time metadata key - * bans that used to enforce the old contract are gone. + * Documented for consumers in `docs/concepts/consistency-model.md` + * ("Reserved fields"). */ /** - * @description Entity (noun) field names owned by the ENGINE in a stored - * metadata record. In v2 (nested-bag) records these are the legal TOP-LEVEL - * keys beside the nested `metadata` bag; in legacy flat records they drive - * the by-name split. They are NOT a user-facing ban list: since the - * field-addressing law, a user metadata field may carry any of these names - * and remains the user's — it lives inside the nested bag, never at the - * record's top level. + * @description Entity (noun) field names reserved by Brainy. These keys are + * stored in the flat per-entity metadata record alongside custom fields, but + * they belong to Brainy: every read path extracts them to top-level + * `Entity` fields, and no write path accepts them inside `metadata`. * * | Key | Canonical write path | * |-----|----------------------| @@ -65,7 +46,7 @@ * | `_rev` | system-managed revision counter — pass `ifRev` to `update()` for CAS | * * @example - * import { RESERVED_ENTITY_FIELDS } from '@soulcraftlabs/brainy' + * import { RESERVED_ENTITY_FIELDS } from '@soulcraft/brainy' * const isReserved = (key: string) => * (RESERVED_ENTITY_FIELDS as readonly string[]).includes(key) */ @@ -138,54 +119,68 @@ export type ReservedRelationField = (typeof RESERVED_RELATION_FIELDS)[number] type IsAny = 0 extends 1 & T ? true : false /** - * @deprecated The compile-time reserved-key ban died with the - * field-addressing law: every name is legal user metadata now. Kept as an - * empty (no-op) guard so external type references keep compiling; it bans - * nothing. + * @description Compile-time tripwire: marks every reserved entity key as + * `never` so an object literal carrying one fails to type-check. Keys that + * `T` itself declares (including via an index signature, where + * `keyof T = string`) are exempted — a consumer who *explicitly* types a + * reserved key into their metadata shape keeps a working (if unwise) type, + * and index-signature metadata types remain assignable. */ -export type NoReservedEntityKeys = unknown +export type NoReservedEntityKeys = { + readonly [K in ReservedEntityField as K extends keyof T ? never : K]?: never +} /** - * @deprecated Relationship mirror of {@link NoReservedEntityKeys} — no-op - * for the same reason. + * @description Relationship mirror of {@link NoReservedEntityKeys}. */ -export type NoReservedRelationKeys = unknown +export type NoReservedRelationKeys = { + readonly [K in ReservedRelationField as K extends keyof T ? never : K]?: never +} + +/** + * @description The metadata bag shape for untyped brains (`T = any`): an + * open index signature (any custom key, any value — exactly the pre-8.0 + * latitude) intersected with the reserved-key guard, whose declared + * `?: never` properties take precedence over the index signature so a + * literal reserved key is still a compile error. + */ +type OpenBag = { [key: string]: any } & Guard /** * @description The type of `AddParams.metadata`: the consumer's metadata - * shape `T`, open. Under the field-addressing law EVERY key is a legal user - * field (engine scalars are written only via their dedicated params and read - * at `system.*`), so no name is banned at compile time. The one illegal - * spelling — a key starting `'system.'` — cannot be expressed as a mapped - * type ban and is refused at runtime (`rejectForgedSystemKeys`). + * shape `T` with reserved entity keys forbidden at compile time. For untyped + * brains (`T = any`) the bag stays open ({@link OpenBag}), so arbitrary + * custom fields remain legal while literal reserved keys still error. */ export type EntityMetadataInput = IsAny extends true - ? { [key: string]: any } - : T + ? OpenBag> + : T & NoReservedEntityKeys /** * @description The type of `UpdateParams.metadata`: a partial patch of the - * consumer's metadata shape. Same openness as {@link EntityMetadataInput}. + * consumer's metadata shape with reserved entity keys forbidden at compile + * time. Same `T = any` handling as {@link EntityMetadataInput}. */ export type EntityMetadataPatch = IsAny extends true - ? { [key: string]: any } - : Partial + ? OpenBag> + : Partial & NoReservedEntityKeys /** * @description The type of `RelateParams.metadata`: the consumer's edge - * metadata shape, open — the relation mirror of {@link EntityMetadataInput}. + * metadata shape with reserved relationship keys forbidden at compile time. */ export type RelationMetadataInput = IsAny extends true - ? { [key: string]: any } - : T + ? OpenBag> + : T & NoReservedRelationKeys /** * @description The type of `UpdateRelationParams.metadata`: a partial patch - * of the consumer's edge metadata shape, open. + * of the consumer's edge metadata shape with reserved relationship keys + * forbidden at compile time. */ export type RelationMetadataPatch = IsAny extends true - ? { [key: string]: any } - : Partial + ? OpenBag> + : Partial & NoReservedRelationKeys /** * @description Result of splitting a stored flat metadata record into its @@ -201,103 +196,6 @@ export interface SplitMetadataRecord { const RESERVED_ENTITY_SET: ReadonlySet = new Set(RESERVED_ENTITY_FIELDS) const RESERVED_RELATION_SET: ReadonlySet = new Set(RESERVED_RELATION_FIELDS) -/** - * @description The format-stamp key of a persisted metadata record. Its - * presence with the exact value {@link NESTED_BAG_FORMAT} marks a v2 - * (nested-bag) record; its absence marks a legacy flat record. The stamp is - * what makes the shape check collision-proof against legacy user data: a - * pre-law record COULD carry a user field named `metadata` (the name was - * never reserved), but it cannot also carry this engine-written stamp. - */ -export const METADATA_RECORD_FORMAT_KEY = '_fmt' - -/** - * @description The nested-bag record format stamp (v2, the field-addressing - * law's storage shape, 2026-08-03): engine fields at top level, the user's - * metadata bag NESTED verbatim under `metadata`. Cross-engine: the native - * provider discriminates record shapes by the same stamp. - */ -export const NESTED_BAG_FORMAT = 2 - -/** - * @description `true` when a persisted record carries the v2 nested-bag - * stamp (and a structurally valid nested bag). - */ -export function isNestedBagRecord( - record: Record | null | undefined -): boolean { - return ( - record !== null && - record !== undefined && - typeof record === 'object' && - record[METADATA_RECORD_FORMAT_KEY] === NESTED_BAG_FORMAT && - typeof record.metadata === 'object' && - record.metadata !== null && - !Array.isArray(record.metadata) - ) -} - -/** - * @description Build a v2 (nested-bag) entity metadata record — THE only - * sanctioned way to construct a persisted noun metadata record. The engine - * half goes top-level; the user bag nests verbatim under `metadata`; the - * format stamp seals the shape. Because the two halves never share a level, - * a user field named `confidence` (or any other engine spelling) survives - * flush / reopen / rebuild / time travel exactly as written. - * @param engineFields - The engine-owned half (keys from - * {@link RESERVED_ENTITY_FIELDS} — `noun`, timestamps, `_rev`, …). - * @param userBag - The consumer's metadata bag, stored verbatim. - * @returns The stamped v2 record. - */ -export function buildNounMetadataRecord( - engineFields: Partial>, - userBag: Record | undefined -): Record { - return { - ...engineFields, - [METADATA_RECORD_FORMAT_KEY]: NESTED_BAG_FORMAT, - metadata: { ...(userBag ?? {}) } - } -} - -/** - * @description Build a v2 (nested-bag) relationship metadata record — the - * verb mirror of {@link buildNounMetadataRecord}. - * @param engineFields - The engine-owned half (keys from - * {@link RESERVED_RELATION_FIELDS} — `verb`, `weight`, timestamps, …). - * @param userBag - The consumer's edge metadata bag, stored verbatim. - * @returns The stamped v2 record. - */ -export function buildVerbMetadataRecord( - engineFields: Partial>, - userBag: Record | undefined -): Record { - return { - ...engineFields, - [METADATA_RECORD_FORMAT_KEY]: NESTED_BAG_FORMAT, - metadata: { ...(userBag ?? {}) } - } -} - -/** - * @description Shape-first split of a v2 record: the engine half is the top - * level filtered through the reserved list (belt — the builders only ever - * write reserved names there), the user bag is `record.metadata` verbatim. - */ -function splitNestedRecord( - record: Record, - reservedSet: ReadonlySet -): SplitMetadataRecord { - const reserved: Record = {} - for (const [key, value] of Object.entries(record)) { - if (reservedSet.has(key)) reserved[key] = value - } - return { - reserved: reserved as Partial>, - custom: { ...(record.metadata as Record) } - } -} - /** * @description Shared splitter — partitions a record's keys against a * reserved-name set. `null`/`undefined` records split to two empty objects. @@ -324,45 +222,33 @@ function splitRecord( } /** - * @description Split a stored entity (noun) metadata record into engine - * fields and the user's metadata bag — THE canonical read-side split, shape - * aware. v2 (nested-bag) records split by SHAPE: engine half top-level, bag - * = `record.metadata` verbatim (user collider names survive faithfully). - * Legacy flat records split BY NAME through the reserved list — sound for - * them because the pre-law write door refused user metadata carrying those - * names. Every entity read path (live `get()`, batch reads, paginated - * listings, and historical `asOf()` materialization — the generation store - * snapshots whole records) goes through this function, so the two shapes - * can never drift between read paths. - * @param record - The stored metadata record (either shape). - * @returns `reserved` (engine-owned fields) and `custom` (the consumer's metadata bag). + * @description Split a stored entity (noun) flat metadata record into + * reserved fields and custom metadata — THE canonical read-side split. Every + * entity read path (live `get()`, batch reads, paginated listings, and + * historical `asOf()` materialization) goes through this function, so the + * reserved list can never drift between read paths. + * @param record - The stored flat metadata record. + * @returns `reserved` (Brainy-owned fields) and `custom` (the consumer's metadata bag). * @example * const { reserved, custom } = splitNounMetadataRecord(stored) * // reserved.noun → entity.type, reserved.confidence → entity.confidence, … - * // custom → entity.metadata (the user's fields only, always — ANY names) + * // custom → entity.metadata (custom fields only, always) */ export function splitNounMetadataRecord( record: Record | null | undefined ): SplitMetadataRecord { - if (isNestedBagRecord(record)) { - return splitNestedRecord(record as Record, RESERVED_ENTITY_SET) - } return splitRecord(record, RESERVED_ENTITY_SET) } /** - * @description Split a stored relationship (verb) metadata record into - * engine fields and the user's edge metadata bag — the verb mirror of - * {@link splitNounMetadataRecord}, shape aware, used by every relationship - * read path. - * @param record - The stored metadata record (either shape). - * @returns `reserved` (engine-owned fields) and `custom` (the consumer's metadata bag). + * @description Split a stored relationship (verb) flat metadata record into + * reserved fields and custom metadata — the verb mirror of + * {@link splitNounMetadataRecord}, used by every relationship read path. + * @param record - The stored flat metadata record. + * @returns `reserved` (Brainy-owned fields) and `custom` (the consumer's metadata bag). */ export function splitVerbMetadataRecord( record: Record | null | undefined ): SplitMetadataRecord { - if (isNestedBagRecord(record)) { - return splitNestedRecord(record as Record, RESERVED_RELATION_SET) - } return splitRecord(record, RESERVED_RELATION_SET) } diff --git a/src/utils/brainyTypes.ts b/src/utils/brainyTypes.ts index a008a5e6..7db469bb 100644 --- a/src/utils/brainyTypes.ts +++ b/src/utils/brainyTypes.ts @@ -6,7 +6,7 @@ * * @example * ```typescript - * import { BrainyTypes } from '@soulcraftlabs/brainy' + * import { BrainyTypes } from '@soulcraft/brainy' * * // Get all available types * const nounTypes = BrainyTypes.nouns // ['Person', 'Organization', ...] diff --git a/src/utils/distance.ts b/src/utils/distance.ts index d61bc12e..36e9e8e5 100644 --- a/src/utils/distance.ts +++ b/src/utils/distance.ts @@ -65,29 +65,6 @@ export const cosineDistance: DistanceFunction = (a: Vector, b: Vector): number = return 1 - similarity } -/** - * True when `vector` is a REAL (non-empty) all-zero vector — the "false - * attractor" shape this engine's own cosine distance treats safely (a - * zero-norm operand always scores the MAXIMUM distance, see - * {@link cosineDistance}) but a downstream engine serving squared-euclidean - * distance cannot distinguish from a legitimate origin point. THE LAW: a - * zero-norm vector is not a vector — it never crosses an engine boundary - * (never handed to a vector-index provider as a searchable item). - * - * A length-0 vector is the UNRELATED "unvectored, not yet embedded" shape - * (the deferred-embed stub, a permanently-vectorless system row) and is - * deliberately NOT zero-norm here — callers checking for "nothing to index" - * should test `vector.length === 0` separately; this only flags the - * dangerous non-empty all-zero case. - */ -export function isZeroNormVector(vector: readonly number[]): boolean { - if (vector.length === 0) return false - for (let i = 0; i < vector.length; i++) { - if (vector[i] !== 0) return false - } - return true -} - /** * Calculates the Manhattan (L1) distance between two vectors. * Lower values indicate higher similarity. diff --git a/src/utils/entityIdMapper.ts b/src/utils/entityIdMapper.ts index d3527d77..5b5afb5e 100644 --- a/src/utils/entityIdMapper.ts +++ b/src/utils/entityIdMapper.ts @@ -129,49 +129,11 @@ export class EntityIdMapper implements EntityIdMapperProvider { // metadata channel as plain JSON; the `nextId` probe above identifies // the persisted EntityIdMapperData shape. const data = metadata as unknown as EntityIdMapperData + this.nextId = data.nextId - // TORN-STATE VALIDATION (power-loss survivor): a torn mapper file - // can carry NaN/garbage where integers belong — unvalidated, those - // NaNs reach BigInt() on the graph's int-resolution (reopen) and - // the mint path (first write after recovery) and kill both with - // RangeErrors. A torn mapper is DISCARDED with narration and the - // maps re-derive through the existing rebuild path (under log - // authority the mint-at-append records reproduce assignments - // exactly; under tree authority the metadata-index reconstruction - // rebuilds them — the same path a missing mapper file takes). - const validInt = (v: unknown): v is number => - typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 - let torn = !validInt(data.nextId) - const uuidToInt = new Map() - const intToUuid = new Map() - if (!torn) { - for (const [k, v] of Object.entries(data.uuidToInt ?? {})) { - const n = Number(v) - if (!validInt(n)) { torn = true; break } - uuidToInt.set(k, n) - } - } - if (!torn) { - for (const [k, v] of Object.entries(data.intToUuid ?? {})) { - const n = Number(k) - if (!validInt(n) || typeof v !== 'string') { torn = true; break } - intToUuid.set(n, v) - } - } - if (torn) { - console.warn( - `[EntityIdMapper] persisted mapper state is TORN (non-integer ids — ` + - `power-loss survivor); discarding and re-deriving via the rebuild ` + - `path. Never a RangeError at reopen or first write.` - ) - this.nextId = 1 - this.uuidToInt = new Map() - this.intToUuid = new Map() - } else { - this.nextId = data.nextId - this.uuidToInt = uuidToInt - this.intToUuid = intToUuid - } + // Rebuild maps from serialized data + this.uuidToInt = new Map(Object.entries(data.uuidToInt).map(([k, v]) => [k, Number(v)])) + this.intToUuid = new Map(Object.entries(data.intToUuid).map(([k, v]) => [Number(k), v])) } else { // Guard: mapper file missing but entities may exist on disk. // If we start from nextId=1 with existing entities, roaring bitmap @@ -202,33 +164,14 @@ export class EntityIdMapper implements EntityIdMapperProvider { * would exceed that, throws {@link EntityIdSpaceExceeded} so the caller * loudly migrates to cor's binary mapper with `idSpace: 'u64'` * rather than silently truncating entity ids. - * - * @param generation - Brainy's commit generation current at mint time - * (contract parity with the `EntityIdMapperProvider` surface). This JS - * mapper keeps a snapshot file, not a per-record delta log, so there is - * no natural slot to store it — accepted and ignored; a native mapper - * stamps its assignment records with it. */ - getOrAssign(uuid: string, generation?: bigint): number { - void generation // Contract parity — no per-record log in the JS mapper. + getOrAssign(uuid: string): number { const existing = this.uuidToInt.get(uuid) if (existing !== undefined) { return existing } - // Assign new ID. Source guard: nextId must be a finite positive integer - // — the load path validates persisted state, but a NaN here would mint - // poison ints that reach BigInt() downstream; heal to the map-derived - // floor with narration rather than propagate. - if (!Number.isSafeInteger(this.nextId) || this.nextId < 1) { - let floor = 1 - for (const n of this.intToUuid.keys()) if (n >= floor) floor = n + 1 - console.warn( - `[EntityIdMapper] nextId was non-integer (${String(this.nextId)}) — ` + - `healed to ${floor} from the live map; torn-state survivor` - ) - this.nextId = floor - } + // Assign new ID if (this.nextId > U32_ENTITY_ID_MAX) { throw new EntityIdSpaceExceeded(this.nextId) } @@ -283,14 +226,8 @@ export class EntityIdMapper implements EntityIdMapperProvider { /** * Remove mapping for UUID - * - * @param generation - Brainy's commit generation for this removal (contract - * parity with the `EntityIdMapperProvider` surface). Accepted and ignored — - * this JS mapper removes immediately; a native mapper tombstones the - * mapping at this generation in its version chain. */ - remove(uuid: string, generation?: bigint): boolean { - void generation // Contract parity — no per-key version chain in the JS mapper. + remove(uuid: string): boolean { const intId = this.uuidToInt.get(uuid) if (intId === undefined) { return false diff --git a/src/utils/fieldTypeInference.ts b/src/utils/fieldTypeInference.ts index 0f085f8c..36a415b2 100644 --- a/src/utils/fieldTypeInference.ts +++ b/src/utils/fieldTypeInference.ts @@ -55,30 +55,8 @@ export enum FieldType { */ export interface FieldTypeInfo { field: string - /** - * The DOMINANT reading of the field — one type, the most specific one every - * sampled value satisfies. - * - * A field is not obliged to hold one kind, so this is not the whole answer - * for a field that holds several. Read {@link kinds} beside it: a field - * carrying `'electronics'` and `5` infers as STRING here and reports - * `['number', 'string']` there, and the metadata index keeps a separate - * posting column for each of them. - */ inferredType: FieldType confidence: number // 0-1 confidence score - /** - * Every value KIND observed in the sample, in the order - * number → string → boolean. More than one entry means a genuinely - * mixed field, and every one of those kinds is independently filterable. - * - * Kinds are JavaScript `typeof` classes, one level coarser than - * {@link FieldType}: a UUID and a category name are both `'string'`, and an - * integer and a timestamp are both `'number'`. - * - * Optional only for cached analyses written before this was reported. - */ - kinds?: Array<'number' | 'string' | 'boolean'> sampleSize: number // Number of values analyzed lastUpdated: number // Timestamp of last analysis detectionMethod: 'value' // Always 'value' (no fallbacks!) @@ -155,71 +133,14 @@ export class FieldTypeInference { } /** - * Analyze values to determine field type, and report every KIND the field - * actually holds alongside it. - * - * The classification below picks ONE type, because every one of its - * heuristics asks `samples.every(...)`: a field carrying `'electronics'` and - * `5` satisfies none of them and lands on STRING. That single answer is true - * as far as it goes — string is the dominant reading — but on its own it - * says nothing about the numbers also in the field, and a caller that treats - * it as the field's only type reproduces the first-writer freeze the index - * itself no longer has. {@link FieldTypeInfo.kinds} carries the rest. - */ - private async analyzeValues(field: string, values: any[]): Promise { - const info = await this.classifyValues(field, values) - info.kinds = FieldTypeInference.observedKinds(values) - if (info.kinds.length > 1 && info.metadata) { - info.metadata.format = `${info.metadata.format} (field also holds: ${info.kinds - .filter((k) => k !== FieldTypeInference.kindOfType(info.inferredType)) - .join(', ')})` - } - return info - } - - /** - * The distinct value kinds present in a sample, in a stable order. - * - * Kinds are JavaScript `typeof` classes — the same classes the metadata - * index keeps separate posting columns for — not the finer - * {@link FieldType} readings, which are interpretations layered on top of - * them (a UUID and a category name are both the `string` kind). - */ - private static observedKinds(values: any[]): Array<'number' | 'string' | 'boolean'> { - const order: Array<'number' | 'string' | 'boolean'> = ['number', 'string', 'boolean'] - const seen = new Set<'number' | 'string' | 'boolean'>() - for (const v of values) { - if (v === null || v === undefined) continue - const t = typeof v - seen.add(t === 'number' ? 'number' : t === 'boolean' ? 'boolean' : 'string') - } - return order.filter((k) => seen.has(k)) - } - - /** The value kind a {@link FieldType} reading is an interpretation of. */ - private static kindOfType(type: FieldType): 'number' | 'string' | 'boolean' { - switch (type) { - case FieldType.BOOLEAN: - return 'boolean' - case FieldType.INTEGER: - case FieldType.FLOAT: - case FieldType.TIMESTAMP_MS: - case FieldType.TIMESTAMP_S: - return 'number' - default: - return 'string' - } - } - - /** - * Classify values into a single field type. + * Analyze values to determine field type * * Uses DuckDB-inspired type detection order: * BOOLEAN → INTEGER → FLOAT → DATE → TIMESTAMP → UUID → STRING * * No fallbacks - pure value-based detection */ - private async classifyValues(field: string, values: any[]): Promise { + private async analyzeValues(field: string, values: any[]): Promise { // Filter null/undefined values const validValues = values.filter(v => v !== null && v !== undefined) diff --git a/src/utils/indexReadiness.ts b/src/utils/indexReadiness.ts index f1b52e3b..16266bec 100644 --- a/src/utils/indexReadiness.ts +++ b/src/utils/indexReadiness.ts @@ -13,28 +13,13 @@ * `size()` or `isInitialized`. When `isReady()` is absent, callers must fall back * to a KNOWN-ITEM PROBE (a real search/lookup that must return a known-present * datum) before trusting an empty result — never a `size()` proxy. - * - * {@link assessProviderHealth} is the NEWER, PREFERRED authority: it reads a - * provider's NAMED, synchronous, O(1) {@link import('../plugin.js').HealthReport} - * when one is exposed, and falls back to this file's `isReady()` classifier only - * when the provider does not (yet) expose a health report. Read paths in - * `brainy.ts` call `assessProviderHealth` exclusively — `assessIndexReadiness` - * stays exported for the other call sites (`storage/baseStorage.ts`) and for the - * fallback branch inside `assessProviderHealth` itself. */ -import type { HealthReport } from '../plugin.js' - /** A provider that MAY expose the honest cold-load readiness signal. */ export interface MaybeReadyProvider { isReady?: () => boolean } -/** A provider that MAY expose the named, synchronous, O(1) health report. */ -export interface MaybeHealthReportingProvider { - healthReport?: () => HealthReport -} - /** Three-valued honest-readiness verdict. */ export type IndexReadiness = 'ready' | 'not-ready' | 'unknown' @@ -51,185 +36,3 @@ export function assessIndexReadiness(provider: unknown): IndexReadiness { if (p == null || typeof p.isReady !== 'function') return 'unknown' return p.isReady() ? 'ready' : 'not-ready' } - -/** - * @description Which signal {@link assessProviderHealth} actually consulted to - * produce its verdict — surfaced so callers can narrate (and tests can pin) how - * a provider was judged, not just what the judgment was. - * - `'health-report'` — the provider's `healthReport()` was called (the authority). - * - `'is-ready'` — no `healthReport()`; fell back to the provider's `isReady()`. - * - `'size-heuristic'` — no `healthReport()` and no `isReady()`; caller must keep its own size-based heuristic. - * - `'none'` — there was no provider to assess (`null`/`undefined`). - */ -export type ProviderHealthVia = 'health-report' | 'is-ready' | 'size-heuristic' | 'none' - -/** The result of {@link assessProviderHealth}. */ -export interface ProviderHealthAssessment { - /** The honest readiness verdict — see {@link IndexReadiness}. */ - readiness: IndexReadiness - /** The provider's raw {@link HealthReport}, when one was obtained; `null` otherwise. */ - report: HealthReport | null - /** Which signal produced the verdict — see {@link ProviderHealthVia}. */ - via: ProviderHealthVia - /** Human-readable reasons: named failing invariants (with `heal`), unledgered families, or the fallback-path explanation. Empty when the provider is healthy and ready. */ - reasons: string[] -} - -/** - * @description THE read-gate authority. Prefers a provider's NAMED, - * synchronous, O(1) {@link HealthReport} over the older `isReady()` / size - * heuristics; falls back to {@link assessIndexReadiness}'s semantics only when - * a provider does not (yet) expose `healthReport()`. - * - * Derivation: - * - `healthReport()` present → call it (wrapped in try/catch). A THROW is a - * CONTRACT VIOLATION, not "unknown": returns `readiness: 'not-ready'`, - * `via: 'health-report'`, and a reason naming the throw — never swallowed - * into `'unknown'`. - * - Otherwise → `readiness = report.serving ? 'ready' : 'not-ready'`; `reasons` - * names every invariant with `holds: false` (with its `heal`), plus an - * `unledgered: [...]` line when {@link HealthReport.unledgered} is non-empty. - * UNLEDGERED IS UNKNOWN: an unledgered family never flips a serving provider - * to not-ready, and never flips a not-serving provider to ready — `serving` - * is always the provider's own verdict, verbatim. - * - No `healthReport()` → fall back to {@link assessIndexReadiness}'s semantics: - * `via: 'is-ready'` when `isReady()` exists, `via: 'size-heuristic'` when - * neither hook exists (caller must keep its own size-based heuristic), - * `via: 'none'` when there is no provider at all. - * @param provider - Any index provider (vector / graph / metadata) or `null`/`undefined`. - */ -export function assessProviderHealth(provider: unknown): ProviderHealthAssessment { - const p = provider as (MaybeHealthReportingProvider & MaybeReadyProvider) | null | undefined - - if (p == null) { - return { readiness: 'unknown', report: null, via: 'none', reasons: ['no provider to assess'] } - } - - if (typeof p.healthReport === 'function') { - let report: HealthReport - try { - report = p.healthReport() - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { - readiness: 'not-ready', - report: null, - via: 'health-report', - reasons: [`healthReport() threw: ${message} — a health-report throw is a contract violation, never read as healthy`] - } - } - - const reasons: string[] = [] - for (const invariant of report.invariants) { - if (!invariant.holds) { - reasons.push(`${invariant.name} (heal:${invariant.heal}): ${invariant.detail}`) - } - } - if (report.unledgered.length > 0) { - reasons.push(`unledgered: ${report.unledgered.join(', ')}`) - } - - return { - readiness: report.serving ? 'ready' : 'not-ready', - report, - via: 'health-report', - reasons - } - } - - const readiness = assessIndexReadiness(p) - if (readiness === 'unknown') { - return { - readiness, - report: null, - via: 'size-heuristic', - reasons: ['provider exposes neither healthReport() nor isReady() — falling back to the size heuristic'] - } - } - return { - readiness, - report: null, - via: 'is-ready', - 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/jsonSafeIndexMetadata.ts b/src/utils/jsonSafeIndexMetadata.ts deleted file mode 100644 index d3b1be5f..00000000 --- a/src/utils/jsonSafeIndexMetadata.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @module utils/jsonSafeIndexMetadata - * @description The metadata-index crossing's JSON-safety law, as a leaf - * function both the coordinator and the transaction operations share. - * - * The seam's metadata is JSON-safe BY CONTRACT (a native provider serializes - * it; u64 ints as Number corrupt above 2^53) — but `resolveVerbEndpointInts` - * MIRRORS the resolved endpoint ints onto the verb object itself as BigInt - * (`verb.sourceInt`/`targetInt`), so a verb object reused as index metadata - * carries BigInts into JSON.stringify, which throws, aborting the whole - * transaction. Endpoint ints ride their OWN op params on the graph legs — the - * metadata crossing drops every BigInt-valued top-level key instead of - * guessing at a lossy numeric encoding. - * - * WHY THIS IS A LEAF MODULE, ENFORCED AT THE CROSSING: sanitizing only at - * operation-construction time is not enough. `transact()`'s delete legs pass - * the SAME verb object to both the graph-retraction op (whose endpoint-int - * thunk deliberately resolves at EXECUTE time, for same-batch forward refs) - * and the metadata-retraction op. At plan time the verb is still clean, so a - * plan-time sanitize returns the same reference — then the graph op executes - * first, mirrors the BigInt ints onto the shared object, and the metadata op - * crosses the seam with them (found by the first fleet adoption of the native - * pair: every transact-wrapped edge delete aborted). The crossing itself is - * the only place ordering cannot bypass. - */ - -/** - * A JSON-safe view of a record bound for the metadata-index crossing. - * - * @param metadata - The candidate index-metadata record. - * @returns The same object when already JSON-safe, else a shallow copy - * without the BigInt-valued keys. - */ -export function jsonSafeIndexMetadata(metadata: unknown): unknown { - if (metadata === null || typeof metadata !== 'object') return metadata - const rec = metadata as Record - let hasBigint = false - for (const k in rec) { - if (typeof rec[k] === 'bigint') { hasBigint = true; break } - } - if (!hasBigint) return metadata - const out: Record = {} - for (const k in rec) { - if (typeof rec[k] !== 'bigint') out[k] = rec[k] - } - return out -} diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 0d6b6594..5154d4fd 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -266,26 +266,6 @@ 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 1a882945..fdb17c22 100644 --- a/src/utils/metadataIndex.ts +++ b/src/utils/metadataIndex.ts @@ -5,22 +5,12 @@ */ import { StorageAdapter, resolveEntityField, NounMetadata, VerbMetadata } from '../coreTypes.js' -import { SYSTEM_ENTITY_SCALARS, parseFieldAddress, UnresolvableFieldError, type FieldAddress } from '../db/fieldAddressing.js' -import { splitNounMetadataRecord } from '../types/reservedFields.js' import { ColumnStore } from '../indexes/columnStore/ColumnStore.js' import type { MetadataIndexProvider } from '../plugin.js' import { MetadataIndexCache, MetadataIndexCacheConfig } from './metadataIndexCache.js' import { compareCodePoints } from './collation.js' import { prodLog } from './logger.js' import { getGlobalCache, UnifiedCache } from './unifiedCache.js' -import { - computeWatermarkVerdict, - makeProjectionStamp, - readStampedWatermark, - type WatermarkVerdict, - type WatermarkVerdictResult -} from './projectionWatermark.js' -import type { FactScanHandle } from '../db/factLog.js' import { NounType, VerbType, @@ -40,7 +30,7 @@ import { import { EntityIdMapper } from './entityIdMapper.js' import { RoaringBitmap32, roaringLibraryInitialize } from './roaring/index.js' import { FieldTypeInference, FieldType } from './fieldTypeInference.js' -import { BrainyError, MAX_INDEXED_ARRAY_LENGTH } from '../errors/brainyError.js' +import { BrainyError } from '../errors/brainyError.js' /** * Fields whose values are stored in the sparse index as BUCKETED values @@ -53,8 +43,8 @@ import { BrainyError, MAX_INDEXED_ARRAY_LENGTH } from '../errors/brainyError.js' * bucketed field is added (e.g. a compressed float), add it here too. */ const BUCKETED_INDEX_FIELDS: ReadonlySet = new Set([ - 'system.createdAt', - 'system.updatedAt' + 'createdAt', + 'updatedAt' ]) export interface MetadataIndexEntry { @@ -78,40 +68,12 @@ export interface MetadataIndexStats { indexSize: number // in bytes } -/** - * @description What {@link MetadataIndexManager.applyWatermarkCatchup} did, - * for the caller's narration. - * - `'noop'` — the verdict was `null`/`'adopt'`: the artifact already - * reflects committed truth. Zero index writes. - * - `'rescan'` — the verdict was `'rescan'`, OR a `'catchup'` verdict was - * demoted (no window, or no fact log to scan) — either way a full - * {@link MetadataIndexManager.rebuild} already ran; `reason` names why. - * - `'caught-up'` — the `(from, to]` window folded successfully; the - * artifact is stamped and flushed at `to`. - */ -export interface CatchupApplyResult { - action: 'noop' | 'rescan' | 'caught-up' - /** Present on `'rescan'` — why the fold could not proceed as a catchup. */ - reason?: string - /** Present on `'caught-up'` — the fact-log window that was folded. */ - window?: { from: number; to: number } - /** Present on `'caught-up'` — noun ops applied (add/update/delete). */ - nounsApplied?: number - /** Present on `'caught-up'` — verb ops applied (add/update/delete). */ - verbsApplied?: number - /** Present on `'caught-up'` — distinct committed generations folded. */ - factsApplied?: number -} - export interface MetadataIndexConfig { maxIndexSize?: number // Max number of entries per field value (default: 10000) rebuildThreshold?: number // Rebuild if index is this % stale (default: 0.1) autoOptimize?: boolean // Auto-cleanup unused entries (default: true) - // NOTE: the name-based indexedFields/excludeFields knobs died with the - // field-addressing law ("no special names"): EVERY user field indexes, - // whatever its name. Bulk-payload protection is value-SHAPE based and - // uniform across all names (large arrays never become posting scalars; - // long values index hashed) — shape is not a name carve-out. + indexedFields?: string[] // Only index these fields (default: all) + excludeFields?: string[] // Never index these fields } export interface MetadataIndexOptions { @@ -142,15 +104,6 @@ interface FieldStats { normalizationStrategy?: 'none' | 'precision' | 'bucket' } -/** - * Storage key for the metadata projection's watermark stamp — a sidecar - * record beside the artifact (field registry + field indexes + chunked - * sparse indexes + column-store segments + id-mapper records). Written LAST - * in {@link MetadataIndexManager.flush} so stamp-after-data ordering holds - * for every byte the stamp certifies. - */ -export const METADATA_INDEX_STAMP_KEY = '__index_metadata_watermark__' - /** * Implements {@link MetadataIndexProvider}: the metadata-index surface Brainy * calls on whatever the `'metadataIndex'` provider resolves to (its own @@ -166,60 +119,6 @@ export class MetadataIndexManager implements MetadataIndexProvider { private lastFlushTime = Date.now() private autoFlushThreshold = 10 // Start with 10 for more frequent non-blocking flushes - // --- Watermark stamp state (see utils/projectionWatermark for the law) --- - /** Generation handed in via {@link stampWatermark}, awaiting the next flush. */ - private pendingWatermark: number | null = null - /** Last watermark durably stamped by this instance or loaded at init. */ - private stampedWatermark: number | null = null - /** The three-way verdict computed at init; null until init runs. */ - private loadVerdict: WatermarkVerdictResult | null = null - /** - * Set only when {@link loadVerdict}.verdict is `'rescan'`: whether a - * persisted artifact existed at load (even an unstamped/unverifiable - * one) — distinguishes genuine first boot (nothing here yet, routine) - * from an artifact whose watermark is unverifiable (the loud case). The - * verdict value alone doesn't carry this distinction; see {@link - * watermarkArtifactPresent}. - */ - private rescanArtifactPresent = false - - /** - * @description THE BUILD-BESIDE SEAM (B3 Deliverable 3): when set (via - * {@link beginShadow}), every live `addToIndex`/`removeFromIndex` call on - * THIS instance also applies to the shadow instance — so a caller building - * a fresh replacement manager beside this one (walking canonical into it) - * never misses a write that lands during the build. This is the ONE seam - * that makes build-beside possible without touching every call site: every - * existing `AddToMetadataIndexOperation`/`RemoveFromMetadataIndexOperation` - * (and the JS manager's own `rebuild()`/catchup fold) keep calling the SAME - * serving instance exactly as before; only THIS instance knows it is also - * mirroring to a shadow. Null = no build in flight (the overwhelmingly - * common case; the check costs one property read per write). - */ - private shadow: MetadataIndexManager | null = null - - /** - * @description Start mirroring every `addToIndex`/`removeFromIndex` call on - * this instance to `shadow` too — see {@link shadow}'s JSDoc. The caller - * owns sequencing: writes mirrored WHILE a canonical walk is populating - * `shadow` may be clobbered by the walk's own (possibly stale) reads for - * the same id; the caller closes that window with a bounded fact-log fold - * AFTER the walk (the same mechanism {@link applyWatermarkCatchup} uses) - * before treating `shadow` as authoritative. - * @param shadow - The manager to mirror writes to. - */ - beginShadow(shadow: MetadataIndexManager): void { - this.shadow = shadow - } - - /** - * @description Stop mirroring writes to a shadow (see {@link beginShadow}). - * Idempotent; a no-op when no shadow is attached. - */ - endShadow(): void { - this.shadow = null - } - // Cardinality and field statistics tracking private fieldStats = new Map() private cardinalityUpdateInterval = 100 // Update cardinality every N operations @@ -285,14 +184,31 @@ export class MetadataIndexManager implements MetadataIndexProvider { this.config = { maxIndexSize: config.maxIndexSize ?? 10000, rebuildThreshold: config.rebuildThreshold ?? 0.1, - autoOptimize: config.autoOptimize ?? true - // No name-based exclude/allow lists — the field-addressing law: every - // user field indexes, whatever its name ('content', 'data', 'id', - // 'vector', … included). Bulk payloads are kept out by uniform value- - // SHAPE rules in extractIndexableFields (arrays longer than - // MAX_INDEXED_ARRAY_LENGTH never become posting scalars, and the write - // door refuses them by name; >100-char values index hashed), never by - // field name. + autoOptimize: config.autoOptimize ?? true, + indexedFields: config.indexedFields ?? [], + excludeFields: config.excludeFields ?? [ + // ONLY exclude truly un-indexable fields (binary data, large content) + // Timestamps are NOW indexed with automatic bucketing (prevents pollution) + + // Vectors and embeddings (binary data, already have HNSW indexes) + 'embedding', + 'vector', + 'embeddings', + 'vectors', + + // Large content fields (too large for metadata indexing) + 'content', + 'data', + 'originalData', + '_data', + + // Primary keys (use direct lookups instead) + 'id' + + // NOTE: 'accessed', 'modified', 'createdAt', etc. are NO LONGER excluded! + // They are now indexed with automatic 1-minute bucketing to prevent file pollution + // This enables range queries like: modified > yesterday + ] } // Initialize metadata cache with similar config to search cache @@ -348,13 +264,6 @@ export class MetadataIndexManager implements MetadataIndexProvider { // Must run first to populate fieldIndexes directory before warming cache await this.loadFieldRegistry() - // Compute the watermark verdict for the persisted artifact BEFORE any - // early return below — the verdict is recorded for every open, whether - // the workspace is empty, rebuilding, or warm. Computed and exposed - // only: today's rebuild triggers are unchanged (acting on 'catchup' — - // the incremental fold — lands with the coordinator's wiring). - await this.loadWatermarkVerdict() - // Initialize EntityIdMapper (loads UUID ↔ integer mappings from storage) await this.idMapper.init() @@ -391,7 +300,7 @@ export class MetadataIndexManager implements MetadataIndexProvider { } // Warm the cache with common fields (lazy loading optimization) - // This loads the type column ('system.type') needed for type counts + // This loads the 'noun' sparse index which is needed for type counts await this.warmCache() // Load type counts AFTER warmCache (sparse index is now cached) @@ -440,9 +349,8 @@ export class MetadataIndexManager implements MetadataIndexProvider { * Target: >80% cache hit rate for typical workloads */ async warmCache(): Promise { - // Common columns used in most queries — the frozen system keys, plus - // legacy spellings for a pre-epoch-3 brain read before its rebuild runs. - const commonFields = ['system.type', 'system.service', 'system.createdAt', 'noun'] + // Common fields used in most queries + const commonFields = ['noun', 'type', 'service', 'createdAt'] prodLog.debug(`🔥 Warming metadata cache with common fields: ${commonFields.join(', ')}`) @@ -628,11 +536,9 @@ export class MetadataIndexManager implements MetadataIndexProvider { } /** - * Lazy load entity counts from the type column (O(n) where n = number of - * types). The frozen key is 'system.type' (epoch 3); the legacy 'noun' - * column is read as a fallback for a pre-epoch-3 brain observed before its - * rebuild has run (e.g. a reader-mode open against an old writer). + * Lazy load entity counts from the 'noun' field sparse index (O(n) where n = number of types) * FIX: Previously read from stats.nounCount which was SERVICE-keyed, not TYPE-keyed + * Now computes counts from the sparse index which has the correct type information */ private async lazyLoadCounts(): Promise { try { @@ -642,31 +548,23 @@ export class MetadataIndexManager implements MetadataIndexProvider { this.entityCountsByTypeFixed.fill(0) this.verbCountsByTypeFixed.fill(0) - // PRIMARY (8.0+): rehydrate per-type counts from the column store's - // type column — the authoritative on-disk source after a cold reopen. - // Frozen key first ('system.type', epoch 3), legacy 'noun' as the - // pre-rebuild fallback. + // PRIMARY (8.0+): rehydrate per-type counts from the column store's 'noun' + // field — the authoritative on-disk source after a cold reopen. // // The chunked sparse-index WRITE path was removed in 7.20.0 (commit - // 11be039): new workspaces persist the type column ONLY to the column - // store, never to a sparse-index blob. So the legacy sparse path below - // finds nothing and leaves every count at 0 — which is exactly why - // counts.byType/byTypeEnum/topTypes/allNounTypeCounts all read empty + // 11be039): new workspaces persist the 'noun' field ONLY to the column + // store, never to a `__sparse_index__noun` blob. So the legacy sparse + // path below finds nothing and leaves every count at 0 — which is exactly + // why counts.byType/byTypeEnum/topTypes/allNounTypeCounts all read empty // after close()+reopen while find()/getNounCount() (different sources) // stay correct. The column store's per-value cardinality matches the warm // `updateTypeFieldAffinity` counts EXACTLY because both are driven from the // same `addToIndex` field set, in lockstep, with no visibility gate on // either — so this rehydration reproduces the warm values precisely. - const indexedCols = this.columnStore ? this.columnStore.getIndexedFields() : [] - const typeCol = indexedCols.includes('system.type') - ? 'system.type' - : indexedCols.includes('noun') - ? 'noun' - : null - if (this.columnStore && typeCol) { - const nounValues = await this.columnStore.getFilterValues(typeCol) + if (this.columnStore && this.columnStore.getIndexedFields().includes('noun')) { + const nounValues = await this.columnStore.getFilterValues('noun') for (const value of nounValues) { - const bitmap = await this.columnStore.filter(typeCol, value) + const bitmap = await this.columnStore.filter('noun', value) if (bitmap.size > 0) { // Use the stored value directly as the key (the legacy sparse path // did the same): it is already the normalized type string that @@ -681,17 +579,16 @@ export class MetadataIndexManager implements MetadataIndexProvider { } // LEGACY FALLBACK (pre-7.20.0 workspaces still on the chunked sparse index). - const sparseCol = (await this.loadSparseIndex('system.type')) ? 'system.type' : 'noun' - const nounSparseIndex = await this.loadSparseIndex(sparseCol) + const nounSparseIndex = await this.loadSparseIndex('noun') if (!nounSparseIndex) { - // No column-store type column and no sparse index yet — counts will be + // No column-store 'noun' field and no sparse index yet — counts will be // populated as entities are added. return } // Iterate through all chunks and sum up bitmap sizes by type for (const chunkId of nounSparseIndex.getAllChunkIds()) { - const chunk = await this.chunkManager.loadChunk(sparseCol, chunkId) + const chunk = await this.chunkManager.loadChunk('noun', chunkId) if (chunk) { for (const [type, bitmap] of chunk.entries) { const currentCount = this.totalEntitiesByType.get(type) || 0 @@ -963,41 +860,9 @@ export class MetadataIndexManager implements MetadataIndexProvider { } /** - * Get IDs for a range using the legacy chunked sparse index (zone maps + - * roaring bitmaps). Lazy-loaded via UnifiedCache. - * - * ORDER IS NOT A KEY. This path compares NORMALIZED values, and - * {@link normalizeValue} carries an escape hatch that is order-destroying by - * design: a string over 100 characters is replaced by {@link hashValue}'s - * digest so it can be used as a filesystem-safe key. Feeding that digest to - * an ORDERING comparison — which is what a `gte` / `lt` / `between` does — - * ranks rows by hash. The result is not empty and not an error: it is a - * confidently ordered wrong answer, and it disagrees with the column-store - * path (`getIdsForRange` above), which compares raw values and is correct. - * - * Two changes hold the line here: - * - * 1. THE BOUNDS ARE NEVER HASHED. They are normalized with `allowHash = - * false`, so a long bound stays comparable instead of collapsing to a - * digest. This alone fixes the common shape — a long bound queried - * against ordinary short values, where the digest sorts below every - * letter and `gte` therefore matched the entire store. - * - * 2. A HASHED KEY IS REFUSED, NEVER GUESSED. The persisted keys are whatever - * the pre-7.20.0 writer normalized them to, so a field whose values ran - * long is stored hashed and its order is simply not recoverable from this - * index. Rather than compare digests, the query throws a typed - * `BrainyError('INVALID_QUERY')` naming the field, the bound and the cure. - * Loud beats wrong. - * - * KNOWN, NAMED DIVERGENCE. The persisted keys are also lower-cased and - * trimmed by `normalizeValue`, so this path's string ranges are - * CASE-INSENSITIVE where the column store's are not. That is a property of - * the bytes a pre-7.20.0 engine wrote, not of the comparison: the raw values - * are not in the index to compare. The bounds are normalized into the same - * case-folded space so the comparison is at least self-consistent, and the - * divergence disappears with the field itself once the column store adopts - * it. See the module note on `getIdsFromChunks` for the path's lifetime. + * Get IDs for a range using chunked sparse index with zone maps and roaring bitmaps + * Now fully lazy-loaded via UnifiedCache (no local sparseIndices Map) + * Normalize min/max for timestamp bucketing before comparison */ private async getIdsFromChunksForRange( field: string, @@ -1013,27 +878,9 @@ export class MetadataIndexManager implements MetadataIndexProvider { } // Normalize min/max for consistent comparison with indexed values - // (indexed values are bucketed for timestamps, so we must bucket the query - // bounds too) — but NEVER through the hash escape hatch, which would make - // the bound incomparable. See the doc comment above. - const normalizedMin = min !== undefined ? this.normalizeValue(min, field, false) : undefined - const normalizedMax = max !== undefined ? this.normalizeValue(max, field, false) : undefined - - // REFUSE BEFORE SELECTING. Chunk selection itself orders values: it tests - // the bounds against each chunk's zone-map min/max. If those are hashes the - // selection is already meaningless — and its failure mode is an EMPTY - // answer (no chunk appears to overlap), which is the quietest wrong answer - // of all. So the key space is checked here, before a single chunk is - // chosen, and again per key below for a chunk whose zone map happens to - // read clean. - for (const chunkId of sparseIndex.getAllChunkIds()) { - const zoneMap = sparseIndex.getChunk(chunkId)?.zoneMap - for (const bound of [zoneMap?.min, zoneMap?.max]) { - if (typeof bound === 'string' && MetadataIndexManager.isHashedValue(bound)) { - throw MetadataIndexManager.rangeOverHashedIndex(field) - } - } - } + // (indexed values are bucketed for timestamps, so we must bucket the query bounds too) + const normalizedMin = min !== undefined ? this.normalizeValue(min, field) : undefined + const normalizedMax = max !== undefined ? this.normalizeValue(max, field) : undefined // Find candidate chunks using zone maps const candidateChunkIds = sparseIndex.findChunksForRange(normalizedMin, normalizedMax) @@ -1048,13 +895,6 @@ export class MetadataIndexManager implements MetadataIndexProvider { const chunk = await this.chunkManager.loadChunk(field, chunkId) if (chunk) { for (const [value, bitmap] of chunk.entries) { - // A hashed key carries no order. Refuse the range rather than rank by - // digest — the whole answer is unsound, so failing on the first one - // is the honest outcome. - if (MetadataIndexManager.isHashedValue(value)) { - throw MetadataIndexManager.rangeOverHashedIndex(field) - } - // Check if value is in range using numeric-aware comparison // (normalizeValue converts numbers to strings, so we must compare numerically) let inRange = true @@ -1083,25 +923,6 @@ export class MetadataIndexManager implements MetadataIndexProvider { return this.idMapper.intsIterableToUuids(allIntIds) } - /** - * The refusal a range query gets when the legacy sparse index holds hashed - * keys for the field. Names the field and the cure; never a wrong answer. - */ - private static rangeOverHashedIndex(field: string): BrainyError { - return new BrainyError( - `Range query on field "${field}" cannot be served by the legacy sparse index: ` + - `its values were persisted as hashes (values over 100 characters are stored ` + - `hashed to stay within filesystem name limits), and a hash carries no order — ` + - `comparing them would return a confidently ordered wrong answer. ` + - `Equality (\`where: { ${field}: value }\`) still works on this index. ` + - `To range over this field, let the column store adopt it: run ` + - `brain.repairIndex({ rebuild: ['metadata'] }), which rebuilds the field into ` + - `the column store, where ranges compare raw values.`, - 'INVALID_QUERY', - false - ) - } - /** * Get roaring bitmap for a field-value pair without converting to UUIDs * This is used for fast multi-field intersection queries using hardware-accelerated bitmap AND @@ -1269,17 +1090,8 @@ export class MetadataIndexManager implements MetadataIndexProvider { * value-based detection (DuckDB-inspired). Analyzes actual data values, not names. * * NO FALLBACKS - Pure value-based detection only. - * - * @param value - The value to normalize. - * @param field - Optional field name (drives the per-field statistics strategy). - * @param allowHash - Whether the >100-character escape hatch may fire. TRUE - * everywhere a normalized value is used as a KEY (equality postings, chunk - * entries, filenames) — that is what the hash exists for. FALSE on the - * ORDER-comparing path: a hash is deliberately order-destroying, so a - * bound that hashes can only be compared as nonsense. See - * {@link isHashedValue} and `getIdsFromChunksForRange`. */ - private normalizeValue(value: any, field?: string, allowHash: boolean = true): string { + private normalizeValue(value: any, field?: string): string { if (value === null || value === undefined) return '__NULL__' if (typeof value === 'boolean') return value ? '__TRUE__' : '__FALSE__' @@ -1337,34 +1149,21 @@ export class MetadataIndexManager implements MetadataIndexProvider { // Default normalization if (typeof value === 'number') return value.toString() if (Array.isArray(value)) { - const joined = value.map(v => this.normalizeValue(v, field, allowHash)).join(',') + const joined = value.map(v => this.normalizeValue(v, field)).join(',') // Hash very long array values to avoid filesystem limits - if (allowHash && joined.length > 100) { + if (joined.length > 100) { return this.hashValue(joined) } return joined } const stringValue = String(value).toLowerCase().trim() // Hash very long string values to avoid filesystem limits - if (allowHash && stringValue.length > 100) { + if (stringValue.length > 100) { return this.hashValue(stringValue) } return stringValue } - /** - * Is this normalized value a HASH rather than the value itself? - * - * {@link hashValue} is an escape hatch for filesystem name limits, and it is - * deliberately order-destroying: two values whose hashes compare one way - * routinely compare the other way themselves. Anything that ORDERS normalized - * values has to know when it is holding one, because comparing hashes yields - * a confident, wrong answer rather than an error. - */ - private static isHashedValue(normalized: string): boolean { - return normalized.startsWith('__HASH_') - } - /** * Create a short hash for long values to avoid filesystem filename limits */ @@ -1379,112 +1178,77 @@ export class MetadataIndexManager implements MetadataIndexProvider { return `__HASH_${Math.abs(hash).toString(36)}` } + /** + * Check if field should be indexed + */ + private shouldIndexField(field: string): boolean { + if (this.config.excludeFields.includes(field)) return false + if (this.config.indexedFields.length > 0) { + return this.config.indexedFields.includes(field) + } + return true + } + /** * Extract indexable field-value pairs from entity or metadata * - * Handles BOTH entity structure (with top-level fields) AND record shapes - * - Record-frame system scalars index under literal 'system.' keys - * - The user's metadata bag indexes under bare keys — EVERY name (the - * field-addressing law: no special names; 'level', 'data', 'id', - * 'content', 'vector' in a bag are ordinary user fields) - * - Record-frame plumbing (vector, connections, level, data, _rev, id) - * never indexes — that is namespace routing, not a name carve-out - * - Value-SHAPE rules apply uniformly to all names: arrays longer than - * MAX_INDEXED_ARRAY_LENGTH never become posting scalars (and say so — - * the write door refuses them outright); purely numeric key names - * (array indices) skip; >100-char values index hashed (normalizeValue) + * Now handles BOTH entity structure (with top-level fields) AND plain metadata + * - Extracts from top-level fields (confidence, weight, timestamps, type, service, etc.) + * - Also extracts from nested metadata field (custom user fields) + * - Skips HNSW-specific fields (vector, connections, level, id) + * - Maps 'type' → 'noun' for backward compatibility with existing indexes + * + * BUG FIX: Exclude vector embeddings and large arrays from indexing + * BUG FIX: Also exclude purely numeric field names (array indices) + * - Vector fields (384+ dimensions) were creating 825K chunk files for 1,144 entities + * - Arrays converted to objects with numeric keys were still being indexed */ private extractIndexableFields(data: any): Array<{ field: string, value: any }> { const fields: Array<{ field: string, value: any }> = [] - // RECORD-FRAME-ONLY plumbing guard: on an entity/stored-record frame - // these keys are the engine's structural payloads (the 384-dim vector, - // embeddings, the adjacency list, the identity field) and never index. - // This set is NEVER applied inside the user's metadata bag — under the - // field-addressing law every user name indexes; a real vector-sized - // value in a bag is kept out by the uniform array-size shape guard, not - // by its name. - const RECORD_PLUMBING = new Set(['vector', 'embedding', 'embeddings', 'connections', 'id']) + // Fields that should NEVER be indexed: bulk structural payloads that would + // blow up the index (the 384-dim vector, embeddings, the adjacency list). + // These are also caught by the array-size guard below, but naming them is + // belt-and-suspenders. NOTE: `level` was previously here (an HNSW node's + // layer) but it never actually reaches this path — every caller passes a + // metadata bag or Entity record, neither of which carries the node's + // `level` — so its only effect was to silently drop a legitimate USER + // metadata field named `level` (log level, skill level, access level…), + // making `where: { level: … }` return nothing. Removed. (`id` stays: it is + // the reserved entity-identity field, resolved specially by find().) + const NEVER_INDEX = new Set(['vector', 'embedding', 'embeddings', 'connections', 'id']) - // THE FROZEN INDEX KEY FORMAT (cross-engine, sealed 2026-08-03; the native - // accelerator keys identically — epoch 3 rebuilds every brain onto it): - // user fields index under BARE keys exactly as the caller wrote them; - // the ten system scalars index under literal 'system.' keys — the - // key IS the query address, so the two namespaces can never collide - // inside the index again. - // Frame kinds: 'entity-record' = entityForIndexing shape / v2 nested-bag - // stored record (user fields nested under `metadata`; stray top-level - // keys are DROPPED, not guessed); 'flat-record' = the LEGACY stored - // metadata-record shape (user fields flat beside the engine's — sound to - // split by name because the pre-law write door refused user metadata - // carrying engine names, so a flat key matching a system name IS the - // system value); 'user' = inside the metadata bag, where EVERY key is - // the user's and indexes bare — collider names included. - type Frame = 'entity-record' | 'flat-record' | 'user' - const extract = (obj: any, prefix = '', frame: Frame = 'entity-record'): void => { + const extract = (obj: any, prefix = ''): void => { for (const [key, value] of Object.entries(obj)) { - let fullKey = prefix ? `${prefix}.${key}` : key + const fullKey = prefix ? `${prefix}.${key}` : key - if (!prefix && frame !== 'user') { - if (key === 'metadata' && typeof value === 'object' && value !== null && !Array.isArray(value)) { - extract(value, '', 'user') // the user's namespace: bare keys - continue - } - if (key === 'type' || key === 'noun') { - fullKey = 'system.type' // legacy 'noun' spelling folds into the frozen key - } else if (SYSTEM_ENTITY_SCALARS.has(key) && key !== 'id') { - fullKey = `system.${key}` - } else if ( - key === 'data' || key === '_rev' || key === 'level' || key === '_fmt' || - RECORD_PLUMBING.has(key) - ) { - continue // plumbing / identity / format stamp — never indexed from a record frame - } else if (frame === 'entity-record') { - continue // stray entity-frame key: dropped, not guessed - } - // flat-record fallthrough: a non-system, non-plumbing key IS a user - // field (flat beside the engine's, legacy shape) — indexes bare. - } - // User frame: NO name-based skips — every user field indexes, whatever - // its name (the field-addressing law). Only the uniform value-shape - // guards below apply. + // Skip fields in never-index list (CRITICAL: prevents vector indexing bug + HNSW fields) + if (!prefix && NEVER_INDEX.has(key)) continue // Skip purely numeric field names (array indices converted to object keys) // Legitimate field names should never be purely numeric // This catches vectors stored as objects: {0: 0.1, 1: 0.2, ...} if (/^\d+$/.test(key)) continue - // THE INDEXABLE-ARRAY BOUND ({@link MAX_INDEXED_ARRAY_LENGTH}). An - // array field mints one posting per element, so the index has always - // carried a ceiling — it was 10, and it was applied by this bare - // `continue`: an eleven-element `tags` array had its whole field - // skipped and the row dropped out of every filtered search on it, with - // no error, no warning, and nothing to distinguish that from "no row - // matches". The ceiling is not the defect; the silence was. - // - // The write door refuses this shape by name now - // (`MetadataArrayTooLargeError`, thrown from paramValidation's - // `rejectOversizeIndexArrays`), so a live add/update never reaches - // here over the bound. Reaching it means the row is ALREADY on disk — - // written by an older engine under the old rule — and this is a - // rebuild, a catch-up fold or a remove reading it back. Refusing there - // would make an existing store un-rebuildable, so the row is admitted - // and the skipped field is NARRATED instead. Never silent, either way. - if (Array.isArray(value) && value.length > MAX_INDEXED_ARRAY_LENGTH) { - prodLog.warn( - `[brainy] metadata field '${fullKey}' holds ${value.length} array elements, ` + - `over the ${MAX_INDEXED_ARRAY_LENGTH}-element indexing bound — the field is ` + - `NOT indexed for this row, so it will not match a where-clause on '${fullKey}'. ` + - `This row predates the bound (the write door refuses this shape now). ` + - `Move the long array into 'data', or pass an embedding as the 'vector' parameter.` - ) + // Skip fields based on user configuration + if (!this.shouldIndexField(fullKey)) continue + + // Special handling for metadata field at top level + // Flatten metadata fields to top-level (no prefix) for cleaner queries + // Standard fields are already at top-level, custom fields go in metadata + // By flattening here, queries can use { category: 'B' } instead of { 'metadata.category': 'B' } + if (key === 'metadata' && !prefix && typeof value === 'object' && !Array.isArray(value)) { + extract(value, '') // Flatten to top-level, no prefix continue } + // Skip large arrays (> 10 elements) - likely vectors or bulk data + if (Array.isArray(value) && value.length > 10) continue + if (value && typeof value === 'object' && !Array.isArray(value)) { - // Recurse into nested objects (but not arrays), keeping the frame - extract(value, fullKey, frame) - } else if (Array.isArray(value)) { + // Recurse into nested objects (but not arrays) + extract(value, fullKey) + } else if (Array.isArray(value) && value.length <= 10) { // Small arrays: index as multi-value field (all with same field name) // Example: tags: ["javascript", "node"] → field="tags", value="javascript" + field="tags", value="node" for (const item of value) { @@ -1494,21 +1258,16 @@ export class MetadataIndexManager implements MetadataIndexProvider { } } } else { - // Primitive value: index it under the frozen key computed above. - // (The legacy 'type'→'noun' remap is gone — 'noun' columns die at - // the epoch-3 rebuild; system.type is the one spelling.) - fields.push({ field: fullKey, value }) + // Primitive value: index it + // Map 'type' → 'noun' for backward compatibility + const indexField = (!prefix && key === 'type') ? 'noun' : fullKey + fields.push({ field: indexField, value }) } } } if (data && typeof data === 'object') { - // Shape detection for the top frame: an object carrying a nested - // `metadata` bag is the entityForIndexing shape; anything else is the - // flat stored-record shape (user fields flat beside reserved ones). - const entityShaped = - 'metadata' in data && typeof data.metadata === 'object' && data.metadata !== null - extract(data, '', entityShaped ? 'entity-record' : 'flat-record') + extract(data) } // Extract words for hybrid text search @@ -1634,56 +1393,11 @@ export class MetadataIndexManager implements MetadataIndexProvider { * @returns Array of { id, matchCount } sorted by matchCount descending */ async getIdsForTextQuery(query: string): Promise> { - return this.scoreTextQuery(query) - } - - /** - * Score a text query over `ids` ONLY — the reference implementation of the - * optional `getIdsForTextQueryWithin` door (see - * {@link import('../plugin.js').MetadataIndexProvider}). The hybrid - * `find({ query, where })` path passes the metadata filter's universe here so - * the text leg ranks INSIDE that universe instead of ranking the whole store - * and discarding the rows the filter would have dropped. - * - * It answers from the same posting-list merge as {@link getIdsForTextQuery}, - * with the candidate membership applied as each word's postings are counted, - * so the two doors can never disagree: the answer is exactly the whole-store - * answer restricted to `ids`, in the same order. - * - * @param query - Text query to search for. - * @param ids - Candidate entity ids; only these may appear in the answer. - * @returns Array of { id, matchCount } sorted by matchCount descending. - */ - async getIdsForTextQueryWithin( - query: string, - ids: readonly string[] - ): Promise> { - if (ids.length === 0) return [] - return this.scoreTextQuery(query, new Set(ids)) - } - - /** - * The one posting-list merge behind both text doors. - * - * Each query word contributes AT MOST one match per entity (a posting list - * can name an id more than once), and entities are ranked by how many of the - * query's words they matched. `within`, when given, restricts the count to - * those candidates — applied during the merge, so a restricted call never - * materializes a whole-store match map. - * - * @param query - Text query to search for. - * @param within - Optional candidate universe; absent = the whole store. - * @returns Array of { id, matchCount } sorted by matchCount descending. - */ - private async scoreTextQuery( - query: string, - within?: ReadonlySet - ): Promise> { const queryWords = this.tokenize(query) if (queryWords.length === 0) return [] - // Count matches per entity, one word's postings at a time. - const matchCounts = new Map() + // Get IDs for each word hash + const wordIdSets: Map[] = [] for (const word of queryWords) { const wordHash = this.hashWord(word) let ids: string[] @@ -1699,12 +1413,19 @@ export class MetadataIndexManager implements MetadataIndexProvider { throw err } } - // One count per (word, entity) — dedupe this word's postings first. - const counted = new Set() + const idSet = new Map() for (const id of ids) { - if (counted.has(id)) continue - counted.add(id) - if (within && !within.has(id)) continue + idSet.set(id, 1) + } + wordIdSets.push(idSet) + } + + if (wordIdSets.length === 0) return [] + + // Count matches per entity + const matchCounts = new Map() + for (const idSet of wordIdSets) { + for (const [id] of idSet) { matchCounts.set(id, (matchCounts.get(id) || 0) + 1) } } @@ -1725,16 +1446,8 @@ export class MetadataIndexManager implements MetadataIndexProvider { * @param id - Entity ID * @param entityOrMetadata - Either full entity structure or plain metadata (backward compat) * @param skipFlush - Skip automatic flush (used during batch operations) - * @param deferWrites - Batch mode: buffer postings for a later flush - * @param generation - Brainy's commit generation for this write (see the - * {@link import('../plugin.js').MetadataIndexProvider} contract). This JS - * manager keeps a single live view with no per-record delta log, so it - * has no slot to store it — the value is accepted for contract parity - * and forwarded to the shared id mapper (an injected native mapper - * stamps its assignment records with it; the JS mapper ignores it). - * The JS twin adopts full per-write stamping with the watermark train. */ - async addToIndex(id: string, entityOrMetadata: any, skipFlush: boolean = false, deferWrites: boolean = false, generation?: bigint): Promise { + async addToIndex(id: string, entityOrMetadata: any, skipFlush: boolean = false, deferWrites: boolean = false): Promise { const fields = this.extractIndexableFields(entityOrMetadata) // Sanity check for excessive indexed fields (indicates possible data issue) @@ -1756,11 +1469,10 @@ export class MetadataIndexManager implements MetadataIndexProvider { prodLog.debug(`Entity ${id} has ${wordFields.length} indexed words (large document)`) } - // Sort fields to process the type column first for type-field affinity - // tracking ('system.type' is the frozen key; 'noun' died at epoch 3). + // Sort fields to process 'noun' field first for type-field affinity tracking fields.sort((a, b) => { - if (a.field === 'system.type') return -1 - if (b.field === 'system.type') return 1 + if (a.field === 'noun') return -1 + if (b.field === 'noun') return 1 return 0 }) @@ -1782,10 +1494,7 @@ export class MetadataIndexManager implements MetadataIndexProvider { // element, so a scalar overwrite (last-value-wins) would index only the final // element and `contains` would miss the rest. if (this.columnStore) { - // Thread the commit generation into the mint: an injected native mapper - // stamps the assignment record's delta log with the real watermark - // instead of a literal 0 (the JS mapper accepts and ignores it). - const entityIntId = this.idMapper.getOrAssign(id, generation) + const entityIntId = this.idMapper.getOrAssign(id) const fieldsMap: Record = {} for (const { field, value } of fields) { if (field === '__words__') { @@ -1839,15 +1548,6 @@ export class MetadataIndexManager implements MetadataIndexProvider { for (const { field } of fields) { this.metadataCache.invalidatePattern(`field_values_${field}`) } - - // THE BUILD-BESIDE SEAM — see `shadow`'s JSDoc. Mirrors this write to a - // shadow manager under construction, if one is attached. `skipFlush: - // true` always: the shadow's own persistence is the build orchestrator's - // job (it flushes once, after the swap — never mid-build, to avoid - // colliding with this instance's own persisted keys). - if (this.shadow) { - await this.shadow.addToIndex(id, entityOrMetadata, true, false, generation) - } } /** @@ -1886,13 +1586,8 @@ export class MetadataIndexManager implements MetadataIndexProvider { * * @param id - Entity ID to remove * @param metadata - Optional entity or metadata structure (if not provided, requires scanning all fields - slow!) - * @param generation - Brainy's commit generation for this removal (see the - * {@link import('../plugin.js').MetadataIndexProvider} contract). Accepted - * for contract parity — this JS manager removes immediately (no tombstone - * chain) and forwards it to the shared id mapper's `remove`, where an - * injected native mapper tombstones the mapping at this generation. */ - async removeFromIndex(id: string, metadata?: any, generation?: bigint): Promise { + async removeFromIndex(id: string, metadata?: any): Promise { if (metadata) { const fields = this.extractIndexableFields(metadata) @@ -1916,15 +1611,8 @@ export class MetadataIndexManager implements MetadataIndexProvider { // Clean up ID mapper — must happen AFTER column store removal since it uses // idMapper.getInt(id). Prevents deleted IDs from persisting in the mapper // universe, which would cause ne/exists:false queries to return deleted entities. - // The generation rides along so a native mapper tombstones the mapping at - // the real commit watermark (the JS mapper ignores it). - this.idMapper.remove(id, generation) + this.idMapper.remove(id) await this.idMapper.flush() - - // THE BUILD-BESIDE SEAM — see `shadow`'s JSDoc. - if (this.shadow) { - await this.shadow.removeFromIndex(id, metadata, generation) - } } /** @@ -2157,35 +1845,6 @@ export class MetadataIndexManager implements MetadataIndexProvider { * index (early-stop at `offset+limit`); the JS index returns ALL matches and lets * the caller window them, so `_opts` is intentionally ignored here. */ - /** Once-per-field throttle for the sparse-store did-you-mean WARN. */ - private readonly warnedNeverCarried = new Set() - - /** - * THE SPARSE-STORE CUT (ruled 2026-08-12): a WHERE filter naming a field - * no row carries is SERVED OPERATOR-TRUTHFULLY (eq/range/contains → []; - * ne/exists:false → all rows; exists:true → []) — the JS evaluator below - * already computes exactly these truths via complements — with the - * did-you-mean demoted to this throttled WARN. A fresh store's first - * filtered read is a correct empty answer, never a refusal. orderBy and - * ambiguous addresses KEEP their hard refusals (no truthful order - * exists; ambiguity is a contract error — absence is data). - */ - /** Is this field known to the index at all (any row ever carried it)? */ - private fieldRegistryHas(field: string): boolean { - return this.fieldStats.has(field) - } - - private warnNeverCarriedOnce(field: string): void { - if (this.warnedNeverCarried.has(field)) return - this.warnedNeverCarried.add(field) - prodLog.warn( - `[MetadataIndex] filter names field '${field}' which no row carries — ` + - `serving the operator-truthful answer (empty for positive matches; ` + - `the complement for ne/exists:false). If this is a typo, check the ` + - `field name; refusals remain on orderBy.` - ) - } - async getIdsForFilter(filter: any, _opts?: { limit?: number; offset?: number }): Promise { if (!filter || Object.keys(filter).length === 0) { return [] @@ -2252,25 +1911,21 @@ export class MetadataIndexManager implements MetadataIndexProvider { // Skip logical operators if (rawField === 'allOf' || rawField === 'anyOf' || rawField === 'not') continue - // THE ONE ADDRESSING LAW (sealed 2026-08-03): every filter key routes - // through parseFieldAddress — bare and 'metadata.'-prefixed spellings - // address the user's fields (indexed under BARE keys), 'system.' - // addresses the ten engine scalars (indexed under their literal - // 'system.' keys). A malformed address (system., - // plumbing in the system spelling) throws typed BEFORE any index read — - // an accepted name either works or refuses. - const address = parseFieldAddress(rawField, 'entity') - const field = address.scope === 'system' ? `system.${address.field}` : address.field - - // Sparse-store cut: a user field no row carries serves operator-truth - // below (the evaluators' complements are already correct) — announce - // it once so a typo is findable without breaking a fresh store. + // Metadata is FLATTENED at index time (metadata.entry.title indexes as + // entry.title), so a `metadata.`-prefixed where key is almost always + // the caller spelling the STORAGE shape rather than the index shape. + // Accept both spellings: when the key as spelled is unindexed but its + // stripped spelling is, query the stripped one. A literal nested + // custom key named `metadata` still wins when indexed as spelled + // (checked first), so that rare shape keeps working. + let field = rawField if ( - address.scope !== 'system' && - !(this.columnStore && this.columnStore.hasField(field)) && - !this.fieldRegistryHas(field) + rawField.startsWith('metadata.') && + this.columnStore && + !this.columnStore.hasField(rawField) && + this.columnStore.hasField(rawField.slice('metadata.'.length)) ) { - this.warnNeverCarriedOnce(field) + field = rawField.slice('metadata.'.length) } let fieldResults: string[] = [] @@ -2311,18 +1966,7 @@ export class MetadataIndexManager implements MetadataIndexProvider { // complement as a bitmap difference over the int-id universe rather // than materializing the whole corpus as UUID strings to filter it. const excludeInts: number[] = [] - // Sparse-store truth: a never-carried field has NOTHING to - // exclude — the complement of nothing is EVERYTHING. getIds - // throws FIELD_NOT_INDEXED there; the clause-level catch - // would wrongly zero this NEGATIVE operator, so absorb it - // here as the empty exclude set (the ruled operator-truth). - let neMatches: string[] = [] - try { - neMatches = await this.getIds(field, operand) - } catch { - neMatches = [] - } - for (const uuid of neMatches) { + for (const uuid of await this.getIds(field, operand)) { const intId = this.idMapper.getInt(uuid) if (intId !== undefined) excludeInts.push(intId) } @@ -2404,74 +2048,6 @@ 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': { @@ -2488,27 +2064,6 @@ 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). @@ -2646,141 +2201,15 @@ export class MetadataIndexManager implements MetadataIndexProvider { * @returns Promise - Entity IDs sorted by specified field * */ - /** - * Resolve the orderBy value for MANY entities in BATCHED metadata-record - * reads — the sort path's one sanctioned value source (BRAINY-PROD-LATENCY-TRIAD). - * - * THE ASYMPTOTIC LAW THIS ENFORCES: an ordered read never does per-row - * storage round-trips. The previous shape — `await getFieldValueForEntity` - * per id, each opening the VECTOR record serially — cost 62–98ms × N on a - * production filesystem brain: 3,224 rows took 199–317 SECONDS, silently. - * The metadata RECORD (smaller, cached, batch-readable) carries everything - * a sort can address: the ten system scalars top-level — EXACT values, no - * bucketing loss — and the user's bag (v2 nested or legacy flat, resolved - * through the shape-aware split). One batched read pass serves any N. - * - * The call-shape is pinned by tests (zero per-row reads, batch calls only) - * so the serial loop cannot quietly return. - * - * @param ids - Entity ids to resolve (any size; reads are chunk-batched). - * @param orderAddress - The parsed orderBy address (system or metadata scope). - * @returns id → value map; ids whose record is missing map to `undefined` - * (they sort LAST per the ordering contract — never dropped). - */ - private async resolveOrderValuesBatch( - ids: string[], - orderAddress: FieldAddress - ): Promise> { - const values = new Map() - if (ids.length === 0) return values - - // Batch door, best first: BaseStorage's getNounMetadataBatch (native - // batch or parallel reads inside), then the adapter-optional - // getMetadataBatch, then chunked-parallel single reads — NEVER serial. - const storage = this.storage as StorageAdapter & { - getNounMetadataBatch?(ids: string[]): Promise> - } - const CHUNK = 500 - const records = new Map() - for (let i = 0; i < ids.length; i += CHUNK) { - const chunk = ids.slice(i, i + CHUNK) - if (typeof storage.getNounMetadataBatch === 'function') { - const batch = await storage.getNounMetadataBatch(chunk) - for (const [id, rec] of batch) records.set(id, rec) - } else if (typeof storage.getMetadataBatch === 'function') { - const batch = await storage.getMetadataBatch(chunk) - for (const [id, rec] of batch) records.set(id, rec) - } else { - const loaded = await Promise.all( - chunk.map(async (id) => [id, await storage.getNounMetadata(id)] as const) - ) - for (const [id, rec] of loaded) if (rec) records.set(id, rec) - } - } - - for (const id of ids) { - const record = records.get(id) - if (!record) { - values.set(id, undefined) - continue - } - // Shape-aware split serves both record eras: engine scalars from the - // reserved half (EXACT timestamps — the bucketed index is never - // consulted here), user fields from the bag. - const { reserved, custom } = splitNounMetadataRecord( - record as Record - ) - if (orderAddress.scope === 'system') { - values.set( - id, - orderAddress.field === 'type' - ? reserved.noun - : (reserved as Record)[orderAddress.field] - ) - } else { - let value: unknown = custom[orderAddress.field] - if (value === undefined && orderAddress.field.includes('.')) { - // Dotted user path: traverse INSIDE the bag. - value = orderAddress.field - .split('.') - .reduce( - (o, seg) => - o && typeof o === 'object' ? (o as Record)[seg] : undefined, - custom - ) - } - values.set(id, value) - } - } - return values - } - - /** Once-per-field flag for the fallback-degradation announcement. */ - private static announcedFallbackSorts = new Set() - - /** - * Evaluate `filter` over `ids` only — the graph-first find's door (the - * neighbour set filtered by id, never the store filtered and then - * intersected). This index answers from its own `getIdsForFilter`, so the - * two doors cannot disagree; the cost is that of the filter over this - * in-memory index, and the answer keeps the caller's order. - */ - async filterIdsWithin(filter: any, ids: readonly string[]): Promise { - if (ids.length === 0) return [] - const matched = new Set(await this.getIdsForFilter(filter)) - return ids.filter((id) => matched.has(id)) - } - async getSortedIdsForFilter( filter: any, orderBy: string, order: 'asc' | 'desc' = 'asc', topK?: number ): Promise { - // THE ONE ADDRESSING LAW — the orderBy address routes through the same - // parse the filter path uses (the historical asymmetry where the filter - // path understood 'metadata.' but the sorted path never did is dead). - // Bare / 'metadata.' → the user's bare index key; 'system.' → the - // literal frozen key; malformed addresses throw typed before any read. - const orderAddress = parseFieldAddress(orderBy, 'entity') - const orderKey = - orderAddress.scope === 'system' ? `system.${orderAddress.field}` : orderAddress.field - - // DATA-AWARE REFUSAL (the did-you-mean): a bare address no user field - // carries cannot mean anything as a sort key — and when the name collides - // with a system scalar the caller almost certainly meant system.. - // Refusing loudly with both candidates beats silently sorting nothing. - if ( - orderAddress.scope === 'metadata' && - !(this.columnStore && this.columnStore.hasField(orderKey)) && - !(await this.loadSparseIndex(orderKey)) - ) { - throw new UnresolvableFieldError(orderAddress.raw, 'entity') - } - // Column store path: O(K log S) sort via k-way merge across segments. // No per-entity storage reads, no precision loss from bucketing. - if (this.columnStore && this.columnStore.hasField(orderKey)) { + if (this.columnStore && this.columnStore.hasField(orderBy)) { // Get filtered IDs from existing roaring bitmap path const hasFilter = filter && Object.keys(filter).length > 0 const filteredIds = hasFilter ? await this.getIdsForFilter(filter) : [] @@ -2800,70 +2229,53 @@ export class MetadataIndexManager implements MetadataIndexProvider { // log K) heap, not a full sort materialization. const k = topK !== undefined ? Math.min(topK, filteredIds.length) : filteredIds.length sortedIntIds = await this.columnStore.filteredSortTopK( - filterBitmap, orderKey, order, k + filterBitmap, orderBy, order, k ) } else { // Unfiltered sort — column store handles the full entity set efficiently sortedIntIds = await this.columnStore.sortTopK( - orderKey, order, topK !== undefined ? Math.min(topK, this.idMapper.size) : this.idMapper.size + orderBy, order, topK !== undefined ? Math.min(topK, this.idMapper.size) : this.idMapper.size ) } // Convert int IDs back to UUIDs. Number() narrowing is lossless — the // shipped EntityIdSpaceExceeded guard caps the JS mapper at u32. - const sortedUuids = sortedIntIds + return sortedIntIds .map(intId => this.idMapper.getUuid(Number(intId))) .filter((uuid): uuid is string => uuid !== undefined) - - // ORDERING CONTRACT (cross-engine, sealed): rows missing the field are - // NEVER dropped — they sort LAST in both directions — and ties break by - // id ascending. The column only contains rows that HAVE the field, so - // (1) re-sort the page deterministically (value, then id) via ONE - // batched value resolution — never per-row reads — and (2) append the - // filtered rows the column omitted, id-ascending, filling any - // remaining page budget. - const pageValues = await this.resolveOrderValuesBatch(sortedUuids, orderAddress) - const page = sortedUuids.map(id => ({ id, value: pageValues.get(id) })) - page.sort((a, b) => this.compareAddressedValues(a.value, b.value, a.id, b.id, order)) - let result = page.map(p => p.id) - - if (hasFilter) { - const present = new Set(sortedUuids) - if (topK === undefined || result.length < topK) { - const missing = filteredIds.filter(id => !present.has(id)).sort() - result = result.concat(missing) - } - } - return topK !== undefined ? result.slice(0, topK) : result } - // Fallback: no column serves this field. BOUNDED + ANNOUNCED, never - // silent (the B2 no-silent-degradation law, BRAINY-PROD-LATENCY-TRIAD): - // O(N) in row count but served by BATCHED metadata-record reads — the - // serial per-row getNoun loop that turned 3,224 rows into a 199–317s - // scan is dead, and the call-shape pin keeps it dead. + // Fallback: sparse index path (for fields not yet in column store). + // Requires a non-empty filter because it reads O(k) entity values from storage. const filteredIds = await this.getIdsForFilter(filter) if (filteredIds.length === 0) { return [] } - if ( - filteredIds.length > 500 && - !MetadataIndexManager.announcedFallbackSorts.has(orderKey) - ) { - MetadataIndexManager.announcedFallbackSorts.add(orderKey) - prodLog.warn( - `[brainy] ordered read on '${orderKey}' has no column index — served by the ` + - `batched fallback over ${filteredIds.length} rows (bounded, one batch pass; ` + - `announced once per field). A native column for this field makes it O(K).` - ) + const idValuePairs: Array<{ id: string, value: any }> = [] + for (const id of filteredIds) { + const value = await this.getFieldValueForEntity(id, orderBy) + idValuePairs.push({ id, value }) } - const fallbackValues = await this.resolveOrderValuesBatch(filteredIds, orderAddress) - const idValuePairs = filteredIds.map(id => ({ id, value: fallbackValues.get(id) })) - - idValuePairs.sort((a, b) => this.compareAddressedValues(a.value, b.value, a.id, b.id, order)) + idValuePairs.sort((a, b) => { + if (a.value == null && b.value == null) return 0 + if (a.value == null) return order === 'asc' ? 1 : -1 + if (b.value == null) return order === 'asc' ? -1 : 1 + if (a.value === b.value) return 0 + // Numbers compare numerically; everything else by code-point (UTF-8 byte) order. + // This makes the JS fallback sort match cor's native column store exactly + // (numeric i64/f64 vs code-point strings) and stay deterministic across + // environments, unlike the `<` operator's UTF-16 ordering for strings. + let comparison: number + if (typeof a.value === 'number' && typeof b.value === 'number') { + comparison = a.value < b.value ? -1 : 1 + } else { + comparison = compareCodePoints(String(a.value), String(b.value)) + } + return order === 'asc' ? comparison : -comparison + }) const sorted = idValuePairs.map(p => p.id) return topK !== undefined ? sorted.slice(0, topK) : sorted @@ -2897,112 +2309,11 @@ export class MetadataIndexManager implements MetadataIndexProvider { * * @public (called from brainy.ts for sorted queries) */ - /** - * The cross-engine ordering contract in one comparator (sealed 2026-08-03): - * missing/null values sort LAST in BOTH directions — the direction flip - * never moves them to the front — and ties break by id ascending, so an - * ordered read is deterministic and identical on both engines. Numbers - * compare numerically; everything else by code-point (UTF-8 byte) order, - * matching the native column store exactly. - */ - private compareAddressedValues( - aVal: any, - bVal: any, - aId: string, - bId: string, - order: 'asc' | 'desc' - ): number { - const aNull = aVal == null - const bNull = bVal == null - if (aNull || bNull) { - if (aNull && bNull) return aId < bId ? -1 : aId > bId ? 1 : 0 - return aNull ? 1 : -1 - } - let comparison = 0 - if (aVal !== bVal) { - if (typeof aVal === 'number' && typeof bVal === 'number') { - comparison = aVal < bVal ? -1 : 1 - } else { - comparison = compareCodePoints(String(aVal), String(bVal)) - } - } - if (comparison === 0) return aId < bId ? -1 : aId > bId ? 1 : 0 - return order === 'asc' ? comparison : -comparison - } - - /** - * Read named scalar fields for many ids from the COLUMN STORE, without - * touching the canonical record — the `find({ fields })` door. - * - * ## Why the column store and not the sparse index - * - * The column store keeps RAW values; the sparse index keeps a normalized, - * bucketed form built for range queries — `system.createdAt` is indexed at - * minute precision there. A projection served from the sparse index would - * hand back a value that differs from the record's, which is a wrong answer - * nobody can see. So this door reads the column store, and a field the - * column store does not hold is OMITTED rather than approximated. - * - * ## Why batched - * - * `getFieldValueForEntity` answers one (id, field) pair by walking the - * field's storage; called per row it re-walks the same column for every id. - * This walks each column ONCE and picks out every requested id as it passes: - * O(fields x column) instead of O(ids x fields x column). - * - * Omission is always safe — it costs the caller a record read. The caller - * diffs what it asked for against what came back and reads records for the - * remainder, so an index that can serve nothing is slow, never wrong. - * - * @param ids - Canonical entity ids. - * @param fields - Index keys (bare = user metadata, `system.*` = engine scalar). - * @returns `id -> { field: value }` for exactly the pairs this index served. - */ - async getScalarsForIds( - ids: readonly string[], - fields: readonly string[] - ): Promise>> { - const out = new Map>() - if (ids.length === 0 || fields.length === 0) return out - - // int -> id, so a column hit resolves back to the caller's id. An id the - // mapper does not know cannot be in any column, so it is simply absent. - const idByInt = new Map() - for (const id of ids) { - const intId = this.idMapper.getInt(id) - if (intId !== undefined) idByInt.set(intId, id) - } - if (idByInt.size === 0) return out - - for (const field of fields) { - if (!this.columnStore.hasField(field)) continue - const values = await this.columnStore.valuesForIds(field, idByInt.keys()) - for (const [intId, value] of values) { - const id = idByInt.get(intId) - if (id === undefined) continue - let row = out.get(id) - if (row === undefined) { - row = {} - out.set(id, row) - } - row[field] = value - } - } - return out - } - async getFieldValueForEntity(entityId: string, field: string): Promise { - // `field` arrives as a FROZEN INDEX KEY (bare = user metadata; - // 'system.' = engine scalar). Storage fallbacks read the matching - // side of the record — a system key reads the record scalar, a bare key - // reads the user's metadata bag; the two can never shadow each other. - const systemInner = field.startsWith('system.') ? field.slice('system.'.length) : null - - // Path 1: Bucketed fields need the actual (un-bucketed) value from storage. + // Path 1: Bucketed fields need the actual value from storage. if (BUCKETED_INDEX_FIELDS.has(field)) { const noun = await this.storage.getNoun(entityId) - if (!noun) return undefined - return (noun as unknown as Record)[systemInner as string] + return noun ? resolveEntityField(noun, field) : undefined } // Path 3 precondition: entity must be in the id mapper for bitmap lookup. @@ -3019,11 +2330,7 @@ export class MetadataIndexManager implements MetadataIndexProvider { // yet indexed. resolveEntityField handles the shape contract. if (!sparseIndex) { const noun = await this.storage.getNoun(entityId) - if (!noun) return undefined - if (systemInner !== null) { - return (noun as unknown as Record)[systemInner] - } - return (noun as { metadata?: Record }).metadata?.[field] + return noun ? resolveEntityField(noun, field) : undefined } // Path 3: Search sparse index chunks for this entity's value. @@ -3093,10 +2400,6 @@ export class MetadataIndexManager implements MetadataIndexProvider { // Check if we have anything else to flush if (this.dirtyFields.size === 0) { - // Nothing dirty — but a pending watermark still stamps (the registry - // + id-mapper writes above are the only bytes this pass touched, and - // they are durable at this point). Stamp-after-data holds. - await this.writePendingStamp() return // No dirty field indexes to flush } @@ -3136,349 +2439,8 @@ export class MetadataIndexManager implements MetadataIndexProvider { if (this.columnStore) { await this.columnStore.flush() } - - // STAMP-AFTER-DATA: the watermark stamp is the LAST write of the flush — - // every byte it certifies (field indexes, registry, id-mapper records, - // column-store segments) is durable before the stamp lands. A crash - // anywhere above leaves the artifact behind-stamped or unstamped, which - // verdicts as catchup/rescan on the next open — never a wrong adopt. - await this.writePendingStamp() } - - /** - * @description Record the committed generation this projection reflects. - * The stamp is NOT written here — it is written as the final storage write - * of the next {@link flush} (stamp-after-data ordering is a module - * guarantee, not a caller obligation). The coordinator calls this with the - * store's committed generation right before flushing. - * @param generation - The committed generation every flushed byte reflects. - */ - stampWatermark(generation: number): void { - this.pendingWatermark = generation - } - - /** - * @description The projection's current watermark: the stamp loaded at - * init (or the last stamp durably written by this instance). Null = - * unstamped (legacy artifact, first boot, or stamping never wired). - */ - watermark(): number | null { - return this.stampedWatermark - } - - /** - * @description The three-way adoption verdict computed at init — - * `'adopt'` (stamped == committed, zero work), `'catchup'` (stamped < - * committed; the gap from {@link watermarkGap} awaits an incremental - * fold), `'rescan'` (unstamped or stamped above committed — never - * trusted). Null until init() has run. The coordinator (`Brainy.open()`) - * consumes this via {@link applyWatermarkCatchup} right after init. - */ - watermarkVerdict(): WatermarkVerdict | null { - return this.loadVerdict?.verdict ?? null - } - - /** - * @description The catch-up window `(from, to]` when the init verdict was - * `'catchup'`; null otherwise. - */ - watermarkGap(): { from: number; to: number } | null { - return this.loadVerdict?.gap ?? null - } - - /** - * @description Meaningful only when {@link watermarkVerdict} is - * `'rescan'`: `true` when a persisted artifact existed at load (even an - * unstamped/unverifiable one — real prior state, worth narrating loudly); - * `false` for a genuine first boot (nothing persisted yet — a caller - * should narrate this at a routine log level, not as an alarm, even - * though the verdict value is the same `'rescan'` either way). - */ - watermarkArtifactPresent(): boolean { - return this.rescanArtifactPresent - } - - /** - * @description Consume the three-way watermark verdict {@link - * watermarkVerdict} computed at init — the cure for a crash-recovered - * store whose canonical reads/counts recover every acked write but whose - * metadata projection (flushed only periodically, not per-commit) keeps - * serving the pre-crash state. Call once, right after `init()`, before - * anything reads from this projection. - * - * - `null`/`'adopt'` → the artifact already reflects the store's - * committed generation. Zero index writes. - * - `'catchup'` → the caller-supplied `scan` (expected already opened - * over `(watermarkGap().from, watermarkGap().to]`) is folded in, ONE - * op at a time, through the SAME two legs {@link rebuild} uses (ADR-007 - * A4 — one mechanism, never a second hand-rolled add/update shape): a - * tombstone (`op.record === null`) retracts id-keyed (this projection - * keeps no per-record delta log, so the pre-crash metadata for that id - * — if any — is what a value-precise removal would need, and it isn't - * available; the same tradeoff `remove()`'s null-metadata closure - * already accepts elsewhere); an after-image retracts-then-reposts, so - * an update never leaves stale postings under the old field values. A - * fact outside the window is skipped defensively (belt: the scan is - * already opened to the window; suspenders: this loop never trusts an - * over-run). On success the artifact is stamped at `to` and flushed — - * the same STAMP-AFTER-DATA door {@link flush} always writes through. - * - `'rescan'` (or a `'catchup'` verdict with no window, or no `scan` to - * fold — the store hosts no fact log) → the persisted artifact is - * unverifiable; this method runs the existing {@link rebuild} itself - * rather than leave the caller to notice and trigger it separately. - * - * @param scan - An open fact scan covering the catchup window (see - * {@link Brainy.scanFacts}), or `null` when none is available/needed. - * Ignored when the verdict is not `'catchup'`. - * @returns What happened — see {@link CatchupApplyResult}. - */ - async applyWatermarkCatchup(scan: FactScanHandle | null): Promise { - const verdict = this.watermarkVerdict() - if (verdict === null || verdict === 'adopt') return { action: 'noop' } - - if (verdict === 'rescan') { - await this.rebuild() - return { - action: 'rescan', - reason: 'persisted artifact is unverifiable (unstamped, or stamped ABOVE the ' + - "store's committed generation) — never adopting unverifiable state" - } - } - - // verdict === 'catchup' - const window = this.watermarkGap() - if (window === null) { - await this.rebuild() - return { action: 'rescan', reason: "'catchup' verdict exposed no window — cannot bound a fold" } - } - if (scan === null) { - await this.rebuild() - return { - action: 'rescan', - reason: `no fact log available to fold the (${window.from}, ${window.to}] catchup window` - } - } - - const { nounsApplied, verbsApplied, factsApplied } = await this.foldFactWindow(scan, window.from, window.to) - - this.stampWatermark(window.to) - await this.flush() - return { action: 'caught-up', window, nounsApplied, verbsApplied, factsApplied } - } - - /** - * @description Fold an open fact scan's `(fromGeneration, toGeneration]` - * window into this projection, ONE op at a time, through the SAME two legs - * {@link rebuild} uses (ADR-007 A4 — one mechanism, never a second - * hand-rolled add/update shape): a tombstone retracts id-keyed; an - * after-image retracts-then-reposts. THE CORE LOOP shared by {@link - * applyWatermarkCatchup} (which stamps + flushes after) and {@link - * buildBeside} (which does neither — persistence is the caller's job, - * exactly once, after a swap). Never stamps, never flushes, never touches - * storage beyond what `addToIndex`/`removeFromIndex` do internally - * (skipFlush is always forced true). - * @param scan - An open fact scan. - * @param fromGeneration - Window lower bound (exclusive). - * @param toGeneration - Window upper bound (inclusive). - * @returns Counts for the caller's narration. - */ - private async foldFactWindow( - scan: FactScanHandle, - fromGeneration: number, - toGeneration: number - ): Promise<{ nounsApplied: number; verbsApplied: number; factsApplied: number }> { - let nounsApplied = 0 - let verbsApplied = 0 - let factsApplied = 0 - for await (const batch of scan.batches()) { - for (const fact of batch.facts) { - // Defensive containment: the scan is already opened to the window, - // but a fact outside it is never applied regardless. - if (fact.generation <= fromGeneration || fact.generation > toGeneration) continue - const generation = BigInt(fact.generation) - for (const op of fact.ops) { - if (op.record === null) { - // TOMBSTONE — the id-keyed removal path (no per-record delta - // log to recover the old field values from). - await this.removeFromIndex(op.id, undefined, generation) - } else { - // AFTER-IMAGE — retract any stale posting for this id, then - // repost the new shape. Covers both a fresh add (nothing to - // retract; a no-op-ish remove) and an update, through the same - // two calls. - await this.removeFromIndex(op.id, undefined, generation) - await this.indexStoredRecord(op.id, op.record.metadata, { - skipFlush: true, - deferWrites: false, - generation - }) - } - if (op.kind === 'noun') nounsApplied++ - else verbsApplied++ - } - factsApplied++ - } - } - return { nounsApplied, verbsApplied, factsApplied } - } - - /** - * @description B3 Deliverable 3 — the shadow-build lifecycle's init: the - * MINIMUM setup {@link buildBeside} needs, deliberately NOT the general - * {@link init} sequence. Two reasons general `init()` is unsafe for a - * build-beside shadow: - * 1. `init()` unconditionally re-initializes the id mapper from storage - * (`idMapper.init()`) — safe for a FRESH mapper, but this instance is - * constructed with the CURRENTLY-SERVING manager's SHARED, already-live - * mapper (identity is shared, never a second mapper — this train's own - * law). Re-running its init() would DISCARD every not-yet-flushed - * UUID↔int assignment sitting in memory, breaking the live manager's - * own serving mid-build. - * 2. `init()` loads the field registry and, on a registry that's - * missing/empty while canonical has entities (exactly the shape a - * rebuild is often invoked to FIX), triggers `rebuild()` itself — - * WITHOUT `inMemoryOnly`, which would touch the shared storage keys - * the live manager depends on. - * What this DOES run: the WASM roaring-bitmap library init (idempotent; - * needed before any column-store write) and the column store's OWN - * segment-manifest discovery (read-only against shared storage; needed so - * THIS instance's eventual post-swap flush continues segment numbering - * correctly instead of colliding with the retiring manager's segments). - */ - private async initForShadowBuild(): Promise { - await roaringLibraryInitialize() - try { - await this.columnStore.init(this.storage, this.idMapper) - } catch (err) { - prodLog.warn('[MetadataIndex] shadow build: column store storage discovery failed:', err) - } - } - - /** - * @description B3 Deliverable 3 — THE ONLINE REBUILD's manager-side half: - * populate THIS instance (expected fresh/empty, constructed with the SAME - * storage + idMapper as the manager it will replace — see {@link - * initForShadowBuild}) from canonical storage without ever touching the - * shared storage keys the currently-serving manager depends on — no chunk - * deletion, no flush, anywhere in this call. The caller (the brain's - * rebuild-beside orchestrator) is responsible for: - * 1. Attaching this instance as a {@link beginShadow} target on the OLD - * manager BEFORE calling this, so live writes during the walk mirror - * here too (best-effort — the walk below may still clobber a mirrored - * write with a stale read for the same id; the fold after the walk is - * what makes the final state authoritative, not the mirror). - * 2. Swapping its own reference to this instance once this resolves. - * 3. Calling {@link stampWatermark} + {@link flush} EXACTLY ONCE, after - * the swap — this instance never persists itself. - * @param committedGenerationAtStart - The store's committed generation - * captured by the caller BEFORE this call — the fold's lower bound. - * @returns The generation this instance's canonical data reflects once the - * walk + fold settle — the fold's upper bound (writes committed after - * this point but before the swap only reach this instance via the live - * {@link beginShadow} mirror, so the caller re-reads the store's - * committed generation right before stamping, rather than trusting this - * return value as final). - * @throws If canonical advanced during the walk but no fact log is - * available to fold the gap — never a silently incomplete shadow. - */ - async buildBeside(committedGenerationAtStart: number): Promise { - await this.initForShadowBuild() - await this.rebuild({ inMemoryOnly: true }) - - const committedAfterWalk = this.storage.committedGeneration?.() ?? committedGenerationAtStart - if (committedAfterWalk > committedGenerationAtStart) { - const scan = this.storage.scanFacts?.({ - fromGeneration: committedGenerationAtStart + 1, - toGeneration: committedAfterWalk - }) ?? null - if (scan === null) { - throw new Error( - `MetadataIndexManager.buildBeside: canonical advanced from generation ` + - `${committedGenerationAtStart} to ${committedAfterWalk} during the walk, but this ` + - `store hosts no fact log to fold the gap — refusing a silently incomplete shadow` - ) - } - await this.foldFactWindow(scan, committedGenerationAtStart, committedAfterWalk) - } - return committedAfterWalk - } - - /** - * @description Write the pending watermark stamp as a sidecar record — - * always called AFTER the data it certifies is durable. A stamp-write - * failure is fail-safe (the artifact stays unstamped/behind → rescan or - * catchup on next open, never a wrong adopt) but is said out loud and the - * pending stamp is retained for the next flush. - */ - private async writePendingStamp(): Promise { - if (this.pendingWatermark === null) return - const watermark = this.pendingWatermark - try { - await this.storage.saveMetadata(METADATA_INDEX_STAMP_KEY, { - noun: 'IndexWatermark', - ...makeProjectionStamp(watermark) - }) - this.stampedWatermark = watermark - this.pendingWatermark = null - } catch (error) { - prodLog.error( - `[MetadataIndex] failed to write watermark stamp (generation ${watermark}) — ` + - `artifact stays behind-stamped (safe: verdicts catchup/rescan, never wrong-adopt); ` + - `retrying on next flush:`, - error - ) - } - } - - /** - * @description Read the artifact's stamp and compute the three-way verdict - * against the store's committed generation. Unstamped state on a stamped - * store verdicts `'rescan'` LOUDLY — never a silent adopt. - * - * MIGRATION COST: existing pre-stamp brains verdict `'rescan'` exactly - * once (this open re-derives from source as it already does today); the - * next flush stamps them, and every later open adopts. - */ - private async loadWatermarkVerdict(): Promise { - const committed = this.storage.committedGeneration?.() ?? null - let stamped: number | null = null - try { - const record = await this.storage.getMetadata(METADATA_INDEX_STAMP_KEY) - stamped = readStampedWatermark(record) - } catch { - // An unreadable stamp is unstamped — the fail-safe direction. - stamped = null - } - const result = computeWatermarkVerdict(stamped, committed) - this.loadVerdict = result - this.stampedWatermark = stamped - - if (result.verdict === 'rescan') { - const artifactPresent = this.fieldIndexes.size > 0 || stamped !== null - this.rescanArtifactPresent = artifactPresent - if (artifactPresent) { - prodLog.warn( - `[MetadataIndex] watermark verdict: RESCAN — persisted index is ` + - (stamped === null - ? 'unstamped (legacy pre-stamp artifact, or a crash between data and stamp)' - : `stamped at generation ${stamped}, ABOVE the store's committed generation ${committed}`) + - ` — never adopting unverifiable state` - ) - } else { - prodLog.debug( - '[MetadataIndex] watermark verdict: rescan (no persisted artifact — first boot)' - ) - } - } else if (result.verdict === 'catchup') { - prodLog.info( - `[MetadataIndex] watermark verdict: catchup — index stamped at generation ` + - `${stamped}, store committed at ${committed}; the (${stamped}, ${committed}] ` + - `window awaits an incremental fold (verdict exposed; the fold lands with the ` + - `coordinator's wiring)` - ) - } - } - + /** * Yield control back to the Node.js event loop * Prevents blocking during long-running operations @@ -3795,17 +2757,6 @@ export class MetadataIndexManager implements MetadataIndexProvider { // VFS Statistics Methods (uses existing Roaring bitmap infrastructure) // ============================================================================ - /** - * Read the type column's bitmap for one type value — frozen key first - * ('system.type', epoch 3), legacy 'noun' as the pre-rebuild fallback. - */ - private async getTypeBitmap(type: string): Promise { - return ( - (await this.getBitmapFromChunks('system.type', type)) ?? - (await this.getBitmapFromChunks('noun', type)) - ) - } - /** * Get VFS entity count for a specific type using Roaring bitmap intersection * Uses hardware-accelerated SIMD operations (AVX2/SSE4.2) @@ -3814,7 +2765,7 @@ export class MetadataIndexManager implements MetadataIndexProvider { */ async getVFSEntityCountByType(type: string): Promise { const vfsBitmap = await this.getBitmapFromChunks('isVFSEntity', true) - const typeBitmap = await this.getTypeBitmap(type) + const typeBitmap = await this.getBitmapFromChunks('noun', type) if (!vfsBitmap || !typeBitmap) return 0 @@ -3837,7 +2788,7 @@ export class MetadataIndexManager implements MetadataIndexProvider { // Iterate through all known types and compute VFS count via intersection for (const type of this.totalEntitiesByType.keys()) { - const typeBitmap = await this.getTypeBitmap(type) + const typeBitmap = await this.getBitmapFromChunks('noun', type) if (typeBitmap) { const intersection = RoaringBitmap32.and(vfsBitmap, typeBitmap) if (intersection.size > 0) { @@ -4114,48 +3065,13 @@ export class MetadataIndexManager implements MetadataIndexProvider { } } - /** - * @description Index one raw stored noun/verb record — THE ONE add leg - * shared by {@link rebuild}'s canonical walk and {@link - * applyWatermarkCatchup}'s fact-log fold (ADR-007 A4: one mechanism, - * never a second hand-rolled shape). No conversion step is needed here: - * a raw stored record (`storage.getNounMetadata`/`getVerbMetadata`, or a - * fact's after-image `record.metadata`) is byte-identical — both read the - * exact same canonical path — and already the v2 nested-bag - * ("entity-record") shape {@link extractIndexableFields} expects. - * @param id - Entity/relationship id. - * @param storedMetadata - The raw stored metadata record. - * @param opts.skipFlush - Forwarded to {@link addToIndex}. - * @param opts.deferWrites - Forwarded to {@link addToIndex}. - * @param opts.generation - Forwarded to {@link addToIndex}. - */ - private async indexStoredRecord( - id: string, - storedMetadata: unknown, - opts: { skipFlush: boolean; deferWrites: boolean; generation?: bigint } - ): Promise { - await this.addToIndex(id, storedMetadata, opts.skipFlush, opts.deferWrites, opts.generation) - } - /** * Rebuild entire index from scratch using pagination * Non-blocking version that yields control back to event loop * Sparse indices now lazy-loaded via UnifiedCache (no need to clear Map) - * - * @param options.inMemoryOnly - B3 Deliverable 3 (build-beside): when - * `true`, this call never touches the shared storage keys another, - * currently-serving `MetadataIndexManager` over the SAME storage may - * depend on — it skips deleting persisted legacy chunk files AND skips - * the final `flush()` (which would otherwise write field indexes AND - * flush the column store's tail buffers to shared segment keys, - * colliding with a live manager's own writes). The caller ({@link - * buildBeside}) owns persistence entirely — exactly once, after this - * instance becomes the sole owner via an atomic swap. Default `false` - * (every other caller keeps today's clear-then-persist behavior). */ - async rebuild(options?: { inMemoryOnly?: boolean }): Promise { + async rebuild(): Promise { if (this.isRebuilding) return - const inMemoryOnly = options?.inMemoryOnly ?? false this.isRebuilding = true try { @@ -4184,22 +3100,15 @@ export class MetadataIndexManager implements MetadataIndexProvider { // here — it's always saved at the end of rebuild via flush(). This ensures // that if rebuild fails partway, the next init() can still discover fields // and trigger another rebuild attempt. - // - // SKIPPED for inMemoryOnly: these are the SHARED storage keys a live - // manager over the same storage may still be reading (see this - // method's JSDoc) — deleting them before the swap is a live-read - // hazard, not a cleanup. - if (!inMemoryOnly) { - prodLog.info('Clearing existing metadata index chunks from storage...') - const existingFields = await this.getPersistedFieldList() + prodLog.info('Clearing existing metadata index chunks from storage...') + const existingFields = await this.getPersistedFieldList() - if (existingFields.length > 0) { - for (const field of existingFields) { - await this.deleteFieldChunks(field) - } - - prodLog.info(`Cleared ${existingFields.length} field indexes from storage`) + if (existingFields.length > 0) { + for (const field of existingFields) { + await this.deleteFieldChunks(field) } + + prodLog.info(`Cleared ${existingFields.length} field indexes from storage`) } // EntityIdMapper is intentionally NOT cleared here. Rebuild re-iterates @@ -4254,7 +3163,7 @@ export class MetadataIndexManager implements MetadataIndexProvider { for (const noun of result.items) { const metadata = metadataBatch.get(noun.id) if (metadata) { - await this.indexStoredRecord(noun.id, metadata, { skipFlush: true, deferWrites: true }) + await this.addToIndex(noun.id, metadata, true, true) } } @@ -4299,7 +3208,7 @@ export class MetadataIndexManager implements MetadataIndexProvider { for (const verb of result.items) { const metadata = verbMetadataBatch.get(verb.id) if (metadata) { - await this.indexStoredRecord(verb.id, metadata, { skipFlush: true, deferWrites: true }) + await this.addToIndex(verb.id, metadata, true, true) } } @@ -4309,16 +3218,8 @@ export class MetadataIndexManager implements MetadataIndexProvider { // Flush to storage. The column store's flush() handles tail-buffer-to- // segment promotion + manifest persistence. - // - // SKIPPED for inMemoryOnly — see this method's JSDoc: flush() writes - // the shared field-index keys AND flushes the column store's tail - // buffers to shared segment keys, which would race a live manager's - // own flushes over the SAME storage. The caller flushes exactly once, - // after the swap. - if (!inMemoryOnly) { - prodLog.debug('💾 Flushing metadata index to storage...') - await this.flush() - } + prodLog.debug('💾 Flushing metadata index to storage...') + await this.flush() prodLog.info(`✅ Metadata index rebuild completed! Processed ${totalNounsProcessed} nouns and ${totalVerbsProcessed} verbs`) @@ -4481,21 +3382,18 @@ export class MetadataIndexManager implements MetadataIndexProvider { * Tracks which fields commonly appear with which entity types */ private updateTypeFieldAffinity(entityId: string, field: string, value: any, operation: 'add' | 'remove', metadata?: any): void { - // Only track affinity for user fields (plus the type column itself, - // which drives detection). Engine columns carry the literal 'system.' - // prefix under the frozen key format. - if (field.startsWith('system.') && field !== 'system.type') return + // Only track affinity for non-system fields (but allow 'noun' for type detection) + if (this.config.excludeFields.includes(field) && field !== 'noun') return - // For the type column ('system.type'), the value IS the entity type + // For the 'noun' field, the value IS the entity type let entityType: string | null = null - if (field === 'system.type') { + if (field === 'noun') { // This is the type definition itself entityType = this.normalizeValue(value, field) // Pass field for bucketing! - } else if (metadata && (metadata.noun ?? metadata.type)) { - // Extract entity type from the source shape: stored records carry it - // under 'noun', entity-for-indexing views under 'type'. - entityType = this.normalizeValue(metadata.noun ?? metadata.type, 'system.type') + } else if (metadata && metadata.noun) { + // Extract entity type from metadata + entityType = this.normalizeValue(metadata.noun, 'noun') } else { // No type information available, skip affinity tracking return @@ -4518,9 +3416,8 @@ export class MetadataIndexManager implements MetadataIndexProvider { const currentCount = typeFields.get(field) || 0 typeFields.set(field, currentCount + 1) - // Update total entities of this type (only count once per entity — - // the type column appears exactly once per entity) - if (field === 'system.type') { + // Update total entities of this type (only count once per entity) + if (field === 'noun') { const newCount = this.totalEntitiesByType.get(entityType)! + 1 this.totalEntitiesByType.set(entityType, newCount) @@ -4543,7 +3440,7 @@ export class MetadataIndexManager implements MetadataIndexProvider { } // Update total entities of this type - if (field === 'system.type') { + if (field === 'noun') { const total = this.totalEntitiesByType.get(entityType)! if (total > 1) { const newCount = total - 1 diff --git a/src/utils/paramValidation.ts b/src/utils/paramValidation.ts index f1addb5b..ca439524 100644 --- a/src/utils/paramValidation.ts +++ b/src/utils/paramValidation.ts @@ -17,8 +17,6 @@ import { findCallerLocation } from './callerLocation.js' // fallback branches that no supported runtime can reach. import * as os from 'node:os' import * as fs from 'node:fs' -import { parseFieldAddress, UnsupportedFindOptionError } from '../db/fieldAddressing.js' -import { MAX_INDEXED_ARRAY_LENGTH, MetadataArrayTooLargeError } from '../errors/brainyError.js' const getSystemMemory = (): number => { if (os) { @@ -468,31 +466,9 @@ export function validateFindParams(params: FindParams): void { throw new Error('cannot specify both query and vector - they are mutually exclusive') } - // ACCEPTED-AND-IGNORED DIED AS A CLASS (sealed 2026-08-03): options the - // engine does not implement REFUSE with a typed error instead of silently - // doing nothing — a production consumer discovered a no-op by measurement - // once; never again. - if (params.cursor !== undefined) { - throw new UnsupportedFindOptionError('cursor') - } - if ((params as Record).includeRelations !== undefined) { - throw new UnsupportedFindOptionError('includeRelations') - } - if ((params as Record).writeOnly !== undefined) { - throw new UnsupportedFindOptionError('writeOnly') - } - - // THE ONE ADDRESSING LAW: the orderBy address must PARSE (bare/metadata. = - // user field, system. = the ruled map, anything else refuses typed - // with the valid map in the message) and order must be a real direction. - if (params.orderBy !== undefined) { - if (typeof params.orderBy !== 'string') { - throw new Error('orderBy must be a string field address') - } - parseFieldAddress(params.orderBy, 'entity') // throws InvalidFieldAddressError on a bad address - } - if (params.order !== undefined && params.order !== 'asc' && params.order !== 'desc') { - throw new Error(`order must be 'asc' or 'desc', got '${String(params.order)}'`) + // Universal truth: can't use both cursor and offset pagination + if (params.cursor !== undefined && params.offset !== undefined) { + throw new Error('cannot use both cursor and offset pagination simultaneously') } // Auto-limit query length based on memory @@ -519,96 +495,9 @@ export function validateFindParams(params: FindParams): void { /** * Validate add parameters */ - -/** - * The namespace cannot be forged: a USER metadata key literally spelled - * 'system.' would collide with the engine's explicit address - * namespace at read time — refuse it at the write door, loudly, with the - * fix in the message (sealed 2026-08-03). - */ -function rejectForgedSystemKeys(metadata: Record | undefined, site: string): void { - if (!metadata) return - for (const key of Object.keys(metadata)) { - if (key.startsWith('system.')) { - throw new Error( - `${site}: metadata key '${key}' is not allowed — the 'system.' prefix is the ` + - `engine's explicit address namespace and cannot be used as a user field name. ` + - `Rename the field (e.g. '${key.slice('system.'.length)}').` - ) - } - } -} - -/** - * THE INDEXABLE-ARRAY BOUND, enforced at the write door. - * - * An array-valued metadata field indexes one posting per element, so the index - * has always carried a ceiling. It used to be 10, and it was applied by a bare - * `continue` deep inside field extraction: a row whose `tags` array held eleven - * entries had that field skipped entirely and dropped out of every filtered - * search on it — no error, no warning, and no way for the caller to tell the - * difference from "no row matches". Silence is the defect; the ceiling is not. - * - * The bound is now {@link MAX_INDEXED_ARRAY_LENGTH}, high enough that every - * legitimate multi-value field clears it, and it REFUSES here instead of - * dropping data downstream. Refusing at the write door is what makes it - * actionable: the caller learns at the moment of writing, with the field, the - * length and the bound in hand. - * - * Scope is the caller's own metadata bag — the values that become postings. - * Nested bags are walked, because a nested field indexes under its dotted - * address exactly like a top-level one. Arrays of OBJECTS are not walked: the - * index only ever makes postings from an array's scalar elements. - * - * @param metadata - The caller's metadata bag (undefined is fine). - * @param site - The write door's name, for the message ('add()', 'update()', …). - * @throws {MetadataArrayTooLargeError} Naming the field, its length and the bound. - */ -function rejectOversizeIndexArrays(metadata: Record | undefined, site: string): void { - if (!metadata) return - - const walk = (bag: Record, prefix: string): void => { - for (const [key, value] of Object.entries(bag)) { - const address = prefix ? `${prefix}.${key}` : key - if (Array.isArray(value)) { - if (value.length > MAX_INDEXED_ARRAY_LENGTH) { - throw new MetadataArrayTooLargeError(site, address, value.length, MAX_INDEXED_ARRAY_LENGTH) - } - } else if (value && typeof value === 'object') { - walk(value as Record, address) - } - } - } - - walk(metadata, '') -} - export function validateAddParams(params: AddParams): void { - rejectForgedSystemKeys(params.metadata as Record | undefined, 'add()') - rejectOversizeIndexArrays(params.metadata as Record | undefined, 'add()') - // 'data' is ABSENT only when null/undefined — an empty string ('') is real - // content (a legitimate empty file's first write) and must not be treated - // as missing. Falsy-but-present values (0, false, '') all count as present; - // only the true "nothing was given" case is absent. - const hasData = params.data !== undefined && params.data !== null - // MT5 deferred embedding: an explicit vector has nothing to defer, and a - // deferral without data has nothing to embed — both are caller bugs that - // must refuse with the fix, never be silently reinterpreted. - if ((params as AddParams & { deferEmbedding?: boolean }).deferEmbedding === true) { - if (params.vector) { - throw new Error( - `add(): deferEmbedding cannot be combined with an explicit 'vector' — ` + - `the vector is already computed; drop one of the two.` - ) - } - if (!hasData) { - throw new Error( - `add(): deferEmbedding requires 'data' (the content the background worker will embed).` - ) - } - } // Universal truth: must have data or vector - if (!hasData && !params.vector) { + if (!params.data && !params.vector) { throw new Error( `Invalid add() parameters: Missing required field 'data'\n` + `\nReceived: ${JSON.stringify({ @@ -634,14 +523,8 @@ export function validateAddParams(params: AddParams): void { ) } - // Validate vector dimensions if provided. A length-0 vector is the - // "unvectored" shape — an explicit `vector: []` (e.g. the VFS root's - // permanently-vectorless creation, see - // VirtualFileSystem.doInitializeRoot()'s zero-norm fix) carries no - // dimension information, exactly like an absent vector or a deferred - // embed's internal stub, so it is exempt from the dimension check rather - // than refused as a "0-dimensional vector". - if (params.vector && params.vector.length > 0) { + // Validate vector dimensions if provided + if (params.vector) { const config = ValidationConfig.getInstance() if (params.vector.length !== config.maxVectorDimensions) { throw new Error(`vector must have exactly ${config.maxVectorDimensions} dimensions`) @@ -653,44 +536,14 @@ export function validateAddParams(params: AddParams): void { * Validate update parameters */ export function validateUpdateParams(params: UpdateParams): void { - rejectForgedSystemKeys(params.metadata as Record | undefined, 'update()') - rejectOversizeIndexArrays(params.metadata as Record | undefined, 'update()') - // Same absent-vs-empty distinction as validateAddParams: '' is a real new - // value (e.g. truncating a file to empty content via overwrite), only - // 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' — ` + - `the vector is already computed; drop one of the two.` - ) - } - if (!hasData) { - throw new Error( - `update(): deferEmbedding requires new 'data' — without a data change there is nothing to re-embed.` - ) - } - } // Universal truth: must have an ID if (!params.id) { throw new Error('id is required for update') } - + // Universal truth: must update something if ( - !hasData && + !params.data && !params.metadata && !params.type && !params.vector && @@ -707,16 +560,8 @@ export function validateUpdateParams(params: UpdateParams): void { throw new Error(`invalid NounType: ${params.type}`) } - // 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) { + // Validate vector dimensions if provided + if (params.vector) { const config = ValidationConfig.getInstance() if (params.vector.length !== config.maxVectorDimensions) { throw new Error(`vector must have exactly ${config.maxVectorDimensions} dimensions`) @@ -728,8 +573,6 @@ export function validateUpdateParams(params: UpdateParams): void { * Validate relate parameters */ export function validateRelateParams(params: RelateParams): void { - rejectForgedSystemKeys(params.metadata as Record | undefined, 'relate()') - rejectOversizeIndexArrays(params.metadata as Record | undefined, 'relate()') // 8.0 verb-id contract (L.7): verb ids are UUIDs, generated by brainy. // RelateParams has no `id` field — an untyped caller passing one would // previously have it silently ignored (a generated UUID was used instead). @@ -778,8 +621,6 @@ export function validateRelateParams(params: RelateParams): void { * accepts type/subtype/weight/confidence/data/metadata changes. */ export function validateUpdateRelationParams(params: UpdateRelationParams): void { - rejectForgedSystemKeys(params.metadata as Record | undefined, 'updateRelation()') - rejectOversizeIndexArrays(params.metadata as Record | undefined, 'updateRelation()') if (!params.id) { throw new Error('id is required for updateRelation') } diff --git a/src/utils/projectionWatermark.ts b/src/utils/projectionWatermark.ts deleted file mode 100644 index 1bd77aeb..00000000 --- a/src/utils/projectionWatermark.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * @module utils/projectionWatermark - * @description The watermark-stamp contract shared by Brainy's persisted TS - * projections (metadata index, JS HNSW vector index, graph adjacency index). - * - * THE LAW: every persisted projection artifact carries a stamp asserting - * "this state reflects every committed generation ≤ watermark and nothing - * above it, atomically". STAMP-AFTER-DATA: the stamp is written only after - * every byte it certifies is durable — a crash between data and stamp leaves - * the artifact unstamped, which verdicts as a rescan, never a wrong adopt. - * - * At load, each owner computes a three-way verdict against the store's - * committed generation — the same rule and verdict names the aggregation - * machinery ships (see `AggregationIndex.stateAdoptionVerdict`): - * - * - `'adopt'` — stamped == committed (clean reopen, zero work), or the - * store exposes no committed generation at all (pre-stamp - * stores keep their pre-stamp behavior). - * - `'catchup'` — stamped < committed (an unclean exit after later writes, - * or a long-lived writer whose last stamp predates recent - * commits). The artifact is exact AS OF its stamp, so the - * missing window `(stamped, committed]` can be folded - * incrementally — at-least-once idempotent, bounded by - * writes since the stamp, never by store size. - * - `'rescan'` — unstamped (a legacy pre-stamp artifact, or a crash between - * data and stamp) or stamped ABOVE committed (e.g. a log - * truncation on a copied store pulled the watermark back): - * the state over-claims unverifiably — one exact rescan, - * said out loud, never a silent adopt. - * - * MIGRATION COST (stated once, honored by every owner): existing pre-stamp - * brains verdict `'rescan'` exactly once — they re-derive from source on - * that open, the next flush stamps them, and every later open adopts. - * - * The verdict is COMPUTED AND EXPOSED by each owner; acting on `'catchup'` - * (the incremental fold) lands with the owner's coordinator wiring. - */ - -/** The three-way load verdict for a persisted projection artifact. */ -export type WatermarkVerdict = 'adopt' | 'catchup' | 'rescan' - -/** - * Format version written into every projection stamp. Bump when the stamp - * record's shape changes incompatibly; readers treat an unknown version as - * unstamped (→ rescan) rather than guessing. - */ -export const PROJECTION_STAMP_FORMAT_VERSION = 1 - -/** - * @description The stamp record a projection writes into (or beside) its - * persisted artifact, always AFTER the data it certifies is durable. - */ -export interface ProjectionStamp { - /** The committed generation this artifact reflects, exactly and entirely. */ - watermark: number - /** {@link PROJECTION_STAMP_FORMAT_VERSION} at write time. */ - formatVersion: number - /** Wall-clock ms at stamp write — diagnostic only, never load-bearing. */ - stampedAt: number - /** - * Identity of the vector space for vector-bearing artifacts (the HNSW - * index). The JS index has no reachable embedding-model id in its module, - * so dimensions are the only identity it can honestly assert. - */ - modelIdentity?: { embedModelId?: string; dimensions: number | null } -} - -/** The verdict plus everything the owner needs to report or act on it. */ -export interface WatermarkVerdictResult { - verdict: WatermarkVerdict - /** Watermark read from the artifact's stamp; null = unstamped. */ - stamped: number | null - /** The store's committed generation at load; null = no capability. */ - committed: number | null - /** The catch-up window `(from, to]` when verdict is `'catchup'`, else null. */ - gap: { from: number; to: number } | null -} - -/** - * @description Build a stamp record for a projection artifact. - * @param watermark - The committed generation the artifact reflects. - * @param modelIdentity - Vector-space identity for vector-bearing artifacts. - * @returns The stamp record to persist (stamp-after-data). - */ -export function makeProjectionStamp( - watermark: number, - modelIdentity?: ProjectionStamp['modelIdentity'] -): ProjectionStamp { - const stamp: ProjectionStamp = { - watermark, - formatVersion: PROJECTION_STAMP_FORMAT_VERSION, - stampedAt: Date.now() - } - if (modelIdentity !== undefined) stamp.modelIdentity = modelIdentity - return stamp -} - -/** - * @description Read the stamped watermark out of a persisted record, treating - * anything malformed (missing, wrong type, non-finite, negative, or an - * unknown format version) as unstamped — the fail-safe direction is rescan, - * never a guessed adopt. - * @param record - The raw persisted record (or null/undefined). - * @returns The stamped watermark, or null if effectively unstamped. - */ -export function readStampedWatermark(record: unknown): number | null { - if (record === null || typeof record !== 'object') return null - const rec = record as Record - const version = rec.formatVersion - if (typeof version !== 'number' || version > PROJECTION_STAMP_FORMAT_VERSION) { - return null - } - const raw = rec.watermark - if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) return null - return raw -} - -/** - * @description The three-way adoption verdict — the single decision rule - * every stamped projection shares (mirrors the aggregation machinery's - * `stateAdoptionVerdict` exactly: same names, same directions). - * @param stamped - Watermark read from the artifact ({@link readStampedWatermark}). - * @param committed - The store's committed generation (null = no capability). - * @returns The verdict with the stamped/committed pair and the catch-up gap. - */ -export function computeWatermarkVerdict( - stamped: number | null, - committed: number | null -): WatermarkVerdictResult { - // No committed-generation capability: hash/shape checks are the only - // adoption gate, exactly the pre-stamp behavior. Never fail a store that - // cannot express the question. - if (committed === null) { - return { verdict: 'adopt', stamped, committed, gap: null } - } - if (stamped === committed) { - return { verdict: 'adopt', stamped, committed, gap: null } - } - if (stamped !== null && stamped < committed) { - return { - verdict: 'catchup', - stamped, - committed, - gap: { from: stamped, to: committed } - } - } - // Unstamped, or stamped above committed: unverifiable — rescan, loudly - // (the caller owns the loud log so it can name its projection). - return { verdict: 'rescan', stamped, committed, gap: null } -} diff --git a/src/utils/version.ts b/src/utils/version.ts index 327f923c..d616cee3 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -1,6 +1,6 @@ /** * @module utils/version - * @description Resolves the running `@soulcraftlabs/brainy` package version. Brainy 8.0 + * @description Resolves the running `@soulcraft/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,27 +83,3 @@ 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/PathResolver.ts b/src/vfs/PathResolver.ts index 502c95f0..e496c834 100644 --- a/src/vfs/PathResolver.ts +++ b/src/vfs/PathResolver.ts @@ -57,7 +57,6 @@ export class PathResolver { // Statistics private cacheHits = 0 private cacheMisses = 0 - private lastLoggedLookups = 0 // last total the maintenance tick logged stats at private metadataIndexHits = 0 private metadataIndexMisses = 0 private graphTraversalFallbacks = 0 @@ -520,14 +519,10 @@ export class PathResolver { } } - // Log cache statistics only when there is new traffic to report — an - // idle resolver stays silent. 0/0 lookups previously rendered - // "NaN% hit rate" (and the %1000 gate passes at zero), which spammed - // production journals once a minute on every idle VFS. - const totalLookups = this.cacheHits + this.cacheMisses - if (totalLookups > 0 && totalLookups !== this.lastLoggedLookups && totalLookups % 1000 === 0) { - this.lastLoggedLookups = totalLookups - prodLog.debug(`[PathResolver] Cache stats: ${Math.round((this.cacheHits / totalLookups) * 100)}% hit rate, ${this.pathCache.size} entries, ${this.hotPaths.size} hot paths`) + // Log cache statistics (in production, send to monitoring) + const hitRate = this.cacheHits / (this.cacheHits + this.cacheMisses) + if ((this.cacheHits + this.cacheMisses) % 1000 === 0) { + console.log(`[PathResolver] Cache stats: ${Math.round(hitRate * 100)}% hit rate, ${this.pathCache.size} entries, ${this.hotPaths.size} hot paths`) } }, 60000) // Every minute // Cache maintenance must never keep the host process alive. diff --git a/src/vfs/VirtualFileSystem.ts b/src/vfs/VirtualFileSystem.ts index bccd6fea..00bddefb 100644 --- a/src/vfs/VirtualFileSystem.ts +++ b/src/vfs/VirtualFileSystem.ts @@ -6,13 +6,11 @@ */ 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' import { Entity, AddParams, RelateParams, FindParams, Relation } from '../types/brainy.types.js' import { NounType, VerbType } from '../types/graphTypes.js' -import { isZeroNormVector } from '../utils/distance.js' import { PathResolver } from './PathResolver.js' import { mimeDetector } from './MimeTypeDetector.js' import { @@ -67,20 +65,6 @@ 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')) @@ -158,17 +142,8 @@ export class VirtualFileSystem implements IVirtualFileSystem { // Create or find root entity this.rootEntityId = await this.initializeRoot() - // 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() + // Clean up old UUID-based roots (one-time migration) + await this.cleanupOldRoots() // Initialize projection registry with auto-discovery of built-in projections this.projectionRegistry = new ProjectionRegistry() @@ -256,11 +231,9 @@ export class VirtualFileSystem implements IVirtualFileSystem { private async doInitializeRoot(): Promise { const rootId = VirtualFileSystem.VFS_ROOT_ID - // Try to get existing root by fixed ID (O(1) lookup, not query). - // includeVectors: true — the zero-norm migration below (leg 2) needs to - // inspect the persisted vector to detect the legacy placeholder shape. + // Try to get existing root by fixed ID (O(1) lookup, not query) try { - const existingRoot = await this.brain.get(rootId, { includeVectors: true }) + const existingRoot = await this.brain.get(rootId) if (existingRoot) { // Root exists - verify metadata is correct @@ -277,34 +250,6 @@ export class VirtualFileSystem implements IVirtualFileSystem { }) } - // ZERO-NORM ROOT MIGRATION (one-time): a pre-fix store persisted the - // root with a REAL all-zero placeholder vector — lawful inside - // brainy (cosineDistance treats a zero-norm operand as MAXIMUM - // distance, see src/utils/distance.ts) but a "false attractor" for a - // downstream engine serving squared-euclidean distance, which cannot - // tell an all-zero vector apart from a legitimate origin point (a - // production incident silently darkened 150+ rows in a partner - // engine's index this way). THE LAW: a zero-norm vector is not a - // vector — it never crosses an engine boundary. Detect the legacy - // shape via NORM, not length or dimension (any real all-zero vector - // qualifies, not just the historical 384-dim one), and rewrite it to - // the "unvectored" `[]` shape through the sanctioned migration path - // (Brainy.unvectorNounForRootMigration — see its JSDoc), which keeps - // `getCanonicalCounts().vectors.all` honest and removes the row from - // the vector index. Idempotent: a store already on the new shape - // (vector.length === 0) takes the false branch below on every - // subsequent init() — a permanent no-op, not a one-time flag. - const existingVector = existingRoot.vector ?? [] - if (existingVector.length > 0 && isZeroNormVector(existingVector)) { - const migrated = await this.brain.unvectorNounForRootMigration(rootId) - if (migrated) { - console.log( - 'VFS: migrated root vector from the legacy all-zero placeholder to the ' + - 'unvectored shape (zero-norm vectors never cross an engine boundary)' - ) - } - } - return rootId } } catch (error) { @@ -315,51 +260,6 @@ export class VirtualFileSystem implements IVirtualFileSystem { try { console.log('VFS: Creating root directory (fixed ID: 00000000-0000-0000-0000-000000000000)') - // OPEN-PATH FIX: the VFS root is Brainy's own system-tier plumbing — it - // is hidden from find()/getNounCount()/stats() by default and nothing - // ever runs a semantic search against it — so it needs no REAL - // embedding. Historically this add() always called embed('/'), which - // meant every writer's FIRST-EVER open forced the process-global WASM - // engine to cold-compile its model (measured 90-140s on throttled - // CPUs) before the brain could even finish init(). This branch only - // runs once per store (a previously-opened store already has a root — - // see the migration above for the pre-fix shape — reopening never - // re-adds it), so the fix applies only to brand-new stores. - // - // ZERO-NORM LAW (current shape, superseding the historical all-zero - // placeholder): the root's vector is `[]` — the SAME "unvectored" - // empty-array shape used for a deferred embed's stub and any other - // not-yet-embedded row — never a real all-zero vector. A zero-norm - // vector is lawful inside brainy (`cosineDistance`, see - // src/utils/distance.ts, returns the MAXIMUM distance whenever either - // operand's norm is zero) but is a "false attractor" for a downstream - // engine serving squared-euclidean distance, which cannot tell a real - // all-zero vector apart from a legitimate origin point — it silently - // darkened 150+ rows in a partner engine's index in production. THE - // LAW: a zero-norm vector is not a vector — it never crosses an engine - // boundary. `vector: []` achieves the SAME cold-compile avoidance the - // original placeholder did (`add()`'s dimension-pin and HNSW-insert - // gates both key off `vector.length > 0`, so an empty vector never - // calls embed(), never pins `brain.dimensions`, and never reaches the - // vector index — see brainy.ts add()'s matching comments) while never - // persisting a searchable zero vector for a downstream engine to trip - // over. Deliberately NOT `deferEmbedding: true`: that flag's landing - // path (`kickEmbedWorker()`, called synchronously right after commit — - // see brainy.ts add()/update()) would still force the WASM engine to - // cold-compile within milliseconds of open (just off the awaited path - // instead of never paying it at all) AND would eventually embed the - // root's data for real, which this fix forbids — the root must NEVER - // be embedded, not merely "not yet". - // - // Only the default WASM engine gets this treatment — a plugin- - // registered native 'embeddings' provider has no cold-compile cost and - // may use a different dimension, so it keeps embedding the root for - // real (same as before this fix) rather than leave Brainy's own - // plumbing permanently unvectored on a store where embedding is cheap. - const rootVector = this.brain.usesDefaultWasmEmbedder() - ? ([] as number[]) - : undefined - await this.brain.add({ id: rootId, // Fixed ID - storage ensures uniqueness data: '/', @@ -371,8 +271,7 @@ export class VirtualFileSystem implements IVirtualFileSystem { // public AddParams.visibility union ('public' | 'internal') — this is the single // sanctioned internal setter, hence the cast. visibility: 'system' as 'public' | 'internal', - metadata: this.getRootMetadata(), - ...(rootVector ? { vector: rootVector } : {}) + metadata: this.getRootMetadata() }) return rootId @@ -418,100 +317,7 @@ export class VirtualFileSystem implements IVirtualFileSystem { * * This is a one-time migration helper that can be removed in future versions. */ - /** - * @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 + private async cleanupOldRoots(): Promise { try { // Find any old VFS roots with UUID-based IDs (not our fixed ID) const oldRoots = await this.brain.find({ @@ -533,7 +339,6 @@ 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) @@ -546,7 +351,6 @@ export class VirtualFileSystem implements IVirtualFileSystem { // Non-critical error - log and continue console.warn('VFS: Cleanup of old roots failed (non-critical):', error) } - return removed } /** @@ -890,12 +694,6 @@ export class VirtualFileSystem implements IVirtualFileSystem { await this.brain.update({ id: existingId, data: embeddingData, - // MT5: the caller's write acks at durability; the re-embed (a neural - // net — it dominated the measured 5.6s p50 per file write) runs on - // the background worker and swaps in atomically. Content is readable - // and metadata-findable immediately; semantic search converges when - // the embed lands (eventual vector index, the documented contract). - deferEmbedding: true, metadata }) @@ -931,9 +729,6 @@ export class VirtualFileSystem implements IVirtualFileSystem { data: embeddingData, // Always provide string for embeddings type: this.getFileNounType(mimeType), subtype: 'vfs-file', // Standard subtype for VFS file entities (7.30+) - // MT5: ack at durability; embedding backgrounds (see the overwrite - // branch note above). - deferEmbedding: true, metadata }) @@ -1322,9 +1117,6 @@ export class VirtualFileSystem implements IVirtualFileSystem { data: path, // Directory path as string content type: NounType.Collection, subtype: 'vfs-directory', // Standard subtype for VFS directory entities (7.30+) - // MT5: a directory creation on a write path must not wait on the - // embedder either — same ack-at-durability contract as file writes. - deferEmbedding: true, metadata }) @@ -1425,20 +1217,7 @@ export class VirtualFileSystem implements IVirtualFileSystem { } /** - * @description List a directory's contents. Non-recursive (default) - * returns direct children only, named by basename. `recursive: true` - * lists every descendant at any depth (files and directories), each - * reported as a path RELATIVE TO THE QUERIED DIRECTORY — matching Node's - * `fs.readdir(dir, { recursive: true })` convention — e.g. `'sub'` and - * `'sub/file.txt'` for a nested file. With `withFileTypes: true`, each - * {@link VFSDirent}'s `name` carries that same value (relative when - * recursive, basename otherwise); `path` is always the absolute VFS path - * either way. - * @param path - The directory to list. - * @param options - `recursive`, `withFileTypes`, `filter`, `sort`, - * `offset`/`limit` (pagination applies AFTER filter/sort, over the full - * recursive set when `recursive: true`). - * @throws {VFSError} ENOTDIR when `path` is not a directory. + * Read directory contents */ async readdir(path: string, options?: ReaddirOptions): Promise { await this.ensureInitialized() @@ -1451,12 +1230,8 @@ export class VirtualFileSystem implements IVirtualFileSystem { throw new VFSError(VFSErrorCode.ENOTDIR, `Not a directory: ${path}`, path, 'readdir') } - // Direct children, or every descendant at any depth. gatherDescendants() - // is the same graph-traversal + ONE-batch-fetch path getTreeStructure()/ - // getDescendants() already use — no per-directory storage round trips. - let children = options?.recursive - ? await this.gatherDescendants(entityId, Infinity) - : await this.pathResolver.getChildren(entityId) + // Get children + let children = await this.pathResolver.getChildren(entityId) // Apply filters if (options?.filter) { @@ -1480,29 +1255,17 @@ export class VirtualFileSystem implements IVirtualFileSystem { // Directory access time updates caused 50-100ms GCS write on EVERY readdir // await this.updateAccessTime(entityId) // ← REMOVED - // The queried directory's own canonical (already-normalized) path — the - // base every recursive entry's relative name is computed against. Using - // the resolved entity's OWN path (rather than the raw `path` argument) - // means no separate normalization step is needed here. - const baseDir = entity.metadata.path - const relativeToBase = (childPath: string): string => { - const prefix = baseDir === '/' ? '/' : `${baseDir}/` - return childPath.startsWith(prefix) ? childPath.slice(prefix.length) : childPath - } - // Return appropriate format if (options?.withFileTypes) { return children.map(child => ({ - name: options?.recursive ? relativeToBase(child.metadata.path) : child.metadata.name, + name: child.metadata.name, path: child.metadata.path, type: child.metadata.vfsType, entityId: child.id } as VFSDirent)) } - return children.map(child => - options?.recursive ? relativeToBase(child.metadata.path) : child.metadata.name - ) + return children.map(child => child.metadata.name) } // ============= Metadata Operations ============= @@ -1572,19 +1335,7 @@ export class VirtualFileSystem implements IVirtualFileSystem { // ============= Semantic Operations ============= /** - * Search files with natural language. - * - * `options.path` scopes the search to a directory: its whole subtree by - * default, its immediate children when `recursive` is `false`. Both scopes - * are metadata filters the index SERVES, so the scope narrows the search - * before it runs — no tree walk, and never an over-fetch filtered afterwards. - * - * @param query - The natural-language query. - * @param options - Scope, metadata filters and paging (see {@link SearchOptions}). - * @returns The matching files, best first. - * @throws {VFSError} ENOENT when `recursive: false` names a path that does - * not exist (the non-recursive scope is the directory's own identity, so - * the directory has to be there). + * Search files with natural language */ async search(query: string, options?: SearchOptions): Promise { await this.ensureInitialized() @@ -1600,26 +1351,11 @@ export class VirtualFileSystem implements IVirtualFileSystem { } } - // Scope to a directory, if asked. This used to emit - // `path: { $startsWith }` — an operator that is not in the filter - // vocabulary at all, and whose `$`-less spelling the metadata index - // REFUSES by the served-operator law (an equality/range posting index - // cannot evaluate a substring without reading every row). Every - // path-scoped VFS search therefore threw, and none has ever worked on - // this engine line. Both scopes below are served shapes. + // Add path filter if specified if (options?.path) { - if (options.recursive === false) { - // Immediate children only: the directory's identity IS the scope, and - // `parent` is an indexed equality on every VFS entity. - params.where = { - ...params.where, - parent: await this.pathResolver.resolve(options.path) - } - } else { - const scope = this.descendantPathScope(options.path) - if (scope) { - params.where = { ...params.where, path: scope } - } + params.where = { + ...params.where, + path: { $startsWith: options.path } } } @@ -1781,42 +1517,6 @@ export class VirtualFileSystem implements IVirtualFileSystem { return entity as VFSEntity } - /** - * The SERVED metadata shape for "everything under this directory". - * - * `metadata.path` is the VFS's truth — write and rename maintain it, and the - * `Contains` edges are a projection of it (see {@link repairContainment}) — - * it is indexed on every VFS entity, and the metadata index serves ordered - * range operators. So a subtree scope is a half-open range over the path - * column: O(log n + matches), no tree walk, and nothing fetched that the - * scope then discards. - * - * The range is `[dir + '/', dir + )`. Every descendant path - * begins with `dir + '/'`, and '0' is the code point directly after '/', so a - * string lies in the range EXACTLY when it carries that prefix. The two - * bounds differ at a single ASCII position, so the answer is the same under - * code-unit and code-point collation alike — no dependence on how the store - * orders the rest of the string. - * - * Sibling exclusion falls out of the same fact and is worth stating, because - * it is where a naive prefix test goes wrong: for `dir = '/scope'`, - * `/scope-sibling/x` sorts BELOW the lower bound ('-' precedes '/') and - * `/scope0` sits at the open upper bound — both outside, while - * `/scope/sub/deep/c.txt` is inside at any depth. - * - * @param path - The directory to scope to. - * @returns The `where` fragment for the `path` field, or `null` for the root - * — every VFS entity is under it, so no clause narrows the search. - */ - private descendantPathScope(path: string): { gte: string; lt: string } | null { - const dir = path.replace(/\/+/g, '/').replace(/\/$/, '') || '/' - if (dir === '/') return null - // Computed, so the bound carries its own reason: the first string that can - // no longer share the `dir + '/'` prefix. - const separatorSuccessor = String.fromCharCode('/'.charCodeAt(0) + 1) - return { gte: `${dir}/`, lt: `${dir}${separatorSuccessor}` } - } - private getParentPath(path: string): string { const normalized = path.replace(/\/+/g, '/').replace(/\/$/, '') const lastSlash = normalized.lastIndexOf('/') @@ -2358,31 +2058,6 @@ export class VirtualFileSystem implements IVirtualFileSystem { cursor = page.nextCursor } - // Pass 2: ONE paged walk over every Contains edge, grouped by target in - // memory. The earlier shape issued one awaited related({ to }) per VFS - // entity — O(entities) serialized graph calls, measured in whole minutes - // on large brains. This shape is O(edges / page) calls regardless of how - // many entities exist; mutations alone stay per-defect. - const incomingByTarget = new Map[]>() - { - const pageSize = 1000 - let pageOffset = 0 - for (;;) { - const page = await this.brain.related({ - type: VerbType.Contains, - limit: pageSize, - offset: pageOffset - }) - for (const edge of page) { - const bucket = incomingByTarget.get(edge.to) - if (bucket) bucket.push(edge) - else incomingByTarget.set(edge.to, [edge]) - } - if (page.length < pageSize) break - pageOffset += pageSize - } - } - let removed = 0 let restored = 0 for (const { id, path } of vfsEntities) { @@ -2395,7 +2070,7 @@ export class VirtualFileSystem implements IVirtualFileSystem { continue } - const incoming = incomingByTarget.get(id) ?? [] + const incoming = await this.brain.related({ to: id, type: VerbType.Contains }) let expectedSeen = false for (const edge of incoming) { const isVfsEdge = edge.subtype === 'vfs-contains' || (edge.metadata as any)?.isVFS === true diff --git a/src/vfs/types.ts b/src/vfs/types.ts index 17687dd3..9188476b 100644 --- a/src/vfs/types.ts +++ b/src/vfs/types.ts @@ -133,17 +133,8 @@ export interface VFSStats { * Directory entry (for readdir) */ export interface VFSDirent { - /** - * The entry's basename (e.g. `'file.txt'`) when `readdir()` was called - * WITHOUT `recursive: true`. When `recursive: true` was set, this is - * instead the entry's path RELATIVE TO THE QUERIED DIRECTORY (e.g. - * `'sub/file.txt'` for a nested file) — the same value that would appear - * in the plain string-array form of a recursive `readdir()` call. `path` - * below always carries the absolute VFS path regardless of `recursive`, - * so nothing is lost either way. - */ name: string - path: string // Full (absolute) VFS path — always absolute, recursive or not + path: string // Full path type: 'file' | 'directory' | 'symlink' entityId: string // Underlying entity ID } @@ -249,15 +240,7 @@ export interface ReaddirOptions { withFileTypes?: boolean // Return Dirent objects // VFS-specific options - /** - * List every descendant (files and directories, all depths), not just - * direct children. Entries are reported as paths RELATIVE TO THE QUERIED - * DIRECTORY (Node's `fs.readdir(dir, { recursive: true })` convention) — - * a string-array result contains e.g. `'sub/file.txt'`, and with - * `withFileTypes: true` each `VFSDirent.name` carries that same relative - * path (see {@link VFSDirent}). Default: `false` (direct children only). - */ - recursive?: boolean + recursive?: boolean // Include subdirectories limit?: number // Max results offset?: number // Skip N results cursor?: string // Pagination cursor diff --git a/tests/configs/vitest.integration.config.ts b/tests/configs/vitest.integration.config.ts index af86097d..3d3a3721 100644 --- a/tests/configs/vitest.integration.config.ts +++ b/tests/configs/vitest.integration.config.ts @@ -20,9 +20,6 @@ export default defineConfig({ // Include only integration tests include: [ 'tests/integration/**/*.test.ts', - // The lifecycle biography lane (day-in-the-life scenarios; see - // tests/lifecycle/README.md) runs in the integration gate. - 'tests/lifecycle/**/*.test.ts', 'tests/**/*.integration.test.ts' ], diff --git a/tests/configs/vitest.perf.config.ts b/tests/configs/vitest.perf.config.ts deleted file mode 100644 index 6936a71c..00000000 --- a/tests/configs/vitest.perf.config.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { defineConfig } from 'vitest/config' - -/** - * Perf/scale + environment-dependent test configuration. - * - * The exclusive on-demand slot for everything the correctness gate - * (`vitest.config.ts`, the config a bare `vitest run` picks up) excludes: - * wall-clock/scale benchmarks and the two tests whose outcome depends on - * the host machine or network rather than the code. See CONTRIBUTING.md's - * "Test gate" section and the exclude list in `vitest.config.ts` (root) for - * why each file lives here instead of the gate. - * - * `include` names this set explicitly — it is the mirror image of the - * root config's exclude list, not an independent glob, so the two stay in - * sync by inspection. Longer timeouts than the gate's 120s/60s: one case in - * tests/critical-performance-benchmark.test.ts measures ~128s of real work. - */ -export default defineConfig({ - test: { - globals: true, - setupFiles: ['./tests/setup.ts'], - environment: 'node', - - // The marker a test uses to tell it is running under this lane (see - // tests/integration/storage-batch-operations.test.ts's batch-vs- - // individual timing case) — a wall-clock RATIO assertion self-skips - // with a reason when this is absent, rather than flaking the - // correctness gate on whichever path happens to be faster this build. - env: { BRAINY_PERF_LANE: '1' }, - - // Sequential, single fork — same isolation the gate uses, so a perf - // measurement isn't skewed by sibling test contention. - pool: 'forks', - poolOptions: { - forks: { - maxForks: 1, - minForks: 1, - singleFork: true, - isolate: true - } - }, - - testTimeout: 300000, // 5 minutes per test (the 128s case plus headroom) - hookTimeout: 120000, - teardownTimeout: 10000, - - maxConcurrency: 1, - fileParallelism: false, - - include: [ - 'tests/performance/**/*.{test,spec}.{js,ts}', - 'tests/critical-performance-benchmark.test.ts', - 'tests/api/performance-benchmarks.test.ts', - 'tests/package-size-limit.test.ts', - 'tests/model-loading.test.ts', - // Not a whole perf file — one wall-clock-ratio case inside an - // otherwise-correctness integration suite (self-skipped everywhere - // else via BRAINY_PERF_LANE). Stays in the integration gate's - // include too, so every OTHER test in the file keeps running there. - 'tests/integration/storage-batch-operations.test.ts', - // Same pattern: one wall-clock budget case (100-file write + readdir, - // 5.5s budget) inside an otherwise-correctness VFS unit suite - // (self-skipped everywhere else via BRAINY_PERF_LANE — see - // tests/vfs/vfs.unit.test.ts's 'Performance > should handle many - // files efficiently'). Stays in the unit gate's *.unit.test.ts match - // too, so every OTHER test in the file keeps running there. - 'tests/vfs/vfs.unit.test.ts' - ], - - reporters: process.env.CI ? ['dot'] : ['basic'], - - retry: process.env.CI ? 1 : 0, - shard: process.env.VITEST_SHARD - } -}) diff --git a/tests/conformance/collider-fidelity.test.ts b/tests/conformance/collider-fidelity.test.ts deleted file mode 100644 index 61c9413d..00000000 --- a/tests/conformance/collider-fidelity.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -/** - * @module tests/conformance/collider-fidelity - * @description THE REOPEN-COLLIDER CONFORMANCE CASE (required cross-engine - * before any RC counts as gates-green — ruled 2026-08-03). The - * field-addressing law's fidelity half: user metadata may carry ANY name — - * including every engine spelling (`confidence`, `type`, `id`, `createdAt`, - * …) and every plumbing name (`level`, `data`, `vector`, `_rev`) — and the - * value survives, verbatim and reachable, across the FULL lifecycle: live - * reads, where/orderBy, flush, close+reopen, a forced epoch rebuild, and - * time travel. The engine scalars stay separately reachable at `system.*` - * the whole way. No halfway states. - * - * Self-arming like the namespace-law suite: skips loudly until the arming - * exports are present, so the suite can sit on a branch ahead of the build. - */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import * as brainyExports from '../../src/index.js' -import { Brainy, NounType, VerbType } from '../../src/index.js' -import { - BRAIN_FORMAT_PATH, - EXPECTED_INDEX_EPOCH -} from '../../src/storage/brainFormat.js' - -const ARMED = 'UnresolvableFieldError' in brainyExports -const suite = ARMED ? describe : describe.skip -if (!ARMED) { - // eslint-disable-next-line no-console - console.warn( - '[collider-fidelity] SKIPPING: package root does not export the ' + - 'field-addressing law surface yet (UnresolvableFieldError absent).' - ) -} - -/** Every entity system scalar name written as a USER metadata field, with - * unmistakable user values, plus the plumbing names and naturals. */ -const COLLIDER_BAG = { - // the ten entity system scalars, as user fields - id: 'user-id', - type: 'user-type', - subtype: 'user-subtype', - createdAt: 'user-createdAt', - updatedAt: 'user-updatedAt', - confidence: 'user-confidence', - weight: 'user-weight', - visibility: 'user-visibility', - service: 'user-service', - createdBy: 'user-createdBy', - // plumbing names, as user fields - level: 7, - data: 'user-data', - vector: 'user-vector', - _rev: 'user-rev', - // naturals previously silently un-indexed by name - content: 'user-content', - // a plain control field - plain: 'control' -} as const - - -suite('collider fidelity — the reopen-collider case (both suites, ruled)', () => { - let dir: string - let brain: Brainy - let colliderId: string - - const open = async (): Promise => { - const b = new Brainy({ - storage: { type: 'filesystem', path: dir }, - requireSubtype: false - }) - await b.init() - return b - } - - /** The full read battery — run at every lifecycle boundary. */ - const verifyColliderTruth = async (label: string): Promise => { - // 1. get(): the bag comes back verbatim; engine scalars stay engine. - const entity = await brain.get(colliderId) - expect(entity, `${label}: entity readable`).toBeTruthy() - for (const [k, v] of Object.entries(COLLIDER_BAG)) { - expect( - (entity!.metadata as Record)[k], - `${label}: bag.${k} verbatim` - ).toEqual(v) - } - expect(entity!.type, `${label}: engine type intact`).toBe(NounType.Document) - expect(entity!.confidence, `${label}: engine confidence intact`).toBe(0.25) - - // 2. where on collider names (bare = the user's field, always). - for (const [k, v] of [ - ['confidence', 'user-confidence'], - ['type', 'user-type'], - ['id', 'user-id'], - ['content', 'user-content'], - ['data', 'user-data'], - ['level', 7] - ] as const) { - const rows = await brain.find({ where: { [k]: v }, limit: 10 }) - expect( - rows.map((r) => r.id), - `${label}: where {${k}} finds the collider row` - ).toContain(colliderId) - } - - // 3. system.* keeps reading the ENGINE values. - const byEngine = await brain.find({ - where: { 'system.confidence': 0.25 }, - limit: 10 - }) - expect( - byEngine.map((r) => r.id), - `${label}: system.confidence reads the engine scalar` - ).toContain(colliderId) - const byUserSpelledSystem = await brain.find({ - where: { 'system.confidence': 'user-confidence' }, - limit: 10 - }) - expect( - byUserSpelledSystem.map((r) => r.id), - `${label}: the user's value is NOT reachable via system.*` - ).not.toContain(colliderId) - - // 4. orderBy a collider name orders by the USER values. - const ordered = await brain.find({ - type: NounType.Document, - orderBy: 'level', - order: 'desc', - limit: 10 - }) - expect(ordered.length, `${label}: ordered read complete`).toBe(3) - expect( - (ordered[0].metadata as Record).plain, - `${label}: user level orders desc (7 first)` - ).toBe('control') - } - - beforeAll(async () => { - dir = mkdtempSync(join(tmpdir(), 'brainy-collider-')) - brain = await open() - - colliderId = await brain.add({ - data: 'the collider probe document', - type: NounType.Document, - confidence: 0.25, - metadata: { ...COLLIDER_BAG } - }) - // two ordering companions with smaller user `level`s - await brain.add({ - data: 'ordering companion low', - type: NounType.Document, - metadata: { level: 3, plain: 'low' } - }) - await brain.add({ - data: 'ordering companion mid', - type: NounType.Document, - metadata: { level: 5, plain: 'mid' } - }) - }, 120000) - - afterAll(async () => { - await brain.close().catch(() => {}) - rmSync(dir, { recursive: true, force: true }) - }) - - it('LIVE: colliders are the user’s, verbatim and fully queryable', async () => { - await verifyColliderTruth('live') - }) - - it('REOPEN: the restart boundary loses nothing', async () => { - await brain.flush() - await brain.close() - brain = await open() - await verifyColliderTruth('reopen') - }) - - it('REBUILD: a forced epoch rebuild re-indexes the colliders from canonical', async () => { - await brain.close() - // Simulate epoch drift: a missing marker forces the full derived-index - // rebuild at open — the exact path every pre-law brain takes once. - rmSync(join(dir, BRAIN_FORMAT_PATH), { force: true }) - brain = await open() - await verifyColliderTruth('rebuild') - // And the rebuild re-stamps the current epoch. - const marker = await ( - brain as unknown as { - storage: { readRawObject(p: string): Promise<{ indexEpoch?: number } | null> } - } - ).storage.readRawObject(BRAIN_FORMAT_PATH) - expect(marker?.indexEpoch).toBe(EXPECTED_INDEX_EPOCH) - }) - - it('TIME TRAVEL: asOf reads historical collider values faithfully', async () => { - const gen = brain.generation() - await brain.update({ id: colliderId, metadata: { confidence: 'user-confidence-v2' } }) - const now = await brain.get(colliderId) - expect((now!.metadata as Record).confidence).toBe('user-confidence-v2') - - const past = await brain.asOf(gen) - try { - const then = await past.get(colliderId) - expect( - (then!.metadata as Record).confidence, - 'asOf reads the pre-update USER value' - ).toBe('user-confidence') - } finally { - await past.release() - } - // engine scalar untouched throughout - expect(now!.confidence).toBe(0.25) - }) - - it('RELATION MIRROR: edge collider bags survive write → read → reopen', async () => { - const a = await brain.add({ data: 'edge endpoint a', type: NounType.Person, metadata: { plain: 'a' } }) - const b = await brain.add({ data: 'edge endpoint b', type: NounType.Person, metadata: { plain: 'b' } }) - const edgeBag = { - verb: 'user-verb', - confidence: 'user-edge-confidence', - weight: 'user-edge-weight', - subtype: 'user-edge-subtype', - createdAt: 'user-edge-createdAt', - service: 'user-edge-service' - } - const relId = await brain.relate({ - from: a, - to: b, - type: VerbType.RelatedTo, - confidence: 0.5, - metadata: { ...edgeBag } - }) - - const check = async (label: string): Promise => { - const rels = await brain.related({ from: a, type: VerbType.RelatedTo }) - const rel = rels.find((r) => r.id === relId) - expect(rel, `${label}: relation readable`).toBeTruthy() - for (const [k, v] of Object.entries(edgeBag)) { - expect( - (rel!.metadata as Record)[k], - `${label}: edge bag.${k} verbatim` - ).toEqual(v) - } - expect(rel!.confidence, `${label}: engine edge confidence intact`).toBe(0.5) - expect(rel!.type, `${label}: engine verb intact`).toBe(VerbType.RelatedTo) - } - - await check('live') - await brain.flush() - await brain.close() - brain = await open() - await check('reopen') - }) - - it('FORGERY: user metadata keys spelled system.* refuse at every write door', async () => { - await expect( - brain.add({ data: 'forged', type: NounType.Document, metadata: { 'system.confidence': 1 } }) - ).rejects.toThrow(/system\./) - await expect( - brain.update({ id: colliderId, metadata: { 'system.type': 'x' } }) - ).rejects.toThrow(/system\./) - const a = await brain.add({ data: 'forgery endpoint a', type: NounType.Person, metadata: {} }) - const b = await brain.add({ data: 'forgery endpoint b', type: NounType.Person, metadata: {} }) - await expect( - brain.relate({ from: a, to: b, type: VerbType.RelatedTo, metadata: { 'system.verb': 'x' } }) - ).rejects.toThrow(/system\./) - }) - - it('CONFIG: the dead reservedFieldPolicy option refuses loudly, never ignored', () => { - expect( - () => new Brainy({ storage: { type: 'memory' }, reservedFieldPolicy: 'throw' } as never) - ).toThrow(/field-addressing law/) - }) - - it('LEGACY: a pre-law flat record still reads with engine fields top-level', async () => { - const storage = ( - brain as unknown as { - storage: { - saveNoun(n: unknown): Promise - saveNounMetadata(id: string, m: Record): Promise - } - } - ).storage - const legacyId = '00000000-0000-4000-8000-00000000f1a7' - await storage.saveNoun({ id: legacyId, vector: new Array(384).fill(0.01), connections: new Map(), level: 0 }) - // Legacy FLAT shape: engine + user keys mixed at one level, NO _fmt stamp. - // Sound to split by name — the pre-law door refused user colliders. - await storage.saveNounMetadata(legacyId, { - noun: NounType.Document, - confidence: 0.75, - createdAt: 1700000000000, - updatedAt: 1700000000000, - _rev: 1, - legacyField: 'legacy-value' - }) - const entity = await brain.get(legacyId) - expect(entity).toBeTruthy() - expect(entity!.confidence, 'legacy flat confidence = engine').toBe(0.75) - expect( - (entity!.metadata as Record).legacyField, - 'legacy custom field = user bag' - ).toBe('legacy-value') - expect( - (entity!.metadata as Record).confidence, - 'legacy flat engine key never leaks into the bag' - ).toBeUndefined() - }) -}) diff --git a/tests/conformance/golden-log-fold.test.ts b/tests/conformance/golden-log-fold.test.ts deleted file mode 100644 index c480bee5..00000000 --- a/tests/conformance/golden-log-fold.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * @module tests/conformance/golden-log-fold - * @description THE GOLDEN-LOG FOLD-CONFORMANCE ORACLE (brainy leg). - * - * One deterministic v2 log — fixed ids, ints, timestamps, vectors — whose - * ENCODED BYTES and whose FOLDED STATE are both pinned by content hash. - * The second (native) reader implementation consumes the identical fixture - * (tests/fixtures/golden-log-v2.bin, written and verified here) and must - * produce the identical fold digest; the pair is normative on disagreement. - * - * What the pins catch, loudly: - * - Any byte drift in the encoder (envelope, msgpack layout, seals, CRC). - * - Any semantic drift in the fold (tombstone masking, vector landing, - * sameAsGeneration resolution, last-writer-wins ordering). - * - Any divergence between the two implementations, before the cut. - * - * The pinned hashes change ONLY with a deliberate, versioned format or - * fold-law change — never silently. Updating them requires updating the - * fixture AND the native side in the same train. - */ -import { describe, it, expect } from 'vitest' -import { createHash } from 'node:crypto' -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs' -import { join, dirname } from 'node:path' -import { - encodeFactV2, - encodeSegmentHeaderV2, - sealGroup, - decodeGroupV2, - SEGMENT_HEADER_BYTES, - type CommitFactV2, - type LogRecord -} from '../../src/db/factLogFormat.js' -import { recordDigest } from '../../src/db/logAuthority.js' - -const FIXTURE = join(__dirname, '../fixtures/golden-log-v2.bin') - -const sha256 = (b: Uint8Array): string => createHash('sha256').update(b).digest('hex') - -// Fixed identities — never regenerate. -const BRAIN = '00000000-0000-4000-8000-00000000b1a1' -const A = '00000000-0000-4000-8000-0000000000a1' -const B = '00000000-0000-4000-8000-0000000000b2' -const C = '00000000-0000-4000-8000-0000000000c3' -const V = '00000000-0000-4000-8000-0000000000d4' - -const vec = (seed: number): number[] => [seed + 0.25, seed + 0.5, seed + 0.75] - -/** The golden fact sequence — every fold-relevant behavior in nine facts. */ -function goldenFacts(): CommitFactV2[] { - const f = (generation: number, records: LogRecord[]): CommitFactV2 => ({ - generation, - timestamp: 1_700_000_000_000 + generation, - records - }) - return [ - f(1, [{ type: 'log.genesis', idSpaceWidth: 64, brainId: BRAIN, createdAt: 1_700_000_000_000 }]), - f(2, [{ type: 'noun.afterImage', id: A, entityInt: 1n, metadata: { name: 'alpha', rank: 1 }, vectorLeg: vec(1) }]), - f(3, [ - { type: 'noun.afterImage', id: B, entityInt: 2n, metadata: { name: 'beta' }, vectorLeg: null }, - { type: 'embed.pending', id: B, enqueuedAt: 1_700_000_000_003 } - ]), - // A metadata-only update: the vector rides by reference to generation 2. - f(4, [{ type: 'noun.afterImage', id: A, entityInt: 1n, metadata: { name: 'alpha', rank: 2 }, vectorLeg: { sameAsGeneration: 2 } }]), - // B's deferred vector lands. - f(5, [{ type: 'embed.landed', id: B, vector: vec(9) }]), - // A relationship. - f(6, [{ type: 'verb.afterImage', id: V, verbInt: 3n, metadata: { w: 0.5 }, vectorLeg: null, verb: 'relatedTo', sourceId: A, sourceInt: 1n, targetId: B, targetInt: 2n }]), - // C exists briefly… - f(7, [{ type: 'noun.afterImage', id: C, entityInt: 4n, metadata: { name: 'gamma' }, vectorLeg: vec(7) }]), - // …and is tombstoned (masking must hold in the fold). - f(8, [{ type: 'noun.tombstone', id: C }]), - // An all-deduped batch: a real generation with zero records. - f(9, []) - ] -} - -/** Build the golden segment: v2 header + sealed frame group. */ -function goldenSegment(): Uint8Array { - // Single-hop law: generation 2 carried A's inline vector (5 carries B's - // via embed.landed); the ref in generation 4 must verify against it. - const inline = new Set([2, 5, 7]) - const frames = goldenFacts().map((fact) => encodeFactV2(fact, { inlineVectorGenerations: inline })) - const sealed = sealGroup(frames, 4096) - const out = new Uint8Array(SEGMENT_HEADER_BYTES + sealed.length) - out.set(encodeSegmentHeaderV2(1, 4096), 0) - out.set(sealed, SEGMENT_HEADER_BYTES) - return out -} - -/** - * THE FOLD LAW (shared with the native implementation, normative): - * fold facts in generation order → per-id latest state with tombstone - * masking; embed.landed applies the vector to the id's current state; - * {sameAsGeneration: N} resolves to the inline vector the log carried at N; - * verbs fold like nouns under their own ids. Digest = recordDigest (key- - * sorted JSON sha256) of the id-sorted state map. - */ -function foldGoldenLog(bytes: Uint8Array): string { - const group = decodeGroupV2(bytes.slice(SEGMENT_HEADER_BYTES)) - const state = new Map>() - const inlineVectorAt = new Map() - for (const fact of group.facts) { - for (const rec of fact.records) { - if (rec.type === 'noun.afterImage' || rec.type === 'verb.afterImage') { - let vector: number[] | null = null - if (Array.isArray(rec.vectorLeg)) { - vector = rec.vectorLeg - inlineVectorAt.set(fact.generation, vector) - } else if (rec.vectorLeg && typeof rec.vectorLeg === 'object' && 'sameAsGeneration' in rec.vectorLeg) { - vector = inlineVectorAt.get((rec.vectorLeg as { sameAsGeneration: number }).sameAsGeneration) ?? null - } - state.set(rec.id, { - kind: rec.type === 'noun.afterImage' ? 'noun' : 'verb', - int: (rec.type === 'noun.afterImage' - ? (rec as { entityInt: bigint }).entityInt - : (rec as { verbInt: bigint }).verbInt - ).toString(), - metadata: rec.metadata, - vector, - generation: fact.generation - }) - } else if (rec.type === 'noun.tombstone' || rec.type === 'verb.tombstone') { - state.delete(rec.id) - } else if (rec.type === 'embed.landed') { - const cur = state.get(rec.id) - if (cur) state.set(rec.id, { ...cur, vector: rec.vector, generation: fact.generation }) - inlineVectorAt.set(fact.generation, rec.vector) - } - // embed.pending / genesis / blob / projection notes carry no fold state here. - } - } - const sorted = [...state.entries()].sort(([x], [y]) => (x < y ? -1 : 1)) - return recordDigest(sorted) -} - -// ── THE PINS ──────────────────────────────────────────────────────────────── -// Byte-exact encode + semantics-exact fold. These literals are the contract. -const GOLDEN_BYTES_SHA256 = 'f898ed29f6f7d41135c6c85eb07725348b20cf8efec5f050ff50ad6d54a09dad' -const GOLDEN_FOLD_DIGEST = 'fad1b1d9865d6c9c84493c5481599ebd39b7ecf4cd203af4c435dfea7cd78ed4' - -describe('golden-log fold conformance (brainy leg)', () => { - it('the encoder reproduces the golden bytes exactly', () => { - const seg = goldenSegment() - expect(seg.length % 4096, 'sealed to the sector boundary (header excluded)').toBe(SEGMENT_HEADER_BYTES % 4096) - expect(sha256(seg)).toBe(GOLDEN_BYTES_SHA256) - }) - - it('the fixture on disk is byte-identical (the shared artifact both readers consume)', () => { - const seg = goldenSegment() - if (!existsSync(FIXTURE)) { - mkdirSync(dirname(FIXTURE), { recursive: true }) - writeFileSync(FIXTURE, seg) - } - const onDisk = new Uint8Array(readFileSync(FIXTURE)) - expect(sha256(onDisk), 'fixture bytes match the encoder').toBe(GOLDEN_BYTES_SHA256) - }) - - it('folding the golden log yields the pinned state digest', () => { - expect(foldGoldenLog(goldenSegment())).toBe(GOLDEN_FOLD_DIGEST) - }) - - it('fold semantics spot-checks (human-readable guardrails beside the hash)', () => { - const group = decodeGroupV2(goldenSegment().slice(SEGMENT_HEADER_BYTES)) - expect(group.facts.length, 'nine facts, pads invisible').toBe(9) - const gens = group.facts.map((f) => f.generation) - expect(gens).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]) - expect(group.facts[8].records).toEqual([]) - }) -}) diff --git a/tests/conformance/namespace-law.test.ts b/tests/conformance/namespace-law.test.ts deleted file mode 100644 index 0231685a..00000000 --- a/tests/conformance/namespace-law.test.ts +++ /dev/null @@ -1,486 +0,0 @@ -/** - * @module tests/conformance/namespace-law - * @description Conformance suite for the ruled field-addressing contract - * announced in RELEASES.md ("Coming next... one field-addressing law — bare - * names = user metadata, `system.` for engine fields, typed refusals - * for unresolvable names"). This suite is the drift-proof shared by this - * engine and its native accelerator: both must satisfy every test here - * bit-for-bit, because they implement the SAME contract independently. - * - * The rule, in full: - * 1. A bare field name in `where` / `orderBy` / `groupBy` / aggregation - * `source.where` ALWAYS means the caller's own `metadata` field. No - * priority resolution, no engine fallback — ever. - * 2. `system.` reaches an engine scalar, and ONLY an engine scalar, - * and ONLY when spelled explicitly. The addressable entity map is exactly - * ten names: id, type, subtype, createdAt, updatedAt, confidence, weight, - * visibility, service, createdBy. The relationship map is system.verb, - * system.sourceId, system.targetId, plus the eight scalars shared with - * entities. - * 3. Some names are invisible plumbing and are never addressable in either - * spelling: vector, connections, level, data, _rev. `system.level`, - * `system.vector`, and `system.data` all refuse — they are not in the - * system map. Bare `level` is a perfectly ordinary user field. - * 4. `metadata.` is the explicit-user-scope spelling: identical - * semantics to the bare spelling, valid everywhere the bare spelling is. - * 5. Anything that resolves to neither a user field nor a system scalar is a - * typed refusal naming both candidates (`UnresolvableFieldError`). - * Unimplemented `find()` options (`cursor`, `includeRelations`, - * `writeOnly`) refuse with `UnsupportedFindOptionError` instead of being - * silently accepted and ignored. - * 6. Ordering is identical on both engines: rows missing/null on the - * `orderBy` field sort LAST in BOTH directions and are never dropped; - * ties break by id ascending. - * - * The motivating incident (told generically — see CLAUDE.md naming rule): an - * internal report from a production deployment showed a user metadata field - * literally named `level` silently shadowed by the engine's internal HNSW - * node layer, breaking sort order with zero errors raised. This contract - * makes that class of bug impossible, and testable forever. - * - * SELF-SKIP: the resolver this suite pins is being built in a parallel - * session and has not landed on every branch yet. Rather than going red on - * a branch that simply hasn't caught up, the suite detects whether the - * contract is live by the one thing any conformant implementation must - * export — `UnresolvableFieldError` from the package root — and skips - * loudly (never silently) until it does. This is the house pattern: a - * sibling engine's gate once went red because a test armed before its - * feature existed. - */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { Brainy } from '../../src/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' -import * as brainyExports from '../../src/index.js' - -const stubEmbedding = async (text: string): Promise => { - const hash = text.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) - return new Array(384).fill(0).map((_, i) => Math.sin(hash + i)) -} - -// Detected purely by the exported error-class NAME — never by reaching into -// implementation internals. Both engines building this contract must export -// it from the package root, so this is a legitimate, implementation-agnostic -// readiness probe. -const lawActive = 'UnresolvableFieldError' in brainyExports -const UnresolvableFieldError = (brainyExports as Record).UnresolvableFieldError as new ( - ...args: any[] -) => Error -const UnsupportedFindOptionError = (brainyExports as Record) - .UnsupportedFindOptionError as new (...args: any[]) => Error - -// Always runs, regardless of lawActive — the loud signal that the rest of -// this file was skipped, and why. -it('namespace law armed?', () => { - if (!lawActive) { - console.warn( - '[conformance] namespace-law suite SKIPPED — UnresolvableFieldError not exported yet; arms when the resolver lands' - ) - } - expect(true).toBe(true) -}) - -/** - * Awaits `promise`, asserting it rejects with an instance of `ErrorClass` - * whose `.message` contains every string in `mustContain`. Fails loudly if - * the promise resolves instead of rejecting. - */ -async function expectRefusal( - promise: Promise, - ErrorClass: new (...args: any[]) => Error, - ...mustContain: string[] -): Promise { - let threw = false - try { - await promise - } catch (err) { - threw = true - expect(err).toBeInstanceOf(ErrorClass) - for (const fragment of mustContain) { - expect((err as Error).message).toContain(fragment) - } - } - expect(threw).toBe(true) -} - -describe.skipIf(!lawActive)('namespace law — bare/system/metadata field addressing', () => { - let brain: Brainy - - beforeEach(async () => { - brain = new Brainy({ - requireSubtype: false, - storage: { type: 'memory' as const }, - embeddingFunction: stubEmbedding - }) - await brain.init() - }) - - afterEach(async () => { - await brain.close() - }) - - /** The star case from the motivating incident: metadata.level 3/9/6. */ - async function addLevelRows(): Promise { - const ids: string[] = [] - for (const level of [3, 9, 6]) { - ids.push( - await brain.add({ - data: `probe level ${level}`, - type: NounType.Person, - subtype: 'ns-law-level', - metadata: { name: `p-${level}`, level } - }) - ) - } - return ids - } - - // ------------------------------------------------------------------- - // Rule 1 — bare field name = the user's metadata field, always. - // ------------------------------------------------------------------- - - it("bare orderBy 'level' reads user metadata, desc and asc (the star case)", async () => { - await addLevelRows() - - const desc = await brain.find({ - type: NounType.Person, - subtype: 'ns-law-level', - orderBy: 'level', - order: 'desc', - limit: 100 - }) - expect(desc.map((r: any) => r.metadata?.level)).toEqual([9, 6, 3]) - - const asc = await brain.find({ - type: NounType.Person, - subtype: 'ns-law-level', - orderBy: 'level', - order: 'asc', - limit: 100 - }) - expect(asc.map((r: any) => r.metadata?.level)).toEqual([3, 6, 9]) - }) - - it("bare where { level: N } matches the user's field", async () => { - const ids = await addLevelRows() - const hit = await brain.find({ type: NounType.Person, subtype: 'ns-law-level', where: { level: 9 } }) - expect(hit).toHaveLength(1) - expect(hit[0].id).toBe(ids[1]) - expect(hit[0].metadata?.level).toBe(9) - }) - - // ------------------------------------------------------------------- - // Rule 4 — metadata. is the explicit-user-scope spelling, - // identical semantics to bare, valid on every path including orderBy. - // ------------------------------------------------------------------- - - it("'metadata.level' resolves identically to bare 'level'", async () => { - await addLevelRows() - const desc = await brain.find({ - type: NounType.Person, - subtype: 'ns-law-level', - orderBy: 'metadata.level', - order: 'desc', - limit: 100 - }) - expect(desc.map((r: any) => r.metadata?.level)).toEqual([9, 6, 3]) - }) - - // ------------------------------------------------------------------- - // Rule 2 — system. reaches an engine scalar explicitly. - // ------------------------------------------------------------------- - - it('system.createdAt sorts by entity age', async () => { - const ids: string[] = [] - for (const name of ['first', 'second', 'third']) { - ids.push( - await brain.add({ - data: `aged ${name}`, - type: NounType.Person, - subtype: 'ns-law-aged', - metadata: { name } - }) - ) - // Guarantee distinct createdAt timestamps between adds. - await new Promise((resolve) => setTimeout(resolve, 5)) - } - - const asc = await brain.find({ - type: NounType.Person, - subtype: 'ns-law-aged', - orderBy: 'system.createdAt', - order: 'asc', - limit: 100 - }) - expect(asc.map((r: any) => r.id)).toEqual(ids) - - const desc = await brain.find({ - type: NounType.Person, - subtype: 'ns-law-aged', - orderBy: 'system.createdAt', - order: 'desc', - limit: 100 - }) - expect(desc.map((r: any) => r.id)).toEqual([...ids].reverse()) - }) - - it('where on system.confidence filters by the engine scalar', async () => { - const highId = await brain.add({ - data: 'high confidence row', - type: NounType.Person, - subtype: 'ns-law-confidence', - confidence: 0.95, - metadata: { name: 'hi' } - }) - await brain.add({ - data: 'low confidence row', - type: NounType.Person, - subtype: 'ns-law-confidence', - confidence: 0.4, - metadata: { name: 'lo' } - }) - - const hit = await brain.find({ - type: NounType.Person, - subtype: 'ns-law-confidence', - where: { 'system.confidence': 0.95 } - }) - expect(hit).toHaveLength(1) - expect(hit[0].id).toBe(highId) - }) - - it('groupBy on system.subtype groups by the engine scalar, not user metadata', async () => { - await brain.add({ data: 'i1', type: NounType.Document, subtype: 'invoice' }) - await brain.add({ data: 'i2', type: NounType.Document, subtype: 'invoice' }) - await brain.add({ data: 'r1', type: NounType.Document, subtype: 'receipt' }) - - brain.defineAggregate({ - name: 'ns_law_by_subtype_system', - source: { type: NounType.Document }, - groupBy: ['system.subtype'], - metrics: { count: { op: 'count' } } - }) - - const groups = await brain.queryAggregate('ns_law_by_subtype_system') - const invoiceGroup = groups.find((g) => Object.values(g.groupKey).includes('invoice')) - const receiptGroup = groups.find((g) => Object.values(g.groupKey).includes('receipt')) - expect(invoiceGroup?.metrics.count).toBe(2) - expect(receiptGroup?.metrics.count).toBe(1) - }) - - // ------------------------------------------------------------------- - // Rule 1 (groupBy face) — bare groupBy dimensions read user metadata, - // never the engine's own notion of the same-sounding name. - // ------------------------------------------------------------------- - - it('groupBy on a bare user metadata field groups by that field', async () => { - await brain.add({ - data: 'd1', - type: NounType.Document, - subtype: 'ns-law-group-bare', - metadata: { team: 'alpha' } - }) - await brain.add({ - data: 'd2', - type: NounType.Document, - subtype: 'ns-law-group-bare', - metadata: { team: 'alpha' } - }) - await brain.add({ - data: 'd3', - type: NounType.Document, - subtype: 'ns-law-group-bare', - metadata: { team: 'beta' } - }) - - brain.defineAggregate({ - name: 'ns_law_by_team_bare', - // system.subtype — bare 'subtype' would address user metadata under the - // law (the exact migration every fleet consumer's aggregates make). - source: { type: NounType.Document, where: { 'system.subtype': 'ns-law-group-bare' } }, - groupBy: ['team'], - metrics: { count: { op: 'count' } } - }) - - const groups = await brain.queryAggregate('ns_law_by_team_bare') - const alphaGroup = groups.find((g) => Object.values(g.groupKey).includes('alpha')) - const betaGroup = groups.find((g) => Object.values(g.groupKey).includes('beta')) - expect(alphaGroup?.metrics.count).toBe(2) - expect(betaGroup?.metrics.count).toBe(1) - }) - - it('where on a bare user metadata field filters normally (score, not a system name)', async () => { - await brain.add({ - data: 'high score', - type: NounType.Person, - subtype: 'ns-law-score', - metadata: { score: 42 } - }) - await brain.add({ - data: 'low score', - type: NounType.Person, - subtype: 'ns-law-score', - metadata: { score: 7 } - }) - - const hit = await brain.find({ type: NounType.Person, subtype: 'ns-law-score', where: { score: 42 } }) - expect(hit).toHaveLength(1) - expect(hit[0].metadata?.score).toBe(42) - }) - - // ------------------------------------------------------------------- - // Rule 5 — typed refusals, naming both candidates. - // ------------------------------------------------------------------- - - it("bare orderBy 'createdAt' refuses when no such metadata field exists — names both candidates", async () => { - await brain.add({ - data: 'no metadata.createdAt here', - type: NounType.Person, - subtype: 'ns-law-refuse-createdAt', - metadata: { name: 'x' } - }) - - await expectRefusal( - brain.find({ - type: NounType.Person, - subtype: 'ns-law-refuse-createdAt', - orderBy: 'createdAt', - limit: 10 - }), - UnresolvableFieldError, - 'system.createdAt', - 'metadata.createdAt' - ) - }) - - // ------------------------------------------------------------------- - // Rule 3 — invisible plumbing refuses in either spelling; system. - // for a name that isn't in the ten-scalar map is unresolvable. - // ------------------------------------------------------------------- - - it('system.level refuses — level is invisible plumbing, never a system scalar', async () => { - await brain.add({ - data: 'has a level metadata field', - type: NounType.Person, - metadata: { level: 5 } - }) - await expectRefusal(brain.find({ orderBy: 'system.level', limit: 10 }), UnresolvableFieldError) - }) - - it('system.vector refuses — vector is invisible plumbing, never a system scalar', async () => { - await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } }) - await expectRefusal(brain.find({ orderBy: 'system.vector', limit: 10 }), UnresolvableFieldError) - }) - - it('system.data refuses — data is a payload container, never a system scalar', async () => { - await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } }) - await expectRefusal(brain.find({ orderBy: 'system.data', limit: 10 }), UnresolvableFieldError) - }) - - // ------------------------------------------------------------------- - // Rule 6 — the ordering contract. - // ------------------------------------------------------------------- - - async function addOrderingProbeRows(): Promise<{ ranked: string[]; missing: string }> { - const low = await brain.add({ - data: 'low score', - type: NounType.Person, - subtype: 'ns-law-ordering', - metadata: { score: 5 } - }) - const high = await brain.add({ - data: 'high score', - type: NounType.Person, - subtype: 'ns-law-ordering', - metadata: { score: 9 } - }) - const missing = await brain.add({ - data: 'no score field at all', - type: NounType.Person, - subtype: 'ns-law-ordering', - metadata: { name: 'no-score' } - }) - return { ranked: [low, high], missing } - } - - it('a row missing the orderBy field sorts LAST in desc — and is never dropped', async () => { - const { ranked, missing } = await addOrderingProbeRows() - const desc = await brain.find({ - type: NounType.Person, - subtype: 'ns-law-ordering', - orderBy: 'score', - order: 'desc', - limit: 100 - }) - expect(desc).toHaveLength(3) - expect(desc.map((r: any) => r.id)).toEqual([ranked[1], ranked[0], missing]) - }) - - it('a row missing the orderBy field sorts LAST in asc too — and is never dropped', async () => { - const { ranked, missing } = await addOrderingProbeRows() - const asc = await brain.find({ - type: NounType.Person, - subtype: 'ns-law-ordering', - orderBy: 'score', - order: 'asc', - limit: 100 - }) - expect(asc).toHaveLength(3) - expect(asc.map((r: any) => r.id)).toEqual([ranked[0], ranked[1], missing]) - }) - - it('ties on the orderBy field break by id ascending, in BOTH directions', async () => { - const tiedIds: string[] = [] - for (let i = 0; i < 4; i++) { - tiedIds.push( - await brain.add({ - data: `tied ${i}`, - type: NounType.Person, - subtype: 'ns-law-ties', - metadata: { score: 5 } - }) - ) - } - const expectedOrder = [...tiedIds].sort() - - const asc = await brain.find({ - type: NounType.Person, - subtype: 'ns-law-ties', - orderBy: 'score', - order: 'asc', - limit: 100 - }) - expect(asc.map((r: any) => r.id)).toEqual(expectedOrder) - - const desc = await brain.find({ - type: NounType.Person, - subtype: 'ns-law-ties', - orderBy: 'score', - order: 'desc', - limit: 100 - }) - // Same tie-break ordering regardless of the primary direction — the - // contract states one universal rule ("id ascending"), not "reverse of - // the primary order". - expect(desc.map((r: any) => r.id)).toEqual(expectedOrder) - }) - - // ------------------------------------------------------------------- - // Rule 5 (options face) — unimplemented find() options refuse loudly - // instead of being accepted and silently ignored. - // ------------------------------------------------------------------- - - it('find({ cursor }) refuses with UnsupportedFindOptionError', async () => { - await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } }) - await expectRefusal(brain.find({ cursor: 'anything', limit: 10 }), UnsupportedFindOptionError) - }) - - it('find({ includeRelations }) refuses with UnsupportedFindOptionError', async () => { - await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } }) - await expectRefusal(brain.find({ includeRelations: true, limit: 10 }), UnsupportedFindOptionError) - }) - - it('find({ writeOnly }) refuses with UnsupportedFindOptionError', async () => { - await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } }) - await expectRefusal(brain.find({ writeOnly: true, limit: 10 }), UnsupportedFindOptionError) - }) -}) diff --git a/tests/conformance/sparse-store-cut.test.ts b/tests/conformance/sparse-store-cut.test.ts deleted file mode 100644 index 8a06c98c..00000000 --- a/tests/conformance/sparse-store-cut.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * @module tests/conformance/sparse-store-cut - * @description THE SPARSE-STORE CUT (ruled 2026-08-12) — the shared - * conformance rows both engines run: a WHERE filter naming a field NO row - * carries is SERVED OPERATOR-TRUTHFULLY, never refused: - * eq / in / range / contains → [] (nothing carries it → nothing matches) - * ne / exists:false → ALL rows (equally true — blanket-empty here - * would be the outlawed silent wrong) - * exists:true → [] - * orderBy on an unresolvable field KEEPS the hard refusal (no truthful - * order exists). The did-you-mean demotes to a throttled WARN on the serve. - * A fresh tenant's first filtered read is a correct empty answer — the - * 341-red first-adopter class, closed. - */ -import { describe, it, expect, afterEach } from 'vitest' -import { Brainy, UnresolvableFieldError } from '../../src/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -const brains: Brainy[] = [] -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) -}) - -async function corpus(): Promise<{ brain: Brainy; ids: string[] }> { - const b = new Brainy({ storage: { type: 'memory' }, requireSubtype: false }) - await b.init() - brains.push(b) - const ids: string[] = [] - for (let i = 0; i < 4; i++) { - ids.push( - await b.add({ data: `row ${i}`, type: NounType.Document, metadata: { carried: i } }) - ) - } - return { brain: b, ids } -} - -describe('sparse-store cut — operator-truthful serve on never-carried fields', () => { - it('positive matches serve EMPTY: eq, in, range, contains', async () => { - const { brain } = await corpus() - expect(await brain.find({ where: { ghost: 'x' }, limit: 10 })).toEqual([]) - expect(await brain.find({ where: { ghost: { in: ['a', 'b'] } }, limit: 10 })).toEqual([]) - expect(await brain.find({ where: { ghost: { gt: 5 } }, limit: 10 })).toEqual([]) - expect(await brain.find({ where: { ghost: { exists: true } }, limit: 10 })).toEqual([]) - }) - - it('negative matches serve ALL rows: ne and exists:false (the truth, not blanket-empty)', async () => { - const { brain, ids } = await corpus() - const ne = await brain.find({ where: { ghost: { ne: 'x' } }, limit: 10 }) - expect(ne.map((r) => r.id).sort()).toEqual([...ids].sort()) - const absent = await brain.find({ where: { ghost: { exists: false } }, limit: 10 }) - expect(absent.map((r) => r.id).sort()).toEqual([...ids].sort()) - }) - - it('the fresh-tenant day-one shape: an EMPTY store answers its first filtered read with [], never a refusal', async () => { - const b = new Brainy({ storage: { type: 'memory' }, requireSubtype: false }) - await b.init() - brains.push(b) - expect(await b.find({ where: { status: 'open' }, limit: 50 })).toEqual([]) - expect(await b.find({ where: { date: { gte: '2026-01-01' } }, limit: 50 })).toEqual([]) - }) - - it('orderBy on an unresolvable field KEEPS the typed refusal', async () => { - const { brain } = await corpus() - await expect( - brain.find({ where: { carried: { gte: 0 } }, orderBy: 'system.notAScalar', limit: 10 }) - ).rejects.toThrow(UnresolvableFieldError) - }) - - it('compound filters: the never-carried clause composes truthfully with carried clauses', async () => { - const { brain, ids } = await corpus() - // carried>=2 AND ghost ne 'x' → the carried>=2 rows (ne-clause = all). - const both = await brain.find({ - where: { carried: { gte: 2 }, ghost: { ne: 'x' } }, - limit: 10 - }) - expect(both.map((r) => r.id).sort()).toEqual([ids[2], ids[3]].sort()) - // carried>=2 AND ghost eq 'x' → [] (eq-clause empties the intersection). - expect( - await brain.find({ where: { carried: { gte: 2 }, ghost: 'x' }, limit: 10 }) - ).toEqual([]) - }) -}) diff --git a/tests/fixtures/golden-log-v2.bin b/tests/fixtures/golden-log-v2.bin deleted file mode 100644 index c1e4cabd..00000000 Binary files a/tests/fixtures/golden-log-v2.bin and /dev/null differ diff --git a/tests/helpers/durabilityKillMatrix.ts b/tests/helpers/durabilityKillMatrix.ts deleted file mode 100644 index c4af622a..00000000 --- a/tests/helpers/durabilityKillMatrix.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * @module tests/helpers/durabilityKillMatrix - * @description Shared machinery for the durability kill-matrix suite - * (tests/integration/durability-kill-matrix.test.ts): open filesystem brains - * with fully explicit durability (no background cadence, no embedder), arm - * the generation store's test-only commit fault injector at one exact phase, - * abandon a "crashed" brain the way a dead process would (its RAM is gone, - * nothing flushes, nothing closes), and read the fact log / on-disk state the - * recovery assertions pin. - * - * The crash model is PROCESS DEATH: in-memory state is lost, file bytes the - * process already handed to the OS survive. One helper additionally models - * POWER LOSS for a chosen entity by removing its canonical files — legal, - * because single-op canonical writes are tmp+rename WITHOUT fsync, and a - * rename that was never fsynced may surface as "no directory entry" after - * power loss. - */ -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import { Brainy } from '../../src/brainy.js' -import type { CommitFaultPhase, GenerationStore } from '../../src/db/generationStore.js' - -/** The error a throwing fault injector uses to simulate a process crash. */ -export class SimulatedCrash extends Error { - constructor(phase: CommitFaultPhase) { - super(`simulated process crash at ${phase}`) - this.name = 'SimulatedCrash' - } -} - -/** Deterministic 384-dim vector so no test ever invokes the embedder. */ -export function vec(seed: number): number[] { - return Array.from({ length: 384 }, (_, i) => ((seed * 31 + i * 7) % 100) / 100) -} - -/** - * Map a readable label to a deterministic UUID-shaped id (entity ids must be - * UUIDs — the sharded storage layout derives the shard from the UUID hex). - */ -export function uid(label: string): string { - let h1 = 0x811c9dc5 - for (let i = 0; i < label.length; i++) { - h1 = Math.imul(h1 ^ label.charCodeAt(i), 0x01000193) >>> 0 - } - let h2 = 0xdeadbeef - for (let i = label.length - 1; i >= 0; i--) { - h2 = Math.imul(h2 ^ label.charCodeAt(i), 0x85ebca6b) >>> 0 - } - const hex = h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0') - return `00000000-0000-4000-8000-${hex.slice(0, 12)}` -} - -/** Create a fresh temp directory for one brain's storage root. */ -export function makeTempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-kill-matrix-')) -} - -/** - * Open a writer brain over `dir` with every implicit durability knob off: - * persistence policy 'manual' (the engine never flushes on its own, so every - * durable transition in a test is an explicit `flush()`/commit), deterministic - * embeddings (tests always pass explicit vectors anyway), silent logs — and - * `logAuthority: 'defer'` (the explicit opt-out of the 10.0.0 adopt-at-open - * fleet default), so the durability POSTURE is explicit per row too: rows - * pinning deferred/tree recovery semantics get exactly that, and at-ack rows - * engage log authority via `flipToAtAck`. The fleet default's open-time - * adoption would inject a baseline-backfill generation into every floor - * computation and pre-flip every row. - */ -export async function openBrain( - dir: string, - opts?: { logAuthority?: 'adopt' | 'defer' } -): Promise { - process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' - const brain = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - silent: true, - persistence: { policy: 'manual' }, - logAuthority: opts?.logAuthority ?? 'defer' - }) - await brain.init() - return brain -} - -/** Typed access to the brain's private generation store (test injection point). */ -export function storeOf(brain: Brainy): GenerationStore { - return (brain as unknown as { generationStore: GenerationStore }).generationStore -} - -/** - * Arm the commit fault injector to simulate a process crash at EXACTLY one - * phase (all other phases pass through untouched). Returns the list of phases - * observed before (and including) the trip, so a test can assert the fault - * actually fired where intended. - */ -export function armCrash(brain: Brainy, phase: CommitFaultPhase): { fired: CommitFaultPhase[] } { - const fired: CommitFaultPhase[] = [] - storeOf(brain).setCommitFaultInjector((p) => { - fired.push(p) - if (p === phase) { - throw new SimulatedCrash(p) - } - }) - return { fired } -} - -/** - * Abandon a crashed brain the way process death would: its buffered RAM state - * is discarded and no background machinery may ever touch the storage - * directory again (a dead process cannot flush). The fault injector stays - * installed so any in-flight commit path still "crashes". Serialized behind - * the store's commit mutex so an interleaved background flush cannot be - * severed mid-section. - * - * NEVER calls close() — graceful close is exactly what a crash denies. - */ -export async function abandonAsCrashed(brain: Brainy): Promise { - const store = storeOf(brain) as unknown as { - withMutex(fn: () => Promise): Promise - clearPendingFlushTimer(): void - pendingGens: number[] - pendingBuffer: Map - } - await store.withMutex(async () => { - store.clearPendingFlushTimer() - store.pendingGens = [] - store.pendingBuffer.clear() - }) -} - -/** - * Every generation present in the brain's fact log, ascending — the suite's - * "what does the log claim is committed" probe. Empty when no fact log exists. - * A scan abort (gap detection) propagates — callers that PIN gap behavior - * catch it themselves. - */ -export async function factGenerations(brain: Brainy): Promise { - const scan = brain.scanFacts({ fromGeneration: 1 }) - if (!scan) return [] - const gens: number[] = [] - for await (const batch of scan.batches()) { - for (const fact of batch.facts) gens.push(fact.generation) - } - return gens.sort((a, b) => a - b) -} - -/** An ENOSPC-shaped error, matching what a full disk surfaces from node:fs. */ -export function enospcError(): NodeJS.ErrnoException { - const err = new Error("ENOSPC: no space left on device, write") as NodeJS.ErrnoException - err.code = 'ENOSPC' - err.errno = -28 - err.syscall = 'write' - return err -} - -/** - * Make the storage adapter's next raw-byte append (the fact-log append path) - * fail once with ENOSPC, then restore the original — "the disk filled for one - * append, then space was freed". Returns a probe telling how many appends - * were failed. - */ -export function failNextAppendWithEnospc(brain: Brainy): { failed: () => number } { - const storage = (brain as unknown as { - storage: { appendRawBytes(p: string, b: Uint8Array): Promise } - }).storage - const original = storage.appendRawBytes.bind(storage) - let failures = 0 - storage.appendRawBytes = async (p: string, b: Uint8Array): Promise => { - storage.appendRawBytes = original - failures++ - throw enospcError() - } - return { failed: () => failures } -} - -/** - * POWER-LOSS MODEL for one entity: remove its canonical noun files from the - * storage root. Legal disk state — a single-op write's canonical bytes are - * tmp+rename WITHOUT fsync (only `transact()` runs the write barrier), and an - * un-fsynced rename may resolve to "no directory entry" after power loss. - * Throws when nothing was removed (the caller's premise would be wrong). - */ -export function dropCanonicalNoun(dir: string, id: string): void { - const removed: string[] = [] - const walk = (p: string): void => { - for (const entry of fs.readdirSync(p, { withFileTypes: true })) { - const full = path.join(p, entry.name) - if (entry.isDirectory()) { - if (entry.name === id) { - fs.rmSync(full, { recursive: true, force: true }) - removed.push(full) - } else { - walk(full) - } - } - } - } - const nounsRoot = path.join(dir, 'entities', 'nouns') - if (fs.existsSync(nounsRoot)) walk(nounsRoot) - if (removed.length === 0) { - throw new Error(`power-loss model: no canonical files found for noun ${id} under ${nounsRoot}`) - } -} - -/** True when the staged record-set directory for `gen` exists on disk. */ -export function generationDirExists(dir: string, gen: number): boolean { - return fs.existsSync(path.join(dir, '_generations', String(gen))) -} diff --git a/tests/integration/adopt-drift-cure.test.ts b/tests/integration/adopt-drift-cure.test.ts deleted file mode 100644 index fb154574..00000000 --- a/tests/integration/adopt-drift-cure.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @module tests/integration/adopt-drift-cure - * @description THE DRIFT-CURING BACKFILL — the actual completion of the - * default-flip ruling: existing brains whose canonical wrappers carry - * pre-hydration-law drift (denormalized fields disagreeing with their own - * metadata leg — the real depot-brain shape, uuid-v7 rows from the 9.0 era) - * must ADOPT AUTOMATICALLY: the backfill rewrites canonical in the law - * shape (metadata leg = the authority; floats preserved), the oracle then - * verifies the rewrite before flipping. Same safety, zero operator chores. - * Log-ahead divergences still refuse as before. - */ -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/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -const dirs: string[] = [] -const brains: Brainy[] = [] -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -type RawBox = { - storage: { - readNounRaw(id: string): Promise<{ metadata: unknown; vector: unknown }> - writeNounRaw(id: string, r: { metadata: unknown; vector: unknown }): Promise - } -} - -describe('adoption cures hydration-law drift automatically', () => { - it('a drifted wrapper (stale denormalized fields) adopts green with floats preserved', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-drift-cure-')) - dirs.push(dir) - const brain = new Brainy({ - storage: { type: 'filesystem', path: dir }, - requireSubtype: false, - logAuthority: 'defer' - }) - await brain.init() - brains.push(brain) - const id = await brain.add({ - data: 'early-era row with drift', - type: NounType.Document, - metadata: { k: 1 } - }) - await brain.flush() - const before = await brain.get(id, { includeVectors: true }) - const floats = [...(before!.vector as number[])] - expect(floats.length).toBeGreaterThan(0) - - // Manufacture the depot shape: the stored wrapper's denormalized fields - // disagree with the metadata leg (pre-hydration-law drift) — an as-is - // identity re-commit preserves this forever; the law-shape rewrite cures it. - const storage = (brain as unknown as RawBox).storage - const raw = await storage.readNounRaw(id) - const wrapper = raw.vector as Record - await storage.writeNounRaw(id, { - metadata: raw.metadata, - vector: { - ...wrapper, - noun: 'thing', // stale denormalized type (metadata leg says document) - legacyField: 'pre-law residue', - createdAt: '1999-01-01T00:00:00.000Z' - } - }) - // Confirm the drift is oracle-visible before the cure. - expect((await brain.verifyLogAuthority()).verdict, 'drift detected').toBe('red') - - // THE PIN: adoption cures it without any operator step. - const report = await brain.adoptLogAuthority() - expect(report.verdict).toBe('green') - expect(brain.logAuthority().authority).toBe('log') - - // Nothing degraded: floats byte-identical, metadata intact, row serves. - const after = await brain.get(id, { includeVectors: true }) - expect(after!.vector as number[], 'floats preserved through the cure').toEqual(floats) - expect((after!.metadata as { k: number }).k).toBe(1) - expect((await brain.find({ where: { k: 1 }, limit: 5 })).map((r) => r.id)).toContain(id) - }, 120000) - - it('log-ahead divergences still refuse — the backfill never papers over a log the witness denies', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-logahead-')) - dirs.push(dir) - const brain = new Brainy({ - storage: { type: 'filesystem', path: dir }, - requireSubtype: false, - logAuthority: 'defer' - }) - await brain.init() - brains.push(brain) - const id = await brain.add({ data: 'row', type: NounType.Document, metadata: { k: 1 } }) - await brain.flush() - - // Log-ahead shape: canonical loses the record while the log still - // claims it live (log-live-canonical-absent — NOT curable by baseline). - const storage = (brain as unknown as RawBox).storage - await storage.writeNounRaw(id, { metadata: null, vector: null }) - - await expect(brain.adoptLogAuthority()).rejects.toThrow( - /log-ahead|witness denies|log claims/i - ) - expect(brain.logAuthority().authority).toBe('tree') - }, 120000) -}) diff --git a/tests/integration/adopt-large-baseline.test.ts b/tests/integration/adopt-large-baseline.test.ts deleted file mode 100644 index 11a3c803..00000000 --- a/tests/integration/adopt-large-baseline.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * @module tests/integration/adopt-large-baseline - * @description Adoption runs the baseline backfill TO COMPLETION in one call. - * A production brain with a 12.7k-row pre-log baseline once advanced exactly - * 800 rows per `adoptLogAuthority()` call (a five-pass ceiling × the oracle's - * 200-row listing cap), refused the flip, and sat tree-authoritative for - * hours across restarts. The pin: a baseline larger than that old ceiling - * — every row oracle-visible as `state-differs` drift — adopts GREEN in a - * SINGLE call, and the row count proves the whole set was cured, not a page. - */ -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/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -type RawBox = { - storage: { - readNounRaw(id: string): Promise<{ metadata: unknown; vector: unknown }> - writeNounRaw(id: string, r: { metadata: unknown; vector: unknown }): Promise - } -} - -const dirs: string[] = [] -const brains: Brainy[] = [] -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -describe('adoption backfill runs to completion', () => { - it('a pre-log baseline larger than the old 800-row ceiling adopts GREEN in ONE call', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-large-baseline-')) - dirs.push(dir) - const brain = new Brainy({ - storage: { type: 'filesystem', path: dir }, - requireSubtype: false, - logAuthority: 'defer' - }) - await brain.init() - brains.push(brain) - - // Above the old ceiling (5 passes × 200 = 800): every row must be cured - // in the one call for the flip to be legal. - const ROWS = 1000 - const ids: string[] = [] - for (let i = 0; i < ROWS; i++) { - ids.push( - await brain.add({ - data: `baseline row ${i}`, - type: NounType.Document, - metadata: { i }, - vector: Array.from({ length: 384 }, (_, k) => ((i + k) % 7) / 7) - }) - ) - } - await brain.flush() - - // Manufacture the production shape on EVERY row: pre-hydration-law drift - // (a stored wrapper whose denormalized fields disagree with its own - // metadata leg) — each is a curable `state-differs` mismatch, so the - // oracle's full curable set is ROWS, well past any per-pass page. - const storage = (brain as unknown as RawBox).storage - for (const id of ids) { - const raw = await storage.readNounRaw(id) - const wrapper = raw.vector as Record - await storage.writeNounRaw(id, { - metadata: raw.metadata, - vector: { ...wrapper, noun: 'thing', legacyField: 'pre-law residue' } - }) - } - const before = await brain.verifyLogAuthority() - expect(before.verdict, 'the whole baseline is oracle-red').toBe('red') - // The wire report is capped at 200 — the truncation flag is what the old - // loop bounded itself on; the cure path no longer reads through it. - expect(before.mismatchListTruncated).toBe(true) - - // THE PIN: one call, green, log-authoritative — no restarts, no loop. - const report = await brain.adoptLogAuthority() - expect(report.verdict).toBe('green') - expect(brain.logAuthority().authority).toBe('log') - expect(report.nounsChecked).toBeGreaterThanOrEqual(ROWS) - - // Nothing degraded: a sample of rows still serves with intact metadata. - for (const id of [ids[0], ids[499], ids[ROWS - 1]]) { - const row = await brain.get(id) - expect(row).not.toBeNull() - expect(typeof (row!.metadata as { i: number }).i).toBe('number') - } - }, 600000) -}) diff --git a/tests/integration/advanced-apis-regression.test.ts b/tests/integration/advanced-apis-regression.test.ts index 069d3e62..12c39112 100644 --- a/tests/integration/advanced-apis-regression.test.ts +++ b/tests/integration/advanced-apis-regression.test.ts @@ -164,19 +164,19 @@ describe('BR-ADV-FEATURES-BUN regression', () => { await b.close() }) - it('groupBy "system.type" resolves to the entity type, not null (the legacy "noun" alias is dead)', async () => { + it('groupBy "noun" resolves to the entity type, not null', async () => { const b: any = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) await b.init() await b.add({ data: 'p', type: NounType.Person }) b.defineAggregate({ name: 'byNoun', source: { type: NounType.Person }, - groupBy: ['system.type'], + groupBy: ['noun'], metrics: { count: { op: 'count' } } }) const rows: any[] = await b.find({ aggregate: 'byNoun' }) expect(rows.length).toBe(1) - expect(rows[0].groupKey['system.type']).toBe(NounType.Person) + expect(rows[0].groupKey.noun).toBe(NounType.Person) await b.close() }) }) diff --git a/tests/integration/aggregate-reserved-fields.test.ts b/tests/integration/aggregate-reserved-fields.test.ts index b8c11b4f..e81692d6 100644 --- a/tests/integration/aggregate-reserved-fields.test.ts +++ b/tests/integration/aggregate-reserved-fields.test.ts @@ -42,11 +42,8 @@ describe('aggregation + query field-resolution law', () => { it('reserved-field groupBy decrements on delete (the drift bug)', async () => { brain.defineAggregate({ name: 'by_subtype', - // system.subtype — subtype is an add() param (an engine scalar), never - // a user metadata field; bare 'subtype' now addresses the user's own - // metadata bag under the sealed field-addressing law. source: { type: NounType.Document }, - groupBy: ['system.subtype'], + groupBy: ['subtype'], metrics: { count: { op: 'count' } } }) @@ -63,7 +60,7 @@ describe('aggregation + query field-resolution law', () => { } let groups = await brain.queryAggregate('by_subtype') expect(groups).toHaveLength(1) - expect(groups[0].groupKey).toEqual({ 'system.subtype': 'note' }) + expect(groups[0].groupKey).toEqual({ subtype: 'note' }) expect(groups[0].metrics.count).toBe(5) await brain.remove(ids[0]) @@ -79,7 +76,7 @@ describe('aggregation + query field-resolution law', () => { brain.defineAggregate({ name: 'by_subtype', source: { type: NounType.Document }, - groupBy: ['system.subtype'], + groupBy: ['subtype'], metrics: { count: { op: 'count' } } }) const id = await brain.add({ @@ -91,7 +88,7 @@ describe('aggregation + query field-resolution law', () => { const groups = await brain.queryAggregate('by_subtype') const byKey = Object.fromEntries( - groups.map((g) => [String(g.groupKey['system.subtype']), g.metrics.count]) + groups.map((g) => [String(g.groupKey.subtype), g.metrics.count]) ) expect(byKey['published']).toBe(1) // The old group must be gone or zero — never still counting the entity. @@ -101,7 +98,7 @@ describe('aggregation + query field-resolution law', () => { it('source.where on a reserved field filters instead of matching nothing', async () => { brain.defineAggregate({ name: 'notes_only', - source: { type: NounType.Document, where: { 'system.subtype': 'note' } }, + source: { type: NounType.Document, where: { subtype: 'note' } }, groupBy: ['team'], metrics: { count: { op: 'count' } } }) diff --git a/tests/integration/aggregation-lifecycle-catchup.test.ts b/tests/integration/aggregation-lifecycle-catchup.test.ts deleted file mode 100644 index d4f9e6bf..00000000 --- a/tests/integration/aggregation-lifecycle-catchup.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * @module tests/integration/aggregation-lifecycle-catchup - * @description THE AGGREGATION LIFECYCLE PINS (SELF-ENGINE-LIFECYCLE-SPRINT / - * BRAINY-PROD-LATENCY-TRIAD asks (a)+(b)). The production disease: the - * aggregation stamp persisted ONLY at close(), so a long-lived writer that - * flushes but never closes left its stamp behind after every write window — - * and the exact-match adoption rule then forced a WHOLE-STORE backfill walk - * (per-entity work, measured >60s and door-starving on a 9k-row production - * brain) on the first stats call after any unclean exit. - * - * The cures pinned here: - * (a) `brain.flush()` persists aggregation state, stamped at the committed - * generation — the stamp tracks every flush, not just close(). - * (b) BEHIND-stamp state is ADOPTED and reconciled INCREMENTALLY over its - * exact missing window (fact-log affected ids + time-travel before/after - * reads) — the full walk never runs for an unclean exit. Pinned by call - * shape (the walk spy), not by latency. - */ -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/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -const AGG = { - name: 'by_subtype', - source: { type: NounType.Document }, - groupBy: ['system.subtype'] as string[], - metrics: { count: { op: 'count' as const } } -} - -const dirs: string[] = [] -const brains: Brainy[] = [] - -async function open(dir: string): Promise { - const b = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false }) - await b.init() - brains.push(b) - return b -} - -function countFor(results: Array<{ groupKey: Record; metrics: Record }>, subtype: string): number { - const row = results.find(r => r.groupKey['system.subtype'] === subtype) - return row ? Number(row.metrics.count) : 0 -} - -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -describe('aggregation lifecycle — flush stamps, behind-stamp catches up incrementally', () => { - it('(a) brain.flush() persists aggregation state stamped at the committed generation', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-agg-flush-')) - dirs.push(dir) - const brain = await open(dir) - brain.defineAggregate(AGG) - await brain.add({ data: 'a', type: NounType.Document, subtype: 'invoice', metadata: {} }) - await brain.add({ data: 'b', type: NounType.Document, subtype: 'invoice', metadata: {} }) - await brain.queryAggregate(AGG.name) // settle backfill-on-define - - await brain.flush() - - const internals = brain as unknown as { - storage: { - getMetadata(k: string): Promise<{ sourceGeneration?: number } | null> - committedGeneration?(): number - } - } - const persisted = await internals.storage.getMetadata('__aggregation_state_by_subtype__') - expect(persisted, 'state persisted by flush(), not only close()').toBeTruthy() - expect( - persisted!.sourceGeneration, - 'stamp equals the committed generation at flush time' - ).toBe(internals.storage.committedGeneration?.()) - }) - - it('(b) an unclean exit reconciles incrementally — exact counts, ZERO full-store walks', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-agg-catchup-')) - dirs.push(dir) - - // Session 1: define + write + flush (stamps at G), then MORE writes of - // every kind (add / update-that-moves-groups / delete) and a clean close - // — but we then REWIND the persisted aggregation artifact to its at-G - // bytes, which is byte-for-byte the unclean-exit state: stamp G, store - // committed at G+k. - let brain = await open(dir) - brain.defineAggregate(AGG) - await brain.add({ data: 'a', type: NounType.Document, subtype: 'invoice', metadata: {} }) - await brain.add({ data: 'b', type: NounType.Document, subtype: 'invoice', metadata: {} }) - const moving = await brain.add({ data: 'c', type: NounType.Document, subtype: 'draft', metadata: {} }) - const doomed = await brain.add({ data: 'd', type: NounType.Document, subtype: 'draft', metadata: {} }) - await brain.queryAggregate(AGG.name) - await brain.flush() - - const internals = brain as unknown as { - storage: { - getMetadata(k: string): Promise | null> - saveMetadata(k: string, v: Record): Promise - } - } - const stateAtG = JSON.parse( - JSON.stringify(await internals.storage.getMetadata('__aggregation_state_by_subtype__')) - ) - - // The missing window: one add, one group-moving update, one delete. - await brain.add({ data: 'e', type: NounType.Document, subtype: 'invoice', metadata: {} }) - await brain.update({ id: moving, subtype: 'invoice' }) - await brain.remove(doomed) - await brain.close() - brains.pop() - - // Rewind the aggregation artifact to the at-G bytes (the unclean exit). - { - const reopenForRewind = await open(dir) - const rw = reopenForRewind as unknown as typeof internals - await rw.storage.saveMetadata('__aggregation_state_by_subtype__', stateAtG) - await reopenForRewind.close() - brains.pop() - } - - // Session 2: reopen — adoption must see BEHIND and reconcile, never walk. - brain = await open(dir) - brain.defineAggregate(AGG) - const walkSpy = vi.spyOn( - brain as unknown as { runAggregationBackfillWalk(): Promise }, - 'runAggregationBackfillWalk' - ) - - const results = await brain.queryAggregate(AGG.name) - - // Ground truth after the window: invoice = a,b,e + moved c = 4; draft = 0 - // (c moved out, d deleted). - expect(countFor(results as never, 'invoice'), 'invoice count exact after catch-up').toBe(4) - expect(countFor(results as never, 'draft'), 'draft count exact after catch-up').toBe(0) - - // THE CALL-SHAPE PIN: the whole-store walk never ran. - expect(walkSpy, 'full backfill walk must not run for a behind-stamp reopen').not.toHaveBeenCalled() - - vi.restoreAllMocks() - }, 120000) -}) diff --git a/tests/integration/all-apis-comprehensive.test.ts b/tests/integration/all-apis-comprehensive.test.ts index 7d82afea..d25f23c8 100644 --- a/tests/integration/all-apis-comprehensive.test.ts +++ b/tests/integration/all-apis-comprehensive.test.ts @@ -331,10 +331,8 @@ describe('Comprehensive All-APIs Test', () => { it('should handle metadata queries efficiently', async () => { const start = Date.now() - // system.type — the legacy where.type→noun alias is dead; bare 'type' - // in where now addresses the user's own metadata field. const results = await brain.find({ - where: { 'system.type': NounType.Document }, + where: { type: NounType.Document }, limit: 100 }) diff --git a/tests/integration/api-parameter-validation.test.ts b/tests/integration/api-parameter-validation.test.ts index da4aed14..4e25e781 100644 --- a/tests/integration/api-parameter-validation.test.ts +++ b/tests/integration/api-parameter-validation.test.ts @@ -34,10 +34,6 @@ describe('API Parameter Validation', () => { }) }) - afterAll(async () => { - await brain.close() - }) - it('should use "where" parameter for metadata filtering', async () => { const results = await brain.find({ where: { category: 'test-category' }, diff --git a/tests/integration/asof-semantic-recall.test.ts b/tests/integration/asof-semantic-recall.test.ts deleted file mode 100644 index 35805326..00000000 --- a/tests/integration/asof-semantic-recall.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * @module tests/integration/asof-semantic-recall - * @description AS-OF SEMANTIC RECALL — the time-travel row of the release: - * vector/semantic search at a pinned past generation, served EXACTLY. - * - * The contract pinned here (brainy-alone leg; the accelerated-provider leg - * carries the same semantics at scale): - * 1. PAST VECTORS ARE THE PAST'S VECTORS: a later re-embed/update never - * leaks into an earlier pin — asOf(G) ranks by the vectors as they - * stood at G, byte-exact. - * 2. TOMBSTONE MASKING: a row deleted after G is FOUND at G; a row deleted - * at or before G is ABSENT at G. - * 3. THE DEFERRED-EMBED CELL of the visibility matrix: at pins before the - * vector landed the row's VECTOR LEG serves the stub (text/metadata - * legs may still surface it — triple intelligence by design); the real - * vector serves only at and after its landing pin. No backward leak. - * 4. TYPED REFUSAL beyond the log head — never a silent latest. - */ -import { describe, it, expect, afterEach } from 'vitest' -import { Brainy } from '../../src/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -const brains: Brainy[] = [] - -async function memBrain(): Promise { - const b = new Brainy({ storage: { type: 'memory' }, requireSubtype: false }) - await b.init() - brains.push(b) - return b -} - -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) -}) - -describe('as-of semantic recall', () => { - it('PAST VECTORS EXACT: a later update never leaks into an earlier pin', async () => { - const brain = await memBrain() - const id = await brain.add({ - data: 'crimson apples in the orchard', - type: NounType.Document, - metadata: { epoch: 'old' } - }) - const g1 = brain.generation() - const v1 = [...(((await brain.get(id, { includeVectors: true }))!.vector) as number[])] - - await brain.update({ id, data: 'deep blue ocean currents', metadata: { epoch: 'new' } }) - const g2 = brain.generation() - const v2 = (await brain.get(id, { includeVectors: true }))!.vector as number[] - expect(v2, 'the update really re-embedded').not.toEqual(v1) - - // The pin: at G1 the row carries its ORIGINAL vector and content. - const dbPast = await brain.asOf(g1) - const past = await dbPast.get(id, { includeVectors: true }) - expect(past, 'row exists at G1').toBeTruthy() - expect(past!.vector as number[], 'as-of vector is byte-exact the OLD vector').toEqual(v1) - expect((past!.metadata as { epoch: string }).epoch).toBe('old') - - // Semantic search at G1 finds it via the OLD content; at G2 via the new. - const hitsOld = await dbPast.find({ query: 'crimson apples in the orchard', limit: 3 }) - expect(hitsOld.map((r) => r.id), 'old content recalls at G1').toContain(id) - const dbNow = await brain.asOf(g2) - const hitsNew = await dbNow.find({ query: 'deep blue ocean currents', limit: 3 }) - expect(hitsNew.map((r) => r.id), 'new content recalls at G2').toContain(id) - await dbPast.release() - await dbNow.release() - }) - - it('TOMBSTONE MASKING: deleted-after-G is found at G; deleted-before-G is absent', async () => { - const brain = await memBrain() - const doomed = await brain.add({ - data: 'ephemeral meteor shower observation', - type: NounType.Document, - metadata: {} - }) - const keeper = await brain.add({ - data: 'permanent granite mountain survey', - type: NounType.Document, - metadata: {} - }) - const gBoth = brain.generation() - await brain.remove(doomed) - const gAfter = brain.generation() - - const dbBoth = await brain.asOf(gBoth) - const atBoth = await dbBoth.find({ query: 'ephemeral meteor shower observation', limit: 5 }) - expect(atBoth.map((r) => r.id), 'pre-delete pin still recalls the row').toContain(doomed) - - const dbAfter = await brain.asOf(gAfter) - const atAfter = await dbAfter.find({ query: 'ephemeral meteor shower observation', limit: 5 }) - expect(atAfter.map((r) => r.id), 'post-delete pin masks the tombstoned row').not.toContain(doomed) - expect((await dbAfter.find({ query: 'permanent granite mountain survey', limit: 5 })).map((r) => r.id)).toContain(keeper) - await dbBoth.release() - await dbAfter.release() - }) - - it('DEFERRED-EMBED CELL: semantically absent before the vector landed, present after — never a stub match', async () => { - const brain = await memBrain() - // Anchor row so the semantic search always has a corpus. - await brain.add({ data: 'unrelated anchor topic entirely', type: NounType.Document, metadata: {} }) - - const id = await brain.add({ - data: 'deferred saffron sunrise essay', - type: NounType.Document, - deferEmbedding: true, - metadata: {} - }) - const gAck = brain.generation() - await brain.awaitPendingEmbeds() - const gLanded = brain.generation() - expect(gLanded, 'the landed vector is its own generation').toBeGreaterThan(gAck) - - // At the ack generation: metadata-visible, and the VECTOR LEG carries - // the stub (the visibility matrix's AT-EMBED cell governs the vector - // leg — find({query})'s text/metadata legs may legitimately still - // surface the row, that is triple intelligence working as designed; - // what must NEVER happen is a stub vector ranking as a real one). - const dbAck = await brain.asOf(gAck) - const metaHits = await dbAck.find({ where: {}, limit: 10 }) - expect(metaHits.map((r) => r.id), 'metadata-visible at ack pin').toContain(id) - const ackRow = await dbAck.get(id, { includeVectors: true }) - expect((ackRow!.vector as number[]).length, 'the as-of vector at the ack pin is the stub — no vector leaked backward').toBe(0) - - // At the landed generation: fully recallable. - const dbLanded = await brain.asOf(gLanded) - const landedRow = await dbLanded.get(id, { includeVectors: true }) - expect((landedRow!.vector as number[]).length, 'the real vector serves at the landed pin').toBeGreaterThan(0) - const semLanded = await dbLanded.find({ query: 'deferred saffron sunrise essay', limit: 5 }) - expect(semLanded.map((r) => r.id), 'recallable at the landed pin').toContain(id) - await dbAck.release() - await dbLanded.release() - }) - - it('TYPED REFUSAL beyond the head — never a silent latest', async () => { - const brain = await memBrain() - await brain.add({ data: 'one row', type: NounType.Document, metadata: {} }) - const head = brain.generation() - await expect(brain.asOf(head + 100)).rejects.toThrow(/generation|beyond|future|exceed/i) - }) -}) diff --git a/tests/integration/batchImportWithRelations.test.ts b/tests/integration/batchImportWithRelations.test.ts index 4095b51c..7fe8e511 100644 --- a/tests/integration/batchImportWithRelations.test.ts +++ b/tests/integration/batchImportWithRelations.test.ts @@ -15,7 +15,13 @@ describe('Batch Import with Immediate Relations (v5.7.3 Fix)', () => { // Initialize brain brain = new Brainy({ requireSubtype: false, - storage: { type: 'filesystem', path: testDir }, + storage: { + type: 'filesystem', + config: { + baseDir: testDir, + enableCompression: false // Faster tests + } + }, dimensions: 384 }) diff --git a/tests/integration/beforeexit-never-closes.test.ts b/tests/integration/beforeexit-never-closes.test.ts deleted file mode 100644 index b7a2f95b..00000000 --- a/tests/integration/beforeexit-never-closes.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * @module tests/integration/beforeexit-never-closes - * @description A DRAINED EVENT LOOP IS NOT A SHUTDOWN. - * - * MEASURED on the 11.1 rehearsal lane, against a copy of a real store. The - * `beforeExit` listener had been wired to the SIGNAL path — the path whose job - * is to `close()` every live brain — so after the heal phase the log printed - * - * "Shutdown signal received - flushing pending data..." - * "Flushed successfully (1 instance)" - * - * with no signal ever sent, and the script's very next `add()` threw - * - * "Brainy instance is not initialized: it was closed via close(). - * Create a new instance." - * - * Node emits `'beforeExit'` whenever the event loop has no REF'd work left. - * That is not "the process is ending" — it is a state a perfectly healthy - * script reaches, because this engine unref's its idle and cadence timers - * ("an idle brain costs nothing"), so a script awaiting anything those timers - * drive is, for that instant, a process with no ref'd work and an open brain. - * The engine closed a live brain out from under a running script. - * - * The contract pinned here: - * (1) `'beforeExit'` firing while a brain is open closes NOTHING: the brain - * is still open, `add()` and `find()` still work, the writer lock is - * still held, and the process still exits 0 on its own afterwards. - * (2) The pass DOES persist derived state — a non-closing `flush()` ran — - * and it wrote no clean-shutdown marker and no clean-close record: those - * are `close()`'s word about itself, and no close happened. - * (3) The signal path is untouched: SIGTERM still closes through `close()` - * (pinned by tests/integration/shutdown-single-owner.test.ts, re-run - * with this change). - */ - -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') -const BRAINY_SRC = join(REPO_ROOT, 'src', 'brainy.ts') - -function makeTempDir(prefix: string): string { - return mkdtempSync(join(tmpdir(), prefix)) -} - -/** The writer lock itself — present for as long as this process owns the store. */ -const writerLockPath = (dir: string) => join(dir, 'locks', '_writer.lock') -/** The clean-close record — written by `releaseWriterLock()`, i.e. by close(). */ -const closeRecordPath = (dir: string) => join(dir, 'locks', '_writer.close') -/** - * The generation store's clean-shutdown marker — written by - * `generationStore.close()` alone, reached only from `close()`. (Raw objects - * are gzipped on disk, so both spellings are accepted.) - */ -const cleanShutdownWritten = (dir: string) => - existsSync(join(dir, '_system', 'clean-shutdown.json.gz')) || - existsSync(join(dir, '_system', 'clean-shutdown.json')) - -/** - * Write a child script and run it under tsx to completion, collecting stdout - * and stderr and the exit code. (A file, not `tsx -e`: the eval form compiles - * to CommonJS, which has no top-level await.) - */ -function runChild( - scriptDir: string, - body: string -): Promise<{ code: number | null; out: string }> { - const scriptPath = join(scriptDir, 'child-process.mts') - writeFileSync(scriptPath, body) - // The child is an ORDINARY consumer process, so it runs the real embedding - // pipeline: this suite's deterministic-embedder switch is inherited through - // the environment, and under it `find()` self-retrieval returns nothing — - // which would make the read half of this pin vacuous. (That property is the - // deterministic embedder's, not this change's: it reproduces in a plain - // script with no 'beforeExit' involved.) - const env = { ...process.env } - delete env.BRAINY_DETERMINISTIC_EMBEDDINGS - const child = spawn(TSX, [scriptPath], { - cwd: REPO_ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - env - }) - let out = '' - child.stdout?.on('data', (d) => { out += String(d) }) - child.stderr?.on('data', (d) => { out += String(d) }) - return new Promise((resolvePromise) => { - child.on('exit', (code) => resolvePromise({ code, out })) - }) -} - -describe('beforeExit never closes a live brain', () => { - let dir: string - let scriptDir: string - let resultPath: string - - beforeEach(() => { - dir = makeTempDir('brainy-beforeexit-') - scriptDir = makeTempDir('brainy-beforeexit-script-') - resultPath = join(scriptDir, 'result.json') - }) - - afterEach(() => { - for (const d of [dir, scriptDir]) { - try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ } - } - }) - - it('(1)+(2) a drained event loop flushes, closes nothing, and the script keeps working', async () => { - /** - * THE DRAIN, and why the script survives it. The script awaits a promise - * that only an UNREF'd timer will resolve — the shape every engine cadence - * timer has, and the reason a healthy script reaches a loop with no ref'd - * work. Node emits `'beforeExit'` there, with the brain wide open. - * - * The engine's listener runs first (registered by `init()`, before the - * script's). The script's own listener is both its witness — it records - * that the emit happened, and the flush count AT that moment — and its - * belt: it resolves the same promise, so the pin never depends on how many - * milliseconds the engine's pass happens to keep the loop turning. - * - * The brain is DIRTY at the drain (one add, after a settling flush), so - * the pass has real work to do and pin (2) is about a flush that ran, not - * a flush that was skipped as a no-op. - */ - const script = ` - import { writeFileSync as __writeFileSync, existsSync as __existsSync } from 'node:fs' - import { join as __join } from 'node:path' - import { Brainy } from ${JSON.stringify(BRAINY_SRC)} - - const DIR = ${JSON.stringify(dir)} - const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: DIR } }) - await brain.init() - - // Count every flush that RUNS on this brain. An own property shadows the - // prototype for every caller, including the engine's own listeners. - let flushes = 0 - const flushImpl = brain.flush.bind(brain) - brain.flush = () => { flushes++; return flushImpl() } - // ...and every close ENTERED. This must still be 0 after the drain. - let closes = 0 - const closeImpl = brain.close.bind(brain) - brain.close = () => { closes++; return closeImpl() } - - await brain.add({ data: 'written before the drain', type: 'concept' }) - await brain.flush() // settle: clean brain - await new Promise((r) => setTimeout(r, 250)) // let the cadence quiet down - await brain.add({ data: 'the write the drain must persist', type: 'concept' }) - - const flushesBeforeDrain = flushes - let drains = 0 - let flushesAtDrain = -1 - const drained = new Promise((resolve) => { - const t = setTimeout(resolve, 5) - if (typeof t.unref === 'function') t.unref() - process.on('beforeExit', () => { - drains++ - if (flushesAtDrain === -1) flushesAtDrain = flushes - resolve() - }) - }) - await drained - - // GIVE THE ENGINE'S PASS ITS FULL TURN before judging it. The signal - // path this listener used to share defers one macrotask before it - // touches an instance, so a script that resumes on the same tick as the - // emit would race past the damage and see an open brain that is about to - // be closed underneath it. Wait it out (a ref'd timer — the drain has - // already happened), then look. - await new Promise((r) => setTimeout(r, 1000)) - - // ---- The script is still running. The brain must still be its brain. ---- - const stateAtResume = { - drains, - flushesBeforeDrain, - flushesAtDrain, - closes, - isClosed: brain.isClosed, - isClosing: brain.isClosing, - writerLockHeld: __existsSync(__join(DIR, 'locks', '_writer.lock')), - cleanCloseRecord: __existsSync(__join(DIR, 'locks', '_writer.close')), - cleanShutdownMarker: - __existsSync(__join(DIR, '_system', 'clean-shutdown.json.gz')) || - __existsSync(__join(DIR, '_system', 'clean-shutdown.json')) - } - - let addAfterDrain = null - let addError = null - try { - addAfterDrain = await brain.add({ data: 'written AFTER the drained event loop', type: 'concept' }) - } catch (error) { - addError = error instanceof Error ? error.message : String(error) - } - - let findHits = -1 - let findError = null - try { - const results = await brain.find('written AFTER the drained event loop') - findHits = results.length - } catch (error) { - findError = error instanceof Error ? error.message : String(error) - } - - __writeFileSync( - ${JSON.stringify(resultPath)}, - JSON.stringify({ ...stateAtResume, addAfterDrain, addError, findHits, findError, closesBeforeOurs: closes }) - ) - - // The script ends the way a script ends: it closes its own brain, and - // the process exits on its own because nothing is left holding the loop. - await brain.close() - ` - - const { code, out } = await runChild(scriptDir, script) - - expect(existsSync(resultPath), `child wrote no result file:\n${out}`).toBe(true) - const r = JSON.parse(readFileSync(resultPath, 'utf-8')) - - // The drain really happened — this test proves nothing otherwise. - expect(r.drains, `'beforeExit' never fired:\n${out}`).toBeGreaterThanOrEqual(1) - - // (1) NOTHING WAS CLOSED. This is the regression: under 10.4.11 the pass - // ran close() here and `addError` carried "it was closed via close()". - expect(r.addError, `add() after the drain failed:\n${out}`).toBeNull() - expect(r.findError, `find() after the drain failed:\n${out}`).toBeNull() - expect(r.closes, 'the engine closed the brain on a drained event loop').toBe(0) - expect(r.isClosed).toBe(false) - expect(r.isClosing).toBe(false) - expect(typeof r.addAfterDrain).toBe('string') - expect(r.findHits, `find() returned nothing:\n${out}`).toBeGreaterThanOrEqual(1) - - // (1) The writer lock was never given up — a drained loop is not a handover. - expect(r.writerLockHeld, 'the writer lock was released on a drained event loop').toBe(true) - - // (2) A flush RAN, and it wrote neither of close()'s markers. - expect( - r.flushesAtDrain, - `the drained-loop pass ran no flush (before=${r.flushesBeforeDrain}):\n${out}` - ).toBeGreaterThan(r.flushesBeforeDrain) - expect(r.cleanShutdownMarker, 'the drained-loop flush stamped a clean-shutdown marker').toBe(false) - expect(r.cleanCloseRecord, 'the drained-loop flush wrote a clean-close record').toBe(false) - expect(out).toMatch(/All indexes flushed to disk/) - - // The narration says what happened, and never claims a shutdown. - expect(out).toMatch(/event loop drained with 1 brain open/) - expect(out).toMatch(/NOTHING was closed\. A drained loop is not a shutdown/) - expect(out).not.toMatch(/Shutdown signal received/) - expect(out).not.toMatch(/Flushed successfully/) - expect(out).not.toMatch(/is not initialized/) - - // (1) And the process still exits 0 on its own once the script closes up. - expect(code, `child output:\n${out}`).toBe(0) - - // The store the script left behind is clean: it closed properly at the end. - expect(cleanShutdownWritten(dir), 'the script\'s own close() wrote no marker').toBe(true) - expect(existsSync(closeRecordPath(dir)), 'the script\'s own close() left no clean-close record').toBe(true) - expect(existsSync(writerLockPath(dir)), 'the writer lock outlived close()').toBe(false) - }, 300_000) - - it('(2) the pass is repeatable and idempotent: a second drain closes nothing either', async () => { - // In-process, so the assertions are on the object itself rather than on a - // report: 'beforeExit' is an ordinary event, and emitting it twice must - // leave the brain exactly as usable as it was. - const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await brain.init() - - const flushed: Promise[] = [] - const flushImpl = brain.flush.bind(brain) - ;(brain as unknown as { flush: () => Promise }).flush = () => { - const p = flushImpl() - flushed.push(p) - return p - } - - await brain.add({ data: 'a write the drained loop must persist', type: NounType.Concept }) - - for (const pass of [1, 2]) { - const before = flushed.length - process.emit('beforeExit', 0) - await Promise.all(flushed.slice(before).map((p) => p.catch(() => {}))) - // Let the pass's own `finally` run (it settles a microtask after ours), - // so the next emit is not turned away by the in-flight guard. - await new Promise((r) => setTimeout(r, 50)) - - expect(brain.isClosed, `pass ${pass} closed the brain`).toBe(false) - expect(brain.isClosing, `pass ${pass} started a close`).toBe(false) - expect(existsSync(writerLockPath(dir)), `pass ${pass} released the writer lock`).toBe(true) - expect(existsSync(closeRecordPath(dir)), `pass ${pass} wrote a clean-close record`).toBe(false) - expect(cleanShutdownWritten(dir), `pass ${pass} stamped a clean-shutdown marker`).toBe(false) - - // Still a working brain, after every pass. - const id = await brain.add({ data: `still writable after drain ${pass}`, type: NounType.Concept }) - expect(id).toBeTruthy() - } - - // The first pass had a dirty brain and flushed it; the second found it - // clean and cost nothing. Either way, neither closed anything. - expect(flushed.length).toBeGreaterThanOrEqual(2) - - await brain.close() - expect(brain.isClosed).toBe(true) - expect(cleanShutdownWritten(dir)).toBe(true) - }, 300_000) -}) diff --git a/tests/integration/brain-relocation.test.ts b/tests/integration/brain-relocation.test.ts deleted file mode 100644 index 827bb959..00000000 --- a/tests/integration/brain-relocation.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @module tests/integration/brain-relocation - * @description LC8 — RELOCATABLE BRAIN DIRECTORY. A brain's directory moved - * wholesale to a new path (rename/copy — backup-restore, disk migration, - * container re-mount) must open and serve IDENTICALLY: no absolute paths may - * hide in any persisted artifact. Pinned across every intelligence: point - * reads, metadata find, semantic find, graph traversal, aggregation — plus - * continued writes with monotonic generations and time-travel reads over - * pre-move history. - */ -import { describe, it, expect, afterEach } from 'vitest' -import { mkdtempSync, rmSync, renameSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Brainy } from '../../src/index.js' -import { NounType, VerbType } from '../../src/types/graphTypes.js' - -const dirs: string[] = [] -const brains: Brainy[] = [] - -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -const AGG = { - name: 'by_kind', - source: { type: NounType.Document }, - groupBy: ['kind'] as string[], - metrics: { count: { op: 'count' as const } } -} - -describe('LC8 — a moved brain directory opens and serves identically', () => { - it('rename the directory: all three intelligences serve, writes continue, history travels', async () => { - const home = mkdtempSync(join(tmpdir(), 'brainy-reloc-')) - dirs.push(home) - const oldPath = join(home, 'brain-old') - const newPath = join(home, 'brain-new') - - // Season a brain: rows, a relation, an aggregate, then flush + close. - let brain = new Brainy({ storage: { type: 'filesystem', path: oldPath }, requireSubtype: false }) - await brain.init() - brains.push(brain) - brain.defineAggregate(AGG) - const alpha = await brain.add({ - data: 'alpha document about mountain geology', - type: NounType.Document, - metadata: { kind: 'report', n: 1 } - }) - const beta = await brain.add({ - data: 'beta document about coastal erosion', - type: NounType.Document, - metadata: { kind: 'report', n: 2 } - }) - await brain.relate({ from: alpha, to: beta, verb: VerbType.RelatedTo }) - await brain.queryAggregate(AGG.name) // settle backfill - const preMoveGen = brain.generation() - await brain.flush() - await brain.close() - brains.pop() - - // The move: wholesale directory rename. - renameSync(oldPath, newPath) - - // Reopen at the NEW path — everything serves. - brain = new Brainy({ storage: { type: 'filesystem', path: newPath }, requireSubtype: false }) - await brain.init() - brains.push(brain) - brain.defineAggregate(AGG) - - // Point read + metadata find. - expect((await brain.get(alpha))!.data).toContain('mountain geology') - const found = await brain.find({ where: { kind: 'report' }, limit: 10 }) - expect(found.map((r) => r.id).sort()).toEqual([alpha, beta].sort()) - - // Semantic find. - const sem = await brain.find({ query: 'alpha document about mountain geology', limit: 3 }) - expect(sem.map((r) => r.id)).toContain(alpha) - - // Graph traversal. - const related = await brain.related(alpha) - expect(related.map((r) => r.to)).toContain(beta) - - // Aggregation. - const agg = (await brain.queryAggregate(AGG.name)) as Array<{ - groupKey: Record - metrics: Record - }> - const reportRow = agg.find((g) => g.groupKey['kind'] === 'report') - expect(Number(reportRow?.metrics.count)).toBe(2) - - // Writes continue with monotonic generations. - const gamma = await brain.add({ - data: 'gamma addendum after the move', - type: NounType.Document, - metadata: { kind: 'report', n: 3 } - }) - expect(brain.generation()).toBeGreaterThan(preMoveGen) - expect((await brain.get(gamma))!.data).toContain('addendum') - - // Time travel across the move boundary: the pre-move pin sees exactly - // the pre-move world (no gamma), served from relocated history. - const dbPast = await brain.asOf(preMoveGen) - expect(await dbPast.get(gamma)).toBeNull() - expect((await dbPast.get(alpha))!.data).toContain('mountain geology') - await dbPast.release() - }, 120000) -}) diff --git a/tests/integration/brainy-core.integration.test.ts b/tests/integration/brainy-core.integration.test.ts index e5b01caf..dd04703e 100644 --- a/tests/integration/brainy-core.integration.test.ts +++ b/tests/integration/brainy-core.integration.test.ts @@ -337,18 +337,12 @@ describe('Brainy 3.0 Core (Integration Tests - Real AI)', () => { describe('Error Handling and Edge Cases', () => { it('should handle invalid inputs gracefully', async () => { - // Empty string is REAL content (e.g. an empty file's first write), not - // a missing field — only null/undefined data (with no vector either) - // is rejected. See src/utils/paramValidation.ts validateAddParams(). + // Empty data is rejected with a clear validation error (8.0 requires a + // non-empty `data` or a `vector` — empty string carries no signal to embed). await expect(brain.add({ data: '', type: 'document' - })).resolves.toBeDefined() - - // Missing BOTH data and vector is still the real "nothing to embed" error. - await expect(brain.add({ - type: 'document' - } as any)).rejects.toThrow(/data/) + })).rejects.toThrow(/data/) // Test with very long text — valid input, resolves to an id. const longText = 'Lorem ipsum '.repeat(10000) diff --git a/tests/integration/canonical-count-ledger.test.ts b/tests/integration/canonical-count-ledger.test.ts deleted file mode 100644 index 3d292e97..00000000 --- a/tests/integration/canonical-count-ledger.test.ts +++ /dev/null @@ -1,360 +0,0 @@ -/** - * @module tests/integration/canonical-count-ledger - * @description The canonical count ledger — the denominators a derived-index - * provider's coverage ledger subtracts from. Laws under test: - * (1) THE ALL-VISIBILITY SCALAR IS THE UNFILTERED WALK'S TOTAL — the - * storage-level `getNouns()` / `getVerbs()` `totalCount` counts EVERY tier - * (system, internal, public) because the walk yields every tier; the - * user-facing `getNounCount()` / `getVerbCount()` keep skipping hidden - * tiers. A ledger built on the user-facing scalar would read "over-posted" - * on every store with a VFS — the mismatch this pin makes unbuildable. - * (2) NEVER CLAMPED — `Math.max(scalar, scanned)` could only move a scalar up, - * so an inflated counter hid forever. An inflated scalar is now VISIBLE - * (totalCount ≠ walk) and the sanctioned recount heals it, durably. - * (3) NEVER GUESSED — a delete that cannot prove the record existed marks the - * ledger SUSPECT (persisted) instead of decrementing on faith; the recount - * clears the flag with proof. - * (4) LEGACY FILES DERIVE ONCE — a counts.json written before the ledger is - * upgraded from the canonical id tree at open, then persisted. - */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import * as zlib from 'node:zlib' -import { Brainy } from '../../src/index.js' - -/** Count canonical `/entities///` directories — every tier. */ -function countIdDirs(root: string, kind: 'nouns' | 'verbs'): number { - const base = path.join(root, 'entities', kind) - if (!fs.existsSync(base)) return 0 - let n = 0 - for (const shard of fs.readdirSync(base)) { - const shardDir = path.join(base, shard) - if (!fs.statSync(shardDir).isDirectory()) continue - for (const id of fs.readdirSync(shardDir)) { - if (fs.statSync(path.join(shardDir, id)).isDirectory()) n++ - } - } - return n -} - -const countsPath = (root: string) => path.join(root, '_system', 'counts.json') - -describe('canonical count ledger — ALL-visibility scalars, unclamped totals, recount heals', () => { - let dir: string - let brain: any - - const open = async () => { - const b: any = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - silent: true, - dimensions: 384 - }) - await b.init() - return b - } - - beforeEach(async () => { - process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-ledger-')) - brain = await open() - }) - afterEach(async () => { - await brain.close?.().catch(() => {}) - fs.rmSync(dir, { recursive: true, force: true }) - }) - - it('the unfiltered walk totalCount is the ALL scalar (every tier); the user-facing count stays counted', async () => { - const a = await brain.add({ data: 'public a', type: 'document' }) - const b = await brain.add({ data: 'internal b', type: 'document', visibility: 'internal' }) - await brain.relate({ from: a, to: b, type: 'relatedTo', visibility: 'internal' }) - await brain.vfs.writeFile('/docs/x.txt', 'hello') // VFS: system-tier nouns + Contains edges - await brain.flush() - - const ledger = await brain.storage.getCanonicalCounts() - expect(ledger.suspect).toBe(false) - expect(ledger.nouns.all).toBe(countIdDirs(dir, 'nouns')) - expect(ledger.verbs.all).toBe(countIdDirs(dir, 'verbs')) - expect(ledger.nouns.counted).toBe(await brain.storage.getNounCount()) - expect(ledger.verbs.counted).toBe(await brain.storage.getVerbCount()) - // Hidden tiers exist (the VFS root at minimum, the internal noun, the internal edge): - expect(ledger.nouns.all).toBeGreaterThan(ledger.nouns.counted) - expect(ledger.verbs.all).toBeGreaterThan(ledger.verbs.counted) - - // The storage-level unfiltered walks report the ALL scalar, and a full page equals it. - const nouns = await brain.storage.getNouns({ pagination: { limit: 1000, offset: 0 } }) - expect(nouns.totalCount).toBe(ledger.nouns.all) - expect(nouns.items.length).toBe(ledger.nouns.all) - const verbs = await brain.storage.getVerbs({ pagination: { limit: 1000, offset: 0 } }) - expect(verbs.totalCount).toBe(ledger.verbs.all) - expect(verbs.items.length).toBe(ledger.verbs.all) - }) - - it('proven deletes move the ALL scalar for every tier and the ledger stays exact and unsuspect', async () => { - const p = await brain.add({ data: 'public p', type: 'document' }) - const q = await brain.add({ data: 'internal q', type: 'document', visibility: 'internal' }) - await brain.relate({ from: p, to: q, type: 'relatedTo' }) - await brain.flush() - const before = await brain.storage.getCanonicalCounts() - - await brain.remove(q) // cascades the edge - await brain.remove(p) - await brain.flush() - - const after = await brain.storage.getCanonicalCounts() - expect(after.nouns.all).toBe(before.nouns.all - 2) - expect(after.verbs.all).toBe(before.verbs.all - 1) - expect(after.nouns.all).toBe(countIdDirs(dir, 'nouns')) - expect(after.verbs.all).toBe(countIdDirs(dir, 'verbs')) - expect(after.nouns.counted).toBe(before.nouns.counted - 1) - expect(after.suspect).toBe(false) - }) - - it('a legacy counts.json without the ALL keys is derived once from the id tree and persisted', async () => { - await brain.add({ data: 'one', type: 'document' }) - await brain.add({ data: 'two', type: 'document', visibility: 'internal' }) - await brain.vfs.writeFile('/a.txt', 'x') - await brain.flush() - await brain.close() - - const raw = JSON.parse(fs.readFileSync(countsPath(dir), 'utf-8')) - expect(typeof raw.totalNounCountAll).toBe('number') - delete raw.totalNounCountAll - delete raw.totalVerbCountAll - delete raw.allCountsSuspect - fs.writeFileSync(countsPath(dir), JSON.stringify(raw, null, 2)) - - brain = await open() - const ledger = await brain.storage.getCanonicalCounts() - expect(ledger.nouns.all).toBe(countIdDirs(dir, 'nouns')) - expect(ledger.verbs.all).toBe(countIdDirs(dir, 'verbs')) - expect(ledger.suspect).toBe(false) - const persisted = JSON.parse(fs.readFileSync(countsPath(dir), 'utf-8')) - expect(persisted.totalNounCountAll).toBe(ledger.nouns.all) - expect(persisted.totalVerbCountAll).toBe(ledger.verbs.all) - }) - - it('an inflated ALL scalar is VISIBLE (unclamped) and healed by repairIndex(), surviving reopen', async () => { - for (let i = 0; i < 3; i++) await brain.add({ data: `real ${i}`, type: 'document' }) - await brain.flush() - const truth = countIdDirs(dir, 'nouns') - - ;(brain.storage as any).totalNounCountAll = truth + 40 - await (brain.storage as any).persistCounts() - await brain.close() - brain = await open() - - // The lie survives reopen AND is observable: totalCount disagrees with the walk. - const page = await brain.storage.getNouns({ pagination: { limit: 1000, offset: 0 } }) - expect(page.totalCount).toBe(truth + 40) - expect(page.items.length).toBe(truth) - - await brain.repairIndex() - expect((await brain.storage.getCanonicalCounts()).nouns.all).toBe(truth) - expect((await brain.storage.getNouns({ pagination: { limit: 1000, offset: 0 } })).totalCount).toBe(truth) - - await brain.close() - brain = await open() - expect((await brain.storage.getCanonicalCounts()).nouns.all).toBe(truth) - }) - - it('an unprovable delete marks the ledger SUSPECT (persisted); the recount clears it with proof', async () => { - await brain.add({ data: 'anchor', type: 'document' }) - await brain.flush() - const truth = countIdDirs(dir, 'nouns') - - // A ghost: no canonical record, no prior image — nothing to prove existence with. - await brain.storage.deleteNounMetadata('00000000-dead-4dea-8dea-000000000000') - let ledger = await brain.storage.getCanonicalCounts() - expect(ledger.suspect).toBe(true) - expect(ledger.nouns.all).toBe(truth) // never decremented on faith - - await brain.close() - brain = await open() - expect((await brain.storage.getCanonicalCounts()).suspect).toBe(true) // the flag persists - - await brain.repairIndex() - ledger = await brain.storage.getCanonicalCounts() - expect(ledger.suspect).toBe(false) - expect(ledger.nouns.all).toBe(truth) - }) -}) - -/** Count `/entities/nouns///vectors.json[.gz]` files holding a non-empty `vector`. */ -function countVectoredNouns(root: string): number { - const base = path.join(root, 'entities', 'nouns') - if (!fs.existsSync(base)) return 0 - let n = 0 - for (const shard of fs.readdirSync(base)) { - const shardDir = path.join(base, shard) - if (!fs.statSync(shardDir).isDirectory()) continue - for (const id of fs.readdirSync(shardDir)) { - const idDir = path.join(shardDir, id) - if (!fs.statSync(idDir).isDirectory()) continue - const plainPath = path.join(idDir, 'vectors.json') - const gzPath = `${plainPath}.gz` - let record: any = null - if (fs.existsSync(plainPath)) { - record = JSON.parse(fs.readFileSync(plainPath, 'utf-8')) - } else if (fs.existsSync(gzPath)) { - record = JSON.parse(zlib.gunzipSync(fs.readFileSync(gzPath)).toString('utf-8')) - } else { - continue - } - if (Array.isArray(record.vector) && record.vector.length > 0) n++ - } - } - return n -} - -describe('canonical count ledger — the vectored-noun scalar (the vector leg\'s coverage denominator)', () => { - let dir: string - let brain: any - - const open = async () => { - const b: any = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - silent: true, - dimensions: 384 - }) - await b.init() - return b - } - - /** Baseline vectored count right after a fresh open() — init() creates a - * hidden system VFS-root noun, but (the zero-norm root cure) it is - * deliberately UNVECTORED (`vector: []`, never a real all-zero - * placeholder — a zero-norm vector never crosses an engine boundary), so - * a brand-new store's `vectors.all` is 0. Tests still assert DELTAS off - * this baseline rather than hardcoding it away, in case that ever - * changes again. */ - let baseline: number - - beforeEach(async () => { - process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-vectored-ledger-')) - brain = await open() - baseline = (await brain.storage.getCanonicalCounts()).vectors.all - expect(baseline).toBe(0) // the unvectored VFS root contributes nothing - }) - afterEach(async () => { - vi.restoreAllMocks() - await brain.close?.().catch(() => {}) - fs.rmSync(dir, { recursive: true, force: true }) - }) - - it('an explicit-vector add counts immediately; the ledger matches the on-disk vectors.json content', async () => { - await brain.add({ data: 'a', type: 'document', vector: Array(384).fill(0).map((_, i) => Math.sin(i)) }) - await brain.add({ data: 'b', type: 'document' }) // embedded (non-deferred) — also a real vector - await brain.flush() - - const ledger = await brain.storage.getCanonicalCounts() - expect(ledger.vectors.all).toBe(baseline + 2) - expect(ledger.vectors.all).toBe(countVectoredNouns(dir)) - expect(ledger.suspect).toBe(false) - }) - - it('a deferred-embed add does NOT count until its embed LANDS', async () => { - // Hold the background worker's embed call open under manual control — a - // deterministic embedder is fast enough that the landing could otherwise - // race ahead of the "still unlanded" assertion below. - let resolveEmbed: ((v: number[]) => void) | undefined - vi.spyOn(brain, 'embed').mockImplementation( - () => new Promise((resolve) => { resolveEmbed = resolve }) - ) - - const id = await brain.add({ data: 'deferred content', type: 'document', deferEmbedding: true }) - await brain.flush() - - // Landed nothing yet — the ledger must not count the stub. - let ledger = await brain.storage.getCanonicalCounts() - expect(ledger.vectors.all).toBe(baseline) - expect(ledger.vectors.all).toBe(countVectoredNouns(dir)) - - // Release the held embed, then cross the barrier: the vector lands - // (system:embed-landing). - resolveEmbed!(Array(384).fill(0).map((_, i) => Math.cos(i))) - await brain.awaitPendingEmbeds() - const landed = await brain.get(id, { includeVectors: true }) - expect((landed!.vector as number[]).length).toBeGreaterThan(0) - - ledger = await brain.storage.getCanonicalCounts() - expect(ledger.vectors.all).toBe(baseline + 1) - expect(ledger.vectors.all).toBe(countVectoredNouns(dir)) - expect(ledger.suspect).toBe(false) - }) - - it('a proven delete of a vectored noun decrements; a non-vectored (unlanded) delete does not', async () => { - const vectoredId = await brain.add({ data: 'v', type: 'document' }) // real embed, unmocked - // Block the embed worker AFTER the real add above — a deterministic - // embedder is fast enough that the deferred noun below could otherwise - // land before this test observes its "still unlanded" state. - vi.spyOn(brain, 'embed').mockImplementation(() => new Promise(() => {})) - const deferredId = await brain.add({ data: 'd', type: 'document', deferEmbedding: true }) - await brain.flush() - expect((await brain.storage.getCanonicalCounts()).vectors.all).toBe(baseline + 1) - - await brain.remove(vectoredId) - await brain.flush() - let ledger = await brain.storage.getCanonicalCounts() - expect(ledger.vectors.all).toBe(baseline) - expect(ledger.suspect).toBe(false) - - await brain.remove(deferredId) // never had a real vector — no decrement, still unsuspect - await brain.flush() - ledger = await brain.storage.getCanonicalCounts() - expect(ledger.vectors.all).toBe(baseline) - expect(ledger.suspect).toBe(false) - expect(ledger.vectors.all).toBe(countVectoredNouns(dir)) - }) - - it('the recount corrects a tampered vectors.all scalar, surviving reopen', async () => { - await brain.add({ data: 'real 1', type: 'document' }) - await brain.add({ data: 'real 2', type: 'document' }) - await brain.flush() - const truth = countVectoredNouns(dir) - expect(truth).toBe(baseline + 2) - - ;(brain.storage as any).totalVectoredNounCount = truth + 40 - await (brain.storage as any).persistCounts() - await brain.close() - brain = await open() - - // The lie survives reopen (never clamped). - expect((await brain.storage.getCanonicalCounts()).vectors.all).toBe(truth + 40) - - await brain.repairIndex() - expect((await brain.storage.getCanonicalCounts()).vectors.all).toBe(truth) - - await brain.close() - brain = await open() - expect((await brain.storage.getCanonicalCounts()).vectors.all).toBe(truth) - }) - - it('a legacy counts.json without totalVectoredNounCount is derived once from vectors.json content and persisted', async () => { - await brain.add({ data: 'one', type: 'document' }) // real embed, unmocked - // Block the embed worker AFTER the real add above — a deterministic - // embedder is fast enough that the deferred noun below could otherwise - // land before close(), which would inflate this test's expected count. - vi.spyOn(brain, 'embed').mockImplementation(() => new Promise(() => {})) - await brain.add({ data: 'two deferred', type: 'document', deferEmbedding: true }) - await brain.flush() - await brain.close() - - const countsPath = path.join(dir, '_system', 'counts.json') - const raw = JSON.parse(fs.readFileSync(countsPath, 'utf-8')) - expect(typeof raw.totalVectoredNounCount).toBe('number') - delete raw.totalVectoredNounCount - fs.writeFileSync(countsPath, JSON.stringify(raw, null, 2)) - - brain = await open() - const ledger = await brain.storage.getCanonicalCounts() - expect(ledger.vectors.all).toBe(baseline + 1) // just the one non-deferred noun — the root is unvectored - expect(ledger.vectors.all).toBe(countVectoredNouns(dir)) - const persisted = JSON.parse(fs.readFileSync(countsPath, 'utf-8')) - expect(persisted.totalVectoredNounCount).toBe(baseline + 1) - }) -}) diff --git a/tests/integration/cold-graph-connected-8.0.test.ts b/tests/integration/cold-graph-connected-8.0.test.ts index 7ee725f5..71ae345d 100644 --- a/tests/integration/cold-graph-connected-8.0.test.ts +++ b/tests/integration/cold-graph-connected-8.0.test.ts @@ -1,29 +1,29 @@ /** * @module tests/integration/cold-graph-connected-8.0 * @description BRAINY-COLD-GRAPH-CONNECTED (8.0) — regression coverage for the silent-empty - * graph-traversal bug, gated on the honest readiness signal: a sync `graphIndex.isReady()` - * that is true ONLY when the source→target EDGES are loaded (NOT the membership/manifest count). + * graph-traversal bug, gated on the converged 8.0 contract: a sync `graphIndex.isReady()` that + * is true ONLY when the source→target EDGES are loaded (NOT the membership/manifest count). * - * On the FIRST `find({ connected })` after a cold process start, a native graph adjacency can - * reload its relationship COUNT (so `size() > 0`) but NOT its edges — so `getNeighbors()` returns - * `[]` for EVERY source and brainy would serve that `[]` as if the anchor were genuinely edgeless. + * On the FIRST `find({ connected })` after a cold process start of a LARGE brain (≥10k nouns, + * which skips the eager index rebuild), a native graph adjacency can reload its relationship + * COUNT (so `size() > 0`) but NOT its edges — so `getNeighbors()` returns `[]` for EVERY source + * and brainy would serve that `[]` as if the anchor were genuinely edgeless. * - * RE-POINTED to the health-gate law: `verifyGraphAdjacencyLive` NEVER rebuilds and NEVER walks the - * store from a read — a read-path rebuild is exactly the dark-rebuild failure mode the law retires - * (open() alone owns building). The guard now: - * - `isReady() === false` → THROWS {@link GraphIndexNotReadyError} immediately — no rebuild attempt; + * The 8.0 guard (`verifyGraphAdjacencyLive`) prefers the honest `isReady()` signal: + * - `isReady() === false` → hydrate the id-mapper, rebuild from storage, re-check; a still-false + * `isReady()` throws {@link GraphIndexNotReadyError} instead of returning `[]` ('rebuilt' when + * the rebuild heals it); * - a genuinely edgeless anchor with `isReady() === true` verifies 'live' and the empty result - * stands — no spurious throw; - * - a provider WITHOUT `isReady()` falls back to the shipped known-edge-sample probe, which is - * now READ-ONLY: it refuses loudly (throws) rather than self-healing via rebuild. + * stands — no spurious rebuild, no throw; + * - a provider WITHOUT `isReady()` falls back to the shipped 7.x known-edge-sample probe. * * These exercise REAL `find({ connected })` against an in-memory brain whose graph index is - * instrumented with a test-double `isReady()` (and, for the fallback case, an always-empty + * instrumented with a test-double `isReady()` (and, for the fallback case, an empty-then-healed * `getNeighbors`). Only the readiness/edge surface is wrapped; the underlying real adjacency - * (built by `relate()`) is what a healthy provider actually serves. + * (built by `relate()`) is unmasked once a rebuild "heals" it. */ -import { describe, it, expect, afterEach, vi } from 'vitest' +import { describe, it, expect, afterEach } from 'vitest' import { Brainy } from '../../src/index.js' import { NounType, VerbType } from '../../src/types/graphTypes.js' import { GraphIndexNotReadyError } from '../../src/errors/brainyError.js' @@ -63,17 +63,17 @@ async function buildBrain( } /** - * Instrument the brain's real graph index with a test-double `isReady()` (the honest-readiness - * contract) plus an edge surface that goes empty while NOT ready. `getNeighbors` returns `[]` - * while `!ready` (modelling the cold-unloaded adjacency) and delegates to the REAL index once - * `ready` flips true (used only by the "healthy" control cases — the guard itself never flips - * this anymore, since it never rebuilds). `rebuild` is counted so tests can assert it is NEVER - * called by a read. + * Instrument the brain's real graph index with a test-double `isReady()` (the 8.0 contract) plus + * an edge surface that goes empty while NOT ready. `getNeighbors` returns `[]` while `!ready` + * (modelling the cold-unloaded adjacency) and delegates to the REAL index once a rebuild flips + * `ready` on. `rebuild` is counted; it heals (`ready = true`) only when `healsOnRebuild` is set. + * Pass `failFirstRebuild` to make the FIRST rebuild throw a transient error (without healing) so + * the empty-result re-collect path in executeGraphSearch is exercised. */ function instrumentIsReady( brain: any, - opts: { ready: boolean } -): { rebuildCalls: number; ready: boolean } { + opts: { ready: boolean; healsOnRebuild: boolean; failFirstRebuild?: boolean } +): { rebuildCalls: number } { const gi = brain.graphIndex const origGetNeighbors = gi.getNeighbors.bind(gi) const state = { ready: opts.ready, rebuildCalls: 0 } @@ -85,6 +85,10 @@ function instrumentIsReady( gi.rebuild = async (): Promise => { state.rebuildCalls++ + if (opts.failFirstRebuild && state.rebuildCalls === 1) { + throw new Error('transient rebuild hiccup') + } + if (opts.healsOnRebuild) state.ready = true // unmask the real (already-populated) adjacency } return state @@ -92,12 +96,12 @@ function instrumentIsReady( /** * Fallback instrumentation — a provider WITHOUT `isReady()` (older cortex / JS baseline). Wraps - * `getNeighbors` to always return `[]` while `broken`. This is the shipped known-edge-sample - * probe path — now READ-ONLY: it refuses loudly rather than self-healing. + * `getNeighbors` to return `[]` while `broken` and delegates to the REAL index once a rebuild + * heals it. This is the shipped 7.x known-edge-sample probe path on 8.0. */ function instrumentNoIsReady( brain: any, - opts: { broken: boolean } + opts: { broken: boolean; healsOnRebuild: boolean } ): { rebuildCalls: number } { const gi = brain.graphIndex // Ensure the provider does NOT expose isReady() — the default JS provider doesn't. @@ -110,12 +114,13 @@ function instrumentNoIsReady( gi.rebuild = async (): Promise => { state.rebuildCalls++ + if (opts.healsOnRebuild) state.broken = false } return state } -describe('BRAINY-COLD-GRAPH-CONNECTED 8.0 — isReady()-gated, never serves a silent [], never rebuilds from a read', () => { +describe('BRAINY-COLD-GRAPH-CONNECTED 8.0 — isReady()-gated, never serves a silent []', () => { let brains: any[] = [] afterEach(async () => { for (const b of brains) { @@ -126,37 +131,35 @@ describe('BRAINY-COLD-GRAPH-CONNECTED 8.0 — isReady()-gated, never serves a si } } brains = [] - vi.restoreAllMocks() }) - it('(a) isReady() false → THROWS GraphIndexNotReadyError immediately, no rebuild attempt', async () => { + it('(a) isReady() false → rebuild heals it true → find({ connected }) returns correct N (rebuilt)', async () => { + const { brain, anchorId, targetIds } = await buildBrain({ anchorEdges: true }) + brains.push(brain) + const state = instrumentIsReady(brain, { ready: false, healsOnRebuild: true }) + + const results = await brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) + + expect(state.rebuildCalls).toBeGreaterThanOrEqual(1) // detected not-ready + healed it + const ids = results.map((r: any) => r.id).sort() + expect(ids).toEqual(targetIds.sort()) // B, C, D — the real edges, served after the heal + }) + + it('(b) isReady() stays false after rebuild → throws GraphIndexNotReadyError (NOT a silent [])', async () => { const { brain, anchorId } = await buildBrain({ anchorEdges: true }) brains.push(brain) - const state = instrumentIsReady(brain, { ready: false }) + instrumentIsReady(brain, { ready: false, healsOnRebuild: false }) // rebuild never makes it ready await expect( brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) ).rejects.toBeInstanceOf(GraphIndexNotReadyError) - - expect(state.rebuildCalls).toBe(0) // a read never rebuilds — it refuses loudly instead - }) - - it('(b) isReady() stays false → throws GraphIndexNotReadyError (NOT a silent [])', async () => { - const { brain, anchorId } = await buildBrain({ anchorEdges: true }) - brains.push(brain) - const state = instrumentIsReady(brain, { ready: false }) - - await expect( - brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) - ).rejects.toBeInstanceOf(GraphIndexNotReadyError) - expect(state.rebuildCalls).toBe(0) }) it('(c) edgeless anchor + isReady() true → returns [] with NO rebuild and NO throw', async () => { // The anchor has no edges, but E -> F does — the adjacency is genuinely loaded (ready). const { brain, anchorId } = await buildBrain({ anchorEdges: false }) brains.push(brain) - const state = instrumentIsReady(brain, { ready: true }) + const state = instrumentIsReady(brain, { ready: true, healsOnRebuild: false }) const results = await brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) @@ -167,7 +170,7 @@ describe('BRAINY-COLD-GRAPH-CONNECTED 8.0 — isReady()-gated, never serves a si it('(d) healthy isReady() true → correct results, NO rebuild', async () => { const { brain, anchorId, targetIds } = await buildBrain({ anchorEdges: true }) brains.push(brain) - const state = instrumentIsReady(brain, { ready: true }) + const state = instrumentIsReady(brain, { ready: true, healsOnRebuild: false }) const results = await brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) @@ -176,30 +179,30 @@ describe('BRAINY-COLD-GRAPH-CONNECTED 8.0 — isReady()-gated, never serves a si expect(ids).toEqual(targetIds.sort()) }) - it('(e) provider WITHOUT isReady() → the known-edge-sample probe REFUSES LOUDLY (never self-heals)', async () => { - const { brain, anchorId } = await buildBrain({ anchorEdges: true }) + it('(e) provider WITHOUT isReady() → falls back to the known-edge-sample probe (self-heals)', async () => { + const { brain, anchorId, targetIds } = await buildBrain({ anchorEdges: true }) brains.push(brain) - const state = instrumentNoIsReady(brain, { broken: true }) + const state = instrumentNoIsReady(brain, { broken: true, healsOnRebuild: true }) - await expect( - brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) - ).rejects.toBeInstanceOf(GraphIndexNotReadyError) + const results = await brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) - expect(state.rebuildCalls).toBe(0) // the fallback probe is READ-ONLY — it never calls rebuild() + expect(state.rebuildCalls).toBeGreaterThanOrEqual(1) // detected the empty adjacency + healed it + const ids = results.map((r: any) => r.id).sort() + expect(ids).toEqual(targetIds.sort()) // B, C, D — served after the heal }) - it('(f) an empty connectedIds set re-verifies against a not-serving adjacency and throws, rather than serving [] as truth', async () => { - // executeGraphSearch's cold-load guard (connectedIds.size === 0 → re-verify) used to - // interpret a healed rebuild as "re-collect and serve." That rebuild-and-heal path is - // retired: the re-verify now either confirms a genuinely edgeless anchor ('live', case (c)) - // or — as here — discovers the adjacency itself is not serving, and throws. - const { brain, anchorId } = await buildBrain({ anchorEdges: true }) + it('(f) executeGraphSearch re-collect: a transient first rebuild leaves connectedIds empty; the empty-result guard then heals + re-collects', async () => { + // First verify (inside neighbors()) hits a transient rebuild failure → returns 'live' without + // healing, so getNeighbors stays empty and connectedIds is empty. The empty connectedIds set + // then drives executeGraphSearch's own verify, whose rebuild now heals → 'rebuilt' → re-collect. + const { brain, anchorId, targetIds } = await buildBrain({ anchorEdges: true }) brains.push(brain) - const state = instrumentIsReady(brain, { ready: false }) + const state = instrumentIsReady(brain, { ready: false, healsOnRebuild: true, failFirstRebuild: true }) - await expect( - brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) - ).rejects.toBeInstanceOf(GraphIndexNotReadyError) - expect(state.rebuildCalls).toBe(0) + const results = await brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) + + expect(state.rebuildCalls).toBeGreaterThanOrEqual(2) // first transient, second heals + const ids = results.map((r: any) => r.id).sort() + expect(ids).toEqual(targetIds.sort()) // re-collected after the heal }) }) diff --git a/tests/integration/count-ledger-identity-record.test.ts b/tests/integration/count-ledger-identity-record.test.ts deleted file mode 100644 index 1066213a..00000000 --- a/tests/integration/count-ledger-identity-record.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * @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/counts-persist-single-flight.test.ts b/tests/integration/counts-persist-single-flight.test.ts deleted file mode 100644 index 5acbdcc3..00000000 --- a/tests/integration/counts-persist-single-flight.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @module tests/integration/counts-persist-single-flight - * @description Regression for a production race in FileSystemStorage's - * counts ledger: `persistCounts()` was write-through on every count change - * with no serialization, and the atomic writer named its temp file with - * millisecond granularity (`.tmp--`). Two persists inside one - * millisecond shared the temp path — both wrote it, the first rename - * consumed it, the second rename found nothing: ENOENT, ~1,500 times a day - * on a busy production brain, with a full ledger write per change behind it. - * - * Under pin: persists are single-flight and coalesced — one in flight, at - * most one trailing pass carrying the burst's final state — and every atomic - * write owns a unique temp path. A burst of N count changes costs at most - * two ledger writes, never errors, and leaves a ledger equal to memory. - */ -import { describe, it, expect, beforeEach, 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/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' - -describe('counts persistence is single-flight, coalesced, and never races its own temp file', () => { - let dir: string - let brain: any - - beforeEach(async () => { - process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-counts-race-')) - brain = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - dimensions: 384, - silent: true - }) - await brain.init() - }) - - afterEach(async () => { - vi.restoreAllMocks() - await brain.close() - fs.rmSync(dir, { recursive: true, force: true }) - }) - - it('a burst of concurrent count changes → at most two ledger writes, zero errors, ledger == memory', async () => { - const storage = brain.storage - const countsPath: string = storage.countsFilePath - expect(countsPath, 'the filesystem adapter persists a counts ledger').toBeTruthy() - - // Let init's own persists settle so the burst is measured alone. - await storage.flushCounts?.() - - const renameSpy = vi.spyOn(fs.promises, 'rename') - const errorSpy = vi.spyOn(console, 'error') - - // Twenty-five concurrent count changes — the shape of a write burst; each - // used to launch its own persist. - const BURST = 25 - await Promise.all( - Array.from({ length: BURST }, () => storage.scheduleCountPersist()) - ) - - const ledgerRenames = renameSpy.mock.calls.filter(([, to]) => String(to) === countsPath) - expect(ledgerRenames.length, 'single-flight + one trailing pass').toBeLessThanOrEqual(2) - expect(ledgerRenames.length, 'the burst was persisted at all').toBeGreaterThanOrEqual(1) - - const persistErrors = errorSpy.mock.calls.filter((args) => String(args[0]).includes('persisting counts')) - expect(persistErrors).toEqual([]) - - const ledger = JSON.parse(fs.readFileSync(countsPath, 'utf-8')) - expect(ledger.totalNounCount).toBe(storage.totalNounCount) - expect(ledger.totalVerbCount).toBe(storage.totalVerbCount) - }) - - it('real writes in parallel: the ledger lands complete and no persist error is logged', async () => { - const storage = brain.storage - const countsPath: string = storage.countsFilePath - const errorSpy = vi.spyOn(console, 'error') - - await Promise.all( - Array.from({ length: 12 }, (_, i) => - brain.add({ data: `burst row ${i}`, type: NounType.Thing }) - ) - ) - await storage.flushCounts?.() - - const persistErrors = errorSpy.mock.calls.filter((args) => String(args[0]).includes('persisting counts')) - expect(persistErrors).toEqual([]) - const ledger = JSON.parse(fs.readFileSync(countsPath, 'utf-8')) - expect(ledger.totalNounCount).toBe(storage.totalNounCount) - expect(await brain.getNounCount()).toBe(ledger.totalNounCount) - }) - - it('every atomic write owns its own temp path — two writes in one millisecond never collide', async () => { - const storage = brain.storage - const tmpNames: string[] = [] - vi.spyOn(fs.promises, 'writeFile').mockImplementation(async (p: any) => { - tmpNames.push(String(p)) - }) - vi.spyOn(fs.promises, 'rename').mockImplementation(async () => undefined) - const target = path.join(dir, 'probe.json') - await Promise.all([ - storage.writeFileAtomic(target, '{"a":1}'), - storage.writeFileAtomic(target, '{"a":2}'), - storage.writeFileAtomic(target, '{"a":3}') - ]) - const probeTmps = tmpNames.filter((n) => n.startsWith(`${target}.tmp-`)) - expect(probeTmps.length).toBe(3) - expect(new Set(probeTmps).size, 'no two writes shared a temp path').toBe(3) - }) -}) diff --git a/tests/integration/db-mvcc.test.ts b/tests/integration/db-mvcc.test.ts index 0efc453e..959d0053 100644 --- a/tests/integration/db-mvcc.test.ts +++ b/tests/integration/db-mvcc.test.ts @@ -96,15 +96,11 @@ describe('8.0 Db API — generational MVCC', () => { } /** Open (and track) a filesystem brain rooted at a fresh temp directory. */ - async function openFsBrain( - dir?: string, - logAuthority?: 'adopt' | 'defer' - ): Promise<{ brain: Brainy; dir: string }> { + async function openFsBrain(dir?: string): Promise<{ brain: Brainy; dir: string }> { const rootDirectory = dir ?? makeTempDir() const brain = new Brainy({ requireSubtype: false, - storage: { type: 'filesystem', path: rootDirectory }, - ...(logAuthority ? { logAuthority } : {}) + storage: { type: 'filesystem', path: rootDirectory } }) await brain.init() brains.push(brain) @@ -651,13 +647,7 @@ describe('8.0 Db API — generational MVCC', () => { // ========================================================================== it('proof 8 — a crash before the manifest rename recovers to the exact pre-transaction state', async () => { const dir = makeTempDir() - // 'defer' (tree authority): this proof pins the TREE commit-point - // contract — the manifest rename is the commit, so a crash before it - // rolls back. Under the adopt-at-open default (log authority) the same - // crash point legitimately REPLAYS the fsynced fact at reopen and the - // transaction lands — that contract is pinned in the durability kill - // matrix's at-ack rows, not here. - const { brain: first } = await openFsBrain(dir, 'defer') + const { brain: first } = await openFsBrain(dir) await first.transact([ { @@ -699,10 +689,9 @@ describe('8.0 Db API — generational MVCC', () => { // the realistic worst case for the recovery path. await first.close() - // Reopen ('defer' again — a reopen under the adopt default would adopt - // and change the recovery path): recovery rolls the uncommitted - // generation back and rebuilds the indexes from the repaired records. - const { brain: second } = await openFsBrain(dir, 'defer') + // Reopen: recovery rolls the uncommitted generation back and rebuilds + // the indexes from the repaired records. + const { brain: second } = await openFsBrain(dir) const recovered = await second.get(uid('crash-e')) expect((recovered?.metadata as { v: number }).v).toBe(1) expect(await second.get(uid('crash-new'))).toBeNull() @@ -1173,16 +1162,13 @@ describe('8.0 Db API — generational MVCC', () => { const brain = await openMemoryBrain() // Model-B: a single-op write is its OWN generation and IS logged (no meta — - // tx metadata is a transact()-only concept). Relative baseline: under the - // adopt-at-open fleet default the open-time baseline backfill is itself a - // logged single-op generation, so the log is not empty on a fresh brain — - // every pin below is expressed against that baseline. - const baseGens = (await brain.transactionLog()).map((entry) => entry.generation) + // tx metadata is a transact()-only concept). It is generation 1 on a fresh + // brain (init-time infrastructure writes are the un-versioned gen-0 baseline). await brain.add({ id: uid('txlog-solo'), type: NounType.Document, data: 'solo', vector: vec(99), subtype: 'note' }) const soloLog = await brain.transactionLog() - const soloGen = brain.generation() - expect(soloLog.map((entry) => entry.generation)).toEqual([soloGen, ...baseGens]) + expect(soloLog.map((entry) => entry.generation)).toEqual([1]) expect(soloLog[0].meta).toBeUndefined() + const soloGen = 1 const first = await brain.transact( [{ op: 'add', id: uid('txlog-a'), type: NounType.Document, data: 'a', vector: vec(100), metadata: {} }], @@ -1195,14 +1181,12 @@ describe('8.0 Db API — generational MVCC', () => { const third = await brain.transact([{ op: 'update', id: uid('txlog-a'), metadata: { v: 3 } }]) const entries = await brain.transactionLog() - // Newest first: the three transacts, then the single-op solo write, then - // whatever the open baseline logged (the adopt-at-open backfill). + // Newest first: the three transacts, then the single-op solo write (gen 1). expect(entries.map((entry) => entry.generation)).toEqual([ third.generation, second.generation, first.generation, - soloGen, - ...baseGens + soloGen ]) expect(entries[1].meta).toEqual({ author: 'job-2' }) expect(entries[2].meta).toEqual({ author: 'job-1' }) @@ -1254,24 +1238,21 @@ describe('8.0 Db API — generational MVCC', () => { const brain = await openMemoryBrain() const a = uid('ov-a') const b = uid('ov-b') - // Pin RELATIVELY at the transact's own generation (not an absolute 1 — - // the adopt-at-open baseline backfill owns the first generation). - const tx = await brain.transact([ - { op: 'add', id: a, type: NounType.Document, data: 'a', vector: vec(1), metadata: { v: 1 } }, - { op: 'add', id: b, type: NounType.Document, data: 'b', vector: vec(2), metadata: { v: 1 } } - ]) - const txGen = tx.generation - await tx.release() - const at1 = await brain.asOf(txGen) + await ( + await brain.transact([ + { op: 'add', id: a, type: NounType.Document, data: 'a', vector: vec(1), metadata: { v: 1 } }, + { op: 'add', id: b, type: NounType.Document, data: 'b', vector: vec(2), metadata: { v: 1 } } + ]) + ).release() + const at1 = await brain.asOf(1) // A single-op REMOVE of `b` lands AFTER the pin and is NOT flushed (pending). await brain.remove(b) const liveIds = (await brain.find({})).map((r) => r.id) const pastIds = (await at1.find({})).map((r) => r.id) - // Live: `b` is gone. Historical (pinned at the transact's generation): the - // un-flushed removal is overlaid out, so `b` is still present at its - // pinned state. + // Live: `b` is gone. Historical (pinned at gen 1): the un-flushed removal is + // overlaid out, so `b` is still present at its pinned state. expect(liveIds).toContain(a) expect(liveIds).not.toContain(b) expect(pastIds).toContain(a) @@ -1281,14 +1262,11 @@ describe('8.0 Db API — generational MVCC', () => { it('Model-B retention — explicit caps reclaim single-op history; committed history survives reopen', async () => { const { brain, dir } = await openFsBrain() - // Relative baseline: the adopt-at-open backfill holds the first - // generation(s), so the 6 writes below land at base+1..base+6. - const base = brain.generation() const a = uid('ret-a') await brain.add({ id: a, type: NounType.Document, data: 'a', vector: vec(1), metadata: { v: 1 } }) for (let v = 2; v <= 6; v++) await brain.update({ id: a, metadata: { v } }) await brain.flush() // persist the per-write generations to disk - expect(brain.generation()).toBe(base + 6) + expect(brain.generation()).toBe(6) // Cap to the 2 most recent generations — older single-op history is reclaimed. const res = await brain.compactHistory({ maxGenerations: 2 }) diff --git a/tests/integration/db-temporal.test.ts b/tests/integration/db-temporal.test.ts index 335a1681..d17f5c16 100644 --- a/tests/integration/db-temporal.test.ts +++ b/tests/integration/db-temporal.test.ts @@ -36,9 +36,6 @@ import { GenerationCompactedError } from '../../src/db/errors.js' import type { GenerationStore } from '../../src/db/generationStore.js' import { NounType } from '../../src/types/graphTypes.js' -/** The VFS root — re-committed by the adopt-at-open baseline backfill. */ -const VFS_ROOT = '00000000-0000-0000-0000-000000000000' - /** Deterministic 384-dim vector so no test ever invokes the embedder. */ function vec(seed: number): number[] { return Array.from({ length: 384 }, (_, i) => ((seed * 31 + i * 7) % 100) / 100) @@ -136,11 +133,7 @@ describe('8.0 Db API — temporal range verbs', () => { expect(viaDb).toEqual(viaGen) expect(viaDb.fromGeneration).toBe(g1) expect(viaDb.nouns).toEqual([a, b].sort()) // a (updated after g1) + b (added after g1) - // (0, now] also includes a's creation — still {a, b} among user rows. The - // adopt-at-open baseline backfill re-commits the VFS root as a real - // generation, so the full-epoch window legitimately reports it too; - // filter it to keep this pin about the user writes. - expect(viaEpoch.nouns.filter((n) => n !== VFS_ROOT)).toEqual([a, b].sort()) + expect(viaEpoch.nouns).toEqual([a, b].sort()) // (0, now] also includes a's creation, still {a, b} // direction guard: an older view cannot be `since` a newer lower bound const older = await brain.asOf(1) @@ -170,11 +163,7 @@ describe('8.0 Db API — temporal range verbs', () => { } const all = await brain.transactionLog() - // Newest first — compared above the open baseline (the adopt-at-open - // backfill logs its own generation(s) below the first user write). - expect(all.map((e) => e.generation).filter((g) => g >= gens[0])).toEqual( - [...gens].reverse() - ) + expect(all.map((e) => e.generation)).toEqual([...gens].reverse()) // newest first // INCLUSIVE both ends — gens[1] AND gens[3] are present (contrast since's exclusive lower). const windowed = await brain.transactionLog({ from: gens[1], to: gens[3] }) @@ -345,22 +334,19 @@ describe('8.0 Db API — temporal range verbs', () => { // 7. Granularity (Model-B) --------------------------------------------------- it('granularity: single-operation writes ARE versioned and visible to the temporal verbs', async () => { const brain = await openMemoryBrain() - // Relative baseline: the adopt-at-open backfill already logged its own - // generation(s) — pin the DELTA this test's writes add, not a count. - const baseCount = (await brain.transactionLog()).length const a = uid('gran-a') const r1 = await brain.transact([ { op: 'add', id: a, type: NounType.Document, data: 'a', vector: vec(1), metadata: { v: 1 } } ]) await r1.release() - expect((await brain.transactionLog()).length).toBe(baseCount + 1) + expect((await brain.transactionLog()).length).toBe(1) // Model-B: a single-op write is its OWN immutable generation — logged, // diffable, and time-travelable, exactly like a transact() of one op. await brain.update({ id: a, metadata: { v: 2 } }) // The single-op update appended a generation/log entry. - expect((await brain.transactionLog()).length).toBe(baseCount + 2) + expect((await brain.transactionLog()).length).toBe(2) expect(brain.generation()).toBe(r1.generation + 1) // diff sees the single-op update as a modification of `a`. diff --git a/tests/integration/deferred-embedding.test.ts b/tests/integration/deferred-embedding.test.ts deleted file mode 100644 index 819ddbad..00000000 --- a/tests/integration/deferred-embedding.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * @module tests/integration/deferred-embedding - * @description MT5 — THE DEFERRED-EMBEDDING CONTRACT (A3 of the service-class - * pair, BRAINY-PROD-LATENCY-TRIAD). The production disease: a VFS file write - * ran a neural network synchronously while the caller waited (5.6s p50 per - * small file). The contract pinned here: - * - * 1. ACK AT DURABILITY: a deferred write never calls the embedder on the - * caller's path — the row is id/metadata-findable immediately, with a - * durable pending marker and an honest `pendingEmbeds` gauge. - * 2. EVENTUAL VECTOR INDEX: `awaitPendingEmbeds()` is the barrier — after - * it, the vector is real, indexed, and the marker is reaped. - * 3. STALE-BEATS-ABSENT on deferred updates: the OLD vector keeps serving - * until the atomic swap (the flicker law, never a dark window). - * 4. CRASH-SAFE: markers survive a session that dies mid-defer; the next - * open recovers and lands the vector. A crash DELAYS a vector, never - * loses one. - * 5. TYPED REFUSALS: deferEmbedding + vector, and deferEmbedding without - * data, are caller bugs that refuse with the fix in the message. - */ -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/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -const dirs: string[] = [] -const brains: Brainy[] = [] - -async function memBrain(): Promise { - const b = new Brainy({ storage: { type: 'memory' }, requireSubtype: false }) - await b.init() - brains.push(b) - return b -} - -afterEach(async () => { - vi.restoreAllMocks() - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -describe('MT5 — deferred embedding', () => { - it('ACK LAW: add({deferEmbedding}) never embeds on the caller path; row findable immediately; barrier lands the vector and reaps the marker', async () => { - const brain = await memBrain() - const embedSpy = vi.spyOn(brain, 'embed') - - const id = await brain.add({ - data: 'deferred content', - type: NounType.Document, - deferEmbedding: true, - metadata: { tag: 'deferred' } - }) - - // The caller's path never ran the embedder. - expect(embedSpy, 'no embed on the ack path').not.toHaveBeenCalled() - - // Immediately findable by metadata; vector is the stub; gauge honest. - const found = await brain.find({ where: { tag: 'deferred' }, limit: 5 }) - expect(found.map((r) => r.id)).toContain(id) - expect((await brain.getIndexStatus()).pendingEmbeds).toBeGreaterThanOrEqual(1) - - // The barrier: vector lands, marker reaped, index carries the row. - await brain.awaitPendingEmbeds() - expect(embedSpy).toHaveBeenCalled() - const after = await brain.get(id, { includeVectors: true }) - expect((after!.vector as number[]).length, 'real vector after the barrier').toBeGreaterThan(0) - expect(brain.pendingEmbedCount()).toBe(0) - expect((await brain.getIndexStatus()).pendingEmbeds).toBe(0) - }) - - it('STALE-BEATS-ABSENT: a deferred update serves the OLD vector until the atomic swap; data reads NEW immediately', async () => { - const brain = await memBrain() - const id = await brain.add({ data: 'original content', type: NounType.Document, metadata: {} }) - const before = await brain.get(id, { includeVectors: true }) - const oldVector = [...(before!.vector as number[])] - expect(oldVector.length).toBeGreaterThan(0) - - await brain.update({ id, data: 'completely different content', deferEmbedding: true }) - - // Data is new IMMEDIATELY; the vector is still the old one (present, - // never absent) until the worker swaps it. - const mid = await brain.get(id, { includeVectors: true }) - expect(mid!.data).toBe('completely different content') - expect(mid!.vector as number[], 'old vector keeps serving').toEqual(oldVector) - - await brain.awaitPendingEmbeds() - const after = await brain.get(id, { includeVectors: true }) - expect((after!.vector as number[]).length).toBeGreaterThan(0) - expect(after!.vector as number[], 'vector swapped after the barrier').not.toEqual(oldVector) - }) - - it('CRASH-SAFE: a session dying mid-defer leaves the durable marker; the next open recovers and lands the vector', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-defer-crash-')) - dirs.push(dir) - - // Session 1: the embedder hangs → the worker can never complete; close() - // does not wait for it (crash-equivalent for the embed leg). - let brain = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false }) - await brain.init() - brains.push(brain) - vi.spyOn(brain, 'embed').mockImplementation(() => new Promise(() => {})) - const id = await brain.add({ - data: 'survives the crash', - type: NounType.Document, - deferEmbedding: true, - metadata: { k: 1 } - }) - expect(brain.pendingEmbedCount()).toBe(1) - await brain.close() - brains.pop() - vi.restoreAllMocks() - - // Session 2: recovery lists the marker and resumes in the background. - brain = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false }) - await brain.init() - brains.push(brain) - expect(brain.pendingEmbedCount(), 'marker recovered at open').toBe(1) - - await brain.awaitPendingEmbeds() - const after = await brain.get(id, { includeVectors: true }) - expect((after!.vector as number[]).length, 'the delayed vector landed').toBeGreaterThan(0) - expect(brain.pendingEmbedCount()).toBe(0) - }, 120000) - - it('VFS ACK LAW: writeFile resolves even when the embedder HANGS forever — the ack never depends on a neural net', async () => { - const brain = await memBrain() - // The strongest form of the pin: an embedder that never resolves. If any - // part of the writeFile ack path awaited an embed, this test would hang. - // (The background worker legitimately picks the deferred embeds up later - // — it may even interleave on the event loop during writeFile's other - // awaits — but the CALLER'S promise must never depend on it.) - const hang = vi - .spyOn(brain, 'embed') - .mockImplementation(() => new Promise(() => {})) - - await brain.vfs.writeFile('/notes/today.md', '# The day\nA deferred capture.') - - // Acked with the embedder hung: content + metadata fully readable. - const content = await brain.vfs.readFile('/notes/today.md') - expect(content.toString()).toContain('A deferred capture.') - expect(brain.pendingEmbedCount()).toBeGreaterThanOrEqual(1) - - // Un-hang, abandon the poisoned in-flight run (its embed promise never - // resolves — production is covered by the worker's 60s hang guard; the - // test takes the white-box shortcut for speed), drain, verify. - hang.mockRestore() - ;(brain as unknown as { _embedWorkerFlight: Promise | null })._embedWorkerFlight = null - await brain.awaitPendingEmbeds() - expect(brain.pendingEmbedCount()).toBe(0) - }) - - it('TYPED REFUSALS: defer+vector and defer-without-data both refuse with the fix', async () => { - const brain = await memBrain() - await expect( - brain.add({ - data: 'x', - vector: new Array(384).fill(0.1), - type: NounType.Document, - deferEmbedding: true, - metadata: {} - }) - ).rejects.toThrow(/deferEmbedding cannot be combined/) - - const id = await brain.add({ data: 'y', type: NounType.Document, metadata: {} }) - await expect( - brain.update({ id, deferEmbedding: true, metadata: { z: 1 } }) - ).rejects.toThrow(/requires new 'data'/) - }) -}) diff --git a/tests/integration/durability-kill-matrix.test.ts b/tests/integration/durability-kill-matrix.test.ts deleted file mode 100644 index 70962dda..00000000 --- a/tests/integration/durability-kill-matrix.test.ts +++ /dev/null @@ -1,701 +0,0 @@ -/** - * @module tests/integration/durability-kill-matrix - * @description THE DURABILITY KILL MATRIX — for every step of the commit - * path, inject a crash AT that step (the generation store's test-only fault - * injector), then reopen the same storage directory with a brand-new Brainy - * and assert the recovery contract BY CONSTRUCTION, not by timing: - * - * - an ACKED write survives the crash (never a lost ack), and - * - an UN-ACKED write leaves no torn state (fully present or fully absent, - * never half). - * - * The crash simulation is honest process death: the crashed brain is NEVER - * closed — `abandonAsCrashed` discards its buffered RAM state exactly as a - * dead process would, and recovery on the next open is the only repair that - * runs. File bytes already handed to the OS survive (process-crash model); - * one row additionally models POWER LOSS by removing an entity's un-fsynced - * canonical files (legal: single-op canonical writes are tmp+rename without - * fsync). - * - * Matrix rows (fault point → durability barrier position): - * - * BEFORE the barrier (nothing durable records the write): - * singleop-after-execute · singleop-after-fact-append · flush-after-staging - * AFTER partial durability (staged/synced bytes exist, manifest did not advance): - * flush-before-manifest · before-manifest-rename (transact) · - * transact-after-fact-sync - * AFTER the commit point: - * after-manifest-rename (transact) - * MODE VARIANTS: singleop-after-fact-append under durable-at-ack. - * DISK FULL: one ENOSPC'd append — loud typed rejection, reads keep - * serving, a later write succeeds. - * - * Where the observed recovery contract differs from the ideal, the pin states - * the OBSERVED behavior with a comment; where the observed behavior violates - * "never a torn state / never a lost ack", the pin asserts the CONTRACT and - * is marked `.fails` — a release-blocking finding, deliberately not weakened. - */ -import { describe, it, expect, afterEach } from 'vitest' -import * as fs from 'node:fs' -import { join } from 'node:path' -import { Brainy } from '../../src/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' -import { - abandonAsCrashed, - armCrash, - dropCanonicalNoun, - factGenerations, - failNextAppendWithEnospc, - generationDirExists, - makeTempDir, - openBrain, - storeOf, - uid, - vec -} from '../helpers/durabilityKillMatrix.js' - -describe('durability kill matrix — crash at every commit-path step, recover by reopen', () => { - const dirs: string[] = [] - const liveBrains: Brainy[] = [] - // Crashed brains are deliberately NEVER closed (a dead process cannot - // close); they are severed by abandonAsCrashed inside each test. - - function trackDir(): string { - const dir = makeTempDir() - dirs.push(dir) - return dir - } - - async function openLive(dir: string): Promise { - const brain = await openBrain(dir) - liveBrains.push(brain) - return brain - } - - afterEach(async () => { - for (const brain of liveBrains.splice(0)) { - try { - await brain.close() - } catch { - // already closed / crashed mid-close — teardown only - } - } - for (const dir of dirs.splice(0)) { - await fs.promises.rm(dir, { recursive: true, force: true }) - } - }) - - /** Baseline arrangement: one durable row + explicit flush = the durable floor. */ - async function arrangeBaseline(label: string): Promise<{ - dir: string - brain: Brainy - baselineId: string - floor: number - }> { - const dir = trackDir() - const brain = await openBrain(dir) // NOT tracked live — most rows crash it - const baselineId = uid(`${label}-baseline`) - await brain.add({ - id: baselineId, - data: 'baseline row', - type: NounType.Document, - vector: vec(1), - metadata: { v: 1 } - }) - await brain.flush() - return { dir, brain, baselineId, floor: storeOf(brain).committedGeneration() } - } - - /** - * Flip a brain to durable-at-ack (log-authority) mode. - * - * NOT via `adoptLogAuthority()` (and the helper opens every brain with - * `logAuthority: 'defer'`, opting out of the 10.0.0 adopt-at-open fleet - * default): the sanctioned path runs the oracle and a baseline backfill, - * which appends its own generation — shifting the floor arithmetic every - * row pins. This helper flips the SAME switch the sanctioned path flips - * (`setLogDurability('at-ack')`) and persists the SAME authority artifact, - * so a reopened brain also runs in log-authority mode. The durability - * semantics under test are governed entirely by that switch. - */ - async function flipToAtAck(brain: Brainy): Promise { - const storage = ( - brain as unknown as { - storage: { - writeRawObject(p: string, d: unknown): Promise - syncRawObjects(p: string[]): Promise - } - } - ).storage - await storage.writeRawObject('_system/log-authority.json', { - authority: 'log', - flippedAt: Date.now() - }) - await storage.syncRawObjects(['_system/log-authority.json']) - storeOf(brain).setLogDurability('at-ack') - } - - // ========================================================================== - // Rows BEFORE the durability barrier — the write never became durable-acked - // ========================================================================== - - it('singleop-after-execute — un-acked write is atomic (present-whole), baseline and log stay at the floor', async () => { - const { dir, brain, baselineId, floor } = await arrangeBaseline('sae') - const crashedId = uid('sae-crashed') - const arm = armCrash(brain, 'singleop-after-execute') - await expect( - brain.add({ - id: crashedId, - data: 'never acked', - type: NounType.Document, - vector: vec(2), - metadata: { v: 2 } - }) - ).rejects.toThrow('simulated process crash at singleop-after-execute') - expect(arm.fired).toContain('singleop-after-execute') - await abandonAsCrashed(brain) - - const reopened = await openLive(dir) - // Baseline intact. - expect(((await reopened.get(baselineId)) as { metadata: { v: number } }).metadata.v).toBe(1) - // The log holds nothing beyond the committed watermark (no fact was ever - // appended for the crashed write). - expect(await factGenerations(reopened)).toEqual([floor]) - expect(storeOf(reopened).committedGeneration()).toBe(floor) - // The un-acked write: Model-B applies the live canonical write BEFORE the - // ack, so under process death its bytes survive — the row is PRESENT and - // WHOLE by id (atomic, not torn). Under power loss the same un-fsynced - // bytes may instead vanish entirely; both end states are atomic. NOTE the - // divergence: the row is get()-visible but find()-invisible (no index - // entry survived, no generation/fact records it, and no repair is pending - // — a permanent canonical orphan; see the suite report). - const orphan = (await reopened.get(crashedId)) as { metadata: { v: number } } | null - expect(orphan).not.toBeNull() - expect(orphan!.metadata.v).toBe(2) // whole, byte-consistent — never torn - const found = (await reopened.find({ type: NounType.Document, limit: 10 })) as Array<{ id: string }> - expect(found.map((f) => f.id)).toContain(baselineId) - expect(found.map((f) => f.id)).not.toContain(crashedId) - // A fresh write succeeds with a monotonic generation. The crashed - // generation number is REUSED (nothing durable references it): the - // counter reopened at the floor. - expect(reopened.generation()).toBe(floor) - const freshId = uid('sae-fresh') - await reopened.add({ - id: freshId, - data: 'fresh after recovery', - type: NounType.Document, - vector: vec(3), - metadata: { v: 3 } - }) - await reopened.flush() - expect(storeOf(reopened).committedGeneration()).toBe(floor + 1) - expect(((await reopened.get(freshId)) as { metadata: { v: number } }).metadata.v).toBe(3) - }) - - it('singleop-after-fact-append (deferred mode) — the appended fact is truncated back at reopen', async () => { - const { dir, brain, baselineId, floor } = await arrangeBaseline('sfa') - const crashedId = uid('sfa-crashed') - const arm = armCrash(brain, 'singleop-after-fact-append') - await expect( - brain.add({ - id: crashedId, - data: 'never acked', - type: NounType.Document, - vector: vec(2), - metadata: { v: 2 } - }) - ).rejects.toThrow('simulated process crash at singleop-after-fact-append') - expect(arm.fired).toContain('singleop-after-fact-append') - await abandonAsCrashed(brain) - - const reopened = await openLive(dir) - // The fact WAS appended to the log file before the crash (process death - // keeps file bytes) — open() must truncate it back to the manifest - // watermark, and does. - expect(await factGenerations(reopened)).toEqual([floor]) - expect(storeOf(reopened).committedGeneration()).toBe(floor) - // Baseline intact; un-acked row atomic (present-whole via canonical, as - // in the singleop-after-execute row). - expect(((await reopened.get(baselineId)) as { metadata: { v: number } }).metadata.v).toBe(1) - const orphan = (await reopened.get(crashedId)) as { metadata: { v: number } } | null - expect(orphan).not.toBeNull() - expect(orphan!.metadata.v).toBe(2) - // Fresh write with a monotonic generation (crashed number reused — the - // truncated fact freed it). - expect(reopened.generation()).toBe(floor) - const freshId = uid('sfa-fresh') - await reopened.add({ - id: freshId, - data: 'fresh', - type: NounType.Document, - vector: vec(3), - metadata: { v: 3 } - }) - await reopened.flush() - expect(storeOf(reopened).committedGeneration()).toBe(floor + 1) - expect(await factGenerations(reopened)).toEqual([floor, floor + 1]) - }) - - it('flush-after-staging — the ACKED write survives (drop-without-restore); only the window history is lost', async () => { - const { dir, brain, baselineId, floor } = await arrangeBaseline('fas') - const ackedId = uid('fas-acked') - await brain.add({ - id: ackedId, - data: 'acked before flush', - type: NounType.Document, - vector: vec(2), - metadata: { v: 2 } - }) - const ackedGen = storeOf(brain).generation() - const arm = armCrash(brain, 'flush-after-staging') - await expect(brain.flush()).rejects.toThrow('simulated process crash at flush-after-staging') - expect(arm.fired).toContain('flush-after-staging') - // The crashed flush left the staged record-set dir on disk, above the manifest. - expect(generationDirExists(dir, ackedGen)).toBe(true) - await abandonAsCrashed(brain) - - const reopened = await openLive(dir) - // Recovery DROPPED the staged group-commit dir WITHOUT restoring its - // before-images — restoring would silently revert an acknowledged write. - expect(generationDirExists(dir, ackedGen)).toBe(false) - expect(storeOf(reopened).committedGeneration()).toBe(floor) - // NEVER A LOST ACK: the acknowledged write is present and whole. - const acked = (await reopened.get(ackedId)) as { metadata: { v: number } } | null - expect(acked).not.toBeNull() - expect(acked!.metadata.v).toBe(2) - expect(((await reopened.get(baselineId)) as { metadata: { v: number } }).metadata.v).toBe(1) - // Recovery rolled generations back → index reconciliation ran → the acked - // row is find()-visible too. - const found = (await reopened.find({ type: NounType.Document, limit: 10 })) as Array<{ id: string }> - expect(found.map((f) => f.id)).toEqual(expect.arrayContaining([baselineId, ackedId])) - // The window's HISTORY is the documented cost: its fact is truncated back - // (the acked row now lives only in canonical bytes, not the log). - expect(await factGenerations(reopened)).toEqual([floor]) - // The crashed generation number is NOT reused (its dropped dir was seen - // at open): fresh writes continue above it. - expect(reopened.generation()).toBe(ackedGen) - const freshId = uid('fas-fresh') - await reopened.add({ - id: freshId, - data: 'fresh', - type: NounType.Document, - vector: vec(3), - metadata: { v: 3 } - }) - await reopened.flush() - expect(storeOf(reopened).committedGeneration()).toBe(ackedGen + 1) - }) - - // ========================================================================== - // Rows AFTER partial durability — staged/synced bytes exist, no manifest - // ========================================================================== - - it('flush-before-manifest — staged bytes + synced facts above the manifest are dropped/truncated; the acked write stays', async () => { - const { dir, brain, baselineId, floor } = await arrangeBaseline('fbm') - const ackedId = uid('fbm-acked') - await brain.add({ - id: ackedId, - data: 'acked before flush', - type: NounType.Document, - vector: vec(2), - metadata: { v: 2 } - }) - const ackedGen = storeOf(brain).generation() - const arm = armCrash(brain, 'flush-before-manifest') - await expect(brain.flush()).rejects.toThrow('simulated process crash at flush-before-manifest') - // The earlier flush phase passed through untripped before the target fired. - expect(arm.fired).toContain('flush-after-staging') - expect(arm.fired).toContain('flush-before-manifest') - expect(generationDirExists(dir, ackedGen)).toBe(true) - await abandonAsCrashed(brain) - - const reopened = await openLive(dir) - // Per the recovery contract in open(): groupCommit record-sets above the - // manifest are dropped WITHOUT restore, and the (fsynced!) facts above - // the manifest are truncated back. The acked live write stays. - expect(generationDirExists(dir, ackedGen)).toBe(false) - expect(storeOf(reopened).committedGeneration()).toBe(floor) - expect(await factGenerations(reopened)).toEqual([floor]) - const acked = (await reopened.get(ackedId)) as { metadata: { v: number } } | null - expect(acked).not.toBeNull() // never a lost ack - expect(acked!.metadata.v).toBe(2) - expect(((await reopened.get(baselineId)) as { metadata: { v: number } }).metadata.v).toBe(1) - // Fresh write above the crashed generation (number not reused). - expect(reopened.generation()).toBe(ackedGen) - const freshId = uid('fbm-fresh') - await reopened.add({ - id: freshId, - data: 'fresh', - type: NounType.Document, - vector: vec(3), - metadata: { v: 3 } - }) - await reopened.flush() - expect(storeOf(reopened).committedGeneration()).toBe(ackedGen + 1) - }) - - it('before-manifest-rename (transact) — fully staged, never committed: rolled back byte-identically', async () => { - const { dir, brain, baselineId, floor } = await arrangeBaseline('bmr') - const newId = uid('bmr-new') - const arm = armCrash(brain, 'before-manifest-rename') - await expect( - brain.transact([ - { op: 'update', id: baselineId, metadata: { v: 2 } }, - { - op: 'add', - id: newId, - type: NounType.Document, - data: 'uncommitted', - vector: vec(2), - metadata: { v: 2 } - } - ]) - ).rejects.toThrow('simulated process crash at before-manifest-rename') - expect(arm.fired).toContain('before-manifest-rename') - const txGen = storeOf(brain).generation() - expect(generationDirExists(dir, txGen)).toBe(true) - await abandonAsCrashed(brain) - - const reopened = await openLive(dir) - // Rolled back cleanly: the update is undone, the add is ABSENT everywhere. - expect(((await reopened.get(baselineId)) as { metadata: { v: number } }).metadata.v).toBe(1) - expect(await reopened.get(newId)).toBeNull() - const found = (await reopened.find({ type: NounType.Document, limit: 10 })) as Array<{ id: string }> - expect(found.map((f) => f.id)).not.toContain(newId) - expect(generationDirExists(dir, txGen)).toBe(false) - expect(storeOf(reopened).committedGeneration()).toBe(floor) - expect(await factGenerations(reopened)).toEqual([floor]) - // The crashed generation number is never reissued (counter persisted - // before the crash point). - expect(reopened.generation()).toBe(txGen) - const freshId = uid('bmr-fresh') - await reopened.add({ - id: freshId, - data: 'fresh', - type: NounType.Document, - vector: vec(3), - metadata: { v: 3 } - }) - await reopened.flush() - expect(storeOf(reopened).committedGeneration()).toBe(txGen + 1) - }) - - it('transact-after-fact-sync — the fsynced fact of an uncommitted transact is truncated back; rollback is clean', async () => { - const { dir, brain, baselineId, floor } = await arrangeBaseline('tfs') - const newId = uid('tfs-new') - const arm = armCrash(brain, 'transact-after-fact-sync') - await expect( - brain.transact([ - { op: 'update', id: baselineId, metadata: { v: 2 } }, - { - op: 'add', - id: newId, - type: NounType.Document, - data: 'uncommitted', - vector: vec(2), - metadata: { v: 2 } - } - ]) - ).rejects.toThrow('simulated process crash at transact-after-fact-sync') - expect(arm.fired).toContain('transact-after-fact-sync') - const txGen = storeOf(brain).generation() - await abandonAsCrashed(brain) - - const reopened = await openLive(dir) - // The batch's fact was appended AND fsynced before the crash — open() - // must truncate it back to the manifest watermark (the generation never - // committed), and the before-images must restore byte-identically. - expect(await factGenerations(reopened)).toEqual([floor]) - expect(((await reopened.get(baselineId)) as { metadata: { v: number } }).metadata.v).toBe(1) - expect(await reopened.get(newId)).toBeNull() - expect(storeOf(reopened).committedGeneration()).toBe(floor) - expect(generationDirExists(dir, txGen)).toBe(false) - // Counter: the staged dir was seen at open, so the number is not reused. - expect(reopened.generation()).toBe(txGen) - const freshId = uid('tfs-fresh') - await reopened.add({ - id: freshId, - data: 'fresh', - type: NounType.Document, - vector: vec(3), - metadata: { v: 3 } - }) - await reopened.flush() - expect(storeOf(reopened).committedGeneration()).toBe(txGen + 1) - }) - - // ========================================================================== - // Row AFTER the commit point — the transaction must be kept - // ========================================================================== - - it('after-manifest-rename (transact) — the manifest rename landed: the transaction is COMMITTED and fully present', async () => { - const { dir, brain, baselineId, floor } = await arrangeBaseline('amr') - const newId = uid('amr-new') - const arm = armCrash(brain, 'after-manifest-rename') - await expect( - brain.transact([ - { op: 'update', id: baselineId, metadata: { v: 2 } }, - { - op: 'add', - id: newId, - type: NounType.Document, - data: 'committed by the rename', - vector: vec(2), - metadata: { v: 2 } - } - ]) - ).rejects.toThrow('simulated process crash at after-manifest-rename') - expect(arm.fired).toContain('after-manifest-rename') - const txGen = storeOf(brain).generation() - await abandonAsCrashed(brain) - - const reopened = await openLive(dir) - // COMMITTED: both operations present, atomically. - expect(((await reopened.get(baselineId)) as { metadata: { v: number } }).metadata.v).toBe(2) - const added = (await reopened.get(newId)) as { metadata: { v: number } } | null - expect(added).not.toBeNull() - expect(added!.metadata.v).toBe(2) - expect(storeOf(reopened).committedGeneration()).toBe(txGen) - // The fact was synced before the commit point and sits at/below the - // manifest — it is KEPT. - expect(await factGenerations(reopened)).toEqual([floor, txGen]) - // Fresh writes continue above the committed generation. - const freshId = uid('amr-fresh') - await reopened.add({ - id: freshId, - data: 'fresh', - type: NounType.Document, - vector: vec(3), - metadata: { v: 3 } - }) - await reopened.flush() - expect(storeOf(reopened).committedGeneration()).toBe(txGen + 1) - }) - - // ========================================================================== - // Durable-at-ack (log-authority) mode variants - // ========================================================================== - - it('singleop-after-fact-append (at-ack mode) — the intact fact is REPLAYED at reopen; the write commits', async () => { - const { dir, brain, baselineId, floor } = await arrangeBaseline('aaf') - await flipToAtAck(brain) - const crashedId = uid('aaf-crashed') - const arm = armCrash(brain, 'singleop-after-fact-append') - await expect( - brain.add({ - id: crashedId, - data: 'fact fsynced, never acked', - type: NounType.Document, - vector: vec(2), - metadata: { v: 2 } - }) - ).rejects.toThrow('simulated process crash at singleop-after-fact-append') - expect(arm.fired).toContain('singleop-after-fact-append') - await abandonAsCrashed(brain) - - const reopened = await openLive(dir) - // LOG-AUTHORITY RECOVERY CONTRACT: under 'log' authority, an intact - // fact above the manifest is adopted at open — REPLAYED into canonical - // and committed — never truncated. (At-least-once at the fact layer: a - // crashed-pre-ack write whose fact survived intact becomes committed; - // that is a valid write landing, never a torn or lost state.) - expect(await factGenerations(reopened)).toEqual([floor, floor + 1]) - expect(storeOf(reopened).committedGeneration()).toBe(floor + 1) - expect(((await reopened.get(baselineId)) as { metadata: { v: number } }).metadata.v).toBe(1) - const replayed = (await reopened.get(crashedId)) as { metadata: { v: number } } | null - expect(replayed).not.toBeNull() - expect(replayed!.metadata.v).toBe(2) - // Fresh write lands monotonically ABOVE the replayed generation. - const freshId = uid('aaf-fresh') - await reopened.add({ - id: freshId, - data: 'fresh', - type: NounType.Document, - vector: vec(3), - metadata: { v: 3 } - }) - await reopened.flush() - expect(storeOf(reopened).committedGeneration()).toBe(floor + 2) - }) - - // THE AT-ACK CONTRACT, END TO END (was a release-blocking finding; fixed - // by log-authority replay-at-open): under power loss the un-fsynced - // tmp+rename canonical bytes legally vanish while the fsynced fact - // survives — recovery REPLAYS that fact into canonical, so the acked - // write lives. This is the sentence 'durable-at-ack' actually promises. - it( - 'at-ack POWER LOSS — an ACKED write whose fact is fsynced SURVIVES reopen via log replay', - async () => { - const { dir, brain, baselineId } = await arrangeBaseline('apl') - await flipToAtAck(brain) - const ackedId = uid('apl-acked') - // No fault injector: this write ACKS normally — in at-ack mode the ack - // returned only after a covering log fsync. - await brain.add({ - id: ackedId, - data: 'acked, fact fsynced', - type: NounType.Document, - vector: vec(2), - metadata: { v: 2 } - }) - // Crash before any flush: RAM is gone… - await abandonAsCrashed(brain) - // …and power loss takes the un-fsynced canonical rename with it. The - // fsynced fact log survives — it is the write's only durable copy. - dropCanonicalNoun(dir, ackedId) - - const reopened = await openLive(dir) - expect(((await reopened.get(baselineId)) as { metadata: { v: number } }).metadata.v).toBe(1) - // THE AT-ACK CONTRACT: the acknowledged write survives the crash. - // Observed today: open() truncates its fact back to the manifest - // watermark and the write is gone everywhere. - const acked = (await reopened.get(ackedId)) as { metadata: { v: number } } | null - expect(acked).not.toBeNull() - expect(acked!.metadata.v).toBe(2) - } - ) - - // ========================================================================== - // Disk full — one ENOSPC'd append - // ========================================================================== - - it('disk full — an ENOSPC append rejects loudly and typed; reads keep serving; a later write succeeds', async () => { - const { dir, brain, baselineId, floor } = await arrangeBaseline('nospc') - liveBrains.push(brain) // this row never crashes the brain - void dir - const failedId = uid('nospc-failed') - const probe = failNextAppendWithEnospc(brain) - // LOUD, TYPED, never a silent success: the raw ENOSPC surfaces to the - // caller with its errno code intact. - await expect( - brain.add({ - id: failedId, - data: 'no space', - type: NounType.Document, - vector: vec(2), - metadata: { v: 2 } - }) - ).rejects.toMatchObject({ code: 'ENOSPC' }) - expect(probe.failed()).toBe(1) - // The store still serves reads. - expect(((await brain.get(baselineId)) as { metadata: { v: number } }).metadata.v).toBe(1) - // Space "restored" (the failing patch self-cleared): a later write succeeds - // end to end, including its fact and an explicit durability barrier. - const laterId = uid('nospc-later') - await brain.add({ - id: laterId, - data: 'space restored', - type: NounType.Document, - vector: vec(3), - metadata: { v: 3 } - }) - await brain.flush() - expect(((await brain.get(laterId)) as { metadata: { v: number } }).metadata.v).toBe(3) - expect(storeOf(brain).committedGeneration()).toBeGreaterThan(floor) - // FIXED BEHAVIOR (was: the rejected generation stayed buffered and the - // next flush committed it with NO fact — a silent log gap): the failure - // path un-buffers the generation and returns the counter reservation, - // so the later write takes floor+1 and the log is gap-free. - expect(storeOf(brain).committedGeneration()).toBe(floor + 1) - expect(await factGenerations(brain)).toEqual([floor, floor + 1]) - // Canonical residue of the rejected write (execute ran before the - // append failed) is the documented Model-B crash-equivalent orphan — - // uncommitted, absent from the log, same shape as a crash at execute. - expect(((await brain.get(failedId)) as { metadata: { v: number } } | null)?.metadata.v).toBe(2) - }) - - // THE NO-SILENT-COMMIT CONTRACT (was a release-blocking finding; fixed by - // un-buffering on append failure): a loudly-rejected write never becomes - // durably committed and the log never carries a gap. Canonical residue - // (the execute-before-commit orphan) is the documented Model-B - // crash-equivalent, pinned in the row above — NOT a commit. - it('disk full — a write rejected for a failed fact append is NOT silently committed', async () => { - const { brain, floor } = await arrangeBaseline('nogap') - liveBrains.push(brain) - const failedId = uid('nogap-failed') - failNextAppendWithEnospc(brain) - await expect( - brain.add({ - id: failedId, - data: 'no space', - type: NounType.Document, - vector: vec(2), - metadata: { v: 2 } - }) - ).rejects.toMatchObject({ code: 'ENOSPC' }) - await brain.flush() - // THE CONTRACT: nothing was committed behind the caller's back — the - // log carries no gap and no generation for the rejected write. (get() - // still serves the canonical execute-residue orphan — the documented - // Model-B crash-equivalent, pinned in the row above.) - expect(storeOf(brain).committedGeneration()).toBe(floor) - expect(await factGenerations(brain)).toEqual([floor]) - }) - - // ========================================================================== - // Block-layer power-loss findings (first dm-flakey run) — the three cures - // ========================================================================== - - it('at-ack POWER LOSS BELOW THE MANIFEST — an unclean open folds the WHOLE log; acked writes committed before the flush still survive vanished canonical', async () => { - const { dir, brain, baselineId } = await arrangeBaseline('wlf') - await flipToAtAck(brain) - const ackedA = uid('wlf-a') - const ackedB = uid('wlf-b') - await brain.add({ id: ackedA, data: 'below manifest one', type: NounType.Document, vector: vec(2), metadata: { v: 2 } }) - await brain.add({ id: ackedB, data: 'below manifest two', type: NounType.Document, vector: vec(3), metadata: { v: 3 } }) - // The group-commit flush advances the manifest OVER these generations — - // but live canonical bytes are tmp+rename without per-file fsync, so a - // power cut can still take them. The fsynced facts are the durable copy. - await (brain as unknown as { flush(): Promise }).flush() - await abandonAsCrashed(brain) // no clean close → no clean-shutdown marker - dropCanonicalNoun(dir, ackedA) - dropCanonicalNoun(dir, ackedB) - - const reopened = await openLive(dir) - // The whole-log fold restores BOTH rows from facts ≤ manifest. - expect(((await reopened.get(ackedA)) as { metadata: { v: number } }).metadata.v).toBe(2) - expect(((await reopened.get(ackedB)) as { metadata: { v: number } }).metadata.v).toBe(3) - expect(((await reopened.get(baselineId)) as { metadata: { v: number } }).metadata.v).toBe(1) - }) - - it('clean-shutdown marker: a clean close writes it, the next open consumes it (no fold on the happy path)', async () => { - const { dir, brain } = await arrangeBaseline('csm') - await flipToAtAck(brain) - await brain.close() - liveBrains.splice(liveBrains.indexOf(brain), 1) - // The adapter stores raw objects gzipped — accept either spelling. - const markerExists = () => - fs.existsSync(join(dir, '_system', 'clean-shutdown.json')) || - fs.existsSync(join(dir, '_system', 'clean-shutdown.json.gz')) - expect(markerExists(), 'clean close stamps the marker').toBe(true) - - const reopened = await openLive(dir) - expect(markerExists(), 'open consumes the marker').toBe(false) - await reopened.close() - liveBrains.splice(liveBrains.indexOf(reopened), 1) - expect(markerExists(), 'the next clean close re-stamps it').toBe(true) - }) - - it('torn writer lock (empty file) — open treats it as stale and recovers; never a permanent lockout', async () => { - const { dir, brain } = await arrangeBaseline('tlk') - await brain.close() - liveBrains.splice(liveBrains.indexOf(brain), 1) - // The power-loss shape: the lock file exists but is EMPTY (torn write). - fs.writeFileSync(join(dir, 'locks', '_writer.lock'), '') - - const reopened = await openLive(dir) // must not throw 'contended' - const fresh = uid('tlk-fresh') - await reopened.add({ id: fresh, data: 'lock recovered', type: NounType.Document, vector: vec(4), metadata: { v: 4 } }) - expect(await reopened.get(fresh)).not.toBeNull() - }) - - it('pair guard: a metadata index without stampWatermark never crashes flush', async () => { - const { brain } = await arrangeBaseline('psg') - liveBrains.push(brain) - // The native pair swaps the metadata manager; the replacement may not - // carry the stamp method — flush must treat that as verdict-side rescan, - // never a TypeError at the fan-out. - ;(brain as unknown as { metadataIndex: { stampWatermark?: unknown } }).metadataIndex.stampWatermark = undefined - await expect((brain as unknown as { flush(): Promise }).flush()).resolves.toBeUndefined() - }) -}) diff --git a/tests/integration/embed-markers-in-log.test.ts b/tests/integration/embed-markers-in-log.test.ts deleted file mode 100644 index 2dcad2f1..00000000 --- a/tests/integration/embed-markers-in-log.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -/** - * @module tests/integration/embed-markers-in-log - * @description DEFERRED-EMBED MARKERS ARE LOG RECORDS — the sidecar is dead. - * The pending-embed lifecycle lives IN the generation log as first-class v2 - * records: `embed.pending` rides the deferred write's OWN commit fact (same - * generation, one atomic append — a marker can never be orphaned from its - * write nor the write from its marker) and `embed.landed` rides the - * background worker's landing commit. Recovery is REPLAY, NOT LISTING: the - * open-time fold arms every pending without a matching landed (minus rows - * the log later tombstoned). The pins: - * - * (a) SAME-FACT ATOMICITY: a deferred add's commit fact carries the - * embed.pending record BESIDE its noun after-image — one generation, - * one frame — and no sidecar file is ever written. - * (b) LANDING: after the barrier, the log carries embed.landed (inline - * vector, per the v2 format) riding the landing commit's own fact, and - * a fresh fold of the whole log nets ZERO pending. - * (c) CRASH RECOVERY VIA THE LOG: kill mid-defer (hung embedder, flushed - * durability, crash-style abandon), reopen — the fold re-arms exactly - * one pending with NO sidecar file existing anywhere, and the vector - * then lands. - * (d) LEGACY BRIDGE: a sidecar marker file left by a pre-log build is - * folded in at open, migrated into the log as an embed.pending record, - * and the file is deleted — one-time, durable, idempotent. - * (e) VFS ACK LAW (unchanged contract, new mechanism): writeFile acks - * under a forever-hung embedder while its pending marker sits durably - * in the log. - */ -import { describe, it, expect, afterEach, vi } from 'vitest' -import * as fs from 'node:fs' -import * as path from 'node:path' -import * as zlib from 'node:zlib' -import { Brainy } from '../../src/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' -import type { CommitFact } from '../../src/db/factLog.js' -import { - makeTempDir, - openBrain, - abandonAsCrashed, - vec, - uid -} from '../helpers/durabilityKillMatrix.js' - -/** The retired sidecar prefix — asserted ABSENT (or bridged away) on disk. */ -const SIDECAR_DIR = ['_system', 'pending_embeds'] as const - -const sidecarDir = (dir: string): string => path.join(dir, ...SIDECAR_DIR) - -/** Every committed fact in the brain's log, generation-ascending. */ -async function allFacts(brain: Brainy): Promise { - const scan = ( - brain as unknown as { - scanFacts(o?: { fromGeneration?: number }): { - batches(): AsyncGenerator<{ facts: CommitFact[] }> - } | null - } - ).scanFacts({ fromGeneration: 1 }) - expect(scan, 'filesystem storage hosts a fact log').not.toBeNull() - const facts: CommitFact[] = [] - for await (const batch of scan!.batches()) facts.push(...batch.facts) - return facts -} - -/** The recovery fold, reimplemented independently: pending arms, landed - * disarms, a noun tombstone disarms (a deleted row owes no vector). */ -function foldPending(facts: CommitFact[]): Set { - const pending = new Set() - for (const fact of facts) { - for (const record of fact.records ?? []) { - if (record.type === 'embed.pending') pending.add(record.id) - else if (record.type === 'embed.landed') pending.delete(record.id) - } - for (const op of fact.ops) { - if (op.kind === 'noun' && op.record === null) pending.delete(op.id) - } - } - return pending -} - -/** Hang the embedder forever (the ack-law adversary). */ -function hangEmbedder(brain: Brainy): ReturnType { - return vi - .spyOn(brain as unknown as { embed(d: unknown): Promise }, 'embed') - .mockImplementation(() => new Promise(() => {})) -} - -/** Abandon a hung worker pass (its embed promise never resolves; production - * is covered by the worker's 60s hang guard — the test takes the white-box - * shortcut for speed, same idiom as the deferred-embedding suite). */ -function abandonHungWorker(brain: Brainy): void { - ;(brain as unknown as { _embedWorkerFlight: Promise | null })._embedWorkerFlight = null -} - -describe('deferred-embed markers in the log — the sidecar is dead', () => { - const dirs: string[] = [] - const brains: Brainy[] = [] - - const trackDir = (): string => { - const dir = makeTempDir() - dirs.push(dir) - return dir - } - const track = (brain: Brainy): Brainy => { - brains.push(brain) - return brain - } - - afterEach(async () => { - vi.restoreAllMocks() - for (const b of brains.splice(0)) { - abandonHungWorker(b) - await b.close().catch(() => {}) - } - for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }) - }) - - it('(a) SAME-FACT ATOMICITY: the deferred add\'s ONE commit fact carries embed.pending beside its after-image; no sidecar file exists', async () => { - const dir = trackDir() - const brain = track(await openBrain(dir)) - hangEmbedder(brain) // hold the pending state open for the scan - - const id = await brain.add({ - data: 'deferred content whose marker rides the fact', - type: NounType.Document, - deferEmbedding: true, - metadata: { pin: 'a' } - }) - expect(brain.pendingEmbedCount()).toBe(1) - - const facts = await allFacts(brain) - const carrying = facts.filter((f) => - (f.records ?? []).some((r) => r.type === 'embed.pending' && r.id === id) - ) - expect(carrying, 'exactly ONE fact carries the pending marker').toHaveLength(1) - const fact = carrying[0] - // The SAME fact (same generation, one atomic append) carries the write's - // own after-image — marker and write are inseparable by construction. - const afterImage = fact.ops.find((op) => op.kind === 'noun' && op.id === id) - expect(afterImage, 'the marker rides the write\'s own fact').toBeDefined() - expect(afterImage!.record, 'an after-image, not a tombstone').not.toBeNull() - const marker = (fact.records ?? []).find((r) => r.type === 'embed.pending' && r.id === id) - expect(marker && marker.type === 'embed.pending' && marker.enqueuedAt).toBeGreaterThan(0) - - // The sidecar is dead: nothing under the retired prefix, ever. - expect(fs.existsSync(sidecarDir(dir)), 'no sidecar directory is created').toBe(false) - }) - - it('(b) LANDING: after the barrier the log carries embed.landed (inline vector) on the landing commit\'s own fact, and a fresh fold nets zero pending', async () => { - const dir = trackDir() - const brain = track(await openBrain(dir)) - - const id = await brain.add({ - data: 'content that lands in the background', - type: NounType.Document, - deferEmbedding: true, - metadata: { pin: 'b' } - }) - await brain.awaitPendingEmbeds() - expect(brain.pendingEmbedCount()).toBe(0) - - const facts = await allFacts(brain) - const landingFacts = facts.filter((f) => - (f.records ?? []).some((r) => r.type === 'embed.landed' && r.id === id) - ) - expect(landingFacts, 'exactly ONE landing fact').toHaveLength(1) - const landed = (landingFacts[0].records ?? []).find( - (r) => r.type === 'embed.landed' && r.id === id - ) - expect(landed && landed.type === 'embed.landed' && landed.vector.length).toBeGreaterThan(0) - // The landing commit's own after-image rides the same fact — the worker's - // vector swap and its durable "pending consumed" are one atomic append. - const landingAfterImage = landingFacts[0].ops.find((op) => op.kind === 'noun' && op.id === id) - expect(landingAfterImage, 'the landed marker rides the swap\'s own fact').toBeDefined() - expect(landingAfterImage!.record).not.toBeNull() - - // A fresh fold of the WHOLE log — the exact recovery computation — nets zero. - expect(foldPending(facts).size).toBe(0) - expect(fs.existsSync(sidecarDir(dir))).toBe(false) - }) - - it('(c) CRASH RECOVERY VIA THE LOG: kill mid-defer, reopen — one pending re-armed from the fold, NO sidecar file anywhere, and the vector then lands', async () => { - const dir = trackDir() - - // Session 1: embedder hung, deferred add acked, durability flushed, then - // a crash-style abandon (RAM gone, no close, no background machinery). - const first = await openBrain(dir) - brains.push(first) - hangEmbedder(first) - const id = await first.add({ - data: 'survives the kill through the log', - type: NounType.Document, - deferEmbedding: true, - metadata: { pin: 'c' } - }) - expect(first.pendingEmbedCount()).toBe(1) - await first.flush() // the durability barrier: fact (with marker) + manifest - expect(fs.existsSync(sidecarDir(dir)), 'no sidecar before the kill').toBe(false) - await abandonAsCrashed(first) - brains.splice(brains.indexOf(first), 1) - vi.restoreAllMocks() - - // Session 2: recovery folds the log — embedder hung BEFORE init so the - // re-armed pending is observable, not raced away by the fast worker. - const second = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - silent: true, - persistence: { policy: 'manual' } - }) - const hang = hangEmbedder(second) - await second.init() - track(second) - expect(second.pendingEmbedCount(), 'the fold re-armed the pending').toBe(1) - expect(fs.existsSync(sidecarDir(dir)), 'recovery used the LOG, not files').toBe(false) - - // Un-hang and drain: a crash DELAYED the vector, never lost it. - hang.mockRestore() - abandonHungWorker(second) - await second.awaitPendingEmbeds() - expect(second.pendingEmbedCount()).toBe(0) - const after = await second.get(id, { includeVectors: true }) - expect(after, 'the deferred row survived the crash').toBeTruthy() - expect((after!.vector as number[]).length, 'the delayed vector landed').toBeGreaterThan(0) - expect(foldPending(await allFacts(second)).size, 'the landing is durable in the log').toBe(0) - }) - - it('(d) LEGACY BRIDGE: a pre-log sidecar marker folds in at open, migrates into the log, and the file dies — one-time and durable', async () => { - const dir = trackDir() - - // Session 1: a normal committed row (the entity the legacy marker names). - const first = await openBrain(dir) - brains.push(first) - const id = uid('legacy-defer') - await first.add({ - id, - data: 'legacy deferred content', - type: NounType.Document, - vector: vec(9), - metadata: { pin: 'd' } - }) - await first.flush() - await first.close() - brains.splice(brains.indexOf(first), 1) - - // A pre-log build's sidecar marker, hand-written exactly as the old - // writeRawObject persisted it (the filesystem adapter compresses raw - // objects by default: gzipped JSON at `.gz`). - fs.mkdirSync(sidecarDir(dir), { recursive: true }) - const sidecarFile = path.join(sidecarDir(dir), id) - fs.writeFileSync( - `${sidecarFile}.gz`, - zlib.gzipSync(JSON.stringify({ id, enqueuedAt: 1234567890 }, null, 2)) - ) - - // Session 2: the bridge fires at open. Embedder hung BEFORE init so the - // folded pending is observable. - const second = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - silent: true, - persistence: { policy: 'manual' } - }) - const hang = hangEmbedder(second) - await second.init() - track(second) - expect(second.pendingEmbedCount(), 'the legacy marker folded in').toBe(1) - expect(fs.existsSync(sidecarFile), 'the sidecar file was deleted').toBe(false) - expect(fs.existsSync(`${sidecarFile}.gz`), 'the compressed variant too').toBe(false) - const migrated = await allFacts(second) - expect( - migrated.some((f) => (f.records ?? []).some((r) => r.type === 'embed.pending' && r.id === id)), - 'the marker now lives IN the log' - ).toBe(true) - - // Drain: the bridged pending embeds and lands like any other. - hang.mockRestore() - abandonHungWorker(second) - await second.awaitPendingEmbeds() - expect(second.pendingEmbedCount()).toBe(0) - const facts = await allFacts(second) - expect( - facts.some((f) => (f.records ?? []).some((r) => r.type === 'embed.landed' && r.id === id)), - 'the bridged pending landed durably' - ).toBe(true) - expect(foldPending(facts).size).toBe(0) - await second.flush() - await second.close() - brains.splice(brains.indexOf(second), 1) - - // Session 3: nothing resurrects — the bridge was one-time, the clear durable. - const third = track(await openBrain(dir)) - expect(third.pendingEmbedCount(), 'no zombie pending on the next open').toBe(0) - expect(fs.existsSync(sidecarDir(dir)) && fs.readdirSync(sidecarDir(dir)).length > 0).toBe(false) - }) - - it('(e) VFS ACK LAW: writeFile acks under a forever-hung embedder while its pending marker sits durably in the log', async () => { - const dir = trackDir() - const brain = track(await openBrain(dir)) - const hang = hangEmbedder(brain) - - await brain.vfs.writeFile('/notes/today.md', '# The day\nA deferred capture.') - - // Acked with the embedder hung: content + metadata fully readable. - const content = await brain.vfs.readFile('/notes/today.md') - expect(content.toString()).toContain('A deferred capture.') - expect(brain.pendingEmbedCount()).toBeGreaterThanOrEqual(1) - - // The marker is already durable IN the log while the embedder hangs — - // the exact state a crash here would recover from. - expect(foldPending(await allFacts(brain)).size).toBeGreaterThanOrEqual(1) - expect(fs.existsSync(sidecarDir(dir))).toBe(false) - - // Un-hang, abandon the poisoned pass, drain, verify. - hang.mockRestore() - abandonHungWorker(brain) - await brain.awaitPendingEmbeds() - expect(brain.pendingEmbedCount()).toBe(0) - expect(foldPending(await allFacts(brain)).size).toBe(0) - }) -}) diff --git a/tests/integration/entity-confidence-weight.test.ts b/tests/integration/entity-confidence-weight.test.ts index 031d29f1..b5bb34c5 100644 --- a/tests/integration/entity-confidence-weight.test.ts +++ b/tests/integration/entity-confidence-weight.test.ts @@ -7,7 +7,7 @@ * - Backward compatibility preserved */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy } from '../../src/brainy.js' import { NounType } from '../../src/types/graphTypes.js' @@ -19,10 +19,6 @@ describe('Entity Confidence & Weight Exposure', () => { await brain.init() }) - afterEach(async () => { - await brain.close() - }) - describe('Entity interface', () => { it('should expose confidence when adding entity with confidence', async () => { const id = await brain.add({ diff --git a/tests/integration/entity-tree-stamp.test.ts b/tests/integration/entity-tree-stamp.test.ts index 23cc0a15..deefc5e6 100644 --- a/tests/integration/entity-tree-stamp.test.ts +++ b/tests/integration/entity-tree-stamp.test.ts @@ -57,11 +57,7 @@ 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()) - // 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.sourceGeneration).toBe(brain.generation()) expect(stamp.generation).toBeGreaterThanOrEqual(1) }) @@ -116,96 +112,6 @@ 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', @@ -221,13 +127,7 @@ describe('entity-tree family stamp', () => { stampSource: 5, head: 9 }) - // 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(rollup, 3, { nounCount: 10 }).state).toBe('incoherent') // ahead of head expect(verifyFamilyStamp(null, 5, {})).toEqual({ state: 'absent' }) const enumerated: FamilyStamp = { diff --git a/tests/integration/enumeration-population-law.test.ts b/tests/integration/enumeration-population-law.test.ts deleted file mode 100644 index eaab7432..00000000 --- a/tests/integration/enumeration-population-law.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -/** - * @module tests/integration/enumeration-population-law - * @description THE POPULATION LAW (ADR-008 G1): the unfiltered noun/verb walk - * and the canonical ALL scalar must agree on the population — a row's - * IDENTITY RECORD (metadata.json) is what defines membership; the vector leg - * is optional data, never a gate on visibility. Before this fix, the walk - * (getNounsWithPagination / getNounIdsWithPagination / getVerbsWithPagination) - * enumerated by keying on the VECTOR leg (`vectors.json`), so a row with - * metadata and no vector file was counted by the ledger (already - * metadata.json-keyed — see `rebuildTypeCounts`) but never yielded by the - * walk: a permanent "counted but invisible" phantom for any downstream - * consumer (a health-coverage row, an index-fill walk) that iterates the walk - * to account for the ledger's total. - * - * Two legs are pinned here: - * (a)/(b) LEG 1 — the walk re-keys on metadata.json. A fold-born - * metadata-only row (the exact shape `GenerationStore.replayFact` can - * leave behind, and the exact shape `writeNounRaw`/`writeVerbRaw` accept) - * must be YIELDED, hydrated with the sanctioned unvectored shape - * (`vector: []`) — not merely counted. - * - * For VERBS this closes only PARTIALLY: `sourceId`/`targetId` are - * HNSWVerb's structural core and live ONLY in the vector leg (never in - * metadata — see `RESERVED_RELATION_FIELDS` in reservedFields.ts, which - * does not include them). A metadata-only verb row therefore cannot be - * safely reconstructed without FABRICATING an edge's endpoints — which - * would silently create a phantom relationship, strictly worse than the - * original defect. The walk recovers the row when its metadata happens - * to carry `sourceId`/`targetId` (a defensive, forward-compatible - * fallback — never true for a CURRENT production write, but not - * disallowed either); otherwise it counts the row (ledger, unchanged) - * but loudly skips yielding it, logging the gap instead of hiding it. - * Closing this fully requires persisting `sourceId`/`targetId` in verb - * metadata — a schema change out of this task's scope; see the session - * report for the explicit call-out. - * - * (c)/(d) LEG 2 — the recovery fold's preserve-if-absent contract, exercised - * directly against `GenerationStore`/`FactLog` (below the `Brainy` API): - * a metadata-only after-image replayed over an already-vectored row must - * PRESERVE the existing vector leg (never delete it); a genuine tombstone - * (both legs absent) still removes both legs. - */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import { randomUUID } from 'node:crypto' -import { Brainy } from '../../src/index.js' -import { GenerationStore } from '../../src/db/generationStore.js' -import { MemoryStorage } from '../../src/storage/adapters/memoryStorage.js' -import { LOG_AUTHORITY_PATH } from '../../src/db/logAuthority.js' -import type { CommitFact } from '../../src/db/factLog.js' - -describe('enumeration population law — LEG 1 (identity-keyed walk)', () => { - let dir: string - let brain: any - - const open = async () => { - const b: any = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - silent: true, - dimensions: 384 - }) - await b.init() - return b - } - - beforeEach(async () => { - process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-population-law-')) - brain = await open() - }) - afterEach(async () => { - await brain.close?.().catch(() => {}) - fs.rmSync(dir, { recursive: true, force: true }) - }) - - it('(a) nouns.all equals the unfiltered walk-yield count with a fold-born metadata-only row present', async () => { - // Ordinary, fully-vectored background population. - await brain.add({ data: 'one', type: 'document' }) - await brain.add({ data: 'two', type: 'document' }) - await brain.flush() - - // THE EXACT PRE-FIX SHAPE: a metadata-only row against a FRESH id — no - // vector ever existed for it. Written through the raw primitive directly, - // exactly as `GenerationStore.replayFact` (the recovery fold) applies a - // replayed after-image whose vector leg came back null. - const freshId = randomUUID() - await brain.storage.writeNounRaw(freshId, { - metadata: { noun: 'document', createdAt: Date.now(), updatedAt: Date.now(), _rev: 1 }, - vector: null - }) - - // writeNounRaw bypasses count bookkeeping on purpose (its own JSDoc) — the - // sanctioned recount brings the ledger scalar to ground truth. This walk - // was ALREADY metadata.json-keyed before this fix (rebuildTypeCounts), so - // the recount's answer does not depend on today's change. - await brain.repairIndex() - - const ledger = await brain.storage.getCanonicalCounts() - const walk = await brain.storage.getNouns({ pagination: { limit: 1000, offset: 0 } }) - - expect(walk.items.length).toBe(ledger.nouns.all) - expect(walk.totalCount).toBe(ledger.nouns.all) - - const yielded = walk.items.find((n: any) => n.id === freshId) - expect(yielded, 'the metadata-only row must be YIELDED, not merely counted').toBeDefined() - expect(yielded.vector).toEqual([]) - }) - - it('(a-ids) getNounIdsWithPagination (the zero-read unfiltered enumerator) also yields the metadata-only row', async () => { - await brain.add({ data: 'one', type: 'document' }) - await brain.flush() - - const freshId = randomUUID() - await brain.storage.writeNounRaw(freshId, { - metadata: { noun: 'document', createdAt: Date.now(), updatedAt: Date.now(), _rev: 1 }, - vector: null - }) - await brain.repairIndex() - - const ledger = await brain.storage.getCanonicalCounts() - const page = await brain.storage.getNounIdsWithPagination({ limit: 1000, offset: 0 }) - expect(page.ids.length).toBe(ledger.nouns.all) - expect(page.ids).toContain(freshId) - }) - - it('(b) verbs.all counts a fold-born metadata-only row; the walk yields it when endpoints are recoverable from metadata, and loudly skips (never fabricates) when they are not', async () => { - const a = await brain.add({ data: 'a', type: 'document' }) - const b = await brain.add({ data: 'b', type: 'document' }) - await brain.relate({ from: a, to: b, type: 'relatedTo' }) - await brain.flush() - - // Case 1 — the REALISTIC production shape: metadata carries the verb - // type (a reserved field, kept for backward compat) but never - // sourceId/targetId — those are HNSWVerb's structural core and live - // ONLY in the vector leg. The walk cannot safely fabricate them (an - // empty-string endpoint would silently create a phantom edge), so this - // row is counted by the ledger but not yielded — a documented, - // loudly-logged gap, not a silent one. - const gapId = randomUUID() - await brain.storage.writeVerbRaw(gapId, { - metadata: { verb: 'relatedTo', createdAt: Date.now(), updatedAt: Date.now(), weight: 1 }, - vector: null - }) - - // Case 2 — endpoints ARE recoverable from metadata (never true for a - // current production write; modeled here as what a repair tool or a - // future schema could supply): the walk reconstructs and yields it. - const recoveredId = randomUUID() - await brain.storage.writeVerbRaw(recoveredId, { - metadata: { - verb: 'relatedTo', - sourceId: a, - targetId: b, - createdAt: Date.now(), - updatedAt: Date.now(), - weight: 1 - }, - vector: null - }) - - await brain.repairIndex() - const ledger = await brain.storage.getCanonicalCounts() - const walk = await brain.storage.getVerbs({ pagination: { limit: 1000, offset: 0 } }) - - // The ledger counts every identity record — the real edge plus both - // synthetic metadata-only rows — unaffected by whether the walk can - // safely hydrate them. - expect(ledger.verbs.all).toBe(3) - - const recovered = walk.items.find((v: any) => v.id === recoveredId) - expect(recovered, 'endpoints recoverable from metadata must be yielded').toBeDefined() - expect(recovered.sourceId).toBe(a) - expect(recovered.targetId).toBe(b) - expect(recovered.vector).toEqual([]) - - // The documented gap: counted, not yielded — this is the one corner of - // the population law this task does NOT close (see the session report). - const gapped = walk.items.find((v: any) => v.id === gapId) - expect(gapped).toBeUndefined() - expect(walk.items.length).toBeLessThan(ledger.verbs.all) - }) -}) - -describe('enumeration population law — LEG 2 (fold preserve-if-absent, below the Brainy API)', () => { - /** A GenerationStore whose brain has already flipped to log authority — the - * precondition for `replayFact` (the recovery fold) to run at open(). */ - async function openLogAuthorityStore(): Promise<{ storage: MemoryStorage; store: GenerationStore }> { - const storage = new MemoryStorage() - await storage.init() - await storage.writeRawObject(LOG_AUTHORITY_PATH, { authority: 'log' }) - const store = new GenerationStore(storage) - await store.open() - return { storage, store } - } - - it('(c) nouns: a metadata-only after-image replayed over a vectored row PRESERVES the vector; it stays readable and the vectored ledger is untouched either way', async () => { - const { storage, store } = await openLogAuthorityStore() - const id = randomUUID() - const vectorRecord = { id, vector: [0.1, 0.2, 0.3], connections: {}, level: 0 } - - // Generation 1 — a real, honest commit: both legs land together. - await store.commitTransaction({ - touched: { nouns: [id], verbs: [] }, - execute: async () => { - await storage.writeNounRaw(id, { - metadata: { noun: 'document', createdAt: 1000, updatedAt: 1000, _rev: 1 }, - vector: vectorRecord - }) - } - }) - const beforeVectoredCount = (await storage.getCanonicalCounts()).vectors.all - - // THE ANOMALOUS FACT, crafted directly (bypassing commitTransaction, - // whose honest read-after-write could never produce this on its own): - // metadata changed, vector leg null, while the row is STILL vectored on - // disk. This is exactly the shape the recovery fold must tolerate — - // modeling the confirmed production defect at the replay boundary. - const factLog = store.getFactLog()! - const anomalousFact: CommitFact = { - generation: 2, - timestamp: Date.now(), - ops: [ - { - kind: 'noun', - id, - record: { - metadata: { noun: 'document', createdAt: 1000, updatedAt: 2000, _rev: 2 }, - vector: null - } - } - ] - } - await factLog.append(anomalousFact) - await factLog.sync() - - // Reopen — a fresh GenerationStore over the SAME storage. Generation 2's - // fact sits above the (still generation-1) manifest, so it replays - // through the recovery fold — `replayFact`'s own call site. - const store2 = new GenerationStore(storage) - await store2.open() - - const after = await storage.readNounRaw(id) - expect(after.vector, 'the vector leg must survive the metadata-only replay').not.toBeNull() - expect((after.vector as { vector: number[] }).vector).toEqual([0.1, 0.2, 0.3]) - expect((after.metadata as { updatedAt: number }).updatedAt).toBe(2000) // the new metadata DID apply - - // writeNounRaw bypasses ledger bookkeeping either way (by design — see - // its JSDoc), so this scalar is unaffected by the replay regardless of - // outcome; asserted for completeness against the task's exact wording. - const afterVectoredCount = (await storage.getCanonicalCounts()).vectors.all - expect(afterVectoredCount).toBe(beforeVectoredCount) - }) - - it('(c-verb) verbs: a metadata-only after-image replayed over a vectored edge PRESERVES the vector leg (sourceId/targetId/verb intact)', async () => { - const { storage, store } = await openLogAuthorityStore() - const id = randomUUID() - const sourceId = randomUUID() - const targetId = randomUUID() - const vectorRecord = { id, vector: [0.7, 0.8], connections: {}, verb: 'relatedTo', sourceId, targetId } - - await store.commitTransaction({ - touched: { nouns: [], verbs: [id] }, - execute: async () => { - await storage.writeVerbRaw(id, { - metadata: { verb: 'relatedTo', createdAt: 1000, updatedAt: 1000, weight: 1 }, - vector: vectorRecord - }) - } - }) - - const factLog = store.getFactLog()! - const anomalousFact: CommitFact = { - generation: 2, - timestamp: Date.now(), - ops: [ - { - kind: 'verb', - id, - record: { - metadata: { verb: 'relatedTo', createdAt: 1000, updatedAt: 2000, weight: 2 }, - vector: null - } - } - ] - } - await factLog.append(anomalousFact) - await factLog.sync() - - const store2 = new GenerationStore(storage) - await store2.open() - - const after = await storage.readVerbRaw(id) - expect(after.vector, 'the vector leg must survive the metadata-only replay').not.toBeNull() - expect((after.vector as { sourceId: string }).sourceId).toBe(sourceId) - expect((after.vector as { targetId: string }).targetId).toBe(targetId) - expect((after.metadata as { weight: number }).weight).toBe(2) - }) - - it('(d) a genuine tombstone replay removes BOTH legs (never preserved)', async () => { - const { storage, store } = await openLogAuthorityStore() - const id = randomUUID() - const vectorRecord = { id, vector: [0.4, 0.5, 0.6], connections: {}, level: 0 } - - await store.commitTransaction({ - touched: { nouns: [id], verbs: [] }, - execute: async () => { - await storage.writeNounRaw(id, { - metadata: { noun: 'document', createdAt: 1000, updatedAt: 1000, _rev: 1 }, - vector: vectorRecord - }) - } - }) - expect((await storage.readNounRaw(id)).vector).not.toBeNull() // sanity: it landed - - const factLog = store.getFactLog()! - await factLog.append({ - generation: 2, - timestamp: Date.now(), - ops: [{ kind: 'noun', id, record: null }] // a genuine tombstone — both legs absent - }) - await factLog.sync() - - const store2 = new GenerationStore(storage) - await store2.open() - - const after = await storage.readNounRaw(id) - expect(after.metadata, 'a genuine delete removes the metadata leg').toBeNull() - expect(after.vector, 'a genuine delete removes the vector leg too — preserve-if-absent never applies to a tombstone').toBeNull() - }) -}) diff --git a/tests/integration/fact-log-contracts.test.ts b/tests/integration/fact-log-contracts.test.ts index 874504c9..eb579da9 100644 --- a/tests/integration/fact-log-contracts.test.ts +++ b/tests/integration/fact-log-contracts.test.ts @@ -4,13 +4,14 @@ * * (1) FSYNC-BEFORE-ACK: an acknowledged write's fact survives an abrupt * process end (no flush, no close — reopen from disk). - * - transact(): HOLDS — the fact is fsync'd before transact returns. - * - single-op: HOLDS (was pinned `it.fails` until the ack-at-log - * destination landed): the 10.0.0 adopt-at-open fleet default flips a - * fresh brain to log authority at open, so single-op acks await the - * covering group fsync (durable-at-ack) and recovery REPLAYS intact - * facts above the manifest at the next open. The contract is now - * permanent on every path. + * - transact(): HOLDS TODAY — the fact is fsync'd before transact returns. + * - single-op: PINNED AS `it.fails` — today's group-commit batches + * DURABILITY (ack precedes the group fsync; a hard kill loses the fact + * AND the generation together, coherently — the documented Model-B + * contract, fine while the tree is authoritative). The destination + * (ack-at-log) requires group commit to become LATENCY batching: the + * ack waits for the shared fsync. When that lands, this pin flips red — + * remove `.fails` and the contract is permanent. No cliff to discover. * * (2) SCAN STABILITY UNDER ROTATION: a scan handle opened before segment * rotation yields exactly its snapshot — byte-identical facts, no gaps, @@ -62,11 +63,9 @@ describe('fsync-before-ack contract (fact durability at the ack boundary)', () = expect(facts.some((f) => f.generation === receipt.generation)).toBe(true) }) - // THE ACK-AT-LOG CONTRACT, HELD (was `.fails` until it landed): under the - // adopt-at-open fleet default this brain runs durable-at-ack from open — - // the ack waits for the covering log fsync, and the log-authority recovery - // path replays the intact fact at the next open instead of truncating it. - it('single-op: the fact is durable the moment the ack returns (the ack-at-log target)', async () => { + // PINNED (flips red when group commit becomes latency batching — then + // remove `.fails` and the ack-at-log contract is permanent on every path). + it.fails('single-op: the fact is durable the moment the ack returns (the ack-at-log target)', async () => { await brain.add({ data: 'acked single-op', type: 'document', metadata: { n: 1 } }) const ackedHead = brain.scanFacts()!.headGeneration // Abrupt end immediately after the ack — before any flush window. diff --git a/tests/integration/fact-log-dual-write.test.ts b/tests/integration/fact-log-dual-write.test.ts index 3c4eee4a..5ec66273 100644 --- a/tests/integration/fact-log-dual-write.test.ts +++ b/tests/integration/fact-log-dual-write.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import { Brainy, ProtectedArtifactError, splitNounMetadataRecord, type CommitFact } from '../../src/index.js' +import { Brainy, ProtectedArtifactError, type CommitFact } from '../../src/index.js' async function allFacts(brain: any): Promise { const scan = brain.scanFacts() @@ -65,13 +65,7 @@ describe('fact log dual-write (memory adapter)', () => { const updateFact = facts[facts.length - 1] const op = updateFact.ops.find((o) => o.id === id)! expect(op.record).not.toBeNull() - // The fact log is byte-faithful: op.record.metadata is the RAW stored - // record (v2 nested-bag since the field-addressing law) — read the user - // field through the shape-aware split, like every other reader. - const { custom } = splitNounMetadataRecord( - op.record!.metadata as Record - ) - expect(custom.v).toBe('new') + expect((op.record!.metadata as any).v).toBe('new') }) it('a transact commits ONE fact carrying all its ops, with meta', async () => { diff --git a/tests/integration/fact-log-v2-cutover.test.ts b/tests/integration/fact-log-v2-cutover.test.ts deleted file mode 100644 index 6e8d9fb6..00000000 --- a/tests/integration/fact-log-v2-cutover.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -/** - * @module tests/integration/fact-log-v2-cutover - * @description The fact log's LIVE WRITE FORMAT cutover to v2, end-to-end - * through real brains: (a) a NEW brain's tail segment carries a v2 header - * (formatVersion 2, sealSize 4096), opens with the log.genesis record - * (id-space width 64 + the manifest-persisted brainId), and scanFacts yields - * the same CommitFact shape a v1 brain would — reconstruction included, - * proven by digest-equality against canonical after a reopen; (b) MIXED - * logs: an existing v1 segment stays readable forever beside a v2 tail - * (cutover-by-rotation; the v1 segment is never rewritten); (c) MINT: - * after-image records carry the metadata index id mapper's exact int - * assignments (white-box compare); (d) SEALS: every flush leaves the tail - * sector-aligned, and pads are invisible to scans; (e) REPLAY: the - * log-authority recovery path resurrects an acked write from a v2 tail - * after a crash-style abandon. - */ -import { describe, it, expect, afterEach } from 'vitest' -import * as fs from 'node:fs' -import * as path from 'node:path' -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 { - parseSegmentHeader, - decodeGroupV2, - SEGMENT_HEADER_BYTES, - FACT_LOG_FORMAT_V1, - FACT_LOG_FORMAT_V2, - type LogGenesisRecord, - type NounAfterImageRecord -} from '../../src/db/factLogFormat.js' -import type { CommitFact, FactIntMinter, FactLog } from '../../src/db/factLog.js' -import { - makeTempDir, - openBrain, - storeOf, - abandonAsCrashed, - factGenerations, - vec, - uid -} from '../helpers/durabilityKillMatrix.js' - -/** The VFS root — created at init by a baseline (generation-less) write. */ -const VFS_ROOT = '00000000-0000-0000-0000-000000000000' -const FACTS_DIR = ['_generations', 'facts'] as const -const MANIFEST_PATH = '_generations/facts/manifest.json' - -/** White-box internals this suite instruments. */ -type BrainInternals = { - storage: { - readRawObject(p: string): Promise - readNounRaw(id: string): Promise<{ metadata: unknown | null; vector: unknown | null }> - } - metadataIndex: { - getIdMapper(): { getInt(uuid: string): number | undefined } - } -} -const internals = (brain: Brainy): BrainInternals => brain as unknown as BrainInternals - -/** The facts manifest as stored (additive brainId included). */ -interface StoredFactsManifest { - segments: Array<{ file: string }> - tailSegment: string | null - brainId?: string -} - -async function readManifest(brain: Brainy): Promise { - const manifest = (await internals(brain).storage.readRawObject( - MANIFEST_PATH - )) as StoredFactsManifest | null - expect(manifest, 'the facts manifest exists').toBeTruthy() - return manifest! -} - -/** Raw on-disk bytes of one fact segment file. */ -function segmentBytes(dir: string, file: string): Uint8Array { - return new Uint8Array(fs.readFileSync(path.join(dir, ...FACTS_DIR, file))) -} - -async function allFacts(brain: Brainy): Promise { - const scan = (brain as unknown as { scanFacts(): { batches(): AsyncGenerator<{ facts: CommitFact[] }> } | null }).scanFacts() - expect(scan, 'this storage hosts a fact log').not.toBeNull() - const facts: CommitFact[] = [] - for await (const batch of scan!.batches()) facts.push(...batch.facts) - return facts -} - -/** The live FactLog instance (white-box: the minter strip in scenario b). */ -function factLogOf(brain: Brainy): FactLog & { intMinter: FactIntMinter | null } { - const log = storeOf(brain).getFactLog() - expect(log, 'filesystem storage hosts a fact log').not.toBeNull() - return log as FactLog & { intMinter: FactIntMinter | null } -} - -describe('fact log v2 cutover — live writes land in the v2 segment format', () => { - const dirs: string[] = [] - const brains: Brainy[] = [] - - const trackDir = (): string => { - const dir = makeTempDir() - dirs.push(dir) - return dir - } - const track = (brain: Brainy): Brainy => { - brains.push(brain) - return brain - } - - afterEach(async () => { - for (const b of brains.splice(0)) { - await (b as unknown as { close?: () => Promise }).close?.().catch(() => {}) - } - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) - }) - - it('(a) NEW BRAIN: v2 tail header, genesis-first, and scanFacts parity with canonical across a reopen', async () => { - const dir = trackDir() - const brain = track(await openBrain(dir)) - const idA = uid('v2-new-a') - const idB = uid('v2-new-b') - await brain.add({ id: idA, data: 'alpha', type: NounType.Document, vector: vec(1), metadata: { n: 1 } }) - await brain.add({ id: idB, data: 'beta', type: NounType.Document, vector: vec(2), metadata: { n: 2 } }) - await brain.flush() - - // The tail segment's raw header bytes: formatVersion 2, sealSize 4096. - const manifest = await readManifest(brain) - expect(manifest.tailSegment).toBeTruthy() - expect(manifest.brainId, 'the brain id was minted into the manifest').toBeTruthy() - const bytes = segmentBytes(dir, manifest.tailSegment!) - const header = parseSegmentHeader(bytes.subarray(0, SEGMENT_HEADER_BYTES)) - expect(header.formatVersion).toBe(FACT_LOG_FORMAT_V2) - expect(header.sealSize).toBe(4096) - - // Genesis is the FIRST record of the FIRST fact — and appears exactly once. - const group = decodeGroupV2(bytes.subarray(SEGMENT_HEADER_BYTES), { expectedIdSpaceWidth: 64 }) - expect(group.facts.length).toBeGreaterThanOrEqual(2) - const firstRecord = group.facts[0].records[0] - expect(firstRecord.type).toBe('log.genesis') - const genesis = firstRecord as LogGenesisRecord - expect(genesis.idSpaceWidth).toBe(64) - expect(genesis.brainId).toBe(manifest.brainId) - const genesisCount = group.facts - .flatMap((f) => f.records) - .filter((r) => r.type === 'log.genesis').length - expect(genesisCount).toBe(1) - - // Shape parity + reconstruction fidelity: REOPEN (so the tail decodes - // from disk, not from the in-session originals) and compare each add's - // CommitFact op against canonical byte truth — metadata leg (bigint - // timestamps normalized back to numbers) AND the reconstructed vector - // wrapper must equal what readNounRaw returns, exactly as a v1 log's - // byte-faithful capture would. - await (brain as unknown as { close: () => Promise }).close() - brains.splice(brains.indexOf(brain), 1) - const reopened = track(await openBrain(dir)) - const facts = await allFacts(reopened) - const gens = facts.map((f) => f.generation) - expect([...gens].sort((a, b) => a - b)).toEqual(gens) - expect(new Set(gens).size).toBe(gens.length) - - const logGens = new Set( - ((await (reopened as unknown as { transactionLog(): Promise> }).transactionLog()) ?? []).map( - (e) => e.generation - ) - ) - for (const g of gens) expect(logGens.has(g), `generation ${g} is a real commit`).toBe(true) - - for (const id of [idA, idB]) { - const fact = facts.find((f) => f.ops.some((op) => op.id === id && op.record !== null)) - expect(fact, `the add fact for ${id} survives the reopen`).toBeDefined() - const op = fact!.ops.find((o) => o.id === id)! - expect(op.kind).toBe('noun') - const canonical = await internals(reopened).storage.readNounRaw(id) - expect(op.record!.metadata).toStrictEqual(canonical.metadata) - // ENTITY TRUTH comparison: canonical wrappers denormalize HNSW residue - // (connections + the randomly-assigned level) that the log record - // deliberately reconstructs empty — strip both sides (the oracle's - // normalizer law) so a nonzero random level can't fake a divergence. - const strip = (w: unknown) => { - const { connections: _c, level: _l, ...rest } = w as Record - return rest - } - expect(strip(op.record!.vector)).toStrictEqual(strip(canonical.vector)) - } - }) - - it('(b) MIXED LOG: an existing v1 segment stays readable forever beside the v2 tail (cutover by rotation, v1 bytes untouched)', async () => { - // ROUTE: a REAL v1 segment is written by the v1 writer itself — the live - // FactLog with its minter stripped (the exact pre-cutover code path, - // still shipped for minter-less configurations) — then the minter is - // restored mid-session and the next append performs the cutover - // rotation. Stronger than hand-crafted bytes: both formats come from - // their real writers, on one log. - const dir = trackDir() - const brain = track(await openBrain(dir)) - const log = factLogOf(brain) - const minter = log.intMinter - expect(minter, 'the brain wired the int minter at init').toBeTruthy() - - log.intMinter = null // the pre-cutover writer - const idOld1 = uid('v1-old-1') - const idOld2 = uid('v1-old-2') - await brain.add({ id: idOld1, data: 'old one', type: NounType.Document, vector: vec(3), metadata: { era: 'v1' } }) - await brain.add({ id: idOld2, data: 'old two', type: NounType.Document, vector: vec(4), metadata: { era: 'v1' } }) - await brain.flush() - - const before = await readManifest(brain) - expect(before.segments).toHaveLength(0) - const v1TailFile = before.tailSegment! - const v1Bytes = segmentBytes(dir, v1TailFile) - expect(parseSegmentHeader(v1Bytes.subarray(0, SEGMENT_HEADER_BYTES)).formatVersion).toBe( - FACT_LOG_FORMAT_V1 - ) - - log.intMinter = minter // the cutover lands mid-session - const idNew = uid('v2-new') - await brain.add({ id: idNew, data: 'new era', type: NounType.Document, vector: vec(5), metadata: { era: 'v2' } }) - await brain.flush() - - // The v1 tail was SEALED (bytes untouched), the new tail is v2. - const after = await readManifest(brain) - expect(after.segments.map((s) => s.file)).toContain(v1TailFile) - expect(after.tailSegment).not.toBe(v1TailFile) - const sealedBytes = segmentBytes(dir, v1TailFile) - expect(parseSegmentHeader(sealedBytes.subarray(0, SEGMENT_HEADER_BYTES)).formatVersion).toBe( - FACT_LOG_FORMAT_V1 - ) - expect( - Buffer.compare(Buffer.from(sealedBytes), Buffer.from(v1Bytes)), - 'the sealed v1 segment is byte-identical — never rewritten' - ).toBe(0) - const tailBytes = segmentBytes(dir, after.tailSegment!) - expect(parseSegmentHeader(tailBytes.subarray(0, SEGMENT_HEADER_BYTES)).formatVersion).toBe( - FACT_LOG_FORMAT_V2 - ) - // NOT a brand-new log: no genesis on a rotated-in v2 tail. - const tailGroup = decodeGroupV2(tailBytes.subarray(SEGMENT_HEADER_BYTES), { - expectedIdSpaceWidth: 64 - }) - expect( - tailGroup.facts.flatMap((f) => f.records).some((r) => r.type === 'log.genesis') - ).toBe(false) - - // One scan spans both formats, shape-identically, in generation order. - const liveFacts = await allFacts(brain) - const liveGens = liveFacts.map((f) => f.generation) - expect([...liveGens].sort((a, b) => a - b)).toEqual(liveGens) - for (const id of [idOld1, idOld2, idNew]) { - const fact = liveFacts.find((f) => f.ops.some((op) => op.id === id)) - expect(fact, `fact for ${id} is scannable`).toBeDefined() - const op = fact!.ops.find((o) => o.id === id)! - expect(op.kind).toBe('noun') - expect(op.record).not.toBeNull() - } - - // The MIXED log survives a reopen and keeps appending (v2 tail). - await (brain as unknown as { close: () => Promise }).close() - brains.splice(brains.indexOf(brain), 1) - const reopened = track(await openBrain(dir)) - const reFacts = await allFacts(reopened) - expect(reFacts.map((f) => f.generation)).toEqual(liveGens) - // The v1 fact still reads exactly as the v1 decoder always read it. - // (Not compared byte-strict against canonical: the v1 CAPTURE has a - // known pre-existing wart — write-cache-warm objects carry - // undefined-valued engine keys that msgpack preserves as nil while the - // durable JSON drops them. v1 bytes are frozen; the v2 encoder - // sanitizes to durable truth instead — pinned in scenario (a).) - const oldOp = reFacts - .find((f) => f.ops.some((op) => op.id === idOld1))! - .ops.find((o) => o.id === idOld1)! - const canonicalOld = await internals(reopened).storage.readNounRaw(idOld1) - const oldMeta = oldOp.record!.metadata as Record - expect(oldMeta.noun).toBe('document') - expect((oldMeta.metadata as Record).era).toBe('v1') - const oldWrapper = oldOp.record!.vector as { id: string; vector: number[] } - const canonicalWrapper = canonicalOld.vector as { id: string; vector: number[] } - expect(oldWrapper.id).toBe(idOld1) - expect(oldWrapper.vector).toStrictEqual(canonicalWrapper.vector) - await reopened.add({ id: uid('post-reopen'), data: 'still writing', type: NounType.Document, vector: vec(6), metadata: {} }) - expect((await factGenerations(reopened)).length).toBe(liveGens.length + 1) - }) - - it('(c) MINT-AT-APPEND: after-image records carry the id mapper\'s EXACT int assignments — distinct, nonzero, reproducible', async () => { - const dir = trackDir() - const brain = track(await openBrain(dir)) - const idA = uid('mint-a') - const idB = uid('mint-b') - await brain.add({ id: idA, data: 'mint one', type: NounType.Document, vector: vec(7), metadata: { m: 1 } }) - await brain.add({ id: idB, data: 'mint two', type: NounType.Document, vector: vec(8), metadata: { m: 2 } }) - await brain.flush() - - const manifest = await readManifest(brain) - const bytes = segmentBytes(dir, manifest.tailSegment!) - const group = decodeGroupV2(bytes.subarray(SEGMENT_HEADER_BYTES), { expectedIdSpaceWidth: 64 }) - const afterImages = new Map() - for (const fact of group.facts) { - for (const record of fact.records) { - if (record.type === 'noun.afterImage') afterImages.set(record.id, record) - } - } - const recA = afterImages.get(idA) - const recB = afterImages.get(idB) - expect(recA, 'idA has a decoded after-image').toBeDefined() - expect(recB, 'idB has a decoded after-image').toBeDefined() - expect(recA!.entityInt).toBeGreaterThan(0n) - expect(recB!.entityInt).toBeGreaterThan(0n) - expect(recA!.entityInt).not.toBe(recB!.entityInt) - - // White-box: the ints on the wire ARE the metadata index mapper's - // assignments — the exact ints a mapper rebuild must reproduce. - const mapper = internals(brain).metadataIndex.getIdMapper() - expect(recA!.entityInt).toBe(BigInt(mapper.getInt(idA)!)) - expect(recB!.entityInt).toBe(BigInt(mapper.getInt(idB)!)) - }) - - it('(d) SEALS AT SYNC: every flush leaves the tail sector-aligned; pads are invisible to scans', async () => { - const dir = trackDir() - const brain = track(await openBrain(dir)) - await brain.add({ id: uid('seal-1'), data: 'one', type: NounType.Document, vector: vec(10), metadata: {} }) - await brain.flush() - - const manifest = await readManifest(brain) - const tailPath = path.join(dir, ...FACTS_DIR, manifest.tailSegment!) - const sizeAfterFirstFlush = fs.statSync(tailPath).size - expect(sizeAfterFirstFlush).toBeGreaterThan(0) - expect(sizeAfterFirstFlush % 4096, 'tail is sector-aligned after flush').toBe(0) - const countAfterFirstFlush = (await factGenerations(brain)).length - - for (let i = 0; i < 3; i++) { - await brain.add({ id: uid(`seal-more-${i}`), data: `more ${i}`, type: NounType.Document, vector: vec(11 + i), metadata: { i } }) - } - await brain.flush() - const sizeAfterSecondFlush = fs.statSync(tailPath).size - expect(sizeAfterSecondFlush).toBeGreaterThan(sizeAfterFirstFlush) - expect(sizeAfterSecondFlush % 4096, 'still aligned after more writes + flush').toBe(0) - - // Pads count toward bytes, never toward facts. - expect((await factGenerations(brain)).length).toBe(countAfterFirstFlush + 3) - }) - - it('(e) REPLAY COMPAT: the log-authority recovery path resurrects an acked write from a v2 tail after a crash-style abandon', async () => { - // The flip idiom from the log-authority suite: seed writes, baseline - // backfill LAST (the init-time VFS root never got a fact), flush, then - // the sanctioned guarded flip — the oracle goes green over an ALL-V2 - // log, which is itself the reproduction proof for the v2 record path. - const dir = mkdtempSync(join(tmpdir(), 'brainy-v2-cutover-')) - dirs.push(dir) - process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' - const open = async (): Promise => { - const b = new Brainy({ - storage: { type: 'filesystem', path: dir }, - requireSubtype: false, - silent: true, - dimensions: 384 - }) - await b.init() - return track(b) - } - - const brain = await open() - const kept = await brain.add({ data: 'alpha document', type: 'document', metadata: { n: 1 } }) - const removed = await brain.add({ data: 'beta document', type: 'document', metadata: { n: 2 } }) - await brain.update({ id: kept, metadata: { n: 10 } }) - await brain.remove(removed) - const root = await brain.get(VFS_ROOT) - expect(root, 'the VFS root exists').toBeTruthy() - await brain.update({ id: VFS_ROOT, metadata: root!.metadata }) // baseline backfill — final write - await brain.flush() - - const report = await (brain as unknown as { adoptLogAuthority(): Promise<{ verdict: string }> }).adoptLogAuthority() - expect(report.verdict, 'the oracle is green over a pure-v2 log').toBe('green') - - // An at-ack write: its v2 fact is fsynced (sector-sealed) at ack. - const survivor = await brain.add({ - data: 'survives power loss', - type: 'document', - metadata: { s: 1 } - }) - - // Crash-style abandon: RAM state gone, no flush, no close. - await abandonAsCrashed(brain) - - // Reopen: open() finds the acked fact ABOVE the manifest watermark in - // the v2 tail (peekFactsAbove → v2 decode) and REPLAYS it into - // canonical — an acked write is never lost. - const reopened = await open() - expect( - (reopened as unknown as { logAuthority(): { authority: string } }).logAuthority().authority - ).toBe('log') - const resurrected = await reopened.get(survivor) - expect(resurrected, 'the acked write survived the crash').toBeTruthy() - expect((resurrected as { metadata?: { s?: number } }).metadata?.s).toBe(1) - expect((await factGenerations(reopened)).length).toBeGreaterThan(0) - }) -}) diff --git a/tests/integration/factlog-open-prune.test.ts b/tests/integration/factlog-open-prune.test.ts deleted file mode 100644 index 223e91f4..00000000 --- a/tests/integration/factlog-open-prune.test.ts +++ /dev/null @@ -1,360 +0,0 @@ -/** - * @module tests/integration/factlog-open-prune - * @description THE OPEN READS THE TAIL, NOT THE HISTORY. - * - * Every log-authority open asks the fact log one question — "is there a fact - * above the committed pointer?" — and until this lane existed it answered by - * reading and CRC-decoding EVERY segment file the manifest names. MEASURED in - * production on a 16k-row brain at generation ~478,819: 34-37 seconds inside - * the `generation-store-open-fold` phase, on every open, including the clean - * one where the answer is always "nothing". - * - * The manifest already records each sealed segment's `lastGeneration`, written - * at seal time AFTER the segment's bytes are fsynced and into a manifest that - * is itself written atomically and fsynced — and a sealed file is never - * appended to again (the same manifest flip re-points `tailSegment`). So an - * entry recording `lastGeneration ≤ committed` PROVES its file holds nothing - * above the bound, and the open can skip it whole. - * - * Pinned here, from the log's own counters (the narration line), never a clock: - * - * 1. A clean close and reopen on a log with ≥4 sealed segments reads - * EXACTLY the tail (1 of 6), prunes the rest, and finds nothing. - * 2. A real SIGKILLed process that sealed segments holding facts ABOVE the - * committed pointer: the reopen READS those sealed segments and recovers - * byte-identically to an unpruned open (differential — the same store, - * with the provable field stripped from its manifest, takes the full-scan - * path and must agree fact for fact, before and after `open()`). - * 3. A manifest entry with no `lastGeneration` (legacy, or hand-repaired) is - * READ. Never prune what the manifest cannot prove. - */ -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 { spawn } from 'node:child_process' -import { - FactLog, - FACTS_MANIFEST_PATH, - type CommitFact, - type FactLogStorage -} from '../../src/db/factLog.js' -import { FileSystemStorage } from '../../src/storage/adapters/fileSystemStorage.js' - -const REPO_ROOT = process.cwd() -const TSX = path.join(REPO_ROOT, 'node_modules', '.bin', 'tsx') -/** ~1KB frames against a 4KB rotation threshold: ~5 facts per segment. */ -const ROTATE_BYTES = 4096 - -const tmpDirs: string[] = [] -function makeTempDir(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-factlog-prune-')) - tmpDirs.push(dir) - return dir -} - -afterEach(() => { - for (const dir of tmpDirs.splice(0)) { - try { - fs.rmSync(dir, { recursive: true, force: true }) - } catch { - /* best effort */ - } - try { - fs.rmSync(`${dir}.ready.json`, { force: true }) - } catch { - /* best effort */ - } - } -}) - -const UUID = (n: number): string => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` - -/** One ~1KB fact — the padding is what makes rotation cheap to provoke. */ -function fact(generation: number): CommitFact { - return { - generation, - timestamp: 1_700_000_000_000 + generation, - ops: [ - { - kind: 'noun', - id: UUID(generation), - record: { - metadata: { noun: 'document', pad: 'x'.repeat(900), g: generation }, - vector: null - } - } - ] - } -} - -/** - * A deterministic int minter so the log writes the V2 format production - * writes (the prune is a manifest-level decision and never touches segment - * bytes — but the pins should run against the bytes the fleet actually has). - */ -function makeMinter(): (kind: 'noun' | 'verb', id: string) => bigint { - const ints = new Map() - return (kind, id) => { - const key = `${kind}:${id}` - let minted = ints.get(key) - if (minted === undefined) { - minted = BigInt(ints.size + 1) - ints.set(key, minted) - } - return minted - } -} - -/** Open a fact log over a store directory (a fresh adapter each time — this is - * what a reopen actually does). */ -async function openStore(dir: string): Promise<{ storage: any; log: FactLog }> { - const storage: any = new FileSystemStorage(dir) - await storage.init() - const log = new FactLog(storage as FactLogStorage, { rotateBytes: ROTATE_BYTES }) - log.setIntMinter(makeMinter()) - return { storage, log } -} - -/** Build a log of `count` facts (rotating every ~5), left durable, not closed. */ -async function buildLog(dir: string, count: number): Promise { - const { log } = await openStore(dir) - await log.open(0) - for (let g = 1; g <= count; g++) await log.append(fact(g)) - await log.sync() - return log.headGeneration() -} - -/** Capture the narration channel (`prodLog.narrate` → console.warn). */ -async function captureNarration( - fn: () => Promise -): Promise<{ result: T; lines: string[] }> { - const lines: string[] = [] - const original = 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 = original - } -} - -/** The counters the open narrated — the pin's only source of truth for what - * was read (a wall-clock assertion could pass on a warm page cache). */ -function scanCounts(lines: string[]): { read: number; pruned: number; total: number } { - const line = lines.find((l) => l.includes('[FactLog] above-manifest peek above generation')) - if (!line) { - throw new Error(`no peek narration in:\n${lines.join('\n')}`) - } - const match = /(\d+) segment\(s\) read, (\d+) pruned of (\d+)/.exec(line) - if (!match) throw new Error(`unparsable peek narration: ${line}`) - return { read: Number(match[1]), pruned: Number(match[2]), total: Number(match[3]) } -} - -interface SegmentEntryOnDisk { - file: string - firstGeneration: number - lastGeneration?: number - facts: number - bytes: number -} - -async function readManifest(dir: string): Promise<{ - segments: SegmentEntryOnDisk[] - tailSegment: string | null -}> { - const storage: any = new FileSystemStorage(dir) - await storage.init() - return (await storage.readRawObject(FACTS_MANIFEST_PATH)) as any -} - -async function rewriteManifest( - dir: string, - mutate: (manifest: any) => void -): Promise { - const storage: any = new FileSystemStorage(dir) - await storage.init() - const manifest = await storage.readRawObject(FACTS_MANIFEST_PATH) - mutate(manifest) - await storage.writeRawObject(FACTS_MANIFEST_PATH, manifest) - await storage.syncRawObjects([FACTS_MANIFEST_PATH]) -} - -/** Every fact the log holds, in order — the recovered state, read back. */ -async function allFacts(log: FactLog): Promise { - const out: CommitFact[] = [] - const handle = log.scanFacts() - for await (const batch of handle.batches()) out.push(...batch.facts) - return out -} - -describe('fact log — the open reads only the segments that can hold facts above the bound', () => { - it('a clean close + reopen over ≥4 sealed segments reads exactly the tail and finds nothing', async () => { - const dir = makeTempDir() - const head = await buildLog(dir, 30) - - const manifest = await readManifest(dir) - expect(manifest.segments.length).toBeGreaterThanOrEqual(4) // the fixture is real - expect(manifest.tailSegment).not.toBeNull() - - // The reopen: a clean close means committed === the log's head. - const { log } = await openStore(dir) - const { result: orphans, lines } = await captureNarration(() => log.peekFactsAbove(head)) - - expect(orphans).toEqual([]) // the fold finds nothing, as it always does after a clean close - const counts = scanCounts(lines) - expect(counts.read).toBe(1) // EXACTLY the tail - expect(counts.total).toBe(manifest.segments.length + 1) - expect(counts.pruned).toBe(manifest.segments.length) - - // And the reconciling open still lands on the same committed prefix. - await log.open(head) - expect(log.headGeneration()).toBe(head) - expect((await allFacts(log)).map((f) => f.generation)).toEqual( - Array.from({ length: head }, (_, i) => i + 1) - ) - }) - - it('a manifest entry with no lastGeneration is READ — never prune what you cannot prove', async () => { - const dir = makeTempDir() - const head = await buildLog(dir, 30) - const before = await readManifest(dir) - expect(before.segments.length).toBeGreaterThanOrEqual(4) - - // A legacy/hand-repaired entry: the field the prune needs is simply absent. - await rewriteManifest(dir, (m) => { - delete m.segments[0].lastGeneration - }) - - const { log } = await openStore(dir) - const { result: orphans, lines } = await captureNarration(() => log.peekFactsAbove(head)) - - expect(orphans).toEqual([]) // still nothing above the bound — it was READ to find out - const counts = scanCounts(lines) - expect(counts.read).toBe(2) // the unprovable entry + the tail - expect(counts.pruned).toBe(before.segments.length - 1) - expect(counts.total).toBe(before.segments.length + 1) - }) - - it( - 'a SIGKILLed writer that sealed segments above the committed pointer recovers identically to an unpruned open', - async () => { - const dir = makeTempDir() - const readyPath = `${dir}.ready.json` - // A real process death: the child fsyncs its segments, records what it - // reached, and SIGKILLs ITSELF — no close, no unwind, no chance to tidy. - const script = ` - import * as fs from 'node:fs' - import { FactLog } from ${JSON.stringify(path.join(REPO_ROOT, 'src', 'db', 'factLog.ts'))} - import { FileSystemStorage } from ${JSON.stringify(path.join(REPO_ROOT, 'src', 'storage', 'adapters', 'fileSystemStorage.ts'))} - const UUID = (n) => '00000000-0000-4000-8000-' + String(n).padStart(12, '0') - const fact = (g) => ({ - generation: g, - timestamp: 1700000000000 + g, - ops: [{ kind: 'noun', id: UUID(g), record: { metadata: { noun: 'document', pad: 'x'.repeat(900), g }, vector: null } }] - }) - const ints = new Map() - const storage = new FileSystemStorage(${JSON.stringify(dir)}) - await storage.init() - const log = new FactLog(storage, { rotateBytes: ${ROTATE_BYTES} }) - log.setIntMinter((kind, id) => { - const key = kind + ':' + id - if (!ints.has(key)) ints.set(key, BigInt(ints.size + 1)) - return ints.get(key) - }) - await log.open(0) - for (let g = 1; g <= 30; g++) await log.append(fact(g)) - await log.sync() - fs.writeFileSync(${JSON.stringify(readyPath)}, JSON.stringify({ head: log.headGeneration() })) - process.kill(process.pid, 'SIGKILL') - ` - const scriptPath = path.join(dir, 'crash-writer.mts') - fs.writeFileSync(scriptPath, script) - const child = spawn(TSX, [scriptPath], { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'] }) - let output = '' - child.stdout.on('data', (d) => { output += String(d) }) - child.stderr.on('data', (d) => { output += String(d) }) - const exit = await new Promise<{ code: number | null; signal: string | null }>((resolve) => - child.on('exit', (code, signal) => resolve({ code, signal })) - ) - if (!fs.existsSync(readyPath)) { - throw new Error(`the crash writer never reached its kill point:\n${output}`) - } - // Death, not a shutdown: no close(), no unwind, no orderly exit code. - expect(exit.signal ?? `code ${exit.code}`).not.toBe('code 0') - const head = JSON.parse(fs.readFileSync(readyPath, 'utf8')).head as number - expect(head).toBe(30) - - // The committed pointer the survivor comes back on: mid-log, so sealed - // segments hold facts ABOVE it — the exact shape the prune must not skip. - const committed = 12 - const manifest = await readManifest(dir) - const straddling = manifest.segments.filter( - (s) => s.firstGeneration <= committed && (s.lastGeneration ?? 0) > committed - ) - const entirelyAbove = manifest.segments.filter((s) => s.firstGeneration > committed) - expect(straddling.length).toBeGreaterThanOrEqual(1) - expect(entirelyAbove.length).toBeGreaterThanOrEqual(1) - - // THE DIFFERENTIAL. The unpruned answer, through the SAME code on the - // SAME bytes: a peek above generation 0 can prune nothing (no sealed - // segment ends at or below 0), so it reads every segment file and - // decodes every frame — exactly what this open used to do — and its - // facts above the pointer are what the fold is entitled to replay. - const { log } = await openStore(dir) - const { result: fullScan, lines: fullLines } = await captureNarration(() => - log.peekFactsAbove(0) - ) - expect(scanCounts(fullLines)).toEqual({ - read: manifest.segments.length + 1, - pruned: 0, - total: manifest.segments.length + 1 - }) - const unprunedAnswer = fullScan.filter((f) => f.generation > committed) - - const { result: prunedAnswer, lines } = await captureNarration(() => - log.peekFactsAbove(committed) - ) - - // The sealed segments above the bound were READ, not skipped. - const counts = scanCounts(lines) - expect(counts.read).toBe(straddling.length + entirelyAbove.length + 1) - expect(counts.pruned).toBe(manifest.segments.length - straddling.length - entirelyAbove.length) - expect(counts.pruned).toBeGreaterThan(0) // the prune did engage, and was still right - expect(prunedAnswer.map((f) => f.generation)).toEqual( - Array.from({ length: head - committed }, (_, i) => committed + 1 + i) - ) - // Facts that live in a SEALED segment (not the tail) came back. - expect(prunedAnswer.some((f) => f.generation <= (straddling[0].lastGeneration ?? 0))).toBe( - true - ) - // Fact for fact, the pruned answer IS the unpruned answer — so whatever - // the recovery replays, it replays identically. - expect(prunedAnswer).toEqual(unprunedAnswer) - - // The fold's streaming twin (the unclean-open path) agrees too. - const streamed: CommitFact[] = [] - for await (const batch of log.streamFactsAbove(committed)) streamed.push(...batch) - expect(streamed).toEqual(unprunedAnswer) - - // And the reconciling open rolls back exactly as it always did: the two - // never-committed sealed segments dropped, the straddling one cut, the - // tail truncated — the log left as the committed prefix. - await log.open(committed) - expect(log.headGeneration()).toBe(committed) - expect((await allFacts(log)).map((f) => f.generation)).toEqual( - Array.from({ length: committed }, (_, i) => i + 1) - ) - const after = await readManifest(dir) - expect(after.segments.map((s) => s.file)).toEqual( - manifest.segments - .filter((s) => s.firstGeneration <= committed) - .map((s) => s.file) - ) - expect(after.segments[after.segments.length - 1].lastGeneration).toBe(committed) - }, - 120_000 - ) -}) diff --git a/tests/integration/filter-operator-conformance.test.ts b/tests/integration/filter-operator-conformance.test.ts deleted file mode 100644 index 628017e7..00000000 --- a/tests/integration/filter-operator-conformance.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * @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/find-connected-order.test.ts b/tests/integration/find-connected-order.test.ts deleted file mode 100644 index 3b7560e4..00000000 --- a/tests/integration/find-connected-order.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * @module tests/integration/find-connected-order - * @description The graph-first law for `find({ connected })` (10.4.8). - * - * With `connected` present the neighbour set is the candidate universe: it is - * resolved from the adjacency first, the metadata filter is evaluated over - * those ids only, and the page is cut last. The earlier order materialized the - * whole-store filtered id list, paged it, hydrated the page, and only then - * intersected with the neighbours — so a neighbour outside the first page of - * the filtered STORE was silently dropped, and every call paid O(store). - * - * These pins hold both halves. The answer: every matching neighbour is - * reachable by paging, a non-neighbour never appears, a negation (`missing`) - * is evaluated over the neighbours, `orderBy` sorts the whole neighbour set - * before the page is cut, and the vector leg walks the neighbours only. The - * cost shape: the metadata index is asked about the neighbour ids only, and - * hydration is one page — never the store. - */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' -import { Brainy } from '../../src/brainy' -import { NounType, VerbType } from '../../src/types/graphTypes' -import { v5 } from '../../src/universal/uuid' -import { generateTestVector } from '../helpers/test-factory' - -/** Matching rows that are NOT neighbours — added FIRST, so the whole-store filtered list leads with them. */ -const NOISE = 120 -/** Matching rows that ARE neighbours of the anchor. */ -const NEIGHBOURS = 30 -/** Neighbours carrying `retracted: true` — excluded by the `missing` negation. */ -const RETRACTED = 4 - -describe('find({ connected }) is graph-first: neighbours → filter → page', () => { - let brain: Brainy - const anchor = 'anchor' - const sharedVector = generateTestVector() - const neighbourIds = new Set(Array.from({ length: NEIGHBOURS }, (_, i) => v5(`nb-${i}`))) - - beforeAll(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - await brain.add({ - id: anchor, - data: 'the anchor', - type: NounType.Person, - metadata: { kind: 'anchor' }, - vector: generateTestVector() - }) - for (let i = 0; i < NOISE; i++) { - await brain.add({ - id: `noise-${i}`, - data: `noise ${i}`, - type: NounType.Person, - metadata: { kind: 'note', rank: 1000 + i }, - vector: sharedVector - }) - } - for (let i = 0; i < NEIGHBOURS; i++) { - await brain.add({ - id: `nb-${i}`, - data: `neighbour ${i}`, - type: NounType.Person, - metadata: { kind: 'note', rank: i + 1, ...(i < RETRACTED ? { retracted: true } : {}) }, - vector: sharedVector - }) - await brain.relate({ from: anchor, to: `nb-${i}`, type: VerbType.Knows }) - } - }) - - afterAll(async () => { - // CLOSE IT. Dropping the reference does not close a brain — it only makes - // it unreachable from here. The instance stays open and registered, its - // unref'd cadence timer keeps running, and because the gate config runs the - // whole suite in ONE process (pool: 'forks', singleFork: true) it goes on - // narrating its flushes into every test file that runs after this one. - // A test that leaks a brain is a defect of the test. - await brain?.close() - brain = null as any - }) - - it('returns the matching neighbours page by page — none dropped, never a non-neighbour', async () => { - const seen = new Set() - for (let offset = 0; offset <= NEIGHBOURS; offset += 10) { - const page = await brain.find({ - connected: { from: anchor, direction: 'out' }, - where: { kind: 'note' }, - limit: 10, - offset - }) - expect(page).toHaveLength(offset < NEIGHBOURS ? 10 : 0) - for (const r of page) { - expect(neighbourIds.has(r.entity.id)).toBe(true) - expect(seen.has(r.entity.id)).toBe(false) - seen.add(r.entity.id) - } - } - expect(seen.size).toBe(NEIGHBOURS) - }) - - it('evaluates a negation (`missing`) over the neighbour set, not the store', async () => { - const results = await brain.find({ - connected: { from: anchor, direction: 'out' }, - where: { kind: 'note', retracted: { missing: true } }, - limit: 100 - }) - expect(results).toHaveLength(NEIGHBOURS - RETRACTED) - for (const r of results) { - expect(neighbourIds.has(r.entity.id)).toBe(true) - expect(r.entity.metadata.retracted).toBeUndefined() - } - }) - - it('asks the metadata index about the neighbour ids only, and hydrates one page', async () => { - const index = (brain as any).metadataIndex - const within = vi.spyOn(index, 'filterIdsWithin') - const hydrate = vi.spyOn(brain as any, 'batchGet') - try { - const results = await brain.find({ - connected: { from: anchor, direction: 'out' }, - where: { kind: 'note' }, - limit: 10 - }) - expect(results).toHaveLength(10) - expect(within).toHaveBeenCalledTimes(1) - const askedIds = within.mock.calls[0][1] as string[] - expect(askedIds).toHaveLength(NEIGHBOURS) - for (const id of askedIds) expect(neighbourIds.has(id)).toBe(true) - expect(hydrate).toHaveBeenCalledTimes(1) - expect(hydrate.mock.calls[0][0]).toHaveLength(10) - } finally { - within.mockRestore() - hydrate.mockRestore() - } - }) - - it('orders the WHOLE neighbour set before cutting the page', async () => { - const results = await brain.find({ - connected: { from: anchor, direction: 'out' }, - where: { kind: 'note' }, - orderBy: 'rank', - order: 'desc', - limit: 5 - }) - expect(results.map((r) => r.entity.metadata.rank)).toEqual([30, 29, 28, 27, 26]) - }) - - it('walks the vector leg over the neighbours only', async () => { - // The SAME query without the vector leg, first. Both legs draw from the - // one neighbour set, so this is the control: it says whether a short answer - // came from the adjacency/filter (both legs short) or from the vector walk - // alone (only the vector leg short). Cheap, and it turns a bare count - // mismatch into a named half — this case has gone red on the gate box - // while passing in isolation and beside its own predecessor, so the next - // red must arrive already carrying the half it belongs to. - const control = await brain.find({ - connected: { from: anchor, direction: 'out' }, - where: { kind: 'note' }, - limit: 5 - }) - - const results = await brain.find({ - vector: sharedVector, - connected: { from: anchor, direction: 'out' }, - where: { kind: 'note' }, - limit: 5 - }) - - expect( - results.length, - `the vector leg returned ${results.length} of a requested 5. The same query ` + - `WITHOUT the vector returned ${control.length}: if that is also short the ` + - `neighbour set or the filter is the cause, and if it is 5 the vector walk is — ` + - `note every row in this corpus carries an identical vector, so the walk is ` + - `ranking an exact tie.` - ).toBe(5) - for (const r of results) expect(neighbourIds.has(r.entity.id)).toBe(true) - }) - - it('an anchor without neighbours answers [] before the filter is asked', async () => { - const index = (brain as any).metadataIndex - const within = vi.spyOn(index, 'filterIdsWithin') - try { - const results = await brain.find({ - connected: { from: 'noise-0', direction: 'out' }, - where: { kind: 'note' }, - limit: 10 - }) - expect(results).toEqual([]) - expect(within).not.toHaveBeenCalled() - } finally { - within.mockRestore() - } - }) -}) diff --git a/tests/integration/find-fields-projection.test.ts b/tests/integration/find-fields-projection.test.ts deleted file mode 100644 index 25ee416c..00000000 --- a/tests/integration/find-fields-projection.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -/** - * @module tests/integration/find-fields-projection - * @description **Field projection** — `find/get({ fields })` returns only the - * named fields, and serves them from the index when it can. - * - * A list view that shows a title and a slug does not need the document body, - * yet without a projection every row hydrates its whole record and discards - * almost all of it. These pins hold the two halves of the fix: - * - * **The answer.** A projected row is a SUBSET of the full row — for every - * requested field, the projected value equals the value the same query returns - * unprojected. Absent `fields` is byte-identical to today. A requested field the - * entity does not carry is simply absent, never an error. `system.*` resolves to - * the engine scalar, a bare name to the user's metadata. - * - * **The cost.** When every requested field is index-served, the canonical - * record is never opened — asserted by counting reads, not by timing them, so - * it cannot flake into a false green. When one requested field is NOT - * index-served (a body field, or a bucketed timestamp), exactly the owing rows - * are read and the rest are still served from the index. - */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' -import { Brainy } from '../../src/brainy' -import { NounType } from '../../src/types/graphTypes' -import { generateTestVector } from '../helpers/test-factory' - -/** Rows carrying a title, a slug, and a large body nobody wants in a list. */ -const ROWS = 12 -const BODY = 'x'.repeat(4096) - -describe('find/get({ fields }) — projection', () => { - let brain: Brainy - const ids: string[] = [] - - beforeAll(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - for (let i = 0; i < ROWS; i++) { - ids.push( - await brain.add({ - id: `post-${i}`, - data: `post ${i}`, - type: NounType.Thing, - metadata: { - kind: 'post', - title: `Title ${i}`, - slug: `slug-${i}`, - rank: i, - body: BODY, - // Only some rows carry this, so "missing is absent" is exercised - // by real data rather than by a name nothing ever had. - ...(i % 2 === 0 ? { featured: true } : {}) - }, - vector: generateTestVector() - }) - ) - } - // Persist so the column store holds the values a projection reads from. - await brain.flush() - }) - - afterAll(async () => { - await brain.close() - }) - - /** Count canonical record reads for one call. */ - const countingReads = async (body: () => Promise): Promise<{ out: R; reads: number }> => { - const spy = vi.spyOn(brain as any, 'batchGet') - try { - const out = await body() - const reads = spy.mock.calls.reduce( - (n, call) => n + ((call[0] as string[] | undefined)?.length ?? 0), - 0 - ) - return { out, reads } - } finally { - spy.mockRestore() - } - } - - it('absent fields is byte-identical to today', async () => { - const params = { where: { kind: 'post' }, limit: 5 } - const a = await brain.find({ ...params }) - const b = await brain.find({ ...params, fields: undefined }) - expect(JSON.stringify(b)).toBe(JSON.stringify(a)) - }) - - it('a projected row is a SUBSET of the full row, field for field', async () => { - const shapes: Array> = [ - { where: { kind: 'post' }, limit: 6 }, - { where: { kind: 'post' }, limit: 6, offset: 3 }, - { where: { kind: 'post' }, orderBy: 'rank', order: 'asc', limit: 6 }, - { where: { kind: 'post' }, orderBy: 'rank', order: 'desc', limit: 4 } - ] - for (const shape of shapes) { - const full = await brain.find(shape as never) - const projected = await brain.find({ ...shape, fields: ['title', 'slug'] } as never) - expect(projected.map((r) => r.id), JSON.stringify(shape)).toEqual(full.map((r) => r.id)) - for (let i = 0; i < full.length; i++) { - const fullMeta = (full[i].entity.metadata ?? {}) as Record - const projMeta = (projected[i].entity.metadata ?? {}) as Record - expect(projMeta.title, `${JSON.stringify(shape)} row ${i}`).toEqual(fullMeta.title) - expect(projMeta.slug).toEqual(fullMeta.slug) - } - } - }) - - it('returns ONLY the named fields — the body never rides along', async () => { - const rows = await brain.find({ where: { kind: 'post' }, fields: ['title'], limit: 4 }) - expect(rows).toHaveLength(4) - for (const r of rows) { - const meta = (r.entity.metadata ?? {}) as Record - expect(Object.keys(meta)).toEqual(['title']) - expect(meta.body).toBeUndefined() - // Identity always survives a projection: a row you cannot identify is - // not a row. - expect(typeof r.id).toBe('string') - expect(r.entity.id).toBe(r.id) - } - }) - - it('a missing field is simply ABSENT — never an error', async () => { - // `featured` exists on half the rows; `no-such-field` on none. Neither - // throws, and neither appears as an explicit undefined. - const rows = await brain.find({ - where: { kind: 'post' }, - fields: ['title', 'featured', 'no-such-field'], - limit: ROWS - }) - expect(rows.length).toBeGreaterThan(0) - let withFeatured = 0 - for (const r of rows) { - const meta = (r.entity.metadata ?? {}) as Record - expect('no-such-field' in meta).toBe(false) - if ('featured' in meta) withFeatured += 1 - } - // Real data, not a name nothing ever had: some rows carry it, some do not. - expect(withFeatured).toBeGreaterThan(0) - expect(withFeatured).toBeLessThan(rows.length) - }) - - it('a strict address resolver is NOT on this path', async () => { - // orderBy throws UnresolvableFieldError for an unknown user key, because a - // typo there silently changes the order. A projection must not inherit that - // strictness: the honest answer to "give me this if you have it" is silence. - await expect( - brain.find({ where: { kind: 'post' }, fields: ['definitely-not-a-field'], limit: 2 }) - ).resolves.toBeInstanceOf(Array) - }) - - it('system.* resolves to the engine scalar, a bare name to user metadata', async () => { - const full = await brain.find({ where: { kind: 'post' }, limit: 3 }) - const rows = await brain.find({ - where: { kind: 'post' }, - fields: ['system.createdAt', 'title'], - limit: 3 - }) - for (let i = 0; i < rows.length; i++) { - expect((rows[i].entity as any).createdAt).toEqual((full[i].entity as any).createdAt) - const meta = (rows[i].entity.metadata ?? {}) as Record - expect(meta.title).toEqual((full[i].entity.metadata as any).title) - // The engine scalar lands at the top level, not in the metadata bag — - // the two address spaces never shadow each other. - expect('system.createdAt' in meta).toBe(false) - expect('createdAt' in meta).toBe(false) - } - }) - - it('reads NO canonical record when every requested field is index-served', async () => { - // The cost pin, counted rather than timed. `title` and `slug` are ordinary - // indexed user fields, so the index can serve them exactly. - const { out, reads } = await countingReads(() => - brain.find({ where: { kind: 'post' }, fields: ['title', 'slug'], limit: ROWS }) - ) - expect(out.length).toBeGreaterThan(0) - expect(reads).toBe(0) - }) - - it('reads records only for the fields the column cannot serve', async () => { - // `system.data` is NOT a column the store holds (verified against - // getIndexedFields), so the record must be opened for it — while `title`, - // which the column does hold, still comes from the index. - const { out, reads } = await countingReads(() => - brain.find({ where: { kind: 'post' }, fields: ['title', 'system.data'], limit: 4 }) - ) - expect(out).toHaveLength(4) - expect(reads).toBe(4) - for (const r of out) { - const meta = (r.entity.metadata ?? {}) as Record - expect(Object.keys(meta)).toEqual(['title']) - expect(typeof (r.entity as any).data).toBe('string') - } - }) - - it('a large field the column DOES hold costs no record read', async () => { - // Worth pinning because it is the venue case: the body is column-served on - // this engine, so a list that projects around it pays nothing for it, and - // a list that projects it still pays no record read. - const { reads } = await countingReads(() => - brain.find({ where: { kind: 'post' }, fields: ['body'], limit: 4 }) - ) - expect(reads).toBe(0) - }) - - it('projects a vector-leg find too — the ANSWER is uniform, only the cost is not', async () => { - // The seam hydrates the metadata and graph page paths. A vector or text leg - // builds its own entities, so those rows are trimmed after the integrity - // guard instead. That difference is a COST difference, and this pin exists - // so it can never quietly become an ANSWER difference. - const rows = await brain.find({ query: 'post', fields: ['title'], limit: 3 }) - for (const r of rows) { - const meta = (r.entity.metadata ?? {}) as Record - expect(Object.keys(meta)).toEqual(['title']) - expect(meta.body).toBeUndefined() - expect(r.entity.id).toBe(r.id) - } - }) - - it('get({ fields }) projects a single row through the same seam', async () => { - const full = await brain.get(ids[0]) - const projected = await brain.get(ids[0], { fields: ['title', 'slug'] }) - expect(projected).not.toBeNull() - expect(projected!.id).toBe(full!.id) - const fullMeta = (full!.metadata ?? {}) as Record - const projMeta = (projected!.metadata ?? {}) as Record - expect(projMeta.title).toEqual(fullMeta.title) - expect(projMeta.slug).toEqual(fullMeta.slug) - expect(Object.keys(projMeta).sort()).toEqual(['slug', 'title']) - expect((projected as any).body).toBeUndefined() - }) - - it('get({ fields }) reads no record when the index serves the fields', async () => { - const { reads } = await countingReads(() => brain.get(ids[1], { fields: ['title'] })) - expect(reads).toBe(0) - }) - - it('the door serves EXACT values — the column, never the bucketed index', async () => { - // The sparse index buckets `system.createdAt` to the minute for range - // queries; the column store keeps raw ms. Serving a projection from the - // former would hand back a value that differs from the record's, so the - // door reads the column — and this pin is what proves which one it read. - const index = (brain as any).metadataIndex - const sample = ids.slice(0, 3) - const served = await index.getScalarsForIds(sample, ['title', 'system.createdAt']) - expect(served.size).toBe(sample.length) - for (const id of sample) { - const row = served.get(id)! - const record = await brain.get(id) - expect(row.title).toEqual((record!.metadata as any).title) - // Exact to the millisecond — a bucketed value would be rounded down to - // the minute and this would fail. - expect(row['system.createdAt']).toEqual((record as any).createdAt) - } - }) - - it('a field the column store does not hold is OMITTED, not approximated', async () => { - const index = (brain as any).metadataIndex - const served = await index.getScalarsForIds(ids.slice(0, 2), ['title', 'system.data']) - for (const [, row] of served) { - expect('title' in row).toBe(true) - // Omission is what makes the caller read the record for it. - expect('system.data' in row).toBe(false) - } - }) -}) diff --git a/tests/integration/find-hybrid-filter-before-hydrate.test.ts b/tests/integration/find-hybrid-filter-before-hydrate.test.ts deleted file mode 100644 index 7f326729..00000000 --- a/tests/integration/find-hybrid-filter-before-hydrate.test.ts +++ /dev/null @@ -1,643 +0,0 @@ -/** - * @module tests/integration/find-hybrid-filter-before-hydrate - * @description FILTER BEFORE HYDRATE, applied to the hybrid `find({ query })` path. - * - * A hybrid find fuses two legs. The semantic leg already walked only the - * metadata filter's universe (`candidateIds` / `allowedIds`). The TEXT leg did - * not: it ranked the WHOLE store, took the top `limit * 4`, read every one of - * those rows from canonical, and only then intersected with the filter — so a - * filtered hybrid find on a large store read hundreds of rows to return a - * handful of them, and a matching row outside the store-wide text prefix was - * silently dropped. That is the same defect `find({ connected })` carried - * before the graph-first law, one leg over. - * - * Both halves are pinned here. - * - * THE ANSWER. Where the filter did not truncate the text leg — the universe - * covers every text match, so both orders rank the same rows — the new - * pipeline's answer is IDENTICAL to the old one's: same rows, same order, same - * scores, same match visibility, same row shape. The oracle below is the - * pre-change pipeline itself, replayed on the same brain through the same - * doors, so the comparison is against what actually ran, not a remembered - * expectation. - * - * THE CORRECTION. Where the filter DID truncate it — the query's words are - * common outside the universe — the old order let the text leg contribute - * nothing at all: every row it ranked was discarded by the filter, and the - * answer came from the semantic leg alone. The new order ranks inside the - * universe, so the text leg contributes the rows it always should have. - * - * THE COST. Canonical is read for exactly the page: one batch, `limit` rows, - * never the legs. And the text leg is asked about the universe's ids only — - * what it marshals is bounded by the universe, not by the store. - */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' -import { Brainy } from '../../src/brainy' -import { NounType, VerbType } from '../../src/types/graphTypes' -import { rankIndicesByScore, reorderByIndices } from '../../src/utils/resultRanking' -import { resolveEntityId } from '../../src/utils/idNormalization' - -/** Embedding width of the default model — the row vectors must match it. */ -const DIM = 384 - -/** - * A deterministic, per-row-distinct unit vector. Distinct so the semantic leg - * has a real ranking to produce (identical vectors would make its order a tie - * break), deterministic so the oracle and the pipeline see the same one. - */ -function seededVector(seed: number): number[] { - const v = new Array(DIM) - for (let i = 0; i < DIM; i++) { - v[i] = Math.sin((i + 1) * 0.11 + seed * 0.37) * 0.5 + Math.cos((i + 1) * 0.05 + seed * 0.13) * 0.3 - } - const magnitude = Math.sqrt(v.reduce((sum, x) => sum + x * x, 0)) - return v.map((x) => x / magnitude) -} - -/** The fields a caller reads off a hybrid row — the whole comparable surface. */ -function project(rows: any[]): any[] { - return rows.map((r) => ({ - id: r.id, - score: r.score, - type: r.type, - metadata: r.metadata, - textMatches: r.textMatches, - textScore: r.textScore, - semanticScore: r.semanticScore, - matchSource: r.matchSource - })) -} - -/** - * The PRE-CHANGE hybrid pipeline, replayed on a live brain through the same - * provider doors it used: whole-store text ranking with both legs hydrated in - * full, RRF fusion, then the metadata intersection, then the page. - * - * Supports the shapes these pins exercise (query + where/type/excludeVFS + - * connected + offset); `orderBy`, `fusion` and `near` are not replayed. - */ -async function legacyHybridFind(brain: any, params: any): Promise { - const index = brain.metadataIndex - const limit = params.limit ?? 10 - const offset = params.offset ?? 0 - const hasFilter = Boolean( - params.where || params.type || params.subtype || params.service || params.excludeVFS - ) - - let preResolvedMetadataIds: string[] | null = null - let preResolvedFilter: any = null - let graphFirstIds: string[] | null = null - - if (params.connected) { - // find() normalizes the anchors to canonical ids before this stage runs. - const anchored = { - ...params, - connected: { - ...params.connected, - ...(params.connected.from && { from: resolveEntityId(params.connected.from) }), - ...(params.connected.to && { to: resolveEntityId(params.connected.to) }) - } - } - graphFirstIds = await brain.resolveConnectedIds(anchored) - if (graphFirstIds!.length > 0 && hasFilter) { - preResolvedFilter = brain.buildMetadataFilter(params) - graphFirstIds = await brain.filterIdsWithinBelted(preResolvedFilter, graphFirstIds) - } - if (graphFirstIds!.length === 0) return [] - preResolvedMetadataIds = graphFirstIds - } else if (hasFilter) { - preResolvedFilter = brain.buildMetadataFilter(params) - preResolvedMetadataIds = await brain.filterIdsBelted(preResolvedFilter) - if (preResolvedMetadataIds!.length === 0) return [] - } - - // Text leg — the whole store, then the top `limit * 4`, hydrated in full. - const allTextMatches = await index.getIdsForTextQuery(params.query) - const topMatches = allTextMatches.slice(0, limit * 2 * 2) - const maxMatches = topMatches[0]?.matchCount || 1 - const textEntities = await brain.batchGet(topMatches.map((m: any) => m.id)) - const textResults = topMatches - .filter((m: any) => textEntities.has(m.id)) - .map((m: any) => ({ id: m.id, score: m.matchCount / maxMatches })) - - // Semantic leg — the beam walk over the universe, hydrated in full. - const vector = await brain.embed(params.query) - const searchOptions = preResolvedMetadataIds ? { candidateIds: preResolvedMetadataIds } : undefined - const searchResults: [string, number][] = await brain.index.search( - vector, - limit * 2, - undefined, - searchOptions - ) - const semanticEntities = await brain.batchGet(searchResults.map(([id]) => id)) - const semanticResults = searchResults - .filter(([id]) => semanticEntities.has(id)) - .map(([id, distance]) => ({ id, score: Math.max(0, Math.min(1, 1 / (1 + distance))) })) - - // RRF fusion, with the match visibility the rows carried. - const alpha = params.hybridAlpha ?? brain.autoAlpha(params.query) - const k = 60 - const matchData = new Map() - const textWeight = 1 - alpha - textResults.forEach((r: any, rank: number) => { - const existing = matchData.get(r.id) || { rrf: 0, hasText: false, hasSemantic: false } - existing.rrf += textWeight * (1 / (k + rank + 1)) - existing.textScore = r.score - existing.hasText = true - matchData.set(r.id, existing) - }) - semanticResults.forEach((r: any, rank: number) => { - const existing = matchData.get(r.id) || { rrf: 0, hasText: false, hasSemantic: false } - existing.rrf += alpha * (1 / (k + rank + 1)) - existing.semanticScore = r.score - existing.hasSemantic = true - matchData.set(r.id, existing) - }) - - const queryWords: string[] = index.tokenize(params.query) - const textResultIds = new Set(textResults.map((r: any) => r.id)) - const fusedIds = Array.from(matchData.entries()) - .sort((a, b) => b[1].rrf - a[1].rrf) - .map(([id, data]) => ({ id, data })) - - const allEntities = await brain.batchGet(fusedIds.map((f) => f.id)) - let rows: any[] = [] - for (const { id, data } of fusedIds) { - const entity = allEntities.get(id) - if (!entity) continue - const textContent = textResultIds.has(id) - ? index.extractTextContent({ data: entity.data, metadata: entity.metadata }).toLowerCase() - : null - rows.push({ - id, - score: data.rrf, - type: entity.type, - metadata: entity.metadata, - textMatches: - textContent === null ? [] : queryWords.filter((w) => textContent.includes(w.toLowerCase())), - textScore: data.textScore, - semanticScore: data.semanticScore, - matchSource: data.hasText && data.hasSemantic ? 'both' : data.hasText ? 'text' : 'semantic' - }) - } - - // The metadata intersection — after the legs, as it was. - if (preResolvedMetadataIds && preResolvedFilter) { - const filteredIdSet = new Set(preResolvedMetadataIds) - rows = rows.filter((r) => filteredIdSet.has(r.id)) - } - if (graphFirstIds !== null) { - const neighbourSet = new Set(graphFirstIds) - rows = rows.filter((r) => neighbourSet.has(r.id)) - } - - // Rank to the page, then cut it. - const order = rankIndicesByScore( - rows.map((r) => r.score), - offset + limit, - true - ) - return reorderByIndices(rows, order).slice(offset, offset + limit) -} - -/** - * FIXTURE A — the filter's universe covers every text match, so the two orders - * rank exactly the same rows and the answers must be identical. - */ -describe('hybrid find: filter before hydrate — the answer is unchanged', () => { - let brain: Brainy - const QUERY = 'orbital telemetry' - const MATCHES = 24 - const FILLER = 120 - const OUTSIDE = 30 - const VFS = 10 - const RETRACTED = 6 - const anchor = 'array-anchor' - const matchIds: string[] = [] - - beforeAll(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - - let seed = 1 - await brain.add({ - id: anchor, - data: 'ground station anchor record', - type: NounType.Thing, - metadata: { lane: 'alpha', role: 'anchor' }, - vector: seededVector(seed++) - }) - - // Rows the query's words actually match — all inside every filter below. - for (let i = 0; i < MATCHES; i++) { - const id = `match-${i}` - await brain.add({ - id, - data: `orbital telemetry packet ${i} recorded downlink`, - type: NounType.Document, - metadata: { lane: 'alpha', rank: i }, - vector: seededVector(seed++) - }) - matchIds.push(resolveEntityId(id)) - await brain.relate({ from: anchor, to: id, type: VerbType.RelatedTo }) - } - // Rows inside the universe that the query's words do NOT match. - for (let i = 0; i < FILLER; i++) { - await brain.add({ - id: `filler-${i}`, - data: `cistern ledger entry ${i} archived`, - type: NounType.Document, - metadata: { lane: 'alpha', rank: 1000 + i }, - vector: seededVector(seed++) - }) - } - // Rows outside the universe. - for (let i = 0; i < OUTSIDE; i++) { - await brain.add({ - id: `outside-${i}`, - data: `unrelated dossier ${i}`, - type: NounType.Person, - metadata: { lane: 'beta' }, - vector: seededVector(seed++) - }) - } - // VFS infrastructure rows — excluded by excludeVFS. - for (let i = 0; i < VFS; i++) { - await brain.add({ - id: `vfs-${i}`, - data: `mounted path ${i}`, - type: NounType.Document, - metadata: { lane: 'alpha', vfsType: 'file' }, - vector: seededVector(seed++) - }) - } - // Retracted rows — excluded by a `missing` negation. - for (let i = 0; i < RETRACTED; i++) { - await brain.add({ - id: `retracted-${i}`, - data: `withdrawn note ${i}`, - type: NounType.Document, - metadata: { lane: 'alpha', retracted: true }, - vector: seededVector(seed++) - }) - } - - // The reference index has no opaque-set door, so the pipeline and the - // oracle both restrict the beam walk with the materialized candidate ids. - expect(typeof (brain as any).metadataIndex.getIdSetForFilter).not.toBe('function') - }) - - afterAll(async () => { - await brain.close() - }) - - it('the fixture does not truncate the text leg — the universe covers every text match', async () => { - const index = (brain as any).metadataIndex - const textMatches = await index.getIdsForTextQuery(QUERY) - expect(textMatches).toHaveLength(MATCHES) - const universe = await (brain as any).filterIdsBelted({ lane: 'alpha' }) - const inUniverse = new Set(universe) - for (const m of textMatches) expect(inUniverse.has(m.id)).toBe(true) - }) - - it('hybrid + where: identical rows, identical order, identical scores', async () => { - const params = { query: QUERY, where: { lane: 'alpha' }, limit: 8 } - const expected = await legacyHybridFind(brain as any, params) - const actual = await brain.find(params as any) - expect(actual.length).toBe(expected.length) - expect(project(actual)).toEqual(expected) - }) - - it('hybrid + where + offset: identical page two', async () => { - const params = { query: QUERY, where: { lane: 'alpha' }, limit: 6, offset: 6 } - const expected = await legacyHybridFind(brain as any, params) - const actual = await brain.find(params as any) - expect(actual.length).toBe(expected.length) - expect(project(actual)).toEqual(expected) - }) - - it('hybrid + type list + excludeVFS + a `missing` negation: identical', async () => { - const params = { - query: QUERY, - type: [NounType.Document, NounType.Person], - excludeVFS: true, - where: { lane: 'alpha', retracted: { missing: true } }, - limit: 8 - } - const expected = await legacyHybridFind(brain as any, params) - const actual = await brain.find(params as any) - expect(actual.length).toBe(expected.length) - expect(project(actual)).toEqual(expected) - for (const r of actual) { - expect(r.metadata.retracted).toBeUndefined() - expect(r.metadata.vfsType).toBeUndefined() - } - }) - - it('hybrid + type list + excludeVFS + a `missing` negation, offset: identical', async () => { - const params = { - query: QUERY, - type: [NounType.Document, NounType.Person], - excludeVFS: true, - where: { lane: 'alpha', retracted: { missing: true } }, - limit: 5, - offset: 5 - } - const expected = await legacyHybridFind(brain as any, params) - const actual = await brain.find(params as any) - expect(actual.length).toBe(expected.length) - expect(project(actual)).toEqual(expected) - }) - - it('hybrid + connected: identical, and never a non-neighbour', async () => { - const params = { - query: QUERY, - connected: { from: anchor, direction: 'out' as const }, - where: { lane: 'alpha' }, - limit: 8 - } - const expected = await legacyHybridFind(brain as any, params) - const actual = await brain.find(params as any) - expect(actual.length).toBe(expected.length) - expect(project(actual)).toEqual(expected) - const neighbours = new Set(matchIds) - for (const r of actual) expect(neighbours.has(r.id)).toBe(true) - }) - - it('hybrid + connected + offset: page two is the page, not an empty answer', async () => { - const params = { - query: QUERY, - connected: { from: anchor, direction: 'out' as const }, - where: { lane: 'alpha' }, - limit: 5, - offset: 5 - } - const expected = await legacyHybridFind(brain as any, params) - expect(expected).toHaveLength(5) - const actual = await brain.find(params as any) - expect(actual.length).toBe(expected.length) - expect(project(actual)).toEqual(expected) - }) - - it('hybrid + connected: paging reaches every matching neighbour exactly once', async () => { - const seen = new Set() - for (let offset = 0; offset < MATCHES; offset += 6) { - const page = await brain.find({ - query: QUERY, - connected: { from: anchor, direction: 'out' as const }, - where: { lane: 'alpha' }, - limit: 6, - offset - } as any) - for (const r of page) { - expect(seen.has(r.id)).toBe(false) - seen.add(r.id) - } - } - // Every row the fused candidate set holds is reachable by paging, and the - // neighbour set is the ceiling. - expect(seen.size).toBeGreaterThanOrEqual(MATCHES) - const neighbours = new Set(matchIds) - for (const id of seen) expect(neighbours.has(id)).toBe(true) - }) - - it('hybrid + fusion + offset: page two is the page', async () => { - const plain = await brain.find({ - query: QUERY, - where: { lane: 'alpha' }, - limit: 5, - offset: 5 - } as any) - const fused = await brain.find({ - query: QUERY, - where: { lane: 'alpha' }, - fusion: 'weighted', - limit: 5, - offset: 5 - } as any) - expect(fused).toHaveLength(plain.length) - expect(fused.map((r) => r.id)).toEqual(plain.map((r) => r.id)) - }) - - it('a hydrated hybrid row is shaped exactly as an eagerly-built one', async () => { - const rows = await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: 8 } as any) - const row = rows[0] - expect(Object.keys(row)).toEqual([ - 'id', - 'score', - 'type', - 'subtype', - 'visibility', - 'metadata', - 'data', - 'confidence', - 'weight', - '_rev', - 'entity', - 'textMatches', - 'textScore', - 'semanticScore', - 'matchSource' - ]) - // The flattened fields are projections of the entity, as always. - expect(row.entity).toBeDefined() - expect(row.type).toBe(row.entity.type) - expect(row.metadata).toBe(row.entity.metadata) - expect(row.data).toBe(row.entity.data) - expect(row._rev).toBe(row.entity._rev) - // The match visibility survives the deferral — every leg's fields, on the - // rows that leg contributed, exactly as the eager pipeline set them. - expect(['text', 'semantic', 'both']).toContain(row.matchSource) - for (const r of rows) { - if (r.matchSource === 'semantic') { - expect(r.textMatches).toEqual([]) - expect(r.textScore).toBeUndefined() - } else { - expect(r.textMatches).toEqual(['orbital', 'telemetry']) - expect(typeof r.textScore).toBe('number') - } - if (r.matchSource === 'text') { - expect(r.semanticScore).toBeUndefined() - } else { - expect(typeof r.semanticScore).toBe('number') - } - } - }) - - it('reads canonical for the page only — one batch, `limit` rows', async () => { - // Warm any first-read verification before the counters are read. - await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: 1 } as any) - - const hydrate = vi.spyOn(brain as any, 'batchGet') - try { - const results = await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: 10 } as any) - expect(results).toHaveLength(10) - expect(hydrate).toHaveBeenCalledTimes(1) - expect((hydrate.mock.calls[0][0] as string[]).length).toBe(10) - } finally { - hydrate.mockRestore() - } - }) - - it('asks the text index about the universe only, never the whole store', async () => { - const index = (brain as any).metadataIndex - const wholeStore = vi.spyOn(index, 'getIdsForTextQuery') - const within = vi.spyOn(index, 'getIdsForTextQueryWithin') - try { - await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: 10 } as any) - expect(wholeStore).not.toHaveBeenCalled() - expect(within).toHaveBeenCalledTimes(1) - - const askedIds = within.mock.calls[0][1] as string[] - const universe = await (brain as any).filterIdsBelted({ lane: 'alpha' }) - expect(askedIds).toHaveLength(universe.length) - - // What the text leg marshals is bounded by the universe, not the store. - const marshalled = (await within.mock.results[0].value) as unknown[] - expect(marshalled.length).toBeLessThanOrEqual(universe.length) - expect(marshalled).toHaveLength(MATCHES) - } finally { - wholeStore.mockRestore() - within.mockRestore() - } - }) - - it('the two text doors agree: within is the whole-store answer restricted', async () => { - const index = (brain as any).metadataIndex - const universe: string[] = await (brain as any).filterIdsBelted({ - lane: 'alpha', - retracted: { missing: true } - }) - const inUniverse = new Set(universe) - const whole = await index.getIdsForTextQuery(QUERY) - const within = await index.getIdsForTextQueryWithin(QUERY, universe) - expect(within).toEqual(whole.filter((m: any) => inUniverse.has(m.id))) - expect(await index.getIdsForTextQueryWithin(QUERY, [])).toEqual([]) - }) -}) - -/** - * FIXTURE B — the query's words are common OUTSIDE the universe, so the old - * order's text leg was entirely consumed by rows the filter then discarded. - * This is the corrected behaviour, held by name. - */ -describe('hybrid find: the text leg ranks inside the filter, not around it', () => { - let brain: Brainy - const QUERY = 'orbital telemetry drift' - const NOISE = 150 - const KEEP = 15 - const keepIds: string[] = [] - - beforeAll(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - - let seed = 5000 - // Added FIRST and matching one more query word, so they lead the - // store-wide text ranking outright — and none of them pass the filter. - for (let i = 0; i < NOISE; i++) { - await brain.add({ - id: `noise-${i}`, - data: `orbital telemetry drift report ${i}`, - type: NounType.Document, - metadata: { lane: 'beta' }, - vector: seededVector(seed++) - }) - } - for (let i = 0; i < KEEP; i++) { - const id = `keep-${i}` - await brain.add({ - id, - data: `orbital telemetry summary ${i}`, - type: NounType.Document, - metadata: { lane: 'alpha' }, - vector: seededVector(seed++) - }) - keepIds.push(resolveEntityId(id)) - } - }) - - afterAll(async () => { - await brain.close() - }) - - it('the old order let the filter consume the whole text leg', async () => { - const index = (brain as any).metadataIndex - const universe: string[] = await (brain as any).filterIdsBelted({ lane: 'alpha' }) - expect(universe).toHaveLength(KEEP) - const inUniverse = new Set(universe) - - // The store-wide prefix the old text leg took (limit 10 → limit * 4). - const prefix = (await index.getIdsForTextQuery(QUERY)).slice(0, 40) - expect(prefix).toHaveLength(40) - expect(prefix.filter((m: any) => inUniverse.has(m.id))).toHaveLength(0) - - // Every row the old text leg ranked was then discarded by the filter, so - // the old answer carried NO text contribution at all — fifteen rows that - // match the query's words exactly, and not one of them reached the page - // through the text leg. What the old order returned was whatever the - // semantic leg alone happened to reach. - const legacy = await legacyHybridFind(brain as any, { - query: QUERY, - where: { lane: 'alpha' }, - limit: 10 - }) - for (const r of legacy) { - expect(r.matchSource).toBe('semantic') - expect(r.textScore).toBeUndefined() - expect(r.textMatches).toEqual([]) - } - }) - - it('the new order ranks the text leg inside the universe', async () => { - const results = await brain.find({ - query: QUERY, - where: { lane: 'alpha' }, - limit: 10 - } as any) - - expect(results).toHaveLength(10) - const keeps = new Set(keepIds) - for (const r of results) { - expect(keeps.has(r.id)).toBe(true) - expect(r.metadata.lane).toBe('alpha') - // The text leg is the contributor the old order threw away. - expect(['text', 'both']).toContain(r.matchSource) - expect(r.textScore).toBe(1) - expect(r.textMatches).toEqual(['orbital', 'telemetry']) - } - }) - - it('paging reaches every matching row the old order could not see', async () => { - const seen = new Set() - for (let offset = 0; offset < KEEP; offset += 5) { - const page = await brain.find({ - query: QUERY, - where: { lane: 'alpha' }, - limit: 5, - offset - } as any) - expect(page).toHaveLength(5) - for (const r of page) { - expect(seen.has(r.id)).toBe(false) - seen.add(r.id) - } - } - expect(seen.size).toBe(KEEP) - expect([...seen].sort()).toEqual([...keepIds].sort()) - }) - - it('reads canonical for the page only, on the truncating shape too', async () => { - await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: 1 } as any) - - const hydrate = vi.spyOn(brain as any, 'batchGet') - try { - const results = await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: 10 } as any) - expect(results).toHaveLength(10) - expect(hydrate).toHaveBeenCalledTimes(1) - expect((hydrate.mock.calls[0][0] as string[]).length).toBe(10) - } finally { - hydrate.mockRestore() - } - }) -}) diff --git a/tests/integration/find-matchall-cold.test.ts b/tests/integration/find-matchall-cold.test.ts deleted file mode 100644 index 158cb163..00000000 --- a/tests/integration/find-matchall-cold.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * @module tests/integration/find-matchall-cold - * @description THE MATCH-ALL SILENT-EMPTY PIN: `find({ where: {} })` is a - * match-all query — zero predicates constrain nothing — yet it used to route - * through the index-filter branch, where `getIdsForFilter({})` answers `[]` - * by contract. Result: 0 rows while storage held rows (worst on a freshly - * reopened brain, where it masqueraded as data loss), the forbidden answer - * class — a silent empty instead of served-or-refused. These tests pin the - * law: an empty `where` routes exactly like an absent `where`, serving from - * truth-complete sources (a storage page bounded to the offset+limit window, - * or the column store's top-K sort under orderBy) — warm AND cold, on the - * live brain, the Db pin path, pagination.count, streaming.entities, and the - * semantic path (`{ query, where: {} }` must not short-circuit to `[]`). - * The one deliberate refusal: `removeMany({ where: {} })` throws — a - * match-all BULK DELETE must be asked for explicitly, never inherited. - */ -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/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -const dirs: string[] = [] -const brains: Brainy[] = [] - -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -async function open(dir: string): Promise { - const b = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false }) - await b.init() - brains.push(b) - return b -} - -/** Seed three plain documents with a sortable numeric field. */ -async function seed(brain: Brainy): Promise { - const ids: string[] = [] - ids.push(await brain.add({ data: 'alpha row', type: NounType.Document, metadata: { n: 1 } })) - ids.push(await brain.add({ data: 'beta row', type: NounType.Document, metadata: { n: 2 } })) - ids.push(await brain.add({ data: 'gamma row', type: NounType.Document, metadata: { n: 3 } })) - await brain.flush() - return ids -} - -describe('find({ where: {} }) — match-all serves, warm and cold', () => { - it('the repro: a freshly reopened filesystem brain serves match-all (not a silent 0)', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-matchall-cold-')) - dirs.push(dir) - const brain = await open(dir) - await seed(brain) - await brain.close() - brains.pop() - - const reopened = await open(dir) - const rows = await reopened.find({ where: {}, limit: 10 }) - expect(rows.length, 'match-all serves every stored row on the cold brain').toBe(3) - - // The predicate paths that always worked cold stay working — same brain. - expect((await reopened.find({ where: { n: 1 }, limit: 10 })).length).toBe(1) - expect((await reopened.find({ where: { 'system.type': 'document' }, limit: 10 })).length).toBe(3) - }, 120000) - - it('match-all + orderBy on a metadata field serves sorted after reopen', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-matchall-order-')) - dirs.push(dir) - const brain = await open(dir) - await seed(brain) - await brain.close() - brains.pop() - - const reopened = await open(dir) - const rows = await reopened.find({ where: {}, orderBy: 'n', order: 'desc', limit: 10 }) - expect(rows.length, 'sorted match-all serves every stored row cold').toBe(3) - expect( - rows.map((r) => (r.metadata as { n: number }).n), - 'orderBy is honored on the cold match-all page' - ).toEqual([3, 2, 1]) - }, 120000) - - it('warm brain unchanged: match-all, sorted match-all, and predicates all serve in-session', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-matchall-warm-')) - dirs.push(dir) - const brain = await open(dir) - await seed(brain) - - expect((await brain.find({ where: {}, limit: 10 })).length).toBe(3) - const sorted = await brain.find({ where: {}, orderBy: 'n', order: 'asc', limit: 2 }) - expect(sorted.map((r) => (r.metadata as { n: number }).n)).toEqual([1, 2]) - expect((await brain.find({ where: { n: 2 }, limit: 10 })).length).toBe(1) - // Pagination window respected: match-all never over-serves the page. - expect((await brain.find({ where: {}, limit: 2, offset: 2 })).length).toBe(1) - }, 120000) - - it('the semantic path: find({ query, where: {} }) must not short-circuit to []', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-matchall-query-')) - dirs.push(dir) - const brain = await open(dir) - await seed(brain) - await brain.close() - brains.pop() - - const reopened = await open(dir) - // Before the fix, the pre-resolved empty filter matched nothing and the - // vector search was skipped entirely — a silent [] for every such query. - const rows = await reopened.find({ query: 'alpha row', where: {}, limit: 10 }) - expect(rows.length, 'an unconstraining where must not empty a semantic query').toBeGreaterThan(0) - }, 120000) - - it('the Db pin path: asOf(g).find({ where: {} }) serves at the pinned generation after reopen', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-matchall-asof-')) - dirs.push(dir) - const brain = await open(dir) - await brain.add({ data: 'first', type: NounType.Document, metadata: { n: 1 } }) - await brain.add({ data: 'second', type: NounType.Document, metadata: { n: 2 } }) - await brain.flush() - const gTwo = brain.generation() - await brain.add({ data: 'third', type: NounType.Document, metadata: { n: 3 } }) - await brain.flush() - await brain.close() - brains.pop() - - const reopened = await open(dir) - // Current-generation pin (delegates to the live find fast path). - const now = reopened.now() - expect((await now.find({ where: {}, limit: 10 })).length).toBe(3) - - // Historical pin: the record-overlay path must serve match-all too. - const past = await reopened.asOf(gTwo) - try { - const rows = await past.find({ where: {}, limit: 10 }) - expect(rows.length, 'match-all at the pinned generation sees exactly the rows of that generation').toBe(2) - } finally { - await past.release() - } - }, 120000) - - it('pagination.count({ where: {} }) counts every row instead of a silent 0', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-matchall-count-')) - dirs.push(dir) - const brain = await open(dir) - await seed(brain) - await brain.close() - brains.pop() - - const reopened = await open(dir) - // The law: an empty where counts exactly like an absent where (the - // unfiltered total — which by long-standing count semantics includes - // system entities such as the VFS root, hence >= the 3 user rows). - const emptyWhere = await reopened.pagination.count({ where: {} }) - expect(emptyWhere).toBe(await reopened.pagination.count({})) - expect(emptyWhere).toBeGreaterThanOrEqual(3) - }, 120000) - - it('streaming.entities({ where: {} }) streams every row instead of nothing', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-matchall-stream-')) - dirs.push(dir) - const brain = await open(dir) - await seed(brain) - await brain.close() - brains.pop() - - const reopened = await open(dir) - const streamed: string[] = [] - for await (const entity of reopened.streaming.entities({ where: {} })) { - streamed.push(entity.id) - } - expect(streamed.length, 'an unconstraining where streams the full store').toBeGreaterThanOrEqual(3) - }, 120000) - - it('removeMany({ where: {} }) refuses loudly — match-all bulk delete is never implicit', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-matchall-remove-')) - dirs.push(dir) - const brain = await open(dir) - await seed(brain) - - await expect(brain.removeMany({ where: {} })).rejects.toThrow(/matches EVERYTHING/) - // Nothing was deleted by the refused call. - expect((await brain.find({ where: {}, limit: 10 })).length).toBe(3) - }, 120000) -}) diff --git a/tests/integration/find-near.test.ts b/tests/integration/find-near.test.ts deleted file mode 100644 index b2bf01cd..00000000 --- a/tests/integration/find-near.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @module tests/integration/find-near - * @description find({ near }) searches around the anchor's OWN vector (10.4.10). - * - * The proximity search fetched its anchor without vectors and fed a - * zero-length vector to the index — every near() refused with a dimension - * mismatch, for every caller. Found by the Rust planner's first-contact pins - * (the planner declines `near`; the pin compared outcomes with and without - * it). Now the anchor is fetched with its vector, and an anchor without one - * refuses by name instead of failing inside the index. - */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { Brainy } from '../../src/brainy' -import { NounType } from '../../src/types/graphTypes' -import { v5 } from '../../src/universal/uuid' -import { generateTestVector } from '../helpers/test-factory' - -describe('find({ near }) uses the anchor vector', () => { - let brain: Brainy - const anchorVector = generateTestVector() - - beforeAll(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - await brain.add({ id: 'anchor', data: 'anchor row', type: NounType.Thing, vector: anchorVector }) - // A twin with the identical vector and a far row. - await brain.add({ id: 'twin', data: 'twin row', type: NounType.Thing, vector: [...anchorVector] }) - await brain.add({ id: 'far', data: 'far row', type: NounType.Thing, vector: generateTestVector() }) - }) - - afterAll(async () => { - await brain.close() - }) - - it('returns the anchor\'s neighbours by its own vector', async () => { - const results = await brain.find({ near: { id: 'anchor' }, limit: 3 }) - expect(results.length).toBeGreaterThan(0) - const ids = results.map((r) => r.entity.id) - expect(ids).toContain(v5('twin')) - }) - - it('refuses by name when the anchor has no vector', async () => { - await brain.add({ - id: 'unvectored', - data: 'no vector here', - type: NounType.Thing, - deferEmbedding: true - }) - ;(brain as any).kickEmbedWorker = () => {} - await expect(brain.find({ near: { id: 'unvectored' }, limit: 3 })).rejects.toThrow(/has no vector to search around/) - }) -}) diff --git a/tests/integration/find-orderby-every-path.test.ts b/tests/integration/find-orderby-every-path.test.ts deleted file mode 100644 index e62ec670..00000000 --- a/tests/integration/find-orderby-every-path.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * @module tests/integration/find-orderby-every-path - * @description `orderBy` IS THE ORDER — on every find() path, not just the - * metadata-only one. - * - * THE DEFECT. `find({ where, orderBy })` (metadata only) answered in field - * order. `find({ query, where, orderBy })` and `find({ vector, where, orderBy })` - * answered in SCORE order, silently: the vector/filter block ranked the fused - * candidates by score, cut the page, and returned early — the tail's `orderBy` - * sort sat below that early return and never ran. Nothing threw, nothing warned, - * and the two paths disagreed about what "ordered by rank" means. A caller - * paging `orderBy: 'rank', order: 'desc'` over a hybrid find got relevance - * order wearing an ordering request's clothes. - * - * Where `connected` or `fusion` kept the tail alive the defect changed shape - * rather than disappearing: the block had already CUT the page by score, so the - * tail ordered the rows relevance had chosen instead of the rows the ordering - * asks for — a correctly sorted page of the wrong rows. - * - * The early cut fires only once the candidate set reaches `offset + limit` - * rows, which is why small fixtures never saw it: below that threshold the - * block falls through and the tail's sort does apply. That is the whole shape - * of the bug — an ordering that is correct until there is enough data to matter. - * - * THE LAW. An explicit `orderBy` displaces score as the ordering key on every - * path. The candidate set the path produced is ordered IN FULL and the page is - * cut from that ordering — the graph-first law's "page last", applied to - * ordering rather than to filtering. Score-ranked early paging is for the - * default (no `orderBy`) case only, where score IS the requested order. - * - * THE PIN. Differential, against the metadata-only path — the one path that - * always honoured `orderBy`. - * - * WHAT THE DIFFERENTIAL CAN AND CANNOT CLAIM. `orderBy` orders the candidate - * set; it does not enlarge it. The hybrid legs are bounded by construction (the - * text leg and the beam walk each take `limit * 2`), so a differential against - * the metadata-only path — whose universe is every matching row — is only - * meaningful where those bounds provably cover the universe. The fixture is - * sized so they do (12 rows, `limit` 6 → a `limit * 2` = 12-row text leg), and - * the covering is ASSERTED from the leg's own output rather than assumed. This - * pin is about ordering, and it says nothing about recall. - */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { Brainy } from '../../src/brainy' -import { NounType, VerbType } from '../../src/types/graphTypes' -import { resolveEntityId } from '../../src/utils/idNormalization' - -/** Embedding width of the default model — the row vectors must match it. */ -const DIM = 384 - -/** A deterministic, per-row-distinct unit vector (no embedder in the fixture). */ -function seededVector(seed: number): number[] { - const v = new Array(DIM) - for (let i = 0; i < DIM; i++) { - v[i] = Math.sin((i + 1) * 0.11 + seed * 0.37) * 0.5 + Math.cos((i + 1) * 0.05 + seed * 0.13) * 0.3 - } - const magnitude = Math.sqrt(v.reduce((sum, x) => sum + x * x, 0)) - return v.map((x) => x / magnitude) -} - -/** - * Ranks, shuffled — so no scoring order can reproduce them by luck, and the - * ordering the pins assert is visibly not the insertion order either. - */ -const RANKS = [7, 3, 11, 1, 9, 5, 12, 2, 10, 4, 8, 6] -const ROWS = RANKS.length -/** The page size every pin uses: `limit * 2` covers the whole universe. */ -const LIMIT = 6 -/** The neighbour subset — the graph-first universe — and its own page size. */ -const NEIGHBOURS = 8 -const GRAPH_LIMIT = 4 - -describe('find(): orderBy is the order on every path', () => { - let brain: Brainy - const QUERY = 'orbital telemetry' - const anchor = 'ordering-anchor' - const neighbourIds: string[] = [] - - beforeAll(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - - let seed = 1 - await brain.add({ - id: anchor, - data: 'ground station anchor record', - type: NounType.Thing, - metadata: { lane: 'anchor', rank: 0 }, - vector: seededVector(seed++) - }) - - for (let i = 0; i < ROWS; i++) { - const id = `row-${i}` - await brain.add({ - id, - // EVERY row carries both query words, so the text leg reaches all of - // them and the hybrid candidate set covers the whole universe. - data: `orbital telemetry packet ${i} recorded downlink`, - type: NounType.Document, - metadata: { lane: 'alpha', rank: RANKS[i] }, - vector: seededVector(seed++) - }) - if (i < NEIGHBOURS) { - await brain.relate({ from: anchor, to: id, type: VerbType.RelatedTo }) - neighbourIds.push(resolveEntityId(id)) - } - } - }) - - afterAll(async () => { - await brain.close() - }) - - it('the fixture: the hybrid candidate set covers the whole filter universe', async () => { - const universe: string[] = await (brain as any).filterIdsBelted({ lane: 'alpha' }) - expect(universe).toHaveLength(ROWS) - - // The text leg is bounded at `limit * 2`; the fixture is sized so that - // bound reaches every row in the universe. This is the precondition the - // differential below rests on — asserted from the leg itself. - const textScored = await (brain as any).executeTextSearchScored(QUERY, LIMIT * 2, universe) - expect(textScored).toHaveLength(ROWS) - - // And the candidate set is large enough to trigger the score-ranked early - // cut this pin exists to keep out of an ordered query's way. - expect(ROWS).toBeGreaterThanOrEqual(LIMIT) - }) - - it('metadata-only + orderBy: the reference ordering', async () => { - const rows = await brain.find({ - where: { lane: 'alpha' }, - orderBy: 'rank', - order: 'desc', - limit: LIMIT - } as any) - expect(rows.map((r: any) => r.metadata.rank)).toEqual([12, 11, 10, 9, 8, 7]) - }) - - it('hybrid (query + where) + orderBy: the same page as the metadata-only path', async () => { - const params = { where: { lane: 'alpha' }, orderBy: 'rank', order: 'desc' as const, limit: LIMIT } - const expected = await brain.find(params as any) - const actual = await brain.find({ ...params, query: QUERY } as any) - - expect(actual).toHaveLength(expected.length) - expect(actual.map((r: any) => r.id)).toEqual(expected.map((r: any) => r.id)) - expect(actual.map((r: any) => r.metadata.rank)).toEqual([12, 11, 10, 9, 8, 7]) - }) - - it('hybrid + orderBy asc: the ordering key is honoured in both directions', async () => { - const params = { where: { lane: 'alpha' }, orderBy: 'rank', order: 'asc' as const, limit: LIMIT } - const expected = await brain.find(params as any) - const actual = await brain.find({ ...params, query: QUERY } as any) - - expect(actual.map((r: any) => r.id)).toEqual(expected.map((r: any) => r.id)) - expect(actual.map((r: any) => r.metadata.rank)).toEqual([1, 2, 3, 4, 5, 6]) - }) - - it('hybrid + orderBy + offset: page two is page two of the ORDERING', async () => { - const params = { - where: { lane: 'alpha' }, - orderBy: 'rank', - order: 'desc' as const, - limit: LIMIT, - offset: LIMIT - } - const expected = await brain.find(params as any) - const actual = await brain.find({ ...params, query: QUERY } as any) - - expect(actual).toHaveLength(LIMIT) - expect(actual.map((r: any) => r.id)).toEqual(expected.map((r: any) => r.id)) - expect(actual.map((r: any) => r.metadata.rank)).toEqual([6, 5, 4, 3, 2, 1]) - }) - - it('hybrid + orderBy: paging walks the ordering monotonically, no row twice', async () => { - const seen: number[] = [] - for (let offset = 0; offset < ROWS; offset += LIMIT) { - const page = await brain.find({ - query: QUERY, - where: { lane: 'alpha' }, - orderBy: 'rank', - order: 'desc', - limit: LIMIT, - offset - } as any) - seen.push(...page.map((r: any) => r.metadata.rank)) - } - expect(seen).toHaveLength(ROWS) - expect(new Set(seen).size).toBe(ROWS) - // Strictly descending across every page boundary. - for (let i = 1; i < seen.length; i++) expect(seen[i]).toBeLessThan(seen[i - 1]) - }) - - it('vector + where + orderBy: field order, not distance order', async () => { - // The beam walk takes `limit * 2` = the whole universe here, so the page is - // the true top of the ordering — which distance order cannot produce. - const rows = await brain.find({ - vector: seededVector(1000), - where: { lane: 'alpha' }, - orderBy: 'rank', - order: 'desc', - limit: LIMIT - } as any) - expect(rows.map((r: any) => r.metadata.rank)).toEqual([12, 11, 10, 9, 8, 7]) - }) - - it('graph-first (query + connected + where) + orderBy: the neighbour set, ordered', async () => { - const actual = await brain.find({ - query: QUERY, - connected: { from: anchor, direction: 'out' as const }, - where: { lane: 'alpha' }, - orderBy: 'rank', - order: 'desc', - limit: GRAPH_LIMIT - } as any) - - expect(actual).toHaveLength(GRAPH_LIMIT) - const neighbours = new Set(neighbourIds) - for (const r of actual) expect(neighbours.has(r.id)).toBe(true) - - // The ordering covers the whole neighbour set, so the page holds the - // highest ranks AMONG THE NEIGHBOURS — not the ones the score ranking - // happened to surface first and the tail then sorted among themselves. - const expectedRanks = RANKS.slice(0, NEIGHBOURS) - .sort((a, b) => b - a) - .slice(0, GRAPH_LIMIT) - expect(expectedRanks).toEqual([12, 11, 9, 7]) - expect(actual.map((r: any) => r.metadata.rank)).toEqual(expectedRanks) - }) - - it('fusion + orderBy: the ordering survives the fusion rescore', async () => { - const actual = await brain.find({ - query: QUERY, - where: { lane: 'alpha' }, - fusion: 'weighted', - orderBy: 'rank', - order: 'desc', - limit: LIMIT - } as any) - expect(actual.map((r: any) => r.metadata.rank)).toEqual([12, 11, 10, 9, 8, 7]) - }) - - it('no orderBy: score order still stands (the default is untouched)', async () => { - const rows = await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: LIMIT } as any) - expect(rows).toHaveLength(LIMIT) - const scores = rows.map((r: any) => r.score) - for (let i = 1; i < scores.length; i++) expect(scores[i]).toBeLessThanOrEqual(scores[i - 1]) - }) -}) diff --git a/tests/integration/find-planner-door.test.ts b/tests/integration/find-planner-door.test.ts deleted file mode 100644 index e5224f6d..00000000 --- a/tests/integration/find-planner-door.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * @module tests/integration/find-planner-door - * @description The optional `MetadataIndexProvider.planFindPage` door. - * - * The stage doors each serve one stage, so a `find()` that consults three of - * them crosses into the index three times and marshals a result set at every - * crossing — a filter matching a hundred thousand rows builds a hundred - * thousand id strings to return a page of twenty-five. An index that can decide - * the stage order itself answers the page in one call. - * - * These pins hold the three properties that make such a door safe to add: - * - * 1. **Absent, nothing changes.** The reference index has no planner, and every - * find is served by the stage doors exactly as before. That is also what - * makes this engine the ordering oracle for any index that implements one. - * 2. **Present, it is asked first and its answer is used** — above the branch - * selection, with the params already normalized, the hidden ids passed, and - * the graph provider handed over. - * 3. **`null` is routing, not an answer.** A door that declines a shape leaves - * it to the path that always served it, and the result is unchanged. - * - * Plus the serving law: an empty page stamped `emptyAt: 'graph'` is re-verified - * against the adjacency before it is believed, so a not-serving graph refuses - * loudly instead of answering `[]` as truth. - */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' -import { Brainy } from '../../src/brainy' -import { NounType, VerbType } from '../../src/types/graphTypes' -import { generateTestVector } from '../helpers/test-factory' - -describe('find(): the optional planner door', () => { - let brain: Brainy - const anchor = 'planner-anchor' - let neighbourId = '' - - beforeAll(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - await brain.add({ - id: anchor, - data: 'anchor', - type: NounType.Person, - metadata: { kind: 'anchor' }, - vector: generateTestVector() - }) - for (let i = 0; i < 12; i++) { - const id = await brain.add({ - id: `row-${i}`, - data: `row ${i}`, - type: NounType.Person, - metadata: { kind: 'note', rank: i }, - vector: generateTestVector() - }) - if (i === 0) neighbourId = id - await brain.relate({ from: anchor, to: id, type: VerbType.Knows }) - } - }) - - afterAll(async () => { - await brain.close() - }) - - /** Install a planner door for one call, then remove it. */ - const withDoor = async ( - door: (...a: any[]) => Promise, - body: () => Promise - ): Promise => { - const index = (brain as any).metadataIndex - index.planFindPage = door - try { - return await body() - } finally { - delete index.planFindPage - } - } - - it('is absent on the reference index — every find is served by the stage doors', async () => { - expect((brain as any).metadataIndex.planFindPage).toBeUndefined() - const results = await brain.find({ where: { kind: 'note' }, limit: 5 }) - expect(results).toHaveLength(5) - }) - - it('is asked before the branches, with normalized params and the graph provider', async () => { - const door = vi.fn(async () => null) - await withDoor(door, async () => { - await brain.find({ where: { kind: 'note' }, limit: 5 }) - }) - expect(door).toHaveBeenCalledTimes(1) - const [params, hidden, graph] = door.mock.calls[0] as any[] - expect(params.where).toEqual({ kind: 'note' }) - expect(Array.isArray(hidden)).toBe(true) - expect(graph).toBe((brain as any).graphIndex) - }) - - it('uses the page it answers, hydrated and in the door\'s order', async () => { - const results = await withDoor( - async () => ({ ids: [neighbourId], emptyAt: 'none' as const }), - async () => brain.find({ where: { kind: 'note' }, limit: 5 }) - ) - expect(results).toHaveLength(1) - expect(results[0].entity.id).toBe(neighbourId) - }) - - it('a declining door changes nothing — the shape is served as it always was', async () => { - const withoutDoor = await brain.find({ where: { kind: 'note' }, orderBy: 'rank', limit: 4 }) - const declined = await withDoor( - async () => null, - async () => brain.find({ where: { kind: 'note' }, orderBy: 'rank', limit: 4 }) - ) - expect(declined.map((r) => r.entity.id)).toEqual(withoutDoor.map((r) => r.entity.id)) - }) - - it('re-verifies the adjacency before believing an empty graph answer', async () => { - const verify = vi.spyOn(brain as any, 'verifyGraphAdjacencyLive') - try { - const results = await withDoor( - async () => ({ ids: [], emptyAt: 'graph' as const }), - async () => brain.find({ connected: { from: anchor }, where: { kind: 'note' }, limit: 5 }) - ) - expect(results).toEqual([]) - expect(verify).toHaveBeenCalled() - } finally { - verify.mockRestore() - } - }) - - it('does not re-verify the adjacency for an empty the FILTER produced', async () => { - const verify = vi.spyOn(brain as any, 'verifyGraphAdjacencyLive') - verify.mockClear() - try { - const results = await withDoor( - async () => ({ ids: [], emptyAt: 'filter' as const }), - async () => brain.find({ where: { kind: 'note' }, limit: 5 }) - ) - expect(results).toEqual([]) - expect(verify).not.toHaveBeenCalled() - } finally { - verify.mockRestore() - } - }) -}) diff --git a/tests/integration/find-unified-integration.test.ts b/tests/integration/find-unified-integration.test.ts index 3c4741c2..4370295e 100644 --- a/tests/integration/find-unified-integration.test.ts +++ b/tests/integration/find-unified-integration.test.ts @@ -48,7 +48,6 @@ describe('Unified Find() Integration Tests', () => { afterAll(async () => { await cleanup.cleanup() - await brain.close() brain = null as any }) @@ -710,14 +709,8 @@ describe('Unified Find() Integration Tests', () => { expect(simpleResult.length).toBeGreaterThan(0) expect(complexResult.length).toBeGreaterThan(0) - // These are both sub-millisecond operations on tiny fixture data, so - // comparing two microsecond-scale timings for absolute equality-class - // ordering (simple <= complex) can never be stable — timer - // resolution and scheduling noise dominate the signal. Assert only - // the order-of-magnitude property: the simple path isn't - // dramatically slower than the complex one. The +5ms floor absorbs - // noise when complexDuration itself rounds to ~0. - expect(simpleDuration).toBeLessThanOrEqual(complexDuration * 3 + 5) + // Simple queries should be faster + expect(simpleDuration).toBeLessThanOrEqual(complexDuration) }) it('should use fast paths for single search types', async () => { diff --git a/tests/integration/flush-watcher-event-driven.test.ts b/tests/integration/flush-watcher-event-driven.test.ts deleted file mode 100644 index 4b2e80c4..00000000 --- a/tests/integration/flush-watcher-event-driven.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * @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/fold-checkpoint-bound.test.ts b/tests/integration/fold-checkpoint-bound.test.ts deleted file mode 100644 index a02d75e0..00000000 --- a/tests/integration/fold-checkpoint-bound.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * @module tests/integration/fold-checkpoint-bound - * @description The fold-checkpoint bound (crash recovery's log fold, bounded): - * `_system/fold-checkpoint.json` at generation G asserts every entity whose - * latest fact is ≤ G has DURABLE canonical bytes — each stamp strictly follows - * a canonical-sync barrier over every live entity touched since the last one - * (stamp-after-data). An unclean open then folds only `(G, head]` instead of - * the whole log. These pins prove the four load-bearing properties: - * - * 1. The stamp exists and tracks the committed watermark (flush + close). - * 2. The fold is genuinely BOUNDED — facts ≤ G are skipped — while facts in - * `(G, head]` are re-applied even BELOW the manifest. - * 3. A failed barrier NEVER advances the stamp (the bound can lag, growing - * a later fold — it can never overstate durability, losing a write). - * 4. A pre-checkpoint brain (the 10.0 shape) bootstraps its chain at its - * first whole-log fold; a tree-authority brain never stamps at all. - */ -import { describe, it, expect, afterEach, vi } from 'vitest' -import * as fs from 'node:fs' -import * as zlib from 'node:zlib' -import { join } from 'node:path' -import { Brainy } from '../../src/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' -import { - abandonAsCrashed, - armCrash, - dropCanonicalNoun, - makeTempDir, - openBrain, - storeOf -} from '../helpers/durabilityKillMatrix.js' - -const CHECKPOINT = join('_system', 'fold-checkpoint.json') - -/** Read the fold-checkpoint artifact's generation from disk, or null. */ -function readCheckpoint(dir: string): number | null { - for (const candidate of [join(dir, `${CHECKPOINT}.gz`), join(dir, CHECKPOINT)]) { - if (!fs.existsSync(candidate)) continue - const raw = fs.readFileSync(candidate) - const text = candidate.endsWith('.gz') ? zlib.gunzipSync(raw).toString('utf8') : raw.toString('utf8') - const parsed = JSON.parse(text) as { generation?: number } - return Number.isSafeInteger(parsed.generation) ? (parsed.generation as number) : null - } - return null -} - -function removeArtifact(dir: string, rel: string): void { - for (const candidate of [join(dir, `${rel}.gz`), join(dir, rel)]) { - fs.rmSync(candidate, { force: true }) - } -} - -function committedOf(brain: Brainy): number { - return (storeOf(brain) as unknown as { committed: number }).committed -} - -describe('fold-checkpoint bound — crash recovery folds (checkpoint, head], never less durability than stamped', () => { - const dirs: string[] = [] - const liveBrains: Brainy[] = [] - afterEach(async () => { - vi.restoreAllMocks() - for (const b of liveBrains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }) - }) - function trackDir(): string { - const dir = makeTempDir() - dirs.push(dir) - return dir - } - - it('a fresh adopt brain stamps at flush and again at close — the stamp tracks the committed watermark', async () => { - const dir = trackDir() - const brain = await openBrain(dir, { logAuthority: 'adopt' }) - liveBrains.push(brain) - expect(brain.logAuthority().authority).toBe('log') - - await brain.add({ data: 'first', type: NounType.Document, metadata: { n: 1 } }) - await brain.add({ data: 'second', type: NounType.Document, metadata: { n: 2 } }) - await brain.flush() - const afterFlush = readCheckpoint(dir) - expect(afterFlush).toBe(committedOf(brain)) - expect(afterFlush!).toBeGreaterThan(0) - - await brain.add({ data: 'third', type: NounType.Document, metadata: { n: 3 } }) - const closingCommit = liveBrains.pop()! - await closingCommit.close() - // Close flushes, so the stamp advanced with it — and the clean-shutdown - // marker it writes afterward never vouches for bytes the stamp has not. - expect(readCheckpoint(dir)).toBeGreaterThanOrEqual(afterFlush!) - }, 120000) - - it('BOUNDED fold: facts ≤ checkpoint are skipped, facts in (checkpoint, head] are re-applied even below the manifest; a failed barrier retains the old bound', async () => { - const dir = trackDir() - const brain = await openBrain(dir, { logAuthority: 'adopt' }) - liveBrains.push(brain) - - // Window 1 — flushed and stamped: the checkpoint's covered past. - const idA = await brain.add({ data: 'covered by the stamp', type: NounType.Document, metadata: { w: 1 } }) - await brain.flush() - const checkpoint1 = readCheckpoint(dir) - expect(checkpoint1).toBe(committedOf(brain)) - - // Window 2 — committed BELOW a new manifest but with the checkpoint stamp - // FAILING: the barrier throws once, so the manifest advances while the - // stamp stays at checkpoint1 (pin 3: a failed barrier never advances it). - const storage = (brain as unknown as { - storage: { syncEntityCanonical(n: string[], v: string[]): Promise } - }).storage - const realBarrier = storage.syncEntityCanonical.bind(storage) - let failedOnce = false - vi.spyOn(storage, 'syncEntityCanonical').mockImplementation(async (n: string[], v: string[]) => { - if (!failedOnce) { - failedOnce = true - throw new Error('injected barrier failure (device hiccup)') - } - return realBarrier(n, v) - }) - const idB = await brain.add({ data: 'below manifest, above checkpoint', type: NounType.Document, metadata: { w: 2 } }) - await brain.flush() - expect(failedOnce).toBe(true) - expect(readCheckpoint(dir)).toBe(checkpoint1) // stamp did NOT advance - expect(committedOf(brain)).toBeGreaterThan(checkpoint1!) // manifest DID - - // Crash. Vaporize BOTH canonical records: idB's fact lives in - // (checkpoint, manifest] — the bounded fold MUST restore it; idA's fact - // is ≤ checkpoint — the fold must SKIP it (its loss here is synthetic: - // the stamp's barrier fsynced it, a power cut cannot take it, and the - // skip is exactly what makes the fold bounded instead of whole-log). - await abandonAsCrashed(liveBrains.pop()!) - dropCanonicalNoun(dir, idA) - dropCanonicalNoun(dir, idB) - - const reopened = await openBrain(dir, { logAuthority: 'adopt' }) - liveBrains.push(reopened) - const restoredB = await reopened.get(idB) - expect(restoredB, 'a fact above the checkpoint is re-applied even below the manifest').not.toBeNull() - const skippedA = await reopened.get(idA) - expect(skippedA, 'a fact at-or-below the checkpoint is outside the fold — the bound is real').toBeNull() - // And recovery re-stamped at its new committed watermark. - expect(readCheckpoint(dir)).toBe(committedOf(reopened)) - }, 120000) - - it('a pre-checkpoint brain (the 10.0 shape) folds the WHOLE log once, then its chain is established', async () => { - const dir = trackDir() - const brain = await openBrain(dir, { logAuthority: 'adopt' }) - liveBrains.push(brain) - const idA = await brain.add({ data: 'ten-point-oh resident', type: NounType.Document, metadata: { era: '10.0' } }) - await brain.flush() - await liveBrains.pop()!.close() - - // Rewind the brain to the 10.0 shape: no checkpoint artifact, and an - // unclean shutdown (marker gone) — exactly what an existing fleet brain - // looks like at its first crash under 10.1. - removeArtifact(dir, CHECKPOINT) - removeArtifact(dir, join('_system', 'clean-shutdown.json')) - dropCanonicalNoun(dir, idA) - - const reopened = await openBrain(dir, { logAuthority: 'adopt' }) - liveBrains.push(reopened) - expect(await reopened.get(idA), 'no checkpoint ⇒ whole-log fold ⇒ every acked write restored').not.toBeNull() - const stamped = readCheckpoint(dir) - expect(stamped, 'the first whole-log fold is the chain’s base case — it stamps').toBe(committedOf(reopened)) - }, 120000) - - it('ARM-AT-FLIP: a non-fresh adoption founds the checkpoint immediately — the first post-flip boot folds BOUNDED, never whole-log', async () => { - const dir = trackDir() - // The production shape: a brain with history flips LIVE (no crash ever). - const brain = await openBrain(dir, { logAuthority: 'defer' }) - liveBrains.push(brain) - const preFlip = await brain.add({ data: 'pre-flip resident', type: NounType.Document, metadata: { era: 'tree' } }) - await brain.flush() - expect(readCheckpoint(dir), 'no checkpoint before the flip').toBeNull() - - const report = await brain.adoptLogAuthority() - expect(report.verdict).toBe('green') - // THE PIN: the flip itself founded the checkpoint — no crash required. - const founded = readCheckpoint(dir) - expect(founded, 'checkpoint founded at flip').toBe(committedOf(brain)) - - // First post-flip boot, unclean (the production first-restart shape): - // a post-flip write above the checkpoint is restored FROM ITS AT-ACK FACT; - // the pre-flip row (its baseline fact ≤ checkpoint, its bytes barrier- - // synced at the flip) is OUTSIDE the fold — vaporizing it synthetically - // proves the bound. - // - // THE CRASH IS ARMED, NOT RACED. The post-flip write "dies" at exactly - // `singleop-after-fact-append`: its fact is in the log and at-ack synced, - // and NO pending flush was ever scheduled — so the checkpoint provably - // still reads the flip's stamp when the bytes are dropped. The earlier - // shape (`add()` then abandon) raced the store's 50ms pending-flush - // timer: on a loaded box the flush won, barrier-synced the row, advanced - // the stamp over it — and the fold, CORRECTLY bounded, did not restore - // bytes the test had synthetically destroyed after they were stamped - // durable. The plant lane caught it; the engine was right, the pin was - // timing-dependent. - const postFlip = `post-flip-${Date.now().toString(36)}-0000-4000-8000-000000000000` - const arm = armCrash(brain, 'singleop-after-fact-append') - await expect( - brain.add({ id: postFlip, data: 'post-flip write', type: NounType.Document, metadata: { era: 'log' } }) - ).rejects.toThrow('simulated process crash at singleop-after-fact-append') - expect(arm.fired).toContain('singleop-after-fact-append') - expect(readCheckpoint(dir), 'the stamp did not move — nothing flushed after the flip').toBe(founded) - await abandonAsCrashed(liveBrains.pop()!) - // The post-flip row's canonical bytes lived only in the pending tier's - // RAM (written at flush, never reached) — the crash takes them for real; - // nothing to drop. Only the pre-flip row is vaporized synthetically. - dropCanonicalNoun(dir, preFlip) - - const reopened = await openBrain(dir, { logAuthority: 'adopt' }) - liveBrains.push(reopened) - expect(await reopened.get(postFlip), 'above-checkpoint fact re-applied').not.toBeNull() - expect(await reopened.get(preFlip), 'below-checkpoint fact skipped — the fold is bounded on the FIRST post-flip boot').toBeNull() - }, 240000) - - it('a tree-authority brain never stamps a checkpoint', async () => { - const dir = trackDir() - const brain = await openBrain(dir, { logAuthority: 'defer' }) - liveBrains.push(brain) - expect(brain.logAuthority().authority).not.toBe('log') - await brain.add({ data: 'tree resident', type: NounType.Document, metadata: { n: 1 } }) - await brain.flush() - await liveBrains.pop()!.close() - expect(readCheckpoint(dir)).toBeNull() - }, 120000) - - it('restore is an UNCLEAN event: the snapshot’s stamps do not survive — the reopen fold re-founds and re-stamps the restored state', async () => { - const dir = trackDir() - const brain = await openBrain(dir, { logAuthority: 'adopt' }) - liveBrains.push(brain) - const idA = await brain.add({ data: 'survives the restore', type: NounType.Document, metadata: { n: 1 } }) - await brain.flush() - - const snapDir = join(trackDir(), 'snap') - const db = brain.now() - await (db as unknown as { persist(p: string): Promise }).persist(snapDir) - await (db as unknown as { release(): Promise }).release() - - // Advance the live brain past the snapshot: a later write, a later flush, - // a later checkpoint stamp — none of which may survive the restore. - const idB = await brain.add({ data: 'must not survive', type: NounType.Document, metadata: { n: 2 } }) - await brain.flush() - const stampBeforeRestore = readCheckpoint(dir) - expect(stampBeforeRestore).toBe(committedOf(brain)) - - // Unflushed traffic in flight at restore time — the quiesced swap discards - // it under the mutex instead of letting its flush timer race the swap - // (the ENOTEMPTY class). - await brain.add({ data: 'in-flight at restore', type: NounType.Document, metadata: { n: 3 } }) - await brain.restore(snapDir, { confirm: true }) - - expect(await brain.get(idA), 'snapshot state restored').not.toBeNull() - expect(await brain.get(idB), 'post-snapshot state replaced').toBeNull() - // The stamp on disk is the REOPEN FOLD's fresh assertion about the - // restored (and now barrier-synced) bytes — at the restored watermark, - // strictly below the pre-restore stamp that must not survive. - const stampAfterRestore = readCheckpoint(dir) - expect(stampAfterRestore).toBe(committedOf(brain)) - expect(stampAfterRestore!).toBeLessThan(stampBeforeRestore!) - }, 120000) - - it('a delete rides the barrier: the tombstoned id is in the synced set and the stamp advances past it', async () => { - const dir = trackDir() - const brain = await openBrain(dir, { logAuthority: 'adopt' }) - liveBrains.push(brain) - const id = await brain.add({ data: 'short-lived', type: NounType.Document, metadata: { n: 1 } }) - await brain.flush() - - const storage = (brain as unknown as { - storage: { syncEntityCanonical(n: string[], v: string[]): Promise } - }).storage - const seen: string[][] = [] - const realBarrier = storage.syncEntityCanonical.bind(storage) - vi.spyOn(storage, 'syncEntityCanonical').mockImplementation(async (n: string[], v: string[]) => { - seen.push([...n]) - return realBarrier(n, v) - }) - - await brain.remove(id) - await brain.flush() - expect( - seen.some((nouns) => nouns.includes(id)), - 'the deleted id must reach the canonical barrier (absence is durable state too)' - ).toBe(true) - expect(readCheckpoint(dir)).toBe(committedOf(brain)) - }, 120000) -}) diff --git a/tests/integration/generation-store-factory.test.ts b/tests/integration/generation-store-factory.test.ts deleted file mode 100644 index 08b62619..00000000 --- a/tests/integration/generation-store-factory.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * @module tests/integration/generation-store-factory - * @description Pins the `createGenerationStore` protected factory hook on - * `Brainy` ({@link Brainy.createGenerationStore}). The hook exists so an - * engine built on top of this reference implementation can substitute a - * `GenerationStore` that keeps the same behavioural contract; this suite - * proves two things: - * - * 1. A subclass overriding the hook is the ONLY path that constructs the - * generation store — it is called exactly once, with the same storage - * instance `performInit` holds — and the store the brain actually uses - * is the one the override returned. - * 2. The default (non-overridden) path is unaffected — proven here by - * confirming the base class still produces a plain `GenerationStore` - * wired to `brain.storage`, and separately by running the existing - * `db-mvcc` and `brainy-core.integration` suites unmodified against this - * change (they exercise generation-store behaviour end to end). - */ - -import { describe, it, expect, afterEach } from 'vitest' -import { Brainy } from '../../src/brainy.js' -import { GenerationStore } from '../../src/db/generationStore.js' -import type { BaseStorage } from '../../src/storage/baseStorage.js' - -/** Typed access to the brain's private storage + generation-store fields (test injection point). */ -function internalsOf(brain: Brainy): { storage: BaseStorage; generationStore: GenerationStore } { - return brain as unknown as { storage: BaseStorage; generationStore: GenerationStore } -} - -/** - * A `GenerationStore` subclass that counts its own construction and - * remembers the storage instance it was built with, so the test can prove - * the hook is the sole construction path without mocking the module. - */ -class SpyGenerationStore extends GenerationStore { - static constructCount = 0 - static lastStorage: BaseStorage | undefined - - constructor(storage: BaseStorage) { - super(storage) - SpyGenerationStore.constructCount++ - SpyGenerationStore.lastStorage = storage - } -} - -/** A Brainy subclass overriding the factory hook — stands in for an engine built on the reference. */ -class BrainyWithSpyStore extends Brainy { - hookCallCount = 0 - hookStorageArg: BaseStorage | undefined - - protected override createGenerationStore(storage: BaseStorage): GenerationStore { - this.hookCallCount++ - this.hookStorageArg = storage - return new SpyGenerationStore(storage) - } -} - -describe('Brainy.createGenerationStore — protected factory hook', () => { - const brains: Brainy[] = [] - - afterEach(async () => { - SpyGenerationStore.constructCount = 0 - SpyGenerationStore.lastStorage = undefined - for (const brain of brains.splice(0)) { - try { - await brain.close() - } catch { - // already closed by the test - } - } - }) - - it('a subclass override is the sole construction path: called once, same storage instance, its store is the one the brain uses', async () => { - const brain = new BrainyWithSpyStore({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - brains.push(brain) - - // Called exactly once, through the hook. - expect(brain.hookCallCount).toBe(1) - expect(SpyGenerationStore.constructCount).toBe(1) - - // Same storage instance the base class holds — not a copy, not a different adapter. - const { storage, generationStore } = internalsOf(brain) - expect(brain.hookStorageArg).toBe(storage) - expect(SpyGenerationStore.lastStorage).toBe(storage) - - // The store the brain actually uses is the one the override returned. - expect(generationStore).toBeInstanceOf(SpyGenerationStore) - }) - - it('the default (non-overridden) path still produces a plain GenerationStore wired to the same storage', async () => { - const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - brains.push(brain) - - const { storage, generationStore } = internalsOf(brain) - expect(generationStore).toBeInstanceOf(GenerationStore) - // The default implementation constructs from the same storage the brain holds. - expect((generationStore as unknown as { storage: BaseStorage }).storage).toBe(storage) - }) -}) diff --git a/tests/integration/graphIndex-pagination.test.ts b/tests/integration/graphIndex-pagination.test.ts index 8ad4d6d8..32a7673c 100644 --- a/tests/integration/graphIndex-pagination.test.ts +++ b/tests/integration/graphIndex-pagination.test.ts @@ -9,34 +9,9 @@ * 8.0 BigInt boundary: entity ints in (resolved via the metadata index's * idMapper), entity/verb ints out (`bigint[]`). Entity ints map back to UUIDs * via `idMapper.getUuid(Number(int))`; verb ints via `verbIntsToIds()`. - * - * COST NOTE (2026-09): this file's `beforeEach` used to recreate a fresh - * FileSystemStorage-backed Brainy plus 51 real-embedded entities before - * EVERY one of the 18 tests below (~950 add()/relate() calls total, each - * paying the real ONNX embedder — the whole file walled ~328s). Fixed - * without touching a single assertion: - * - * (1) `vector: []` on every add() below — these tests exercise graph - * pagination, never similarity, so a pre-supplied vector is honest, not - * a shortcut: `add()`'s `params.vector || (await this.embed(...))` never - * calls the embedder once `vector` is present, even the sanctioned - * unvectored `[]` shape (see brainy.ts's add(), the zero-norm-law - * comment) — and the `vector.length > 0` gate on dimension-pinning means - * `[]` never poisons `this.dimensions` for later real embeds. - * (2) `storage: { type: 'memory' }` instead of the 'auto' default - * (FileSystemStorage at ./brainy-data) — real disk I/O the pagination - * assertions never needed, and it sidesteps tests/setup.ts's global - * per-test `rm -rf brainy-data`, which would otherwise corrupt a brain - * shared across a describe's beforeAll out from under it. - * (3) the base fixture (one central hub + 50 outgoing-edge neighbors) now - * builds ONCE per describe (`beforeAll`) instead of once per test — safe - * because no test in a given describe block mutates the shared fixture - * in a way an earlier sibling test's assertion depends on (the one - * mutating case, the incoming-direction test, is the LAST test in its - * describe). */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy } from '../../src/brainy.js' import { NounType, VerbType } from '../../src/types/graphTypes.js' @@ -64,21 +39,14 @@ describe('GraphAdjacencyIndex Pagination', () => { .map((i) => idMapper().getUuid(Number(i))) .filter((u: string | undefined): u is string => u !== undefined) - /** - * Builds one central hub + 50 neighbor entities (all outgoing edges from - * the hub), unvectored and on in-memory storage (see the file header). - * Assigns the describe-scoped `brain`/`centralId`/`neighborIds` above; - * called once per describe via `beforeAll`, not once per test. - */ - async function buildFixture(): Promise { + beforeEach(async () => { brain = new Brainy({ requireSubtype: false }) - await brain.init({ storage: { type: 'memory' } }) + await brain.init() // Create central entity centralId = await brain.add({ data: { name: 'Central Hub' }, - type: NounType.Thing, - vector: [] + type: NounType.Thing }) // Create 50 neighbor entities with relationships @@ -86,8 +54,7 @@ describe('GraphAdjacencyIndex Pagination', () => { for (let i = 0; i < 50; i++) { const neighborId = await brain.add({ data: { name: `Neighbor ${i}`, index: i }, - type: NounType.Thing, - vector: [] + type: NounType.Thing }) neighborIds.push(neighborId) @@ -98,14 +65,9 @@ describe('GraphAdjacencyIndex Pagination', () => { type: VerbType.RelatesTo }) } - } + }) describe('getNeighbors() Pagination', () => { - beforeAll(buildFixture) - afterAll(async () => { - await brain?.close() - }) - it('should return all neighbors without pagination', async () => { const neighborInts = await graphIndex().getNeighbors(entityInt(centralId)) const neighbors = intsToUuids(neighborInts) @@ -187,8 +149,7 @@ describe('GraphAdjacencyIndex Pagination', () => { // Create some incoming relationships const sourceId = await brain.add({ data: { name: 'Source' }, - type: NounType.Thing, - vector: [] + type: NounType.Thing }) await brain.relate({ @@ -208,11 +169,6 @@ describe('GraphAdjacencyIndex Pagination', () => { }) describe('getVerbIdsBySource() Pagination', () => { - beforeAll(buildFixture) - afterAll(async () => { - await brain?.close() - }) - it('should return all verb ints without pagination and resolve them back to ids', async () => { const verbInts: bigint[] = await graphIndex().getVerbIdsBySource(entityInt(centralId)) @@ -267,11 +223,6 @@ describe('GraphAdjacencyIndex Pagination', () => { }) describe('getVerbIdsByTarget() Pagination', () => { - beforeAll(buildFixture) - afterAll(async () => { - await brain?.close() - }) - it('should return all verb ints targeting an entity', async () => { // Pick a neighbor that's a target of relationships const targetId = neighborIds[0] @@ -285,16 +236,14 @@ describe('GraphAdjacencyIndex Pagination', () => { // Create entity with many incoming relationships const popularTarget = await brain.add({ data: { name: 'Popular Target' }, - type: NounType.Thing, - vector: [] + type: NounType.Thing }) // Create 30 relationships pointing to it for (let i = 0; i < 30; i++) { const sourceId = await brain.add({ data: { name: `Source ${i}` }, - type: NounType.Thing, - vector: [] + type: NounType.Thing }) await brain.relate({ from: sourceId, @@ -318,11 +267,6 @@ describe('GraphAdjacencyIndex Pagination', () => { }) describe('Performance with Pagination', () => { - beforeAll(buildFixture) - afterAll(async () => { - await brain?.close() - }) - it('should maintain sub-5ms performance with pagination', async () => { const central = entityInt(centralId) @@ -341,17 +285,11 @@ describe('GraphAdjacencyIndex Pagination', () => { }) describe('Real-World Use Cases', () => { - beforeAll(buildFixture) - afterAll(async () => { - await brain?.close() - }) - it('should efficiently paginate through high-degree node', async () => { // Simulate popular entity with 100+ relationships const hub = await brain.add({ data: { name: 'Popular Hub' }, - type: NounType.Thing, - vector: [] + type: NounType.Thing }) // Create 100 relationships @@ -359,8 +297,7 @@ describe('GraphAdjacencyIndex Pagination', () => { for (let i = 0; i < 100; i++) { const targetId = await brain.add({ data: { name: `Target ${i}` }, - type: NounType.Thing, - vector: [] + type: NounType.Thing }) targetIds.push(targetId) await brain.relate({ diff --git a/tests/integration/health-gate.test.ts b/tests/integration/health-gate.test.ts deleted file mode 100644 index f2952116..00000000 --- a/tests/integration/health-gate.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * @module tests/integration/health-gate - * @description Pins for the health-by-accounting read gate: the read gate stops - * consulting an unnamed `isReady()` boolean and reads a NAMED, sync, O(1) - * {@link HealthReport}; no read path may ever start a store walk; the open path - * brings every provider to serving before it returns; an explicit operator door - * (`repairIndex({ rebuild: [...] })`) rebuilds a named leg unconditionally. - * - * Providers here are white-box test doubles: a `healthReport()` (or, for the - * interim-path pins, an `isReady()`) function assigned directly onto the LIVE - * JS provider object, the same pattern `tests/unit/validate-invariants-delegation.test.ts` - * uses for `validateInvariants`. This exercises brainy's real gate/verify code - * against a controlled provider self-report — no engine mocks. - */ -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, - NounType, - VerbType, - GraphIndexNotReadyError, - MetadataIndexNotReadyError, - VectorIndexNotReadyError -} from '../../src/index.js' -import type { HealthReport, LedgerInvariantResult } from '../../src/plugin.js' -import { prodLog } from '../../src/utils/logger.js' -import { createTestConfig } from '../helpers/test-factory.js' - -/** The white-box surface these pins drive on a live brain instance. */ -interface BrainInternals { - storage: { - getNoun(id: string): Promise - getNounMetadata(id: string): Promise - getNouns(options?: unknown): Promise - getVerbs(options?: unknown): Promise - } - index: { healthReport?: () => HealthReport; isReady?: () => boolean; rebuild(): Promise } - metadataIndex: { - healthReport?: () => HealthReport - isReady?: () => boolean - rebuild(): Promise - validateInvariants?: () => Promise - } - graphIndex: { - healthReport?: () => HealthReport - isReady?: () => boolean - rebuild(): Promise - validateInvariants?: () => Promise - } - rebuildIndexesIfNeeded(force?: boolean): Promise -} - -function internalsOf(brain: Brainy): BrainInternals { - return brain as unknown as BrainInternals -} - -function invariant(overrides: Partial = {}): LedgerInvariantResult { - return { - name: 'manifest-residency', - holds: true, - detail: 'ok', - heal: 'none', - source: 'ledger', - ...overrides - } -} - -function healthReport(overrides: Partial = {}): HealthReport { - return { - provider: 'vector', - healthy: true, - serving: true, - invariants: [], - checkedAt: Date.now(), - durationMs: 1, - generation: 1, - unledgered: [], - ...overrides - } -} - -const brains: Brainy[] = [] -const dirs: string[] = [] -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) - vi.restoreAllMocks() -}) - -describe('health gate (a) — not-serving refuses loudly, ZERO canonical reads during the refusal', () => { - it('metadata not-serving: find() throws MetadataIndexNotReadyError naming the failing invariant', async () => { - const brain = new Brainy(createTestConfig({ silent: true })) - await brain.init() - brains.push(brain) - await brain.add({ data: 'row', type: NounType.Document, metadata: { team: 'atlas' } }) - await brain.flush() - - const internals = internalsOf(brain) - internals.metadataIndex.healthReport = () => - healthReport({ - provider: 'metadata', - serving: false, - healthy: false, - invariants: [invariant({ name: 'posted-count-floor', holds: false, heal: 'rebuild', detail: 'posted 2 < canonical 5' })] - }) - - const getNounSpy = vi.spyOn(internals.storage, 'getNoun') - const getNounMetadataSpy = vi.spyOn(internals.storage, 'getNounMetadata') - const getNounsSpy = vi.spyOn(internals.storage, 'getNouns') - - await expect(brain.find({ where: { team: 'atlas' } })).rejects.toBeInstanceOf(MetadataIndexNotReadyError) - await expect(brain.find({ where: { team: 'atlas' } })).rejects.toThrow(/posted-count-floor/) - - expect(getNounSpy).not.toHaveBeenCalled() - expect(getNounMetadataSpy).not.toHaveBeenCalled() - expect(getNounsSpy).not.toHaveBeenCalled() - - delete internals.metadataIndex.healthReport - }) - - it('graph not-serving: related() throws GraphIndexNotReadyError naming the failing invariant, no canonical reads', async () => { - const brain = new Brainy(createTestConfig({ silent: true })) - await brain.init() - brains.push(brain) - const a = await brain.add({ data: 'a', type: NounType.Person }) - const b = await brain.add({ data: 'b', type: NounType.Person }) - await brain.relate({ from: a, to: b, type: VerbType.Knows }) - await brain.flush() - - const internals = internalsOf(brain) - internals.graphIndex.healthReport = () => - healthReport({ - provider: 'graph', - serving: false, - healthy: false, - invariants: [invariant({ name: 'adjacency-residency', holds: false, heal: 'rebuild', detail: 'edges not loaded' })] - }) - - const getNounSpy = vi.spyOn(internals.storage, 'getNoun') - const getVerbsSpy = vi.spyOn(internals.storage, 'getVerbs') - - await expect(brain.related({ from: a })).rejects.toBeInstanceOf(GraphIndexNotReadyError) - await expect(brain.related({ from: a })).rejects.toThrow(/adjacency-residency/) - - expect(getNounSpy).not.toHaveBeenCalled() - expect(getVerbsSpy).not.toHaveBeenCalled() - - delete internals.graphIndex.healthReport - }) -}) - -describe('health gate (b) — unledgered is unknown: never blocks a serving provider', () => { - it('serving:true with an unledgered family and no failing invariant serves normally; at most one narration', async () => { - const brain = new Brainy(createTestConfig({ silent: true })) - await brain.init() - brains.push(brain) - await brain.add({ data: 'row', type: NounType.Document, metadata: { team: 'atlas' } }) - await brain.flush() - - const internals = internalsOf(brain) - internals.metadataIndex.healthReport = () => - healthReport({ - provider: 'metadata', - serving: true, - healthy: true, - invariants: [], - unledgered: ['canonical-verb-coverage'] - }) - - const warnSpy = vi.spyOn(prodLog, 'warn') - - const r1 = await brain.find({ where: { team: 'atlas' } }) - const r2 = await brain.find({ where: { team: 'atlas' } }) - expect(r1.length).toBe(1) - expect(r2.length).toBe(1) - - const narrations = warnSpy.mock.calls.filter( - ([msg]) => typeof msg === 'string' && msg.includes('canonical-verb-coverage') - ) - expect(narrations.length).toBe(1) // one narration at most across both reads (same generation) - - delete internals.metadataIndex.healthReport - }) -}) - -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 DISTINCT VERDICT, not once per generation bump', async () => { - const brain = new Brainy(createTestConfig({ silent: true })) - await brain.init() - brains.push(brain) - await brain.add({ data: 'row', type: NounType.Document, metadata: { team: 'atlas' } }) - await brain.flush() - - 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 })], - generation - }) - - const warnSpy = vi.spyOn(prodLog, 'warn') - const countNarrations = () => - warnSpy.mock.calls.filter(([msg]) => typeof msg === 'string' && msg.includes('stale-vector-counter')).length - - 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 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(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 - }) -}) - -describe('health gate (d) — interim isReady()-only path (no healthReport) is unchanged', () => { - it('isReady() === true serves; isReady() === false refuses via the typed NotReady error', async () => { - const brain = new Brainy(createTestConfig({ silent: true })) - await brain.init() - brains.push(brain) - await brain.add({ data: 'row', type: NounType.Document, metadata: { team: 'atlas' } }) - await brain.flush() - - const internals = internalsOf(brain) - internals.metadataIndex.isReady = () => true - await expect(brain.find({ where: { team: 'atlas' } })).resolves.toHaveLength(1) - - internals.metadataIndex.isReady = () => false - await expect(brain.find({ where: { team: 'atlas' } })).rejects.toBeInstanceOf(MetadataIndexNotReadyError) - - delete internals.metadataIndex.isReady - }) -}) - -describe('health gate (e) — open builds; the first read never does', () => { - it('disableAutoRebuild:true on a populated store: open narrates + builds; the first find() triggers zero rebuilds', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-healthgate-open-')) - dirs.push(dir) - - const writer = new Brainy({ - storage: { type: 'filesystem', path: dir }, - requireSubtype: false, - silent: true, - disableAutoRebuild: true - }) - await writer.init() - brains.push(writer) - await writer.add({ data: 'row one', type: NounType.Document, metadata: { team: 'atlas' } }) - await writer.flush() - await brains.pop()!.close() - - const warnSpy = vi.spyOn(prodLog, 'warn') - const reader = new Brainy({ - storage: { type: 'filesystem', path: dir }, - requireSubtype: false, - silent: true, - disableAutoRebuild: true - }) - const internals = internalsOf(reader) - const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded') - - await reader.init() - brains.push(reader) - - expect(rebuildSpy).toHaveBeenCalledTimes(1) // open() built it, exactly once - expect( - warnSpy.mock.calls.some( - ([msg]) => typeof msg === 'string' && msg.includes('open() is building') - ) - ).toBe(true) - - rebuildSpy.mockClear() - const rows = await reader.find({ where: { team: 'atlas' } }) - expect(rebuildSpy).toHaveBeenCalledTimes(0) // the read never builds - expect(rows.length).toBe(1) - }, 30000) -}) - -describe('health gate (f) — the ceremony door: explicit rebuild bypasses invariant consultation', () => { - it("repairIndex({ rebuild: ['graph'] }) rebuilds unconditionally without consulting validateInvariants", async () => { - const brain = new Brainy(createTestConfig({ silent: true })) - await brain.init() - brains.push(brain) - await brain.add({ data: 'x', type: NounType.Concept }) - await brain.flush() - - const internals = internalsOf(brain) - let validateCalls = 0 - internals.graphIndex.validateInvariants = async () => { - validateCalls++ - return healthReport({ provider: 'graph' }) - } - const rebuildSpy = vi.spyOn(internals.graphIndex, 'rebuild') - - const report = await brain.repairIndex({ rebuild: ['graph'] }) - - expect(rebuildSpy).toHaveBeenCalledTimes(1) - expect(validateCalls).toBe(0) // the door never consults validateInvariants to decide - - const graphFamily = report.families.find((f) => f.family === 'provider:graph') - expect(graphFamily?.rebuilt).toBe(true) - expect(graphFamily?.checked).toBe(true) - expect(graphFamily?.reason).toBe('explicit rebuild requested') - - delete internals.graphIndex.validateInvariants - }) - - it('bare repairIndex() on a healthy provider calls no rebuild()', async () => { - const brain = new Brainy(createTestConfig({ silent: true })) - await brain.init() - brains.push(brain) - await brain.add({ data: 'x', type: NounType.Concept }) - await brain.flush() - - const internals = internalsOf(brain) - internals.graphIndex.validateInvariants = async () => healthReport({ provider: 'graph', healthy: true, serving: true }) - const rebuildSpy = vi.spyOn(internals.graphIndex, 'rebuild') - - await brain.repairIndex() - - expect(rebuildSpy).not.toHaveBeenCalled() - - delete internals.graphIndex.validateInvariants - }) -}) - -describe('health gate (g) — a throwing healthReport() is a contract violation, never read as healthy', () => { - // PER-FAMILY LAW (10.4.1): the throwing report sits on the family the read consults. - it('healthReport() that throws refuses loudly with the typed NotReady error naming the throw', async () => { - const brain = new Brainy(createTestConfig({ silent: true })) - await brain.init() - brains.push(brain) - await brain.add({ data: 'row', type: NounType.Document, metadata: { team: 'atlas' } }) - await brain.flush() - - const internals = internalsOf(brain) - internals.metadataIndex.healthReport = () => { - throw new Error('accelerator: mmap window busy') - } - - await expect(brain.find({ where: { team: 'atlas' } })).rejects.toBeInstanceOf(MetadataIndexNotReadyError) - await expect(brain.find({ where: { team: 'atlas' } })).rejects.toThrow(/mmap window busy/) - - delete internals.metadataIndex.healthReport - }) -}) diff --git a/tests/integration/history-repacking.test.ts b/tests/integration/history-repacking.test.ts deleted file mode 100644 index bb07268d..00000000 --- a/tests/integration/history-repacking.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -/** - * @module tests/integration/history-repacking - * @description The D1+D3 two-tier history lifecycle end-to-end on a real - * brain. Laws: (1) repacking is RE-REPRESENTATION — after folding, every - * asOf() read below the fold boundary answers exactly as before, across a - * cold reopen; (2) folded per-generation directories are physically gone - * (the file-count cure is real, not cosmetic); (3) repack + reclaim compose: - * bounded retention after repacking drops whole segments and asOf below the - * horizon throws GenerationCompactedError; (4) repackHistory is explicit - * API and time-bounded (spent budget = consistent no-op). - * - * Uses a tiny REPACK_LIVE_WINDOW override so a small history has a cold - * tier at all (the production window is 1024). - */ -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' -import { GenerationCompactedError } from '../../src/db/errors.js' -import { SEGMENTS_PREFIX } from '../../src/db/generationSegments.js' - -const stub = async (text: string): Promise => { - const h = text.split('').reduce((a, c) => a + c.charCodeAt(0), 0) - return new Array(384).fill(0).map((_, i) => Math.sin(h + i)) -} - -const openBrain = async (dir: string): Promise => { - const brain = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - embeddingFunction: stub - }) - await brain.init() - return brain -} - -describe('history repacking — the two-tier lifecycle', () => { - const dirs: string[] = [] - const tempDir = (): string => { - const d = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-repack-')) - dirs.push(d) - return d - } - const originalWindow = GenerationStore.REPACK_LIVE_WINDOW - - afterEach(() => { - ;(GenerationStore as any).REPACK_LIVE_WINDOW = originalWindow - for (const d of dirs.splice(0)) { - try { - fs.rmSync(d, { recursive: true, force: true }) - } catch { - /* best effort */ - } - } - }) - - /** - * 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() - const brain = await openBrain(dir) - - const id = await brain.add({ - data: 'versioned-entity', - type: NounType.Document, - metadata: { v: 0 } - }) - for (let v = 1; v <= 10; v++) await brain.update({ id, metadata: { v } }) - await brain.flush() - - // Ground truth BEFORE repacking: capture asOf views for early generations. - const before: Record = {} - for (const g of [2, 4, 6]) { - const db = await brain.asOf(g) - before[g] = (await db.get(id))?.metadata?.v as number - await db.release() - } - - const result = await brain.repackHistory() - expect(result.foldedGenerations).toBeGreaterThan(0) - expect(result.segmentsCreated).toBeGreaterThan(0) - - // The folded per-generation directories are PHYSICALLY gone… - const genDirs = fs - .readdirSync(path.join(dir, '_generations'), { withFileTypes: true }) - .filter((e) => e.isDirectory() && /^\d+$/.test(e.name)).length - expect(genDirs).toBeLessThanOrEqual(4) // live window (3) + at most the newest - // …and the segment tier exists (the filesystem adapter stores objects - // gzipped, so the manifest may live at either spelling). - const segDir = path.join(dir, SEGMENTS_PREFIX) - expect( - fs.existsSync(path.join(segDir, 'manifest.json')) || - fs.existsSync(path.join(segDir, 'manifest.json.gz')) - ).toBe(true) - expect(fs.readdirSync(segDir).some((f) => f.endsWith('.bgs'))).toBe(true) - - // Same asOf answers from the packed tier, same process… - for (const g of [2, 4, 6]) { - const db = await brain.asOf(g) - expect((await db.get(id))?.metadata?.v).toBe(before[g]) - await db.release() - } - await brain.close() - - // …and across a COLD REOPEN (manifest discovery, no live dirs to list). - const reopened = await openBrain(dir) - for (const g of [2, 4, 6]) { - const db = await reopened.asOf(g) - expect((await db.get(id))?.metadata?.v).toBe(before[g]) - await db.release() - } - expect((await reopened.get(id))?.metadata?.v).toBe(10) // live state untouched - await reopened.close() - }) - - it('repack + bounded reclaim compose: whole segments drop, horizon is loud', async () => { - ;(GenerationStore as any).REPACK_LIVE_WINDOW = 2 - const dir = tempDir() - const brain = await openBrain(dir) - const id = await brain.add({ data: 'reclaim-probe', type: NounType.Document, metadata: { v: 0 } }) - for (let v = 1; v <= 8; v++) await brain.update({ id, metadata: { v } }) - await brain.flush() - await brain.repackHistory() - - // Reclaim down to the 3 newest generations — packed segments below the - // horizon drop whole; asOf below throws loudly. - const res = await brain.compactHistory({ maxGenerations: 3 }) - expect(res.removedGenerations).toBeGreaterThan(0) - await expect(brain.asOf(1)).rejects.toBeInstanceOf(GenerationCompactedError) - expect((await brain.get(id))?.metadata?.v).toBe(8) - await brain.close() - }) - - it('generationDigest: reopen-stable, divergence-sensitive, loud below the horizon', async () => { - ;(GenerationStore as any).REPACK_LIVE_WINDOW = 2 - const dir = tempDir() - const brain = await openBrain(dir) - const id = await brain.add({ data: 'digest-probe', type: NounType.Document, metadata: { v: 0 } }) - for (let v = 1; v <= 6; v++) await brain.update({ id, metadata: { v } }) - await brain.flush() - await brain.repackHistory() - - const gen = brain.generation() - const atHead = await brain.generationDigest(gen) - const atMid = await brain.generationDigest(3) - expect(atHead).toMatch(/^[0-9a-f]{8}$/) - expect(atMid).not.toBe(atHead) // more history ⇒ different digest - await brain.close() - - // Reopen-stable: same history, same digests (packed prefix stability). - const reopened = await openBrain(dir) - expect(await reopened.generationDigest(gen)).toBe(atHead) - expect(await reopened.generationDigest(3)).toBe(atMid) - - // New history diverges the head digest. - await reopened.update({ id, metadata: { v: 7 } }) - await reopened.flush() - expect(await reopened.generationDigest(reopened.generation())).not.toBe(atHead) - - // Below the horizon: LOUD, never a silent pin of reclaimed history. - await reopened.compactHistory({ maxGenerations: 2 }) - await expect(reopened.generationDigest(1)).rejects.toBeInstanceOf(GenerationCompactedError) - await reopened.close() - }) - - it('a spent time budget is a consistent no-op; the next pass resumes', async () => { - ;(GenerationStore as any).REPACK_LIVE_WINDOW = 2 - const dir = tempDir() - const brain = await openBrain(dir) - const id = await brain.add({ data: 'budget-probe', type: NounType.Document, metadata: { v: 0 } }) - for (let v = 1; v <= 6; v++) await brain.update({ id, metadata: { v } }) - await brain.flush() - - const bounded = await brain.repackHistory({ timeBudgetMs: 0 }) - expect(bounded).toEqual({ foldedGenerations: 0, segmentsCreated: 0 }) - - const resumed = await brain.repackHistory() - expect(resumed.foldedGenerations).toBeGreaterThan(0) - const db = await brain.asOf(3) - expect((await db.get(id))?.metadata?.v).toBeDefined() - await db.release() - await brain.close() - }) -}) diff --git a/tests/integration/hybrid-search-vfs.test.ts b/tests/integration/hybrid-search-vfs.test.ts index 219c4c83..c881fa97 100644 --- a/tests/integration/hybrid-search-vfs.test.ts +++ b/tests/integration/hybrid-search-vfs.test.ts @@ -21,16 +21,10 @@ 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', - path: testDir + options: { basePath: testDir } } }) await brain.init() diff --git a/tests/integration/id-normalization.test.ts b/tests/integration/id-normalization.test.ts index 1eb14ab1..1ea1a221 100644 --- a/tests/integration/id-normalization.test.ts +++ b/tests/integration/id-normalization.test.ts @@ -18,7 +18,7 @@ * All entities carry explicit 384-dim vectors so no test invokes the embedder. */ -import { describe, it, expect, afterEach } from 'vitest' +import { describe, it, expect } from 'vitest' import { Brainy } from '../../src/brainy.js' import { NounType, VerbType } from '../../src/types/graphTypes.js' import { v5, v7, isUUID } from '../../src/universal/uuid.js' @@ -37,15 +37,8 @@ async function makeBrain(): Promise { } describe('id normalization — transparent string-key round-trips', () => { - const opened: Brainy[] = [] - - afterEach(async () => { - for (const b of opened.splice(0)) await b.close().catch(() => {}) - }) - it('1. add() returns v5(key); get(key) and get(returnedId) both resolve; _originalId preserved', async () => { const brain = await makeBrain() - opened.push(brain) const returnedId = await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person }) @@ -67,7 +60,6 @@ describe('id normalization — transparent string-key round-trips', () => { it('2. relate() by string keys; related(key) and related({from:key}) return the edge to v5(toKey)', async () => { const brain = await makeBrain() - opened.push(brain) await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person }) await brain.add({ id: 'doc-1', vector: vec(2), type: NounType.Document }) @@ -93,7 +85,6 @@ describe('id normalization — transparent string-key round-trips', () => { it('3. update() by string key reflects on get(key)', async () => { const brain = await makeBrain() - opened.push(brain) await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person, metadata: { role: 'admin' } }) await brain.update({ id: 'user-1', metadata: { role: 'owner' } }) @@ -107,7 +98,6 @@ describe('id normalization — transparent string-key round-trips', () => { it('4. remove() by string key deletes; get(key) is null', async () => { const brain = await makeBrain() - opened.push(brain) await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person }) expect(await brain.get('user-1')).not.toBeNull() @@ -120,7 +110,6 @@ describe('id normalization — transparent string-key round-trips', () => { it('5. find({ connected: { from: key } }) resolves the anchor key', async () => { const brain = await makeBrain() - opened.push(brain) await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person }) await brain.add({ id: 'doc-1', vector: vec(2), type: NounType.Document }) @@ -133,7 +122,6 @@ describe('id normalization — transparent string-key round-trips', () => { it('6. transact() add+relate by string keys round-trips with consistent canonical ids', async () => { const brain = await makeBrain() - opened.push(brain) // Seed user-1 so the relate op has a target to point at. await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person }) @@ -161,7 +149,6 @@ describe('id normalization — transparent string-key round-trips', () => { it('7. addMany() + relateMany() with string ids round-trip', async () => { const brain = await makeBrain() - opened.push(brain) const added = await brain.addMany({ items: [ @@ -188,7 +175,6 @@ describe('id normalization — transparent string-key round-trips', () => { it('8. determinism: same key maps to same UUID — two adds upsert ONE entity, not two', async () => { const brain = await makeBrain() - opened.push(brain) const id1 = await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person, metadata: { n: 1 } }) const id2 = await brain.add({ id: 'user-1', vector: vec(1), type: NounType.Person, metadata: { n: 2 } }) @@ -207,7 +193,6 @@ describe('id normalization — transparent string-key round-trips', () => { it('9. valid-UUID passthrough: a real UUID is kept verbatim with NO _originalId', async () => { const brain = await makeBrain() - opened.push(brain) const realUuid = v7() const returnedId = await brain.add({ id: realUuid, vector: vec(5), type: NounType.Thing }) @@ -222,7 +207,6 @@ describe('id normalization — transparent string-key round-trips', () => { it('10. no-id add() mints a v7; newId() mints a v7', async () => { const brain = await makeBrain() - opened.push(brain) const autoId = await brain.add({ vector: vec(6), type: NounType.Thing }) expect(isUUID(autoId)).toBe(true) diff --git a/tests/integration/idle-costs-nothing.test.ts b/tests/integration/idle-costs-nothing.test.ts deleted file mode 100644 index 7e664a28..00000000 --- a/tests/integration/idle-costs-nothing.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * @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[] = [] - // The STACK behind each narration, kept beside the line it belongs to. - // vitest tags a stdout block with the test that is RUNNING, not the brain - // that wrote it, so teeing these lines through would only ever name this - // test. The call stack does name the driver: `kickBackgroundFlush('idle')` - // under `armIdleFlushTimer` is a cadence flush on some brain, the deferred- - // embed worker's commit path is a brain still landing vectors, and a bare - // `flush()` is an explicit caller. That distinction is the whole question. - const stacks: string[] = [] - const origLog = console.log - console.log = ((...a: unknown[]) => { - const line = a.map(String).join(' ') - logged.push(line) - if (/All indexes flushed to disk|Flushing Brainy indexes/.test(line)) { - stacks.push(new Error('flush narration').stack ?? '(no stack)') - } - }) 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. - // - // THE SPIES COME FIRST, AND THEY ARE THE ATTRIBUTABLE HALF. They are bound - // to THIS brain's providers, so they answer "did this brain flush?" and - // nothing else. The console filters below cannot: the gate config runs the - // whole suite in ONE process (`pool: 'forks'`, `singleFork: true`), so - // `console.log` carries the narration of every brain alive in that - // process — including one a previous file opened and never closed, whose - // unref'd cadence timer is still doing honest work. A neighbour narrating - // is a REAL finding about suite hygiene, but it is not this brain failing - // its own law, and the two must not be reported as the same thing. - // - // So: spies first (whose failure means the engine broke the law), console - // second (whose failure means SOMETHING in the process narrated), and the - // console assertion carries the captured lines in its message. vitest's - // stdout blocks are prefixed `stdout | > `, so those lines - // plus the surrounding gate log name the brain that printed them. - expect(countsSpy).not.toHaveBeenCalled() - expect(metadataSpy).not.toHaveBeenCalled() - expect(graphSpy).not.toHaveBeenCalled() - - const flushChatter = logged.filter( - (l) => /All indexes flushed to disk/.test(l) || /Flushing Brainy indexes/.test(l) - ) - expect( - flushChatter, - `${flushChatter.length} flush line(s) narrated during the ${IDLE_WATCH_MS}ms idle ` + - `window. This brain's own providers were NOT called (asserted above), so another ` + - `brain alive in this process printed them — the suite runs every file in ONE ` + - `process and 67 test files create more brains than they close.\n` + - `${flushChatter.join('\n')}\n\n` + - `The stack behind the first one names the driver:\n${stacks[0] ?? '(none captured)'}` - ).toEqual([]) - }, 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 deleted file mode 100644 index c65aaa01..00000000 --- a/tests/integration/index-skips-unvectored.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * @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 deleted file mode 100644 index 7d09e893..00000000 --- a/tests/integration/ledger-derivation-identity.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -/** - * @module tests/integration/ledger-derivation-identity - * @description The ALL-visibility ledger scalars are an IDENTITY-RECORD - * count, never a container count. A pre-8.3.1 partial-delete defect can - * leave a "ghost" container (a stale `vectors.json` with no metadata content - * leg) or a "scar" container (an empty `entities////` - * directory) on disk. Neither is a live entity — `getNoun`/`getVerb` need - * the metadata content leg — yet the legacy derivation counted one entity - * per id DIRECTORY, so orphaned containers inflated the ALL scalars forever - * (they were never clamped and never re-derived). Laws under test: - * (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, 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. - */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import { Brainy, FileSystemStorage } from '../../src/index.js' -import { prodLog } from '../../src/utils/logger.js' - -const countsPath = (root: string) => path.join(root, '_system', 'counts.json') - -/** Plant a ghost container: a stale `vectors.json` leg, no metadata leg. */ -function plantGhost(root: string, shard: string, id: string): void { - const idDir = path.join(root, 'entities', 'nouns', shard, id) - fs.mkdirSync(idDir, { recursive: true }) - fs.writeFileSync(path.join(idDir, 'vectors.json'), JSON.stringify({ vector: [0.1, 0.2, 0.3] })) -} - -/** Plant a scar container: an empty id directory, no legs at all. */ -function plantScar(root: string, shard: string, id: string): void { - fs.mkdirSync(path.join(root, 'entities', 'nouns', shard, id), { recursive: true }) -} - -describe('ledger derivation identity — the ALL scalar is the identity-record population, never the container count', () => { - let dir: string - - const open = async () => { - const b: any = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - silent: true, - dimensions: 384 - }) - await b.init() - return b - } - - beforeEach(() => { - process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-ledger-identity-')) - }) - afterEach(() => { - vi.restoreAllMocks() - fs.rmSync(dir, { recursive: true, force: true }) - }) - - it('(a) ghost + scar containers count ZERO; the fresh derivation stamps counts.json', async () => { - let brain = await open() - const baseline = (await brain.storage.getCanonicalCounts()).nouns.all // the VFS root alone - for (let i = 0; i < 3; i++) { - await brain.add({ data: `real ${i}`, type: 'document' }) - } - await brain.flush() - const realTotal = baseline + 3 - await brain.close() - - // 3 ghosts (stale vectors.json, no metadata leg) + 2 scars (empty dirs) — - // neither is a live entity. - for (let i = 0; i < 3; i++) plantGhost(dir, 'fe', `ghost-${i}`) - for (let i = 0; i < 2; i++) plantScar(dir, 'fd', `scar-${i}`) - - // Remove counts.json so open() re-derives from scratch (the one-time - // legacy/lost-file derivation path). - fs.rmSync(countsPath(dir), { force: true }) - - brain = await open() - const ledger = await brain.storage.getCanonicalCounts() - expect(ledger.nouns.all).toBe(realTotal) // ghosts + scars contribute nothing - expect(ledger.suspect).toBe(false) - - const raw = JSON.parse(fs.readFileSync(countsPath(dir), 'utf-8')) - expect(raw.totalNounCountAll).toBe(realTotal) - expect(raw.allCountsDerivedBy).toBe('identity-record') - - await brain.close() - }) - - it('(b) a counts.json with the ALL scalars but no stamp is marked suspect at open — an O(1) field read, never a walk', async () => { - let brain = await open() - await brain.add({ data: 'one', type: 'document' }) - await brain.add({ data: 'two', type: 'document' }) - await brain.flush() - await brain.close() - - // Confirm a normal close under the fix DOES stamp — then strip the stamp - // to simulate a counts.json produced before this fix existed. - const raw = JSON.parse(fs.readFileSync(countsPath(dir), 'utf-8')) - expect(raw.allCountsDerivedBy).toBe('identity-record') - expect(typeof raw.totalNounCountAll).toBe('number') - expect(typeof raw.totalVerbCountAll).toBe('number') - expect(typeof raw.totalVectoredNounCount).toBe('number') - delete raw.allCountsDerivedBy - fs.writeFileSync(countsPath(dir), JSON.stringify(raw, null, 2)) - - 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 - - // THE OPEN DID NOT WALK: two slowed walks would have added 2.4s to it. - expect(openMs).toBeLessThan(2_000) - - // 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 - - // ...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() - }) - - it('(c) repairIndex() prunes the orphans, recounts, and re-stamps — suspect clears, the ALL scalar is exact, and it survives reopen', async () => { - let brain = await open() - const baseline = (await brain.storage.getCanonicalCounts()).nouns.all - for (let i = 0; i < 3; i++) { - await brain.add({ data: `real ${i}`, type: 'document' }) - } - await brain.flush() - const realTotal = baseline + 3 - await brain.close() - - for (let i = 0; i < 3; i++) plantGhost(dir, 'fe', `ghost-${i}`) - for (let i = 0; i < 2; i++) plantScar(dir, 'fd', `scar-${i}`) - - // Force the legacy (unstamped, container-rule-inflated) shape directly — - // the shape a pre-existing production store actually carries. - const raw = JSON.parse(fs.readFileSync(countsPath(dir), 'utf-8')) - raw.totalNounCountAll = realTotal + 5 // the old rule: +3 ghosts +2 scars - delete raw.allCountsDerivedBy - fs.writeFileSync(countsPath(dir), JSON.stringify(raw, null, 2)) - - brain = await open() - // 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() - - let ledger = await brain.storage.getCanonicalCounts() - expect(ledger.suspect).toBe(false) - expect(ledger.nouns.all).toBe(realTotal) // ghosts + scars pruned; exact again - - const persisted = JSON.parse(fs.readFileSync(countsPath(dir), 'utf-8')) - expect(persisted.allCountsDerivedBy).toBe('identity-record') - expect(persisted.allCountsSuspect).toBe(false) - expect(persisted.totalNounCountAll).toBe(realTotal) - - await brain.close() - brain = await open() - ledger = await brain.storage.getCanonicalCounts() - expect(ledger.suspect).toBe(false) - expect(ledger.nouns.all).toBe(realTotal) - await brain.close() - }) - - it('(d) a fresh store derives with the stamp and is never suspect', async () => { - const brain = await open() - const ledger = await brain.storage.getCanonicalCounts() - expect(ledger.suspect).toBe(false) - const raw = JSON.parse(fs.readFileSync(countsPath(dir), 'utf-8')) - expect(raw.allCountsDerivedBy).toBe('identity-record') - await brain.close() - }) -}) diff --git a/tests/integration/lens-consistency.test.ts b/tests/integration/lens-consistency.test.ts index 1b1cef81..64484a38 100644 --- a/tests/integration/lens-consistency.test.ts +++ b/tests/integration/lens-consistency.test.ts @@ -2,8 +2,8 @@ * @module tests/integration/lens-consistency * @description The three metadata "lenses" over one corpus must agree with * canonical ground truth id-for-id, warm AND after a cold reopen: - * - combined: find({ type: T, where: { 'system.subtype': S } }) - * - subtype-only: find({ where: { 'system.subtype': S } }) + * - combined: find({ type: T, where: { subtype: S } }) + * - subtype-only: find({ where: { subtype: S } }) * - type-only: find({ type: T }) * Ported from the fresh-brain probe that closed the type+subtype lens-drop * investigation (a restored pre-8.2.2 torn capture had entities visible to the @@ -63,11 +63,8 @@ async function assertAllLenses(brain: any): Promise { const subtypes = [...new Set(CORPUS.map((c) => c.subtype))] for (const { type, subtype } of CORPUS) { - // system.subtype — subtype is an add()/update() param (an engine scalar), - // never a user metadata field; bare 'subtype' now addresses the user's - // own metadata bag under the sealed field-addressing law. - const combined = idSet(await brain.find({ type, where: { 'system.subtype': subtype }, limit: 1000 })) - const subtypeOnly = idSet(await brain.find({ where: { 'system.subtype': subtype }, limit: 1000 })) + const combined = idSet(await brain.find({ type, where: { subtype }, limit: 1000 })) + const subtypeOnly = idSet(await brain.find({ where: { subtype }, limit: 1000 })) const truthPair = await groundTruth(brain, { type, subtype }) const truthSubtype = await groundTruth(brain, { subtype }) @@ -85,7 +82,7 @@ async function assertAllLenses(brain: any): Promise { // Count cross-check against the corpus definition itself. for (const subtype of subtypes) { const expected = CORPUS.filter((c) => c.subtype === subtype).reduce((s, c) => s + c.count, 0) - const got = (await brain.find({ where: { 'system.subtype': subtype }, limit: 1000 })).length + const got = (await brain.find({ where: { subtype }, limit: 1000 })).length expect(got).toBe(expected) } } @@ -126,16 +123,16 @@ describe('lens consistency — combined vs subtype-only vs canonical ground trut it('after an update() flips type AND subtype, every lens tracks the move exactly', async () => { // The historical cross-bucket-staleness path: change (concept, action) -> (task, review). - const victims = await brain.find({ type: 'concept', where: { 'system.subtype': 'action' }, limit: 1 }) + const victims = await brain.find({ type: 'concept', where: { subtype: 'action' }, limit: 1 }) expect(victims.length).toBe(1) const id = victims[0].id await brain.update({ id, type: 'task', subtype: 'review' }) - const oldCombined = idSet(await brain.find({ type: 'concept', where: { 'system.subtype': 'action' }, limit: 1000 })) + const oldCombined = idSet(await brain.find({ type: 'concept', where: { subtype: 'action' }, limit: 1000 })) expect(oldCombined.has(id)).toBe(false) // unposted from the old buckets - const newCombined = idSet(await brain.find({ type: 'task', where: { 'system.subtype': 'review' }, limit: 1000 })) + const newCombined = idSet(await brain.find({ type: 'task', where: { subtype: 'review' }, limit: 1000 })) expect(newCombined.has(id)).toBe(true) // posted to the new buckets - const subtypeOnly = idSet(await brain.find({ where: { 'system.subtype': 'review' }, limit: 1000 })) + const subtypeOnly = idSet(await brain.find({ where: { subtype: 'review' }, limit: 1000 })) expect(subtypeOnly.has(id)).toBe(true) }) }) diff --git a/tests/integration/level-field-shadow.test.ts b/tests/integration/level-field-shadow.test.ts deleted file mode 100644 index cfe34c13..00000000 --- a/tests/integration/level-field-shadow.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * @module tests/integration/level-field-shadow - * @description The reserved-name shadow fix (VENUE-BRAINY-ORDERBY-NOOP, - * 2026-08-03): `level` is HNSW plumbing, not an entity field — it must never - * shadow user metadata of the same name. Pre-fix, STANDARD_ENTITY_FIELDS - * listed `level`, so every by-name read returned the engine's internal 0 - * (all-equal → stable sort → insertion order, silently), and the indexing - * views stamped level:0 into the same flattened column as user values - * (multi-valued [0, real] poison). Laws: - * (1) venue's exact repro sorts: three adds with metadata.level 3/9/6 → - * find({orderBy:'level'}) returns 9,6,3 desc and 3,6,9 asc; - * (2) where {level: N} matches through filter AND egress guard; - * (3) the index column carries the user value only (no 0 poison); - * (4) update() keeps `level` readable (the update indexing view is clean too); - * (5) the transact() update path never rewrites the noun record on a - * metadata-only patch (the planUpdate granularity completion). - */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { Brainy } from '../../src/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' -import { EXPECTED_INDEX_EPOCH } from '../../src/storage/brainFormat.js' - -const stubEmbedding = async (text: string): Promise => { - const hash = text.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) - return new Array(384).fill(0).map((_, i) => Math.sin(hash + i)) -} - -describe('level field shadow — user metadata named level is a real field', () => { - let brain: Brainy - - beforeEach(async () => { - brain = new Brainy({ - requireSubtype: false, - storage: { type: 'memory' as const }, - embeddingFunction: stubEmbedding - }) - await brain.init() - }) - - afterEach(async () => { - await brain.close() - }) - - async function addProbeRows(): Promise { - const ids: string[] = [] - for (const level of [3, 9, 6]) { - ids.push( - await brain.add({ - data: `probe character level ${level}`, - type: NounType.Person, - subtype: 'probe-char', - metadata: { name: `char-${level}`, level } - }) - ) - } - return ids - } - - it("venue's exact repro: orderBy 'level' sorts desc and asc", async () => { - await addProbeRows() - - const desc = await brain.find({ - type: NounType.Person, - subtype: 'probe-char', - orderBy: 'level', - order: 'desc', - limit: 100 - }) - expect(desc.map((r: any) => r.metadata?.level)).toEqual([9, 6, 3]) - - const asc = await brain.find({ - type: NounType.Person, - subtype: 'probe-char', - orderBy: 'level', - order: 'asc', - limit: 100 - }) - expect(asc.map((r: any) => r.metadata?.level)).toEqual([3, 6, 9]) - }) - - it('ordered reads are COMPLETE — no row dropped (the 2-of-3 face)', async () => { - const ids = await addProbeRows() - const desc = await brain.find({ - type: NounType.Person, - subtype: 'probe-char', - orderBy: 'level', - order: 'desc', - limit: 100 - }) - expect(desc).toHaveLength(3) - expect(new Set(desc.map((r: any) => r.id))).toEqual(new Set(ids)) - }) - - it('where {level: N} matches through the filter and the egress guard', async () => { - const ids = await addProbeRows() - const hit = await brain.find({ where: { level: 9 } }) - expect(hit).toHaveLength(1) - expect(hit[0].id).toBe(ids[1]) - expect(hit[0].metadata?.level).toBe(9) - }) - - it('the index column carries ONLY the user value (no 0 poison)', async () => { - const ids = await addProbeRows() - const metadataIndex = (brain as any).metadataIndex - const value = await metadataIndex.getFieldValueForEntity(ids[1], 'level') - expect(value).toBe(9) - - // Zero must not match anything — pre-fix every entity carried a phantom 0. - const phantom = await brain.find({ where: { level: 0 } }) - expect(phantom).toHaveLength(0) - }) - - it('update() keeps level readable (the update indexing view is clean)', async () => { - const ids = await addProbeRows() - await brain.update({ id: ids[0], metadata: { level: 12 } }) - const desc = await brain.find({ - type: NounType.Person, - subtype: 'probe-char', - orderBy: 'level', - order: 'desc', - limit: 100 - }) - expect(desc.map((r: any) => r.metadata?.level)).toEqual([12, 9, 6]) - }) - - it('transact() metadata-only update never rewrites the noun record', async () => { - const ids = await addProbeRows() - const storage = (brain as any).storage - const saveNounSpy = vi.spyOn(storage, 'saveNoun') - - await brain.transact([ - { op: 'update', id: ids[0], metadata: { level: 4 } }, - { op: 'update', id: ids[2], metadata: { level: 7 } } - ]) - - expect(saveNounSpy).not.toHaveBeenCalled() - saveNounSpy.mockRestore() - - const after = await brain.get(ids[0], { includeVectors: true }) - expect(after?.metadata?.level).toBe(4) - expect(Array.isArray(after?.vector) && after!.vector!.length).toBe(384) - }) - - it('this build runs index epoch 3 (the namespace-law key split rebuild)', () => { - expect(EXPECTED_INDEX_EPOCH).toBe(3) - }) -}) - -describe('noun-record writes never stamp over stored graph state', () => { - let brain: Brainy - - beforeEach(async () => { - brain = new Brainy({ - requireSubtype: false, - storage: { type: 'memory' as const }, - embeddingFunction: stubEmbedding - }) - await brain.init() - }) - - afterEach(async () => { - await brain.close() - }) - - it('a data-changing update preserves LEGACY inline connections in the record', async () => { - // Codec-era records carry an EMPTY connections field by design (the - // adjacency lives in a separate compressed blob) — the clobber window - // exists only for legacy pre-codec records whose adjacency is inline. - // Simulate one: write the record with inline connections directly. - const id = await brain.add({ - data: 'legacy-shaped node', - type: NounType.Concept, - metadata: { n: 1 } - }) - const storage = (brain as any).storage - const rec = await storage.getNoun(id) - const legacy = { - ...rec, - connections: new Map([[0, new Set(['00000000-0000-4000-8000-00000000aaaa'])]]), - level: 1 - } - await storage.saveNoun(legacy) - const before = await storage.getNoun(id) - expect(before.connections.size).toBeGreaterThan(0) - - // A data-changing update stages SaveNounOperation with placeholder - // adjacency — the legacy inline connections must survive the write. - await brain.update({ id, data: 'completely re-embedded text' }) - - const after = await storage.getNoun(id) - expect(after.connections.size).toBeGreaterThan(0) - expect(after.level).toBe(1) - }) -}) diff --git a/tests/integration/log-authority-adopt.test.ts b/tests/integration/log-authority-adopt.test.ts deleted file mode 100644 index 5e810b1f..00000000 --- a/tests/integration/log-authority-adopt.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * @module tests/integration/log-authority-adopt - * @description THE SANCTIONED FLIP, END TO END: adoptLogAuthority() cures - * its own curable divergences by baseline backfill — a FRESH brain (whose - * generation-0 VFS root never entered the log) flips WITHOUT any manual - * white-box backfill. Before this, no fresh brain could ever flip: the - * oracle reported the bootstrap row as pre-log-record and the flip refused. - * Log-AHEAD divergences stay incurable and refuse loudly (witness wins). - */ -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/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -const dirs: string[] = [] -const brains: Brainy[] = [] - -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -async function open(dir: string, logAuthority?: 'adopt' | 'defer'): Promise { - const b = new Brainy({ - storage: { type: 'filesystem', path: dir }, - requireSubtype: false, - ...(logAuthority ? { logAuthority } : {}) - }) - await b.init() - brains.push(b) - return b -} - -describe('adoptLogAuthority — the sanctioned flip with self-backfill', () => { - it('a fresh brain flips directly: the backfill cures the generation-0 baseline', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-adopt-')) - dirs.push(dir) - const brain = await open(dir) - const idA = await brain.add({ data: 'first row', type: NounType.Document, metadata: { n: 1 } }) - await brain.add({ data: 'second row', type: NounType.Document, metadata: { n: 2 } }) - await brain.flush() - - const report = await brain.adoptLogAuthority() - expect(report.verdict, 'the flip receipt is a green oracle').toBe('green') - expect(brain.logAuthority().authority).toBe('log') - - // The switch survives reopen; the brain keeps serving identically. - await brain.close() - brains.pop() - const reopened = await open(dir) - expect(reopened.logAuthority().authority).toBe('log') - expect(await reopened.get(idA), 'records serve at reopen').toBeTruthy() - const rows = await reopened.find({ where: {}, limit: 10 }) - expect(rows.length, 'match-all serves on the reopened flipped brain').toBeGreaterThanOrEqual(2) - // And a fresh oracle run on the flipped brain stays green. - expect((await reopened.verifyLogAuthority()).verdict).toBe('green') - }, 120000) - - it('witness drift (out-of-generation canonical rewrite) is cured by the backfill, then flips', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-adopt-drift-')) - dirs.push(dir) - const brain = await open(dir) - const id = await brain.add({ data: 'drifter', type: NounType.Document, metadata: { v: 1 } }) - await brain.flush() - - // Simulate maintenance rewriting canonical OUTSIDE a generation (the - // witness-drift class): mutate the stored record directly. - const storage = (brain as unknown as { - storage: { - readNounRaw(id: string): Promise<{ metadata: unknown; vector: unknown }> - writeNounRaw(id: string, r: { metadata: unknown; vector: unknown }): Promise - } - }).storage - const raw = await storage.readNounRaw(id) - await storage.writeNounRaw(id, { - metadata: { ...(raw.metadata as Record), drifted: true }, - vector: raw.vector - }) - expect((await brain.verifyLogAuthority()).verdict, 'drift detected').toBe('red') - - const report = await brain.adoptLogAuthority() - expect(report.verdict).toBe('green') - expect(brain.logAuthority().authority).toBe('log') - }, 120000) - - // THE OPT-OUT CONTRACT (`logAuthority: 'defer'`): no automatic adoption — - // the fresh brain stays tree-authoritative and writes NO artifact (a - // deferred posture is config, not stored state); the EXPLICIT - // adoptLogAuthority() then flips it exactly as before the fleet default. - it("opt-out: 'defer' stays tree with no artifact until the explicit adoptLogAuthority() flips it", async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-adopt-defer-')) - dirs.push(dir) - const brain = await open(dir, 'defer') - await brain.add({ data: 'deferred row', type: NounType.Document, metadata: { n: 1 } }) - await brain.flush() - - expect(brain.logAuthority().authority, "'defer' skips open-time adoption").toBe('tree') - const storage = (brain as unknown as { - storage: { readRawObject(p: string): Promise } - }).storage - const artifact = await storage.readRawObject('_system/log-authority.json').catch(() => null) - expect(artifact, "'defer' writes no authority artifact").toBeNull() - - const report = await brain.adoptLogAuthority() - expect(report.verdict, 'the explicit flip still lands on green').toBe('green') - expect(brain.logAuthority().authority).toBe('log') - const stored = (await storage.readRawObject('_system/log-authority.json')) as { - authority?: string - } | null - expect(stored?.authority, 'the explicit flip stores the artifact').toBe('log') - }, 120000) -}) diff --git a/tests/integration/log-authority.test.ts b/tests/integration/log-authority.test.ts deleted file mode 100644 index a828c9a3..00000000 --- a/tests/integration/log-authority.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -/** - * @module tests/integration/log-authority - * @description The guarded log-authority core, end-to-end: the per-brain - * authority switch (stored artifact, checked at open only), the - * verification oracle (replay the fact log, diff latest per-id state - * against the canonical tree, NAME every divergence by class), the guarded - * flip (refuses on red with the cure in the message; lands on green and - * engages durable-at-ack immediately), and the switch surviving reopen. - * - * THE 10.0.0 FLEET DEFAULT is ADOPT-AT-OPEN (`logAuthority: 'adopt'`): a - * fresh brain with no stored artifact runs the oracle at open, backfills - * curable divergences, and flips to log authority on green — so a - * default-config brain opens ALREADY log-authoritative and durable-at-ack. - * The first two pins hold that default and its explicit opt-out - * (`logAuthority: 'defer'`, the pre-10 tree behavior). Every test below - * them that exercises the ORACLE or the EXPLICIT flip opens its brain with - * `'defer'` — otherwise the open-time adoption would have pre-flipped the - * brain and pre-cured the very divergences under test. - * - * KNOWN GAPS PINNED WITH `.fails` (real findings, not test bugs — see the - * comments on each): a fresh brain is NOT log-complete by construction - * today, because the VFS root is written at init as a baseline - * (generation-less) write that never gets a fact, so the oracle reports it - * as a `pre-log-record`. The open-time adoption (and adoptLogAuthority()) - * CURES this by baseline backfill — a re-commit, not construction — so the - * by-construction pin stays `.fails` on a deferred brain. Tests that need - * a green oracle on a deferred brain perform that backfill explicitly (an - * identity update of the root as the FINAL write — final, because - * derived-index maintenance rewrites canonical noun records outside - * generations, so an earlier fact's after-image goes stale; see the module - * tail comment on `backfillBaseline`). - */ -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/index.js' -import type { OracleReport } from '../../src/db/logAuthority.js' - -/** The VFS root — created at init by a baseline (generation-less) write. */ -const VFS_ROOT = '00000000-0000-0000-0000-000000000000' -const AUTHORITY_ARTIFACT = '_system/log-authority.json' - -/** White-box view of the internals this suite instruments (read-only spies - * plus the sanctioned direct-storage writes for aging/drifting a brain). */ -type BrainInternals = { - generationStore: { - getFactLog(): { ensureSynced(): Promise } | null - logDurability: 'deferred' | 'at-ack' - } - storage: { - readRawObject(path: string): Promise - saveNoun(n: unknown): Promise - saveNounMetadata(id: string, m: Record): Promise - getNounMetadata(id: string): Promise | null> - writeNounRaw(id: string, r: { metadata: null; vector: null }): Promise - } -} - -const internals = (brain: Brainy): BrainInternals => - brain as unknown as BrainInternals - -/** Count calls to the fact log's ensureSynced without changing behavior. */ -function spyEnsureSynced(brain: Brainy): { calls: () => number } { - const factLog = internals(brain).generationStore.getFactLog() - expect(factLog, 'filesystem storage hosts a fact log').not.toBeNull() - let calls = 0 - const original = factLog!.ensureSynced.bind(factLog) - factLog!.ensureSynced = async () => { - calls++ - return original() - } - return { calls: () => calls } -} - -/** - * The minimal baseline backfill: an identity update of the VFS root, so the - * one canonical record the log never saw (the init-time baseline write) gets - * a fact carrying its current state. MUST be the final write of the setup — - * derived-index maintenance (HNSW/enumeration denormalization) rewrites the - * root's canonical noun record outside any generation, so a root fact taken - * before later writes digests stale and reports `state-differs`. - */ -async function backfillBaseline(brain: Brainy): Promise { - const root = await brain.get(VFS_ROOT) - expect(root, 'the VFS root exists on a fresh brain').toBeTruthy() - await brain.update({ id: VFS_ROOT, metadata: root!.metadata }) -} - -/** Seed a brain with the standard write mix: 2 adds, an update, a remove. */ -async function seedWrites(brain: Brainy): Promise<{ kept: string; removed: string }> { - const kept = await brain.add({ data: 'alpha document', type: 'document', metadata: { n: 1 } }) - const removed = await brain.add({ data: 'beta document', type: 'document', metadata: { n: 2 } }) - await brain.update({ id: kept, metadata: { n: 10 } }) - await brain.remove(removed) - return { kept, removed } -} - -describe('log authority — the switch, the oracle, the guarded flip', () => { - const dirs: string[] = [] - const brains: Brainy[] = [] - - /** - * Open a brain over `dir`. Omit `logAuthority` to exercise the FLEET - * DEFAULT (adopt-at-open); pass `'defer'` for the tests that need a - * tree-authoritative brain so the oracle/explicit-flip path is actually - * the thing under test (the default would pre-flip and pre-backfill). - */ - const openBrain = async ( - dir?: string, - logAuthority?: 'adopt' | 'defer' - ): Promise<{ brain: Brainy; dir: string }> => { - const d = dir ?? mkdtempSync(join(tmpdir(), 'brainy-log-authority-')) - if (!dir) dirs.push(d) - const brain = new Brainy({ - storage: { type: 'filesystem', path: d }, - requireSubtype: false, - silent: true, - dimensions: 384, - ...(logAuthority ? { logAuthority } : {}) - }) - brains.push(brain) - await brain.init() - return { brain, dir: d } - } - - afterEach(async () => { - for (const b of brains.splice(0)) { - await (b as unknown as { close?: () => Promise }).close?.().catch(() => {}) - } - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) - }) - - // THE RULED DEFAULT (10.0.0): with no config and no stored artifact, a - // fresh brain ADOPTS log authority at open — oracle green (the open-time - // baseline backfill cures the generation-0 VFS root), artifact on disk, - // durable-at-ack live from the first write. - it('DEFAULT IS ADOPT-AT-OPEN: a fresh brain opens already log-authoritative — artifact stored, plain acks await the covering log fsync', async () => { - const { brain } = await openBrain() // no logAuthority config = the fleet default - - const authority = brain.logAuthority() - expect(authority.authority).toBe('log') - expect(typeof authority.flippedAt).toBe('number') - expect(authority.oracle, 'the open-time flip records its green oracle summary').toBeDefined() - - const artifact = (await internals(brain) - .storage.readRawObject(AUTHORITY_ARTIFACT) - .catch(() => null)) as { authority?: string } | null - expect(artifact, 'the adoption wrote the switch artifact').not.toBeNull() - expect(artifact!.authority).toBe('log') - - // The MODE assertion (not a timing one): in log authority a single-op - // ack awaits the log's covering-fsync path. - expect(internals(brain).generationStore.logDurability).toBe('at-ack') - const spy = spyEnsureSynced(brain) - await brain.add({ data: 'log mode write', type: 'document', metadata: { n: 1 } }) - expect(spy.calls(), 'adopted default: add() awaits the covering fsync').toBeGreaterThanOrEqual(1) - }) - - // THE EXPLICIT OPT-OUT: `logAuthority: 'defer'` is the pre-10 behavior — - // tree authority, NO artifact written (a deferred posture is config, not - // stored state), and single-op acks never await a log fsync. - it("OPT-OUT ('defer'): the brain stays tree-authoritative, stores no artifact, and plain acks never await a log fsync", async () => { - const { brain } = await openBrain(undefined, 'defer') - - expect(brain.logAuthority().authority).toBe('tree') - expect(brain.logAuthority().flippedAt).toBeUndefined() - - const artifact = await internals(brain) - .storage.readRawObject(AUTHORITY_ARTIFACT) - .catch(() => null) - expect(artifact, "'defer' writes no switch artifact").toBeNull() - - // The MODE assertion (not a timing one): in tree authority a single-op - // ack must never call the log's covering-fsync path. - const spy = spyEnsureSynced(brain) - await brain.add({ data: 'tree mode write', type: 'document', metadata: { n: 1 } }) - expect(spy.calls(), 'tree mode: add() does not call ensureSynced').toBe(0) - expect(internals(brain).generationStore.logDurability).toBe('deferred') - }) - - // KNOWN GAP (marked .fails — remove the marker when fixed in src): the - // intended contract is that a fresh brain is log-complete by construction, - // because every write dual-writes a fact. Today the VFS root - // (00000000-0000-0000-0000-000000000000) is created at init by a baseline - // write with NO generation and NO fact, yet it is enumerated by the - // canonical walk — so the oracle on a fresh brain is red with exactly one - // `pre-log-record` mismatch on the root. The adopt-at-open default (and - // adoptLogAuthority()) CURES this by baseline backfill — a re-commit, - // which is why this pin opens with 'defer': it holds the BY-CONSTRUCTION - // intent, which the backfill masks but does not deliver. - it.fails('ORACLE INTENT: a fresh brain is log-complete by construction — verdict green with zero mismatches', async () => { - const { brain } = await openBrain(undefined, 'defer') - await seedWrites(brain) - await brain.flush() - - const report = await brain.verifyLogAuthority() - expect(report.verdict).toBe('green') - expect(report.mismatches).toEqual([]) - }) - - it('a fresh, un-backfilled brain diverges ONLY on the init-time baseline record — every user write is exactly reproduced', async () => { - // 'defer': the adopt-at-open default would have backfilled the baseline - // already — this pin needs the brain genuinely un-backfilled. - const { brain } = await openBrain(undefined, 'defer') - await seedWrites(brain) - await brain.flush() - - const report = await brain.verifyLogAuthority() - // Tolerant pin (stays true after the baseline gap is fixed in src): - // whatever the verdict, no USER record may ever diverge — the only - // admissible mismatch is the init-time baseline root, as pre-log-record. - expect( - report.mismatches.every( - (m) => m.id === VFS_ROOT && m.reason === 'pre-log-record' && m.kind === 'noun' - ), - 'the only divergence on a fresh brain is the baseline root record' - ).toBe(true) - expect(report.matched).toBe(report.nounsChecked - report.mismatches.length) - expect(report.mismatchListTruncated).toBe(false) - }) - - it('THE ORACLE GOES GREEN on a log-complete brain: adds + update + remove, every canonical row exactly reproduced', async () => { - // 'defer' + manual backfill: the exact-count pins below (5 generations) - // depend on the log holding ONLY this test's writes — the adopt-at-open - // default would inject its own backfill generation at init. - const { brain } = await openBrain(undefined, 'defer') - await seedWrites(brain) - await backfillBaseline(brain) // final write — see the helper's contract - await brain.flush() - - const report = await brain.verifyLogAuthority() - expect(report.verdict).toBe('green') - expect(report.mismatches).toEqual([]) - expect(report.mismatchListTruncated).toBe(false) - // Live count: the kept document + the VFS root (the removed one is a - // tombstone in the log and absent from canonical — checked, not counted). - expect(report.nounsChecked).toBe(2) - expect(report.matched).toBe(2) - // 5 committed generations: add, add, update, remove, root backfill. - expect(report.generationsScanned).toBe(5) - }) - - it('THE ORACLE NAMES pre-log records: a canonical row no fact ever recorded reports pre-log-record, by id', async () => { - const { brain } = await openBrain(undefined, 'defer') - await seedWrites(brain) - await backfillBaseline(brain) - await brain.flush() - expect((await brain.verifyLogAuthority()).verdict, 'sanity: green before aging').toBe('green') - - // Simulate an aged brain: write one canonical record DIRECTLY at the - // storage layer (the write path never sees it, so no fact exists) — - // the pre-log shape: flat metadata, no _fmt stamp, 384-dim vector. - const legacyId = '00000000-0000-4000-8000-00000000a6ed' - const storage = internals(brain).storage - await storage.saveNoun({ - id: legacyId, - vector: new Array(384).fill(0.01), - connections: new Map(), - level: 0 - }) - await storage.saveNounMetadata(legacyId, { - noun: 'document', - confidence: 0.75, - createdAt: 1700000000000, - updatedAt: 1700000000000, - _rev: 1, - legacyField: 'legacy-value' - }) - - const report = await brain.verifyLogAuthority() - expect(report.verdict).toBe('red') - expect(report.mismatches).toHaveLength(1) - expect(report.mismatches[0]).toEqual({ - id: legacyId, - kind: 'noun', - reason: 'pre-log-record' - }) - }) - - it('THE FLIP REFUSES ON A LOG-AHEAD DIVERGENCE: the witness denies what the log claims — nothing written, nothing changed', async () => { - // Contract update (adoptLogAuthority's baseline backfill): curable - // divergences — pre-log records and witness drift — are re-committed - // and the flip proceeds; ONLY log-AHEAD divergences (the log claims - // state canonical denies) refuse, because no backfill can make the log - // un-claim a live row. This test stages exactly that incurable shape. - // 'defer': the brain must still be tree-authoritative (no artifact) so - // the refusal's nothing-written pins below have meaning. - const { brain } = await openBrain(undefined, 'defer') - const { kept } = await seedWrites(brain) - await backfillBaseline(brain) - await brain.flush() - - // The log says `kept` is live; its canonical record vanishes behind the - // write path's back (log-live-canonical-absent — the witness wins). - const storage = internals(brain).storage - await storage.writeNounRaw(kept, { metadata: null, vector: null }) - - let error: Error | null = null - try { - await brain.adoptLogAuthority() - } catch (err) { - error = err as Error - } - expect(error, 'the flip rejects on a log-ahead divergence').not.toBeNull() - expect(error!.message).toMatch(/witness denies/) - expect(error!.message).toMatch(/log-live-canonical-absent/) - - // Nothing changed: authority still tree, no artifact, deferred durability. - expect(brain.logAuthority().authority).toBe('tree') - const artifact = await storage.readRawObject(AUTHORITY_ARTIFACT).catch(() => null) - expect(artifact, 'a refused flip writes no artifact').toBeNull() - expect(internals(brain).generationStore.logDurability).toBe('deferred') - }) - - it('THE FLIP LANDS ON GREEN: the report is the receipt, the artifact is on disk, and durable-at-ack engages immediately', async () => { - // 'defer': this pin exercises the EXPLICIT flip — the adopt-at-open - // default would have landed it before the test began. - const { brain } = await openBrain(undefined, 'defer') - await seedWrites(brain) - await backfillBaseline(brain) - await brain.flush() - - const report: OracleReport = await brain.adoptLogAuthority() - expect(report.verdict).toBe('green') - - const authority = brain.logAuthority() - expect(authority.authority).toBe('log') - expect(typeof authority.flippedAt).toBe('number') - expect(authority.oracle).toBeDefined() - expect(authority.oracle!.nounsChecked).toBe(report.nounsChecked) - expect(authority.oracle!.generationsScanned).toBe(report.generationsScanned) - - const artifact = (await internals(brain) - .storage.readRawObject(AUTHORITY_ARTIFACT) - .catch(() => null)) as { authority?: string } | null - expect(artifact, 'the switch artifact exists on disk').not.toBeNull() - expect(artifact!.authority).toBe('log') - - // Durable-at-ack engaged in THIS session: the next single-op ack awaits - // a covering log fsync. - expect(internals(brain).generationStore.logDurability).toBe('at-ack') - const spy = spyEnsureSynced(brain) - await brain.add({ data: 'post-flip write', type: 'document', metadata: { n: 3 } }) - expect(spy.calls(), 'log mode: add() awaits the covering fsync').toBeGreaterThanOrEqual(1) - }) - - it('THE SWITCH SURVIVES REOPEN: authority restored at open with no re-verification, durable-at-ack active in the new session', async () => { - const { brain, dir } = await openBrain(undefined, 'defer') - await seedWrites(brain) - await backfillBaseline(brain) - await brain.flush() - await brain.adoptLogAuthority() - const flipReceipt = brain.logAuthority() - await (brain as unknown as { close: () => Promise }).close() - - // Reopen with 'defer' too: the restored authority below can then ONLY - // come from the stored artifact (a stored artifact always wins; had the - // default re-adopted, flippedAt/oracle would differ from the receipt). - const { brain: reopened } = await openBrain(dir, 'defer') - const restored = reopened.logAuthority() - expect(restored.authority).toBe('log') - // No re-verification happened at open: the restored record IS the stored - // flip receipt, oracle summary and timestamp intact. - expect(restored.flippedAt).toBe(flipReceipt.flippedAt) - expect(restored.oracle).toEqual(flipReceipt.oracle) - - // Mode restored at open: an ack in the new session awaits the log fsync. - expect(internals(reopened).generationStore.logDurability).toBe('at-ack') - const spy = spyEnsureSynced(reopened) - await reopened.add({ data: 'new session write', type: 'document', metadata: { n: 4 } }) - expect(spy.calls(), 'reopened log mode: add() awaits the covering fsync').toBeGreaterThanOrEqual(1) - }) - - it('STATE-DIFFERS: canonical drift the write path never saw is named, by id', async () => { - const { brain } = await openBrain(undefined, 'defer') - const { kept } = await seedWrites(brain) - await backfillBaseline(brain) - await brain.flush() - expect((await brain.verifyLogAuthority()).verdict, 'sanity: green before drift').toBe('green') - - // Drift one canonical metadata record DIRECTLY at the storage layer — - // the log never hears about it. This is the witness-drift case the - // oracle exists to catch. - const storage = internals(brain).storage - const current = await storage.getNounMetadata(kept) - expect(current, 'the seeded record has stored metadata').toBeTruthy() - await storage.saveNounMetadata(kept, { ...current!, driftedByTest: true }) - - const report = await brain.verifyLogAuthority() - expect(report.verdict).toBe('red') - expect(report.mismatches).toHaveLength(1) - expect(report.mismatches[0]).toEqual({ - id: kept, - kind: 'noun', - reason: 'state-differs' - }) - }) -}) diff --git a/tests/integration/metadata-online-rebuild.test.ts b/tests/integration/metadata-online-rebuild.test.ts deleted file mode 100644 index bf7cebdb..00000000 --- a/tests/integration/metadata-online-rebuild.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * @module tests/integration/metadata-online-rebuild - * @description THE ONLINE JS METADATA REBUILD (B3 Deliverable 3) pins. - * `MetadataIndexManager.rebuild()` used to be clear-then-walk — reads went - * dark for the duration. `repairIndex({ rebuild: ['metadata'] })` now builds - * a fresh replacement index BESIDE the live one (walk canonical + mirror - * every live write via `beginShadow`/`endShadow` + a bounded fact-log fold), - * then atomically swaps the brain's reference — `find()` never observes a - * half-built index, and a write landing DURING the build is never lost. - */ -process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' - -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, VerbType } from '../../src/types/graphTypes.js' -import type { MetadataIndexManager } from '../../src/utils/metadataIndex.js' - -const dirs: string[] = [] -const brains: Brainy[] = [] -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -function metadataIndexOf(brain: Brainy): MetadataIndexManager { - return (brain as unknown as { metadataIndex: MetadataIndexManager }).metadataIndex -} - -async function openBrain(): Promise<{ brain: Brainy; dir: string }> { - const dir = mkdtempSync(join(tmpdir(), 'brainy-online-rebuild-')) - dirs.push(dir) - const brain = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - silent: true, - persistence: { policy: 'manual' }, - logAuthority: 'adopt' - }) - await brain.init() - brains.push(brain) - return { brain, dir } -} - -describe('repairIndex({ rebuild: ["metadata"] }) — the online build-beside rebuild', () => { - it( - 'a find() polled throughout the rebuild of a 2k-noun store never returns fewer rows than ' + - 'before the build started, and a write landing DURING the build is never lost', - async () => { - const { brain, dir } = await openBrain() - void dir - - const N = 2000 - const ids: string[] = [] - for (let i = 0; i < N; i++) { - ids.push( - await brain.add({ - data: `entity ${i}`, - type: NounType.Person, - metadata: { status: i % 2 === 0 ? 'active' : 'inactive' } - }) - ) - } - for (let i = 0; i < 20; i++) { - await brain.relate({ - from: ids[i], to: ids[i + 1], type: VerbType.WorksWith, metadata: { tag: 'orig' } - }) - } - await brain.flush() - - const baseline = await brain.find({ where: { status: 'active' }, limit: 10000 }) - expect(baseline.length).toBe(N / 2) - - // Kick off the online rebuild WITHOUT awaiting — poll reads and - // perform a live write concurrently with it. - const repairPromise = brain.repairIndex({ rebuild: ['metadata'] }) - - let minObserved = Infinity - let polls = 0 - const pollPromise = (async () => { - // Poll until the rebuild settles — bounded so a slow CI box can't - // spin forever, generous enough to actually overlap the walk. - while (polls < 200) { - const rows = await brain.find({ where: { status: 'active' }, limit: 10000 }) - minObserved = Math.min(minObserved, rows.length) - polls++ - await new Promise((resolve) => setTimeout(resolve, 1)) - } - })() - - const newId = await brain.add({ - data: 'added during the rebuild', - type: NounType.Person, - metadata: { status: 'active' } - }) - const newRelId = await brain.relate({ - from: newId, to: ids[0], type: VerbType.WorksWith, metadata: { tag: 'during-build' } - }) - - const [report] = await Promise.all([repairPromise, pollPromise]) - - // THE PIN: never fewer rows than the pre-build baseline, at any polled - // instant — reads served the OLD (fully-populated) manager throughout. - expect(polls).toBeGreaterThan(0) - expect(minObserved).toBeGreaterThanOrEqual(baseline.length) - - // The repair report still accounts for the family (same receipt shape - // regardless of which rebuild mechanism actually ran underneath). - const metadataFamily = report.families.find((f) => f.family === 'provider:metadata') - expect(metadataFamily?.checked).toBe(true) - expect(metadataFamily?.rebuilt).toBe(true) - - // Post-swap correctness: the live write during the build was never - // lost (the beginShadow mirror + post-walk fold caught it). - const afterActive = await brain.find({ where: { status: 'active' }, limit: 10000 }) - expect(afterActive.length).toBe(baseline.length + 1) - expect(afterActive.some((r) => r.id === newId)).toBe(true) - - const index = metadataIndexOf(brain) - expect(await index.getIds('tag', 'during-build')).toEqual([newRelId]) - expect((await index.getIds('tag', 'orig')).length).toBe(20) - - // The swap stamped the watermark — a reopen adopts, zero rebuild. - await brain.close() - brains.length = 0 // already closed above; afterEach must not double-close - const reopened = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - silent: true, - persistence: { policy: 'manual' }, - logAuthority: 'adopt' - }) - await reopened.init() - brains.push(reopened) - const reopenedIndex = metadataIndexOf(reopened) - expect(reopenedIndex.watermarkVerdict()).toBe('adopt') - const reopenedActive = await reopened.find({ where: { status: 'active' }, limit: 10000 }) - expect(reopenedActive.length).toBe(afterActive.length) - }, - 60000 - ) - - it('repairIndex({ rebuild: ["metadata"] }) on an empty store is a trivial no-op walk', async () => { - const { brain } = await openBrain() - const report = await brain.repairIndex({ rebuild: ['metadata'] }) - const metadataFamily = report.families.find((f) => f.family === 'provider:metadata') - expect(metadataFamily?.checked).toBe(true) - expect(await brain.getNounCount()).toBe(0) - }) - - it('two consecutive online rebuilds both leave the index correct (idempotent)', async () => { - const { brain } = await openBrain() - const a = await brain.add({ data: 'a', type: NounType.Person, metadata: { status: 'active' } }) - await brain.add({ data: 'b', type: NounType.Person, metadata: { status: 'inactive' } }) - await brain.flush() - - await brain.repairIndex({ rebuild: ['metadata'] }) - const first = await brain.find({ where: { status: 'active' } }) - expect(first.map((r) => r.id)).toEqual([a]) - - await brain.repairIndex({ rebuild: ['metadata'] }) - const second = await brain.find({ where: { status: 'active' } }) - expect(second.map((r) => r.id)).toEqual([a]) - }) -}) diff --git a/tests/integration/metadata-vector-exclusion.test.ts b/tests/integration/metadata-vector-exclusion.test.ts index 1943b215..9e11f9dc 100644 --- a/tests/integration/metadata-vector-exclusion.test.ts +++ b/tests/integration/metadata-vector-exclusion.test.ts @@ -26,7 +26,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../src/brainy.js' import { NounType } from '../../src/types/graphTypes.js' import { existsSync, rmSync } from 'fs' -import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../src/errors/brainyError.js' describe('Metadata Vector Exclusion Fix', () => { let brainy: Brainy @@ -156,57 +155,29 @@ describe('Metadata Vector Exclusion Fix', () => { expect(results[0].entity.metadata?.name).toBe('Bob') }) - it('should REFUSE an array over the indexing bound, by name', async () => { - // A large array (not a vector, just bulk data). This used to be SKIPPED in - // silence at a bound of 10 — the field simply vanished from the index and - // the row dropped out of every `where` on it, indistinguishably from "no - // row matches". The bound is now MAX_INDEXED_ARRAY_LENGTH and it REFUSES. - const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1 - const largeArray = Array.from({ length: overTheBound }, (_, i) => `item${i}`) + it('should skip indexing large arrays (>10 elements)', async () => { + // Add entity with a large array (not a vector, just bulk data). + const largeArray = Array.from({ length: 100 }, (_, i) => `item${i}`) - const err = await brainy - .add({ - type: NounType.Document, - data: 'Doc with large array', - metadata: { - name: 'Doc with large array', - items: largeArray - } - }) - .catch((e: any) => e) + await brainy.add({ + type: NounType.Document, + data: 'Doc with large array', + metadata: { + name: 'Doc with large array', + items: largeArray + } + }) - expect(err).toBeInstanceOf(MetadataArrayTooLargeError) - expect(err.field).toBe('items') - expect(err.length).toBe(overTheBound) - expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH) - - // Nothing was indexed from the refused write — no 'items' field, and above - // all no per-element numeric fields (the original explosion class). + // Large arrays (> 10 elements) are deliberately skipped to avoid indexing + // bulk/vector-like payloads: 'items' must NOT appear, and the 100 elements + // must NOT have produced 100 indexed fields. const fields = await brainy.getAvailableFields() expect(fields).not.toContain('items') const numericFields = fields.filter(f => /(^|\.)\d+$/.test(f)) expect(numericFields).toEqual([]) - }) - it('should index an array UP TO the bound — the old limit of 10 was the bug', async () => { - await brainy.add({ - type: NounType.Document, - data: 'Doc with a long-but-legitimate tag list', - metadata: { - name: 'Doc with many tags', - items: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`) - } - }) - - const fields = await brainy.getAvailableFields() - // The field IS indexed now, and still without per-element numeric fields. - expect(fields).toContain('items') - expect(fields.filter(f => /(^|\.)\d+$/.test(f))).toEqual([]) - - // And the eleventh element — the one the old bound silently dropped the - // whole field for — really is searchable. - const hits = await brainy.find({ where: { items: 'item10' } }) - expect(hits.length).toBeGreaterThan(0) + // The scalar 'name' field IS indexed. + expect(fields).toContain('name') }) it('should preserve HNSW vector search functionality', async () => { diff --git a/tests/integration/migration.test.ts b/tests/integration/migration.test.ts index f6c3741b..daa5fb2d 100644 --- a/tests/integration/migration.test.ts +++ b/tests/integration/migration.test.ts @@ -20,16 +20,6 @@ import { MigrationRunner, MIGRATIONS } from '../../src/migration/index.js' import type { Migration } from '../../src/migration/index.js' import { NounType, VerbType } from '../../src/types/graphTypes.js' -// THE VIEW CONTRACT (field-addressing law): transforms receive engine fields -// top-level and the USER's bag nested under `metadata` — user-field changes -// go inside the bag. These two helpers keep the one-liner migrations tidy. -const bagOf = (m: Record): Record => - m.metadata as Record -const withBag = ( - m: Record, - patch: Record -): Record => ({ ...m, metadata: { ...bagOf(m), ...patch } }) - // Helper to temporarily inject migrations into the MIGRATIONS array function withMigrations(migrations: Migration[], fn: () => Promise): Promise { const original = MIGRATIONS.splice(0, MIGRATIONS.length) @@ -88,11 +78,9 @@ describe('Migration System', () => { description: 'Add version field to entities with status', applies: 'nouns', transform: (m) => { - // Only transform entities that have our specific 'status' USER field - // (user fields live in the nested bag — the view contract). - const bag = m.metadata as Record - if ('status' in bag && !('version' in bag)) { - return { ...m, metadata: { ...bag, version: 1 } } + // Only transform entities that have our specific 'status' field + if ('status' in m && !('version' in m)) { + return { ...m, version: 1 } } return null } @@ -106,8 +94,7 @@ describe('Migration System', () => { // All 3 entities have 'status' metadata expect(p.affectedEntities).toBeGreaterThanOrEqual(3) expect(p.sampleChanges.length).toBeGreaterThan(0) - // Samples carry the VIEW shape: user fields inside `.metadata`. - expect(p.sampleChanges[0].after.metadata.version).toBe(1) + expect(p.sampleChanges[0].after.version).toBe(1) // Verify no data was modified (dry-run) const entity = await brain.get(id1) @@ -124,10 +111,9 @@ describe('Migration System', () => { description: 'Rename state to status', applies: 'nouns', transform: (m) => { - const bag = m.metadata as Record - if ('state' in bag) { - const { state, ...rest } = bag - return { ...m, metadata: { ...rest, status: state } } + if ('state' in m) { + const { state, ...rest } = m + return { ...rest, status: state } } return null } @@ -138,12 +124,11 @@ describe('Migration System', () => { const p = preview as any expect(p.sampleChanges.length).toBeGreaterThanOrEqual(1) - // Find the sample for our entity (it has the 'state' USER field — - // samples carry the VIEW shape, user fields inside `.metadata`) - const sample = p.sampleChanges.find((s: any) => s.before.metadata.state === 'draft') + // Find the sample for our entity (it has the 'state' field) + const sample = p.sampleChanges.find((s: any) => s.before.state === 'draft') expect(sample).toBeDefined() - expect(sample.after.metadata.status).toBe('draft') - expect(sample.after.metadata.state).toBeUndefined() + expect(sample.after.status).toBe('draft') + expect(sample.after.state).toBeUndefined() }) }) }) @@ -164,8 +149,8 @@ describe('Migration System', () => { description: 'Add migrated flag to entities with priority', applies: 'nouns', transform: (m) => { - if ('priority' in bagOf(m) && !('migrated' in bagOf(m))) { - return withBag(m, { migrated: true }) + if ('priority' in m && !('migrated' in m)) { + return { ...m, migrated: true } } return null } @@ -194,8 +179,8 @@ describe('Migration System', () => { description: 'Uppercase status field only when present', applies: 'nouns', transform: (m) => { - if (typeof bagOf(m).status === 'string') { - return withBag(m, { status: (bagOf(m).status as string).toUpperCase() }) + if (typeof m.status === 'string') { + return { ...m, status: (m.status as string).toUpperCase() } } return null } @@ -218,7 +203,7 @@ describe('Migration System', () => { version: '1.0.0', description: 'Double count', applies: 'nouns', - transform: (m) => typeof bagOf(m).count === 'number' ? withBag(m, { count: (bagOf(m).count as number) * 2 }) : null + transform: (m) => typeof m.count === 'number' ? { ...m, count: (m.count as number) * 2 } : null } const migration2: Migration = { @@ -226,7 +211,7 @@ describe('Migration System', () => { version: '1.1.0', description: 'Add 10 to count', applies: 'nouns', - transform: (m) => typeof bagOf(m).count === 'number' ? withBag(m, { count: (bagOf(m).count as number) + 10 }) : null + transform: (m) => typeof m.count === 'number' ? { ...m, count: (m.count as number) + 10 } : null } await withMigrations([migration1, migration2], async () => { @@ -244,7 +229,7 @@ describe('Migration System', () => { version: '1.0.0', description: 'Increment v', applies: 'nouns', - transform: (m) => typeof bagOf(m).v === 'number' ? withBag(m, { v: (bagOf(m).v as number) + 1 }) : null + transform: (m) => typeof m.v === 'number' ? { ...m, v: (m.v as number) + 1 } : null } await withMigrations([migration], async () => { @@ -281,7 +266,7 @@ describe('Migration System', () => { version: '2.0.0', description: 'Add y field to entities with x', applies: 'nouns', - transform: (m) => 'x' in bagOf(m) && !('y' in bagOf(m)) ? withBag(m, { y: 2 }) : null + transform: (m) => 'x' in m && !('y' in m) ? { ...m, y: 2 } : null } await withMigrations([migration], async () => { @@ -305,8 +290,8 @@ describe('Migration System', () => { description: 'Replace original with migrated', applies: 'nouns', transform: (m) => { - if (bagOf(m).original === true) { - return withBag(m, { original: false, migrated: true }) + if (m.original === true) { + return { ...m, original: false, migrated: true } } return null } @@ -338,7 +323,7 @@ describe('Migration System', () => { version: '4.0.0', description: 'Add field', applies: 'nouns', - transform: (m) => 'q' in bagOf(m) && !('r' in bagOf(m)) ? withBag(m, { r: 2 }) : null + transform: (m) => 'q' in m && !('r' in m) ? { ...m, r: 2 } : null } await withMigrations([migration], async () => { @@ -399,7 +384,7 @@ describe('Migration System', () => { version: '1.0.0', description: 'Auto migrate test', applies: 'nouns', - transform: (m) => 'legacy' in bagOf(m) ? withBag(m, { legacy: false, upgraded: true }) : null + transform: (m) => 'legacy' in m ? { ...m, legacy: false, upgraded: true } : null } await withMigrations([migration], async () => { @@ -425,7 +410,7 @@ describe('Migration System', () => { version: '1.0.0', description: 'Add y to entities with x', applies: 'nouns', - transform: (m) => 'x' in bagOf(m) ? withBag(m, { y: true }) : null + transform: (m) => 'x' in m ? { ...m, y: true } : null } const progressCalls: any[] = [] @@ -459,7 +444,7 @@ describe('Migration System', () => { version: '1.0.0', description: 'Increment v on entities that have it', applies: 'nouns', - transform: (m) => typeof bagOf(m).v === 'number' ? withBag(m, { v: (bagOf(m).v as number) + 1 }) : null + transform: (m) => typeof m.v === 'number' ? { ...m, v: (m.v as number) + 1 } : null } await withMigrations([migration], async () => { @@ -492,10 +477,9 @@ describe('Migration System', () => { description: 'Rename strength to intensity', applies: 'verbs', transform: (m) => { - const bag = bagOf(m) - if ('strength' in bag) { - const { strength, ...rest } = bag - return { ...m, metadata: { ...rest, intensity: strength } } + if ('strength' in m) { + const { strength, ...rest } = m + return { ...rest, intensity: strength } } return null } @@ -523,7 +507,7 @@ describe('Migration System', () => { version: '1.0.0', description: 'Update tag from old to new', applies: 'both', - transform: (m) => bagOf(m).tag === 'old' ? withBag(m, { tag: 'new' }) : null + transform: (m) => m.tag === 'old' ? { ...m, tag: 'new' } : null } await withMigrations([migration], async () => { @@ -593,11 +577,11 @@ describe('Migration System', () => { description: 'Transform that throws on non-number values', applies: 'nouns', transform: (m) => { - if ('value' in bagOf(m)) { - if (typeof bagOf(m).value !== 'number') { + if ('value' in m) { + if (typeof m.value !== 'number') { throw new Error('value must be a number') } - return withBag(m, { value: (bagOf(m).value as number) * 10 }) + return { ...m, value: (m.value as number) * 10 } } return null } @@ -631,7 +615,7 @@ describe('Migration System', () => { description: 'Always throws', applies: 'nouns', transform: (m) => { - if ('boom' in bagOf(m)) { + if ('boom' in m) { throw new Error('deliberate failure') } return null diff --git a/tests/integration/multi-process-safety.test.ts b/tests/integration/multi-process-safety.test.ts index dd1b8901..592d7969 100644 --- a/tests/integration/multi-process-safety.test.ts +++ b/tests/integration/multi-process-safety.test.ts @@ -107,11 +107,7 @@ describe('Multi-process safety + read-only mode', () => { const blocked = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) await expect(blocked.init()).rejects.toThrow(/another writer holds/i) - // A rejected init() still registered `blocked` in Brainy's global - // instance registry (the constructor does that unconditionally) — close() - // is safe to call even though init() never completed, and is what - // deregisters it (and, once idle, the process-level shutdown hooks). - await blocked.close().catch(() => {}) + // Don't track `blocked` for afterEach cleanup since init failed. }) it('takes over a STALE foreign lock (dead PID + old heartbeat) and claims atomically', async () => { @@ -155,7 +151,6 @@ describe('Multi-process safety + read-only mode', () => { const err: any = await blocked.init().catch((e) => e) expect(err.code).toBe('BRAINY_WRITER_LOCKED') expect(err.lockInfo?.pid).toBe(otherPid) - await blocked.close().catch(() => {}) }) it('release drains an in-flight heartbeat — no phantom lock re-created after unlink', async () => { diff --git a/tests/integration/null-metadata-delete.test.ts b/tests/integration/null-metadata-delete.test.ts deleted file mode 100644 index 46b69030..00000000 --- a/tests/integration/null-metadata-delete.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @module tests/integration/null-metadata-delete - * @description The null-metadata delete skip is CLOSED. remove() used to - * guard its index legs with `if (metadata)` — a row whose canonical - * metadata was unreadable at delete time (torn, or a leg lost to an old - * defect) kept its postings FOREVER, silently. Now: the JS index gets an - * id-keyed cleanup (deleted bitmap + id mapper), the id-keyed native - * contract is used when a provider offers it, and the one remaining - * skip-shape (native without the contract) is narrated and tracked, never - * silent. Pinned: a metadata-less row with live postings deletes cleanly - * and leaves the query universe. - */ -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/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -type RawBox = { - storage: { - readNounRaw(id: string): Promise<{ metadata: unknown; vector: unknown }> - writeNounRaw(id: string, r: { metadata: unknown; vector: unknown }): Promise - } -} - -const dirs: string[] = [] -const brains: Brainy[] = [] -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -describe('null-metadata delete', () => { - it('a row whose metadata leg is gone still deletes — id-keyed cleanup, no silent skip, gone from the query universe', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-nullmeta-del-')) - dirs.push(dir) - const brain = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false, silent: true }) - await brain.init() - brains.push(brain) - - const keep = await brain.add({ data: 'survivor', type: NounType.Document, metadata: { team: 'atlas' } }) - const victim = await brain.add({ data: 'doomed', type: NounType.Document, metadata: { team: 'atlas' } }) - await brain.flush() - expect((await brain.find({ where: { team: 'atlas' } })).length).toBe(2) - - // Manufacture the shape: the victim's metadata leg vanishes behind the - // engine's back (vector leg + postings stay live). - const storage = (brain as unknown as RawBox).storage - const raw = await storage.readNounRaw(victim) - await storage.writeNounRaw(victim, { metadata: null, vector: raw.vector }) - - // THE PIN: the delete neither throws nor silently strands postings. - await brain.remove(victim) - await brain.flush() - - const after = await brain.find({ where: { team: 'atlas' } }) - expect(after.length, 'victim left the query universe; survivor serves').toBe(1) - expect(after[0].id).toBe(keep) - expect(await brain.get(victim)).toBeNull() - }, 120000) -}) 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 deleted file mode 100644 index a46ad6a5..00000000 --- a/tests/integration/open-does-not-wait-for-a-rebuilding-provider.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * @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 deleted file mode 100644 index 95aba9f1..00000000 --- a/tests/integration/open-narration.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * @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/orderby-sort-bug.test.ts b/tests/integration/orderby-sort-bug.test.ts index aeb7ff66..9c28b1c9 100644 --- a/tests/integration/orderby-sort-bug.test.ts +++ b/tests/integration/orderby-sort-bug.test.ts @@ -56,7 +56,7 @@ describe('find({ orderBy }) sort bug regression', () => { const results = await brain.find({ type: NounType.Concept, - orderBy: 'system.createdAt', + orderBy: 'createdAt', order: 'desc', limit: 1 }) @@ -76,7 +76,7 @@ describe('find({ orderBy }) sort bug regression', () => { const results = await brain.find({ type: NounType.Concept, - orderBy: 'system.createdAt', + orderBy: 'createdAt', order: 'asc', limit: 1 }) @@ -94,7 +94,7 @@ describe('find({ orderBy }) sort bug regression', () => { const results = await brain.find({ type: NounType.Concept, - orderBy: 'system.createdAt', + orderBy: 'createdAt', order: 'desc' }) @@ -115,7 +115,7 @@ describe('find({ orderBy }) sort bug regression', () => { const results = await brain.find({ type: NounType.Concept, - orderBy: 'system.updatedAt', + orderBy: 'updatedAt', order: 'desc', limit: 1 }) @@ -136,7 +136,7 @@ describe('find({ orderBy }) sort bug regression', () => { const id3 = await brain.add({ data: 'third', type: NounType.Concept }) const results = await brain.find({ - orderBy: 'system.createdAt', + orderBy: 'createdAt', order: 'desc', limit: 2 }) @@ -215,6 +215,7 @@ describe('resolveEntityField helper', () => { 'id', 'vector', 'connections', + 'level', 'type', 'confidence', 'weight', @@ -227,9 +228,5 @@ describe('resolveEntityField helper', () => { for (const field of expected) { expect(STANDARD_ENTITY_FIELDS.has(field)).toBe(true) } - // `level` is deliberately NOT resolvable: it is HNSW plumbing, and listing - // it here shadowed user metadata named `level` in every by-name read - // (the reserved-name shadow bug). Plumbing stays out of the resolver. - expect(STANDARD_ENTITY_FIELDS.has('level')).toBe(false) }) }) diff --git a/tests/integration/pending-embed-checkpoint.test.ts b/tests/integration/pending-embed-checkpoint.test.ts deleted file mode 100644 index 1cf3ec2c..00000000 --- a/tests/integration/pending-embed-checkpoint.test.ts +++ /dev/null @@ -1,547 +0,0 @@ -/** - * @module tests/integration/pending-embed-checkpoint - * @description THE PENDING-EMBED CHECKPOINT — the bound that engages on the - * brains that need it. - * - * 10.4.9 bounded the open-path `recover-pending-embeds` fold with a LOW-WATER - * MARK: the log head at which the pending set last drained to EMPTY. That mark - * carries no set, so it can only be written when the set is empty — and a brain - * holding even ONE id that never lands (an embed that keeps failing, a worker - * that never gets to it, a row reaped in memory only and re-folded every open) - * never drains, therefore never writes a mark, therefore re-reads its WHOLE - * fact log on every single open. The bound was absent from exactly the brains - * whose fold is expensive: a silent scaling defect. - * - * The cure is a CHECKPOINT of the pending set — - * `_system/pending_embeds_checkpoint.json` = `{ generation, pending, writtenAt }`, - * meaning "as of durable generation G the pending set was exactly this list". - * Open seeds the set from `pending` and scans only from `G + 1`, so the fold is - * O(facts since G) whether or not the set ever drains. - * - * What this suite pins: - * 1. A brain with one permanently-stuck pending id, closed cleanly and - * reopened, scans ONLY the facts after the checkpoint — asserted from the - * fold's own accounting, never a clock. The same fixture pins the DEFECT: - * no low-water mark exists on that brain, because it never drained. - * 2. A crash matrix in a REAL child process (SIGKILL, no close), for kills - * before a checkpoint write, after one with embeds landed and flushed - * after it, and after one with an UN-FLUSHED tail at the moment of death. - * The invariant in every row is differential: the checkpoint-bounded fold - * the reopened brain actually ran ≡ a full fold from generation 1 over the - * same recovered log. - * 3. A torn checkpoint falls back — loudly (the adapter's torn-record gauge - * plus the fold's own narration of which bound applied) and correctly. - * 4. The existing low-water pins keep passing unchanged - * (`pending-embed-low-water.test.ts`): the mark is still written and is - * still read, now as the FALLBACK bound beneath the checkpoint. - * - * The crash-recovery contract is untouched: the fold runs on the open's - * foreground, so a reopened brain has its markers re-armed when open() returns. - */ -import { describe, it, expect, afterEach } from 'vitest' -import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs' -import { spawn } from 'node:child_process' -import { gunzipSync } from 'node:zlib' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Brainy } from '../../src/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' -import { getTornRecordGauge } from '../../src/storage/tornRecordError.js' - -const CHECKPOINT_PATH = '_system/pending_embeds_checkpoint.json' -const LOWWATER_PATH = '_system/pending_embeds_lowwater.json' -const REPO_ROOT = process.cwd() -const TSX = join(REPO_ROOT, 'node_modules', '.bin', 'tsx') - -/** The fold's own accounting for the most recent open. */ -interface FoldReport { - bound: 'checkpoint' | 'low-water' | 'genesis' - fromGeneration: number - factsScanned: number - seeded: number - pending: number -} - -const roots: string[] = [] -const liveBrains: Brainy[] = [] - -function dir(): string { - const d = mkdtempSync(join(tmpdir(), 'brainy-embed-ckpt-')) - roots.push(d) - return d -} - -async function open(root: string, opts?: { blockWorker?: boolean }): Promise> { - const brain = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: root } - }) - // Blocking the worker BEFORE init() is how a "permanently stuck" pending id - // is built deterministically: the state under test is "an id the fold keeps - // re-arming and nothing ever disarms", and its production causes (a failing - // embedder, a wedged model, a data-less row) all reduce to exactly that. - if (opts?.blockWorker) (brain as unknown as { kickEmbedWorker: () => void }).kickEmbedWorker = () => {} - await brain.init() - liveBrains.push(brain) - return brain -} - -function foldReport(brain: Brainy): FoldReport { - const report = (brain as unknown as { _pendingEmbedFoldReport: FoldReport | null }) - ._pendingEmbedFoldReport - if (report === null) throw new Error('the open ran no pending-embed fold') - return report -} - -function pendingIds(brain: Brainy): string[] { - return [ - ...(brain as unknown as { _pendingEmbedIds: Set })._pendingEmbedIds - ].sort() -} - -/** Read an artifact straight off disk (the adapter gzips raw objects). */ -function readArtifact(root: string, path: string): Record | null { - const plain = join(root, ...path.split('/')) - const gz = `${plain}.gz` - if (existsSync(gz)) return JSON.parse(gunzipSync(readFileSync(gz)).toString('utf-8')) - if (existsSync(plain)) return JSON.parse(readFileSync(plain, 'utf-8')) - return null -} - -/** The on-disk path the adapter actually used for an artifact. */ -function artifactPath(root: string, path: string): string | null { - const plain = join(root, ...path.split('/')) - const gz = `${plain}.gz` - if (existsSync(gz)) return gz - if (existsSync(plain)) return plain - return null -} - -/** - * THE DIFFERENTIAL ORACLE: fold the log from generation 1 with exactly the - * engine's own rules. This is what the bounded fold must agree with, and its - * fact count is what the unbounded fold used to read at every open. - */ -async function fullFold(brain: Brainy): Promise<{ ids: string[]; facts: number }> { - const log = ( - brain as unknown as { generationStore: { getFactLog(): any } } - ).generationStore.getFactLog() - const pending = new Set() - let facts = 0 - const scan = log.scanFacts({ fromGeneration: 1 }) - for await (const batch of scan.batches()) { - for (const fact of batch.facts) { - facts++ - for (const record of fact.records ?? []) { - if (record.type === 'embed.pending') pending.add(record.id) - else if (record.type === 'embed.landed') pending.delete(record.id) - } - for (const op of fact.ops) { - if (op.kind === 'noun' && op.record === null) pending.delete(op.id) - } - } - } - return { ids: [...pending].sort(), facts } -} - -/** 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 arranges a store and then waits forever, so the - * parent can SIGKILL it. A real process death is the only honest way to pin - * "no close ran, no shutdown hook ran, RAM is gone". - * - * `detached` puts the child in its own process GROUP: tsx runs the script in a - * grandchild, and only a group-wide signal reaches the process holding the - * writer lock. - */ -function spawnArranger(root: string, body: string): Promise<{ - child: ReturnType - output: () => string -}> { - const scriptPath = join(root, 'arrange.mts') - writeFileSync(scriptPath, body) - const child = spawn(TSX, [scriptPath], { - cwd: REPO_ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - detached: true - }) - 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(`arranger never became READY:\n${out}`)), - 180_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(`arranger exited ${code}:\n${out}`)) - }) - }) -} - -/** Parse the `IDS:{...}` line an arranger prints — supplied ids are normalised - * to canonical uuids, and the markers, checkpoint and fold all speak those. */ -function childIds(output: string): Record { - const line = output.split('\n').find((l) => l.startsWith('IDS:')) - if (!line) throw new Error(`arranger printed no IDS line:\n${output}`) - return JSON.parse(line.slice('IDS:'.length)) -} - -/** SIGKILL the whole group and wait for the grandchild's death to settle. */ -async function sigkill(child: ReturnType): Promise { - process.kill(-(child.pid as number), 'SIGKILL') - await new Promise((r) => child.on('exit', () => r())) - await new Promise((r) => setTimeout(r, 500)) -} - -/** The preamble every arranger child shares. */ -function childPreamble(root: string): string { - return ` - import { Brainy } from ${JSON.stringify(join(REPO_ROOT, 'src', 'brainy.ts'))} - const ROOT = ${JSON.stringify(root)} - const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: ROOT } }) - const block = () => { (brain as any).kickEmbedWorker = () => {} } - const settleCheckpoint = async () => { - // The cadence write is fire-and-forget; wait for the single flight. - for (let i = 0; i < 200; i++) { - if (!(brain as any)._pendingEmbedCheckpointFlight) break - await (brain as any)._pendingEmbedCheckpointFlight.catch(() => {}) - } - } - ` -} - -afterEach(async () => { - for (const brain of liveBrains.splice(0)) { - try { await brain.close() } catch { /* already closed / crashed — teardown only */ } - } - for (const d of roots.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -// =========================================================================== -// 1. The stuck-id brain — the defect, and the bound that now engages on it -// =========================================================================== - -describe('pending-embed checkpoint — a brain whose pending set never drains', () => { - it('a permanently-stuck pending id: the reopen scans only the facts after the checkpoint', async () => { - const root = dir() - const first = await open(root, { blockWorker: true }) - // add() returns the CANONICAL id (supplied ids are normalised), and that is - // the id the markers, the checkpoint and the fold all speak. - const stuck = await first.add({ - id: 'stuck', - data: 'a deferred row whose embed never lands', - type: NounType.Thing, - deferEmbedding: true - }) - expect(first.pendingEmbedCount()).toBe(1) - // Ordinary traffic after it — every one of these is a fact the unbounded - // fold had to re-read at every open, forever, because of that one id. - for (let i = 0; i < 12; i++) { - await first.add({ id: `row-${i}`, data: `row ${i}`, type: NounType.Thing }) - } - await first.close() - liveBrains.splice(liveBrains.indexOf(first), 1) - - // THE DEFECT, PINNED: the pending set never drained, so the old bound was - // never written — nothing on this brain could have shortened its fold. - expect(readArtifact(root, LOWWATER_PATH)).toBeNull() - // The checkpoint IS written at the clean close, set non-empty and all. - const checkpoint = readArtifact(root, CHECKPOINT_PATH) as { - generation: number - pending: string[] - } | null - expect(checkpoint).not.toBeNull() - expect(checkpoint!.generation).toBeGreaterThan(0) - expect(checkpoint!.pending).toEqual([stuck]) - - const second = await open(root, { blockWorker: true }) - const report = foldReport(second) - // THE FIX, from the fold's own counter — not the clock. - expect(report.bound).toBe('checkpoint') - expect(report.fromGeneration).toBe(checkpoint!.generation + 1) - expect(report.factsScanned).toBe(0) - expect(report.seeded).toBe(1) - // The crash-recovery contract is intact: the marker is re-armed by open(). - expect(pendingIds(second)).toEqual([stuck]) - expect(second.pendingEmbedCount()).toBe(1) - - // The differential: the bounded answer is the full-fold answer, and the - // full fold is what the previous bound would have had to read. - const full = await fullFold(second) - expect(full.ids).toEqual([stuck]) - expect(full.facts).toBeGreaterThanOrEqual(13) - expect(report.factsScanned).toBeLessThan(full.facts) - }, 180_000) - - it('the bound stays O(delta) across repeated opens while the id is still stuck', async () => { - const root = dir() - const first = await open(root, { blockWorker: true }) - const stuck = await first.add({ - id: 'stuck', - data: 'never lands', - type: NounType.Thing, - deferEmbedding: true - }) - for (let i = 0; i < 6; i++) { - await first.add({ id: `a-${i}`, data: `a ${i}`, type: NounType.Thing }) - } - await first.close() - liveBrains.splice(liveBrains.indexOf(first), 1) - - const second = await open(root, { blockWorker: true }) - expect(foldReport(second).factsScanned).toBe(0) - // More history under the same stuck id. - for (let i = 0; i < 9; i++) { - await second.add({ id: `b-${i}`, data: `b ${i}`, type: NounType.Thing }) - } - await second.close() - liveBrains.splice(liveBrains.indexOf(second), 1) - - const third = await open(root, { blockWorker: true }) - const report = foldReport(third) - const full = await fullFold(third) - expect(report.bound).toBe('checkpoint') - expect(report.factsScanned).toBe(0) - // The unbounded fold grew with the store; the bounded one did not. - expect(full.facts).toBeGreaterThanOrEqual(16) - expect(pendingIds(third)).toEqual([stuck]) - expect(full.ids).toEqual([stuck]) - }, 180_000) -}) - -// =========================================================================== -// 2. Torn checkpoint — falls back, loudly, correctly -// =========================================================================== - -describe('pending-embed checkpoint — a torn checkpoint never shortens the fold', () => { - it('an undecodable checkpoint file degrades to the next bound, loudly, with the right pending set', async () => { - const root = dir() - const first = await open(root, { blockWorker: true }) - const stuck = await first.add({ - id: 'stuck', - data: 'never lands', - type: NounType.Thing, - deferEmbedding: true - }) - for (let i = 0; i < 5; i++) { - await first.add({ id: `row-${i}`, data: `row ${i}`, type: NounType.Thing }) - } - await first.close() - liveBrains.splice(liveBrains.indexOf(first), 1) - - const onDisk = artifactPath(root, CHECKPOINT_PATH) - expect(onDisk).not.toBeNull() - // Tear it: bytes that are neither valid gzip nor valid JSON. A torn file - // must THROW on read — never parse into a partial `pending` list. - writeFileSync(onDisk!, 'not a checkpoint at all {{{') - - const before = getTornRecordGauge().count - const { result: second, lines } = await captureConsole(async () => - open(root, { blockWorker: true }) - ) - const report = foldReport(second) - // Fell back — never to a shorter bound, and never silently. - expect(report.bound).not.toBe('checkpoint') - expect(report.seeded).toBe(0) - expect(report.fromGeneration).toBe(1) // no mark either: this brain never drained - // LOUD, two ways: the adapter's torn-record gauge and its production error… - expect(getTornRecordGauge().count).toBeGreaterThan(before) - expect(getTornRecordGauge().lastPath).toContain('pending_embeds_checkpoint') - expect(lines.some((l) => /TORN RECORD/.test(l))).toBe(true) - // …and the fold's own narration of which bound it actually used. - expect(lines.some((l) => /pending-embed fold: genesis bound/.test(l))).toBe(true) - - // CORRECT: the marker is still recovered, from the log itself. - expect(pendingIds(second)).toEqual([stuck]) - const full = await fullFold(second) - expect(full.ids).toEqual([stuck]) - expect(report.factsScanned).toBe(full.facts) - }, 180_000) - - it('a well-formed but shape-invalid checkpoint is refused whole, never partially trusted', async () => { - const root = dir() - const first = await open(root, { blockWorker: true }) - const stuck = await first.add({ - id: 'stuck', - data: 'never lands', - type: NounType.Thing, - deferEmbedding: true - }) - await first.add({ id: 'other', data: 'ordinary row', type: NounType.Thing }) - await first.close() - liveBrains.splice(liveBrains.indexOf(first), 1) - - // A checkpoint with a plausible generation but a `pending` that is not a - // list of ids: trusting the generation alone would bound the scan behind a - // set that was never recovered — the exact shape that loses a vector. - const onDisk = artifactPath(root, CHECKPOINT_PATH)! - const good = readArtifact(root, CHECKPOINT_PATH) as { generation: number } - rmSync(onDisk) - writeFileSync( - join(root, '_system', 'pending_embeds_checkpoint.json'), - JSON.stringify({ generation: good.generation, pending: { stuck: true }, writtenAt: 1 }) - ) - - const { result: second, lines } = await captureConsole(async () => - open(root, { blockWorker: true }) - ) - expect(lines.some((l) => /pending-embed checkpoint REFUSED/.test(l))).toBe(true) - const report = foldReport(second) - expect(report.bound).not.toBe('checkpoint') - expect(report.seeded).toBe(0) - expect(pendingIds(second)).toEqual([stuck]) - }, 180_000) -}) - -// =========================================================================== -// 3. The crash matrix — real processes, real SIGKILL, differential invariant -// =========================================================================== - -describe('pending-embed checkpoint — crash matrix (real child process, SIGKILL)', () => { - /** - * The invariant every row shares: whatever the reopened brain's fold did with - * whatever bound survived the crash, its pending set must equal the truth a - * full fold from generation 1 derives from the SAME recovered log. - */ - async function assertDifferentialAfterCrash(root: string): Promise<{ - report: FoldReport - full: { ids: string[]; facts: number } - pending: string[] - }> { - const reopened = await open(root, { blockWorker: true }) - const report = foldReport(reopened) - const full = await fullFold(reopened) - const pending = pendingIds(reopened) - expect(pending).toEqual(full.ids) - return { report, full, pending } - } - - it('killed BEFORE any checkpoint was written — falls back and recovers the marker from the log', async () => { - const root = dir() - const { child, output } = await spawnArranger( - root, - `${childPreamble(root)} - block() - await brain.init() - await brain.add({ id: 'landed-row', data: 'an ordinary row', type: 'thing' }) - const stuck = await brain.add({ id: 'stuck-1', data: 'deferred, never lands', type: 'thing', deferEmbedding: true }) - await brain.flush() - console.log('IDS:' + JSON.stringify({ stuck })) - console.log('READY') - setInterval(() => {}, 1000) - ` - ) - const ids = childIds(output()) - // One enqueue is well under the cadence and the set never drained, so no - // checkpoint exists — this is the pre-checkpoint crash. - expect(readArtifact(root, CHECKPOINT_PATH)).toBeNull() - await sigkill(child) - - const { report, pending } = await assertDifferentialAfterCrash(root) - expect(report.bound).toBe('genesis') - expect(pending).toEqual([ids.stuck]) - }, 300_000) - - it('killed AFTER a checkpoint, with an embed landed and flushed after it — the post-checkpoint facts carry the disarm', async () => { - const root = dir() - const { child, output } = await spawnArranger( - root, - `${childPreamble(root)} - await brain.init() - // Land one deferred embed: the drain arms the checkpoint debt. - await brain.add({ id: 'seed', data: 'lands first', type: 'thing', deferEmbedding: true }) - await brain.awaitPendingEmbeds() - await brain.flush() - // A second deferred write pays the debt (the head is at the manifest now), - // then LANDS — its embed.landed rides a fact ABOVE the checkpoint. - const landsAfter = await brain.add({ id: 'lands-after', data: 'lands after the checkpoint', type: 'thing', deferEmbedding: true }) - await settleCheckpoint() - await brain.awaitPendingEmbeds() - // …and one that never will. - block() - const stuck = await brain.add({ id: 'stuck-1', data: 'deferred, never lands', type: 'thing', deferEmbedding: true }) - await brain.add({ id: 'plain', data: 'more history', type: 'thing' }) - await brain.flush() - console.log('IDS:' + JSON.stringify({ stuck, landsAfter })) - console.log('READY') - setInterval(() => {}, 1000) - ` - ) - const ids = childIds(output()) - const checkpoint = readArtifact(root, CHECKPOINT_PATH) as { - generation: number - pending: string[] - } | null - expect(checkpoint).not.toBeNull() - await sigkill(child) - - const { report, full, pending } = await assertDifferentialAfterCrash(root) - expect(report.bound).toBe('checkpoint') - expect(report.fromGeneration).toBe(checkpoint!.generation + 1) - // The bound really bounded: fewer facts than the whole log. - expect(report.factsScanned).toBeLessThan(full.facts) - // A landed embed above the checkpoint is disarmed by the scan, not lost; - // the stuck one is re-armed. - expect(pending).toEqual([ids.stuck]) - expect(pending).not.toContain(ids.landsAfter) - }, 300_000) - - it('killed AFTER a checkpoint with an UN-FLUSHED tail — truncated facts and the bounded fold still agree', async () => { - const root = dir() - const { child } = await spawnArranger( - root, - `${childPreamble(root)} - await brain.init() - await brain.add({ id: 'seed', data: 'lands first', type: 'thing', deferEmbedding: true }) - await brain.awaitPendingEmbeds() - await brain.flush() - await brain.add({ id: 'lands-after', data: 'lands after the checkpoint', type: 'thing', deferEmbedding: true }) - await settleCheckpoint() - await brain.awaitPendingEmbeds() - await brain.flush() - // Now write PAST the manifest and never flush: these facts are the tail a - // crash truncates. Whatever survives, the two folds must agree on it. - block() - await brain.add({ id: 'stuck-tail', data: 'deferred, never lands', type: 'thing', deferEmbedding: true }) - await brain.add({ id: 'plain-tail', data: 'unflushed history', type: 'thing' }) - console.log('READY') - setInterval(() => {}, 1000) - ` - ) - const checkpoint = readArtifact(root, CHECKPOINT_PATH) as { generation: number } | null - expect(checkpoint).not.toBeNull() - await sigkill(child) - - const { report } = await assertDifferentialAfterCrash(root) - // The checkpoint's generation is at or below the manifest by construction, - // so it survived the truncation and still bounds the fold. - expect(report.bound).toBe('checkpoint') - expect(report.fromGeneration).toBe(checkpoint!.generation + 1) - }, 300_000) -}) diff --git a/tests/integration/pending-embed-low-water.test.ts b/tests/integration/pending-embed-low-water.test.ts deleted file mode 100644 index f966d0a1..00000000 --- a/tests/integration/pending-embed-low-water.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * @module tests/integration/pending-embed-low-water - * @description The pending-embed recovery fold is bounded and background (10.4.9). - * - * The fold used to scan the generation log from generation 1 at EVERY open, - * on the open's foreground — O(whole history) per open on long-lived brains. - * Now: an advisory low-water mark (`_system/pending_embeds_lowwater.json`) - * records the committed generation whenever the pending set drains to empty, - * recovery scans from `mark + 1` on the open's foreground — the crash-recovery - * contract keeps markers re-armed when open() returns. The mark is advisory: stale-low costs a longer scan, never a - * marker — a pending embed enqueued before a crash is still recovered. - */ -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' -import { NounType } from '../../src/types/graphTypes' - -const LOWWATER_PATH = '_system/pending_embeds_lowwater.json' - -describe('pending-embed recovery: bounded by the low-water mark', () => { - const roots: string[] = [] - const dir = (): string => { - const d = mkdtempSync(join(tmpdir(), 'brainy-lowwater-')) - roots.push(d) - return d - } - const open = async (root: string): Promise> => { - const brain = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: root } - }) - await brain.init() - return brain - } - - afterEach(() => { - for (const d of roots.splice(0)) rmSync(d, { recursive: true, force: true }) - }) - - it('drain-to-empty writes the mark, and the next open scans from mark + 1', async () => { - const root = dir() - const brain = await open(root) - // Hold the worker so the pending state is observable, then release it. - const realKick = (brain as any).kickEmbedWorker.bind(brain) - ;(brain as any).kickEmbedWorker = () => {} - await brain.add({ - id: 'row-1', - data: 'the first deferred row', - type: NounType.Thing, - deferEmbedding: true - }) - expect(brain.pendingEmbedCount()).toBeGreaterThan(0) - ;(brain as any).kickEmbedWorker = realKick - await brain.awaitPendingEmbeds() - // The drain wrote the advisory mark (fire-and-forget: settle the microtask). - await new Promise((r) => setTimeout(r, 50)) - const mark = (await (brain as any).storage.readRawObject(LOWWATER_PATH)) as { - generation: number - } | null - expect(mark).not.toBeNull() - expect(mark!.generation).toBeGreaterThan(0) - await brain.close() - - const brain2 = await open(root) - const log = (brain2 as any).generationStore.getFactLog() - const scanSpy = vi.spyOn(log, 'scanFacts') - try { - await (brain2 as any).recoverPendingEmbedsFromLog() - expect(scanSpy).toHaveBeenCalledTimes(1) - const opts = scanSpy.mock.calls[0][0] as { fromGeneration?: number } - expect(opts.fromGeneration).toBeGreaterThanOrEqual(mark!.generation + 1) - } finally { - scanSpy.mockRestore() - await brain2.close() - } - }) - - it('a pending embed enqueued after the mark survives an unclean stop', async () => { - const root = dir() - const brain = await open(root) - await brain.add({ id: 'settled', data: 'lands before the mark', type: NounType.Thing }) - await brain.awaitPendingEmbeds() - await new Promise((r) => setTimeout(r, 50)) - - // A deferred write whose embed never lands: block the worker, then drop - // the instance without close() — the unclean-stop shape. - ;(brain as any).kickEmbedWorker = () => {} - await brain.add({ - id: 'orphan', - data: 'enqueued then abandoned', - type: NounType.Thing, - deferEmbedding: true - }) - expect(brain.pendingEmbedCount()).toBeGreaterThan(0) - // No close(): simulate the crash by releasing only the writer lock so the - // next open can proceed. - await (brain as any).storage.releaseWriterLock() - - const brain2 = await open(root) - expect(brain2.pendingEmbedCount()).toBeGreaterThan(0) - await brain2.awaitPendingEmbeds() - expect(brain2.pendingEmbedCount()).toBe(0) - await brain2.close() - // Reap the crashed instance: its fence is gone, so close() fails loudly — - // swallow that here; the point is clearing its watchers and registry entry. - await brain.close().catch(() => undefined) - }) - - it('a reopened brain has its pending set settled when open() returns', async () => { - const root = dir() - const brain = await open(root) - await brain.add({ id: 'a-row', data: 'some data', type: NounType.Thing }) - await brain.awaitPendingEmbeds() - await brain.close() - - const brain2 = await open(root) - // The crash-recovery contract: markers are re-armed by open itself — - // no latch, no background race. (Here the drain landed, so zero.) - expect(brain2.pendingEmbedCount()).toBe(0) - await brain2.close() - }) - - it('a clean close with an empty set writes the mark even if no drain happened', async () => { - const root = dir() - const brain = await open(root) - await brain.add({ id: 'r1', data: 'row one', type: NounType.Thing }) - await brain.awaitPendingEmbeds() - await brain.close() - // Read the mark back through the storage door (the adapter owns the - // on-disk encoding), on a fresh instance. - const brain2 = await open(root) - const mark = (await (brain2 as any).storage.readRawObject(LOWWATER_PATH)) as { - generation: number - } | null - expect(mark).not.toBeNull() - expect(mark!.generation).toBeGreaterThan(0) - await brain2.close() - }) -}) diff --git a/tests/integration/read-gate-scope-and-no-reembed.test.ts b/tests/integration/read-gate-scope-and-no-reembed.test.ts deleted file mode 100644 index b1319249..00000000 --- a/tests/integration/read-gate-scope-and-no-reembed.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @module tests/integration/read-gate-scope-and-no-reembed - * @description Two cures from the pair's first production adoption: - * (1) THE READ GATE IS PER-FAMILY — a not-serving VECTOR leg refuses vector - * search only; a pure metadata find({ where }) and graph traversal keep - * serving. The brain-global gate refused a deployment's badge reads for a - * vector-leg verdict that had nothing to do with them. - * (2) NO RE-EMBED ON UNCHANGED DATA — an update() carrying the row's current - * data lands no vector, defers no embed, rewrites nothing. A host - * heartbeat re-writing an unchanged row fed a live index-row loop. - */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import { Brainy, VectorIndexNotReadyError } from '../../src/index.js' - -describe('read gate scope + no re-embed on unchanged data', () => { - let dir: string - let brain: any - - beforeEach(async () => { - process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-gate-scope-')) - brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir }, silent: true, dimensions: 384 }) - await brain.init() - }) - afterEach(async () => { - await brain.close?.().catch(() => {}) - fs.rmSync(dir, { recursive: true, force: true }) - }) - - it('a not-serving VECTOR leg refuses vector search only — metadata and graph reads keep serving', async () => { - const a = await brain.add({ data: 'employee alpha', type: 'person', metadata: { status: 'active' } }) - const b = await brain.add({ data: 'employee beta', type: 'person', metadata: { status: 'active' } }) - await brain.relate({ from: a, to: b, type: 'relatedTo' }) - await brain.flush() - - // The vector provider says it is NOT serving (a rebuild-class failure). - brain.index.healthReport = () => ({ - provider: 'vector', healthy: false, serving: false, generation: 7, unledgered: [], - invariants: [{ name: 'node-coverage', holds: false, heal: 'rebuild', detail: 'posted 0 < canonical 2', source: 'ledger' }], - checkedAt: 1, durationMs: 1 - }) - try { - const byStatus = await brain.find({ where: { status: 'active' } }) - expect(byStatus.map((r: any) => r.id).sort(), 'metadata find serves').toEqual([a, b].sort()) - const rel = await brain.related(a) - expect(rel.length, 'graph traversal serves').toBe(1) - await expect(brain.find({ query: 'employee' }), 'vector search refuses typed').rejects.toBeInstanceOf(VectorIndexNotReadyError) - } finally { - delete brain.index.healthReport - } - }) - - it('update() with the row\'s current data re-embeds nothing; a real change re-embeds', async () => { - const id = await brain.add({ data: 'invoice 1042 pending', type: 'document', metadata: { n: 1 } }) - await brain.flush() - const before = (await brain.get(id, { includeVectors: true })).vector - const ledgerBefore = await brain.storage.getCanonicalCounts() - const logBefore = (await brain.transactionLog({ limit: 50 })).length - - // The heartbeat shape: same data, re-written, deferred. - for (let i = 0; i < 3; i++) { - await brain.update({ id, data: 'invoice 1042 pending', metadata: { n: 1, tick: i }, deferEmbedding: true }) - } - await brain.flush() - const after = (await brain.get(id, { includeVectors: true })).vector - const ledgerAfter = await brain.storage.getCanonicalCounts() - const log = await brain.transactionLog({ limit: 50 }) - expect(after, 'vector untouched by unchanged-data writes').toEqual(before) - expect(ledgerAfter.vectors.all, 'vectored ledger untouched').toBe(ledgerBefore.vectors.all) - expect(log.filter((e: any) => e.origin === 'system:embed-landing').length, 'no landing commit for unchanged data').toBe(0) - expect(log.length - logBefore, 'the metadata writes themselves still commit').toBe(3) - - // A REAL change re-embeds (deferred → the worker lands it). - await brain.update({ id, data: 'invoice 1042 PAID', deferEmbedding: true }) - await brain.flush() - const changed = (await brain.get(id, { includeVectors: true })).vector - expect(changed, 'a real data change re-embeds').not.toEqual(before) - expect((await brain.storage.getCanonicalCounts()).vectors.all, 'a re-embed of a vectored row never double-counts').toBe(ledgerBefore.vectors.all) - }) -}) diff --git a/tests/integration/read-surface-readiness.test.ts b/tests/integration/read-surface-readiness.test.ts deleted file mode 100644 index 0215d81f..00000000 --- a/tests/integration/read-surface-readiness.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * @module tests/integration/read-surface-readiness - * @description THE READ-SURFACE READINESS GATE (a production blackout's - * brainy half): with `disableAutoRebuild: true`, init defers index builds — - * and before this gate, only find() waited for the lazy rebuild while - * related() and every VFS path served EMPTY from the not-ready providers - * (writes acked into canonical, readback empty — fifteen live minutes). - * The pins: on a fresh instance over a populated store, the FIRST read on - * every surface serves truth (it waits for the build), never 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/index.js' -import { NounType, VerbType } from '../../src/types/graphTypes.js' - -const dirs: string[] = [] -const brains: Brainy[] = [] -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -async function openLazy(dir: string): Promise { - const brain = new Brainy({ - storage: { type: 'filesystem', path: dir }, - requireSubtype: false, - silent: true, - disableAutoRebuild: true - }) - await brain.init() - brains.push(brain) - return brain -} - -describe('read-surface readiness gate', () => { - it('related() as the FIRST read on a fresh lazy instance serves truth, never empty', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-readgate-')) - dirs.push(dir) - const writer = await openLazy(dir) - const a = await writer.add({ data: 'hub row', type: NounType.Document, metadata: { n: 1 } }) - const b = await writer.add({ data: 'leaf row', type: NounType.Document, metadata: { n: 2 } }) - await writer.relate({ from: a, to: b, type: VerbType.RelatedTo }) - await writer.flush() - await brains.pop()!.close() - - // Fresh instance: indexes deferred at open. The production shape called - // related() FIRST (no find() to trigger the old, only gate). - const reader = await openLazy(dir) - const rels = await reader.related({ from: a }) - expect(rels.length, 'the FIRST related() read waits for the build and serves').toBeGreaterThan(0) - expect(rels.some((r) => r.to === b || (r as { target?: string }).target === b)).toBe(true) - }, 120000) - - it('a metadata-filtered read as the FIRST read serves truth, never empty', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-readgate2-')) - dirs.push(dir) - const writer = await openLazy(dir) - await writer.add({ data: 'tagged row', type: NounType.Document, metadata: { team: 'atlas' } }) - await writer.flush() - await brains.pop()!.close() - - const reader = await openLazy(dir) - const rows = await reader.find({ where: { team: 'atlas' } }) - expect(rows.length, 'filtered find on a cold lazy instance serves').toBe(1) - }, 120000) -}) diff --git a/tests/integration/readAfterWrite.test.ts b/tests/integration/readAfterWrite.test.ts index cf1dc9ec..e0ab5863 100644 --- a/tests/integration/readAfterWrite.test.ts +++ b/tests/integration/readAfterWrite.test.ts @@ -34,7 +34,13 @@ describe('Read-After-Write Consistency (v5.7.2 Bug Fix)', () => { testDir = join(tmpdir(), `brainy-consistency-${Date.now()}-${Math.random().toString(36).substring(7)}`) brain = new Brainy({ requireSubtype: false, - storage: { type: 'filesystem', path: testDir }, + storage: { + type: 'filesystem', + config: { + baseDir: testDir, + enableCompression: false // Faster tests + } + }, dimensions: 384 }) diff --git a/tests/integration/readonly-close-no-marker.test.ts b/tests/integration/readonly-close-no-marker.test.ts deleted file mode 100644 index ad9357db..00000000 --- a/tests/integration/readonly-close-no-marker.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * @module tests/integration/readonly-close-no-marker - * @description A READ-ONLY BRAIN WRITES NO CLEAN-SHUTDOWN EVIDENCE. - * - * `_system/clean-shutdown.json` is the WRITER's own word about the writer's - * own process: "everything above this line, from THIS session, is durable." - * Two call sites treated a reader exactly like a writer: - * - * 1. `Brainy#closeDurableSteps()` called `generationStore.close()` - * unconditionally — a reader's close re-stamped the marker at the - * generation the reader merely OBSERVED, never committed. - * 2. `GenerationStore#open()` consumed (deleted) the marker on every open, - * reader or writer alike, so a reader that never got to a matching - * close left the store looking crashed to the next writer. - * - * Both are fixed by making a read-only brain leave `_system/` exactly as it - * found it — at open AND at close. Pinned here: - * - * 1. `_system/` is byte-for-byte identical (file set + contents) before and - * after a reader opens a cleanly-closed store, reads it, and closes. - * 2. After the reader's close, the next WRITER open adopts the marker as - * clean — no recovery fold narrates. - * 3. A reader creates no file under `_system/` merely by opening (before it - * ever closes). - * 4. A reader that opens and is then abandoned (crash-style, no close) does - * not force the next writer to pay a recovery fold — the concrete harm - * the fix closes. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync, readdirSync, readFileSync, statSync } from 'node:fs' -import { createHash } from 'node:crypto' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Brainy } from '../../src/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' -import { abandonAsCrashed } from '../helpers/durabilityKillMatrix.js' - -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), 'brainy-readonly-close-')) -} - -/** Recursively hash every regular file under `dir`, keyed by its path relative to `dir`. */ -function snapshotDir(dir: string): Map { - const out = new Map() - const walk = (rel: string): void => { - const abs = rel ? join(dir, rel) : dir - let entries: string[] - try { - entries = readdirSync(abs) - } catch { - return - } - for (const name of entries) { - const childRel = rel ? join(rel, name) : name - const childAbs = join(dir, childRel) - const st = statSync(childAbs) - if (st.isDirectory()) { - walk(childRel) - } else if (st.isFile()) { - const hash = createHash('sha256').update(readFileSync(childAbs)).digest('hex') - out.set(childRel, hash) - } - } - } - walk('') - return out -} - -/** Capture console.warn lines (the narration channel — see `prodLog.narrate`) 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('a read-only brain writes no clean-shutdown evidence', () => { - 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 */ - } - }) - - const systemDir = () => join(dir, '_system') - /** - * The marker file's actual on-disk name — `clean-shutdown.json` or, under - * FileSystemStorage's default gzip compression, `clean-shutdown.json.gz`. - * Returns null when absent. - */ - const findMarkerPath = (): string | null => { - let entries: string[] - try { - entries = readdirSync(systemDir()) - } catch { - return null - } - const name = entries.find((n) => n.startsWith('clean-shutdown.json')) - return name ? join(systemDir(), name) : null - } - - it('leaves `_system/`\'s file set and the clean-shutdown marker\'s bytes identical across a reader open → read → close', async () => { - // A writer opens, writes, and closes cleanly — the marker lands at - // whatever generation the writer actually committed. - const writer = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await writer.init() - await writer.add({ data: 'seed entity', type: NounType.Concept }) - await writer.add({ data: 'second entity', type: NounType.Concept }) - await writer.flush() - await writer.close() - - const markerBeforePath = findMarkerPath() - expect(markerBeforePath, 'the writer left a clean-shutdown marker').not.toBeNull() - const before = snapshotDir(systemDir()) - expect(before.size).toBeGreaterThan(0) - const markerBeforeHash = before.get( - (markerBeforePath as string).slice(systemDir().length + 1) - ) - expect(markerBeforeHash).toBeTruthy() - - // A reader opens the same store, reads, and closes. - brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - expect(brain.isReadOnly).toBe(true) - await brain.stats() - await brain.close() - brain = null - - // The FILE SET under `_system/` is unchanged — a reader creates and - // removes nothing. This pin is specifically about the generation store's - // clean-shutdown evidence. The wider law — that a reader leaves EVERY - // file under `_system/` byte-identical, which this fix left open as a - // known residual (the metadata field registry and the three statistics - // files were still re-stamped by a reader's close) — is closed and pinned - // in `readonly-close-writes-nothing.test.ts`. - const after = snapshotDir(systemDir()) - expect([...after.keys()].sort()).toEqual([...before.keys()].sort()) - - // The MARKER's bytes are byte-for-byte identical — the reader neither - // consumed it at open nor re-stamped it at close. - const markerAfterPath = findMarkerPath() - expect(markerAfterPath, 'the marker must still exist, under the same name').toBe(markerBeforePath) - const markerAfterHash = after.get((markerAfterPath as string).slice(systemDir().length + 1)) - expect(markerAfterHash).toBe(markerBeforeHash) - }, 120_000) - - it('creates no file under `_system/` merely by opening read-only', async () => { - const writer = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await writer.init() - await writer.add({ data: 'seed entity', type: NounType.Concept }) - await writer.flush() - await writer.close() - - const baselineNames = [...snapshotDir(systemDir()).keys()].sort() - expect(baselineNames.length).toBeGreaterThan(0) - - // Open the reader and inspect `_system/` BEFORE it ever closes — open() - // alone must create nothing. - brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - const whileOpenNames = [...snapshotDir(systemDir()).keys()].sort() - expect(whileOpenNames).toEqual(baselineNames) - - await brain.close() - brain = null - }, 120_000) - - it('a writer reopening after the reader closes adopts the marker — no recovery fold', async () => { - const writer1 = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await writer1.init() - await writer1.add({ data: 'seed entity', type: NounType.Concept }) - await writer1.flush() - await writer1.close() - - // A reader opens and closes in between — must not disturb the marker. - const reader = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - await reader.stats() - await reader.close() - - // The next writer open must be a clean, no-fold open: no - // "log-authority recovery" / "WHOLE-LOG fold" narration line. - const { result: writer2, lines } = await captureWarn(async () => { - const w = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await w.init() - return w - }) - brain = writer2 - - const foldLines = lines.filter((l) => /log-authority recovery|WHOLE-LOG fold|recovery fold/i.test(l)) - expect(foldLines, `unexpected recovery narration:\n${foldLines.join('\n')}`).toEqual([]) - - // And the store is exactly what the first writer left — the seed row is - // still there, nothing was rolled back or re-derived. - const found = await writer2.find({ where: {} } as any) - expect(found.length).toBeGreaterThanOrEqual(1) - }, 120_000) - - it('a reader that opens and is then abandoned (never closes) does not force the next writer to fold', async () => { - // This is the concrete harm the fix closes: pre-fix, a reader's open() - // unconditionally DELETED the marker (consuming it as if it were the - // writer). A reader that opened and then died — no close, exactly like - // a killed process — left the marker gone, so the actual writer's next - // open read the store as crashed and paid a full recovery fold for a - // "crash" that was really just a reader that came and went. - const writer1 = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await writer1.init() - await writer1.add({ data: 'seed entity', type: NounType.Concept }) - await writer1.flush() - await writer1.close() - - const reader = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - await reader.stats() - // NEVER calls reader.close() — abandon it exactly like a killed process. - await abandonAsCrashed(reader) - - const { result: writer2, lines } = await captureWarn(async () => { - const w = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await w.init() - return w - }) - brain = writer2 - - const foldLines = lines.filter((l) => /log-authority recovery|WHOLE-LOG fold|recovery fold/i.test(l)) - expect( - foldLines, - `an abandoned READER forced a recovery fold on the next writer open:\n${foldLines.join('\n')}` - ).toEqual([]) - }, 120_000) -}) diff --git a/tests/integration/readonly-close-writes-nothing.test.ts b/tests/integration/readonly-close-writes-nothing.test.ts deleted file mode 100644 index 701a1974..00000000 --- a/tests/integration/readonly-close-writes-nothing.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * @module tests/integration/readonly-close-writes-nothing - * @description A READ-ONLY BRAIN LEAVES `_system/` BYTE-IDENTICAL — the WHOLE - * directory, not just the clean-shutdown marker. - * - * `readonly-close-no-marker` closed the marker half of this law and named the - * rest as a known, out-of-scope residual: - * - * "Other files under `_system/` — e.g. the metadata field registry, which - * stamps its own `lastUpdated` on every persist — are a pre-existing, - * separate concern outside this fix's scope." - * - * This is that residual, closed. MEASURED on the base before the fix, a - * read-only open → read → close rewrote FOUR files: - * - * _system/__metadata_field_registry__.json.gz - * _system/type-statistics.json.gz - * _system/subtype-statistics.json.gz - * _system/verb-subtype-statistics.json.gz - * - * THE CAUSE was not the closes the marker fix guarded — it was Phase 1 of - * `closeDurableSteps`, where every component flush ran unconditionally. A flush - * is a write by definition: `MetadataIndexManager#flush()` saves the field - * registry "even with no dirty fields" (its own comment), and the storage - * adapter's count flush re-stamps the three statistics files. A session that - * committed nothing re-stamped all four. Phase 2's closes were ungated too — - * the graph index's close drains both LSM MemTables and stamps a watermark, - * and the optional vector/metadata `close` hooks (unimplemented in the - * reference engine, filled in by a native provider) persist buffered state. - * - * THE LAW. A reader writes nothing, anywhere under `_system/`, at open or at - * close. It still RELEASES what it holds: the graph index's auto-flush interval - * is cleared through `stopBackgroundFlush()`, the non-writing half of its - * close, so nothing outlives the session. - * - * WHY IT MATTERS beyond tidiness: `_system/` is where a store keeps its - * evidence about itself — what the writer committed, what the projections have - * seen. A reader that rewrites any of it is vouching for a state it only - * observed, and on shared or snapshot storage it mutates bytes another process - * owns. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtempSync, rmSync, readdirSync, readFileSync, statSync } from 'node:fs' -import { createHash } from 'node:crypto' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Brainy } from '../../src/brainy.js' -import { NounType, VerbType } from '../../src/types/graphTypes.js' - -/** Recursively hash every regular file under `dir`, keyed by its path relative to `dir`. */ -function snapshotDir(dir: string): Map { - const out = new Map() - const walk = (rel: string): void => { - const abs = rel ? join(dir, rel) : dir - let entries: string[] - try { - entries = readdirSync(abs) - } catch { - return - } - for (const name of entries) { - const childRel = rel ? join(rel, name) : name - const childAbs = join(dir, childRel) - const st = statSync(childAbs) - if (st.isDirectory()) { - walk(childRel) - } else if (st.isFile()) { - out.set(childRel, createHash('sha256').update(readFileSync(childAbs)).digest('hex')) - } - } - } - walk('') - return out -} - -/** Every path where `after` differs from `before`, labelled — the failure message. */ -function diff(before: Map, after: Map): string[] { - const lines: string[] = [] - for (const [path, hash] of after) { - if (!before.has(path)) lines.push(`ADDED ${path}`) - else if (before.get(path) !== hash) lines.push(`CHANGED ${path}`) - } - for (const path of before.keys()) if (!after.has(path)) lines.push(`REMOVED ${path}`) - return lines.sort() -} - -describe('a read-only brain writes nothing under `_system/`', () => { - let dir: string - let brain: Brainy | null = null - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'brainy-readonly-writes-')) - }) - - afterEach(async () => { - if (brain) { - try { - await brain.close() - } catch { - /* already closed */ - } - brain = null - } - try { - rmSync(dir, { recursive: true, force: true }) - } catch { - /* ignore */ - } - }) - - const systemDir = () => join(dir, '_system') - - /** - * A writer seeds a store with nouns, verbs and queryable metadata — enough - * that the field registry, the statistics files and the graph index all hold - * real content — then closes cleanly. - */ - async function seedStore(): Promise { - const writer = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir } - }) - await writer.init() - for (let i = 0; i < 6; i++) { - await writer.add({ - id: `seed-${i}`, - data: `seed entity ${i}`, - type: i % 2 === 0 ? NounType.Concept : NounType.Document, - metadata: { lane: i % 2 === 0 ? 'alpha' : 'beta', rank: i, tags: [`t${i}`, 'shared'] }, - vector: [] - }) - } - for (let i = 1; i < 6; i++) { - await writer.relate({ from: 'seed-0', to: `seed-${i}`, type: VerbType.RelatedTo }) - } - await writer.flush() - await writer.close() - } - - it('open → read → close leaves every file under `_system/` byte-identical', async () => { - await seedStore() - - const before = snapshotDir(systemDir()) - expect(before.size, 'the writer left a populated `_system/`').toBeGreaterThan(0) - - brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - expect(brain.isReadOnly).toBe(true) - - // Exercise the read surface that drives each subsystem: statistics (counts), - // a metadata filter (field index + registry), a graph walk (adjacency), a - // vector search, and a direct get. - await brain.stats() - await brain.find({ where: { lane: 'alpha' }, limit: 10 } as any) - await brain.find({ where: { tags: 'shared' }, limit: 10 } as any) - await brain.find({ connected: { from: 'seed-0', direction: 'out' }, limit: 10 } as any) - await brain.get('seed-1') - - await brain.close() - brain = null - - const after = snapshotDir(systemDir()) - const changes = diff(before, after) - expect(changes, `a reader modified \`_system/\`:\n${changes.join('\n')}`).toEqual([]) - }, 120_000) - - it('names the four files that used to change — the measured shape of the defect', async () => { - await seedStore() - const before = snapshotDir(systemDir()) - - // These are the exact paths the base rewrote. Naming them keeps the pin - // honest about what it caught: if a future change reintroduces the write, - // the test above fails and this one says which subsystem did it. - const previouslyRewritten = [ - '__metadata_field_registry__.json.gz', - 'type-statistics.json.gz', - 'subtype-statistics.json.gz', - 'verb-subtype-statistics.json.gz' - ] - for (const name of previouslyRewritten) { - expect(before.has(name), `fixture must contain ${name}`).toBe(true) - } - - brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - await brain.stats() - await brain.find({ where: { lane: 'alpha' }, limit: 10 } as any) - await brain.close() - brain = null - - const after = snapshotDir(systemDir()) - for (const name of previouslyRewritten) { - expect(after.get(name), `${name} was rewritten by a reader`).toBe(before.get(name)) - } - }, 120_000) - - it('a reader that only opens and closes — touching nothing — writes nothing', async () => { - await seedStore() - const before = snapshotDir(systemDir()) - - brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - await brain.close() - brain = null - - const changes = diff(before, snapshotDir(systemDir())) - expect(changes, `an idle reader modified \`_system/\`:\n${changes.join('\n')}`).toEqual([]) - }, 120_000) - - it('two readers in sequence each leave the store exactly as they found it', async () => { - await seedStore() - const before = snapshotDir(systemDir()) - - for (let i = 0; i < 2; i++) { - const reader = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - await reader.find({ where: { lane: 'beta' }, limit: 10 } as any) - await reader.close() - const changes = diff(before, snapshotDir(systemDir())) - expect(changes, `reader ${i + 1} modified \`_system/\`:\n${changes.join('\n')}`).toEqual([]) - } - }, 120_000) - - it('the store outside `_system/` is untouched too — a reader writes nowhere', async () => { - await seedStore() - const before = snapshotDir(dir) - - brain = await Brainy.openReadOnly({ storage: { type: 'filesystem', path: dir } }) - await brain.stats() - await brain.find({ where: { lane: 'alpha' }, limit: 10 } as any) - await brain.close() - brain = null - - const changes = diff(before, snapshotDir(dir)) - expect(changes, `a reader modified the store:\n${changes.join('\n')}`).toEqual([]) - }, 120_000) - - it('a WRITER still persists on close — the guard did not disarm the write path', async () => { - await seedStore() - const before = snapshotDir(systemDir()) - - const writer = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await writer.init() - await writer.add({ - id: 'after-reader', - data: 'a new row', - type: NounType.Concept, - metadata: { lane: 'gamma', rank: 99 }, - vector: [] - }) - await writer.close() - - // The writer's close DID move `_system/` — that is the whole point of the - // asymmetry, and the guard must not have flattened it. - expect(diff(before, snapshotDir(systemDir())).length).toBeGreaterThan(0) - - // And the row is really there on the next open. - const reopened = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await reopened.init() - brain = reopened - const hits = await reopened.find({ where: { lane: 'gamma' }, limit: 10 } as any) - expect(hits.length).toBe(1) - }, 120_000) -}) diff --git a/tests/integration/recovery-walk-tolerance.test.ts b/tests/integration/recovery-walk-tolerance.test.ts deleted file mode 100644 index 6a37e3bd..00000000 --- a/tests/integration/recovery-walk-tolerance.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * @module tests/integration/recovery-walk-tolerance - * @description The rc6-red cures — the typed/tolerant boundary redrawn where - * block-layer fault injection proved it belonged: - * 1. WALKS ARE HEALERS: an init-time recovery/rebuild/pagination walk that - * meets a torn record narrates+counts (the adapter's loud floor) and - * HEALS PAST it — the open succeeds, remaining rows serve. rc6 died - * typed here; rc5 survived silently; the cure is loud survival. - * 2. IDENTITY READS STAY TYPED: get-by-id of the torn record itself still - * throws TornRecordError — a caller who asked for THAT record can act. - * 3. TORN MAPPER STATE (the NaN→BigInt source): a mapper file carrying - * garbage integers is discarded with narration; reopen succeeds and the - * FIRST WRITE after recovery mints sanely — never a RangeError. - */ -import { describe, it, expect, afterEach } from 'vitest' -import { mkdtempSync, rmSync, readdirSync, writeFileSync, existsSync, statSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { gzipSync } from 'node:zlib' -import { Brainy, TornRecordError } from '../../src/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -const dirs: string[] = [] -const brains: Brainy[] = [] -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -async function open(dir: string): Promise { - const b = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false }) - await b.init() - brains.push(b) - return b -} - -/** Find one entity metadata file under entities/nouns and tear it. */ -function tearOneNounMetadata(dir: string, excludeId?: string): string { - const nounsRoot = join(dir, 'entities', 'nouns') - const walk = (d: string): string | null => { - for (const e of readdirSync(d, { withFileTypes: true })) { - const p = join(d, e.name) - if (e.isDirectory()) { - if (excludeId && e.name === excludeId) continue - const hit = walk(p) - if (hit) return hit - } else if (/^metadata\.json(\.gz)?$/.test(e.name)) { - writeFileSync(p, Buffer.from([0x1f, 0x8b, 0x00, 0xde, 0xad])) // torn gz - return p - } - } - return null - } - const torn = walk(nounsRoot) - if (!torn) throw new Error('layout probe: no noun metadata file found to tear') - // The id is the parent directory name. - return torn.split('/').slice(-2, -1)[0] -} - -describe('recovery-walk tolerance (the rc6-red cures)', () => { - it('a torn entity record does not kill the open: recovery walks heal past it, remaining rows serve, identity read throws typed', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-walk-tol-')) - dirs.push(dir) - let brain = await open(dir) - const keeper = await brain.add({ data: 'keeper row', type: NounType.Document, metadata: { k: 1 } }) - await brain.add({ data: 'victim row', type: NounType.Document, metadata: { k: 2 } }) - await brain.flush() - await brain.close() - brains.pop() - - const tornId = tearOneNounMetadata(dir, keeper) - - // THE PIN: the open succeeds (rc6 died right here), the keeper serves, - // and walks (find) heal past the victim. - brain = await open(dir) - expect((await brain.get(keeper))!.data).toContain('keeper row') - const rows = await brain.find({ where: {}, limit: 10 }) - expect(rows.map((r) => r.id)).toContain(keeper) - - // Identity read of the victim itself: typed, catchable — the caller - // asked for THAT record; under log authority the replay may have - // already HEALED it from the fact log (also a valid outcome) — accept - // healed-or-typed, never silent-absent-without-narration. - try { - const victim = await brain.get(tornId) - // Healed by replay: the record must be real (log authority rewrote it). - expect(victim).not.toBeNull() - } catch (err) { - expect(err).toBeInstanceOf(TornRecordError) - } - }, 120000) - - it('a torn mapper file (NaN ints) discards with narration; reopen succeeds and the first write mints sanely', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-torn-mapper-')) - dirs.push(dir) - let brain = await open(dir) - await brain.add({ data: 'pre-crash row', type: NounType.Document, metadata: { k: 1 } }) - await brain.flush() - await brain.close() - brains.pop() - - // The power-cut shape: the persisted mapper carries garbage integers. - const sys = join(dir, '_system') - const mapperPath = readdirSync(sys) - .filter((f) => /entityIdMapper/.test(f)) - .map((f) => join(sys, f))[0] - expect(mapperPath, 'layout probe: mapper artifact exists').toBeTruthy() - const torn = { nextId: 'NaN-garbage', uuidToInt: { x: 'junk' }, intToUuid: { junk: 42 } } - if (mapperPath.endsWith('.gz')) writeFileSync(mapperPath, gzipSync(JSON.stringify(torn))) - else writeFileSync(mapperPath, JSON.stringify(torn)) - expect(statSync(mapperPath).size).toBeGreaterThan(0) - - // Reopen MUST succeed; the first write after recovery must mint sanely - // (rc6's fresh-write RangeError shape), and graph int resolution at - // reopen must not throw (rc6's reopen shape). - brain = await open(dir) - const fresh = await brain.add({ data: 'post-recovery write', type: NounType.Document, metadata: { k: 2 } }) - expect((await brain.get(fresh))!.data).toContain('post-recovery') - await brain.flush() - expect(Number.isSafeInteger(brain.generation())).toBe(true) - }, 120000) -}) diff --git a/tests/integration/related-verb-array.test.ts b/tests/integration/related-verb-array.test.ts deleted file mode 100644 index 7ed1bd3f..00000000 --- a/tests/integration/related-verb-array.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @module tests/integration/related-verb-array - * @description related() honours EVERY verb type in an array (10.4.9). - * - * The storage fast paths for `sourceId + verbType` and `verbType` collapsed a - * verb-type ARRAY to its first element — `related({ from, type: [a, b] })` - * silently returned only `a` edges, whichever order the array came in. The - * same quiet-loss class as the graph-first paging defect, one seam over. - * These pins seed a store where the SECOND requested type's edge must come - * back, on every path the collapse lived in. - */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { Brainy } from '../../src/brainy' -import { NounType, VerbType } from '../../src/types/graphTypes' -import { v5 } from '../../src/universal/uuid' - -describe('related() with a verb-type array returns every requested type', () => { - let brain: Brainy - - beforeAll(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - for (const id of ['a', 'b', 'c', 'd']) { - await brain.add({ id, data: `node ${id}`, type: NounType.Person }) - } - await brain.relate({ from: 'a', to: 'b', type: VerbType.Supports }) - await brain.relate({ from: 'a', to: 'c', type: VerbType.RelatedTo }) - await brain.relate({ from: 'a', to: 'd', type: VerbType.Knows }) - await brain.relate({ from: 'b', to: 'c', type: VerbType.RelatedTo }) - }) - - afterAll(async () => { - await brain.close() - brain = null as any - }) - - it('from + type array: the second type\'s edge comes back, both orders', async () => { - for (const types of [ - [VerbType.Supports, VerbType.RelatedTo], - [VerbType.RelatedTo, VerbType.Supports] - ]) { - const edges = await brain.related({ from: 'a', type: types }) - const targets = new Set(edges.map((e) => e.to)) - expect(targets.has(v5('b')), `types [${types}] missing Supports edge`).toBe(true) - expect(targets.has(v5('c')), `types [${types}] missing RelatedTo edge`).toBe(true) - expect(targets.has(v5('d'))).toBe(false) - expect(edges).toHaveLength(2) - } - }) - - it('a single-element array behaves exactly like the scalar', async () => { - const scalar = await brain.related({ from: 'a', type: VerbType.Supports }) - const array = await brain.related({ from: 'a', type: [VerbType.Supports] }) - expect(array.map((e) => e.id).sort()).toEqual(scalar.map((e) => e.id).sort()) - expect(array).toHaveLength(1) - }) - - it('no duplicate edges when types overlap the same edge set', async () => { - const edges = await brain.related({ - from: 'a', - type: [VerbType.Supports, VerbType.RelatedTo, VerbType.Knows] - }) - const ids = edges.map((e) => e.id) - expect(new Set(ids).size).toBe(ids.length) - expect(edges).toHaveLength(3) - }) - - it('type-only asks (no anchor) honour the whole array too', async () => { - const edges = await brain.related({ type: [VerbType.Supports, VerbType.Knows] }) - const verbs = new Set(edges.map((e) => e.type)) - expect(verbs.has(VerbType.Supports)).toBe(true) - expect(verbs.has(VerbType.Knows)).toBe(true) - expect(edges).toHaveLength(2) - }) - - it('to + type array: the target side honours every type too', async () => { - const edges = await brain.related({ to: 'c', type: [VerbType.RelatedTo, VerbType.Supports] }) - const froms = new Set(edges.map((e) => e.from)) - expect(froms.has(v5('a'))).toBe(true) - expect(froms.has(v5('b'))).toBe(true) - expect(edges).toHaveLength(2) - }) - - it('pagination stays consistent across the union', async () => { - const page1 = await brain.related({ from: 'a', type: [VerbType.Supports, VerbType.RelatedTo, VerbType.Knows], limit: 2 }) - const page2 = await brain.related({ from: 'a', type: [VerbType.Supports, VerbType.RelatedTo, VerbType.Knows], limit: 2, offset: 2 }) - const all = [...page1, ...page2].map((e) => e.id) - expect(new Set(all).size).toBe(3) - }) -}) diff --git a/tests/integration/relationship-intelligence.test.ts b/tests/integration/relationship-intelligence.test.ts index c18057fb..b6e11cb5 100644 --- a/tests/integration/relationship-intelligence.test.ts +++ b/tests/integration/relationship-intelligence.test.ts @@ -59,8 +59,7 @@ describe('Relationship Intelligence', () => { await brain.init() }) - afterEach(async () => { - await brain.close() + afterEach(() => { if (fs.existsSync(testDir)) { fs.rmSync(testDir, { recursive: true }) } diff --git a/tests/integration/remaining-apis.test.ts b/tests/integration/remaining-apis.test.ts index f7cd17e9..12d60983 100644 --- a/tests/integration/remaining-apis.test.ts +++ b/tests/integration/remaining-apis.test.ts @@ -367,9 +367,7 @@ Gadget,20` const time = Date.now() - start expect(entries.length).toBe(20) - // order-of-magnitude guard: worst honest-iron measurement 8.85s - // (32-core CPU-only box), 3x headroom - expect(time).toBeLessThan(30000) + expect(time).toBeLessThan(5000) // < 5 seconds console.log(` ✅ Created and copied 20 files in ${time}ms`) }) }) diff --git a/tests/integration/repair-narration.test.ts b/tests/integration/repair-narration.test.ts deleted file mode 100644 index 1fbe15e4..00000000 --- a/tests/integration/repair-narration.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * @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/repair-report.test.ts b/tests/integration/repair-report.test.ts deleted file mode 100644 index 28e0ad99..00000000 --- a/tests/integration/repair-report.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * @module tests/integration/repair-report - * @description repairIndex() returns the per-family receipt (checked / - * healed / skipped-with-reason per family) and narrates a summary — the - * "repair that shows its work" half of the graph-trust program's ask. A - * repair nobody can audit is a repair nobody can trust. - */ -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/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -type RawBox = { - storage: { writeNounRaw(id: string, r: { metadata: unknown; vector: unknown }): Promise } -} - -const dirs: string[] = [] -const brains: Brainy[] = [] -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -describe('repairIndex per-family receipt', () => { - it('a healthy store gets a complete zero-heal receipt — every family accounted, none silent', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-repair-clean-')) - dirs.push(dir) - const brain = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false, silent: true }) - await brain.init() - brains.push(brain) - await brain.add({ data: 'healthy row', type: NounType.Document, metadata: { n: 1 } }) - await brain.flush() - - const report = await brain.repairIndex() - expect(report.families.length, 'every family reports a row').toBeGreaterThanOrEqual(5) - const names = report.families.map((f) => f.family) - for (const expected of ['orphaned-containers', 'count-rollups', 'metadata-corruption']) { - expect(names, `family ${expected} accounted`).toContain(expected) - } - // Every row is either checked or carries its skip reason — no silent rows. - for (const f of report.families) { - expect(f.checked || !!f.skipped, `${f.family} is checked or explains itself`).toBe(true) - } - expect(report.healedTotal).toBe(0) - expect(report.durationMs).toBeGreaterThanOrEqual(0) - }, 120000) - - it('a manufactured ghost container appears in the receipt as a heal', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-repair-ghost-')) - dirs.push(dir) - const brain = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false, silent: true }) - await brain.init() - brains.push(brain) - await brain.add({ data: 'real row', type: NounType.Document, metadata: { n: 1 } }) - await brain.flush() - // The pre-8.3.1 ghost shape: a vector leg with no content leg. - const storage = (brain as unknown as RawBox).storage - await storage.writeNounRaw('00000000-0000-7000-8000-00000000dead', { - metadata: null, - vector: { vector: [0.1, 0.2], noun: 'document' } - }) - - const report = await brain.repairIndex() - const orphans = report.families.find((f) => f.family === 'orphaned-containers') - expect(orphans?.checked).toBe(true) - expect(orphans!.healed, 'the ghost was pruned and receipted').toBeGreaterThan(0) - expect(report.healedTotal).toBeGreaterThan(0) - }, 120000) - - - it("a heal:'repair' verdict routes to the provider's own repair(), and the re-read decides", async () => { - // A fake provider report: one failing invariant asking for the INCREMENTAL - // heal. repairIndex must call repair() (never rebuild()) and count the heal - // only when the post-repair re-read clears the same verdict. - const dir = mkdtempSync(join(tmpdir(), 'brainy-repair-route-')) - const brain: any = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false, silent: true }) - await brain.init() - brains.push(brain) - let repairCalls = 0 - let rebuildCalls = 0 - let healed = false - const failing = { - provider: 'vector', healthy: false, serving: true, - invariants: [{ name: 'node-coverage', holds: false, detail: 'short 3', heal: 'repair' as const }], - checkedAt: 1, durationMs: 1 - } - const clean = { - provider: 'vector', healthy: true, serving: true, - invariants: [{ name: 'node-coverage', holds: true, detail: 'ok', heal: 'none' as const }], - checkedAt: 2, durationMs: 1 - } - ;(brain.index as any).validateInvariants = async () => (healed ? clean : failing) - ;(brain.index as any).repair = async () => { repairCalls++; healed = true; return { repaired: 3 } } - const origRebuild = (brain.index as any).rebuild - ;(brain.index as any).rebuild = async () => { rebuildCalls++ } - try { - const report = await brain.repairIndex() - const row = report.families.find((f: any) => f.family === 'provider:vector') - expect(row, 'the provider family is in the receipt').toBeDefined() - expect(repairCalls, 'repair() ran exactly once').toBe(1) - expect(rebuildCalls, "a heal:'repair' verdict never runs rebuild()").toBe(0) - expect(row!.healed, 'the cleared verdict counts as healed').toBe(1) - expect(String(row!.detail)).toMatch(/incremental repair cleared: node-coverage/) - } finally { - delete (brain.index as any).validateInvariants - delete (brain.index as any).repair - ;(brain.index as any).rebuild = origRebuild - } - }) - -}) diff --git a/tests/integration/reprojection-doors-open.test.ts b/tests/integration/reprojection-doors-open.test.ts deleted file mode 100644 index 343bc536..00000000 --- a/tests/integration/reprojection-doors-open.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * @module tests/integration/reprojection-doors-open - * @description The reprojection engine against a REAL brain on filesystem - * storage: a toy secondary projection (bucket counts with its own watermark - * artifact, stamp-after-data per src/utils/projectionWatermark.ts) folds the - * brain's committed facts through the engine, wired with the callback-form - * {@link FactLogSource} over `brain.scanFacts`. - * - * Proves the three doors-open rows: - * (i) folding to caught-up matches ground-truth counts; - * (ii) mid-fold, `find()` and `get()` still answer, and a door bump - * preempts the advance at the next boundary (mechanism-pinned via - * batch counts, not wall-clock); - * (iii) a crash mid-fold (abandon; reopen; re-advance) resumes from the - * durable stamp — never refolds from zero. - */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Brainy } from '../../src/index.js' -import { - ReprojectionEngine, - type ProjectionAdapter -} from '../../src/reprojection/reprojectionEngine.js' -import { FactLogSource } from '../../src/reprojection/factLogSource.js' -import { makeProjectionStamp, readStampedWatermark } from '../../src/utils/projectionWatermark.js' -import type { CommitFact } from '../../src/db/factLog.js' - -/** 50 rows, 5 buckets, 10 each. */ -const ROWS = 50 -const BUCKETS = 5 -const GROUND_TRUTH: Record = { b0: 10, b1: 10, b2: 10, b3: 10, b4: 10 } - -/** - * The toy secondary projection: latest bucket per entity id, persisted as a - * data file plus a SEPARATE stamp artifact written stamp-after-data via the - * shared projectionWatermark helpers. Idempotent by construction (latest- - * state per id), so at-least-once redelivery on resume is harmless. - */ -class BucketCountProjection implements ProjectionAdapter { - readonly family = 'bucket-counts' - /** Every generation this INSTANCE applied — the refold detector for (iii). */ - readonly appliedGenerations: number[] = [] - private latest: Map - private wm: number | null - - private constructor( - private readonly dir: string, - wm: number | null, - latest: Map - ) { - this.wm = wm - this.latest = latest - } - - /** Load from the artifact dir — data is trusted only under a valid stamp. */ - static async open(dir: string): Promise { - mkdirSync(dir, { recursive: true }) - const stampPath = join(dir, 'stamp.json') - const dataPath = join(dir, 'data.json') - let wm: number | null = null - if (existsSync(stampPath)) { - wm = readStampedWatermark(JSON.parse(readFileSync(stampPath, 'utf8'))) - } - const latest = new Map( - wm !== null && existsSync(dataPath) - ? (JSON.parse(readFileSync(dataPath, 'utf8')) as Array<[string, string | null]>) - : [] - ) - return new BucketCountProjection(dir, wm, latest) - } - - /** Non-null bucket tallies from the latest-state map. */ - counts(): Record { - const out: Record = {} - for (const bucket of this.latest.values()) { - if (bucket !== null) out[bucket] = (out[bucket] ?? 0) + 1 - } - return out - } - - watermark(): number | null { - return this.wm - } - - async applyBatch(facts: CommitFact[], upTo: number): Promise { - for (const fact of facts) { - this.appliedGenerations.push(fact.generation) - for (const op of fact.ops) { - if (op.kind !== 'noun') continue - if (op.record === null) { - this.latest.set(op.id, null) // tombstone - continue - } - // The stored noun record nests user metadata under `.metadata`. - const stored = op.record.metadata as Record | null - const user = (stored?.metadata ?? stored) as Record | null - const bucket = typeof user?.bucket === 'string' ? user.bucket : null - this.latest.set(op.id, bucket) - } - } - // Durability THEN stamp — the projectionWatermark law. - writeFileSync(join(this.dir, 'data.json'), JSON.stringify([...this.latest])) - writeFileSync(join(this.dir, 'stamp.json'), JSON.stringify(makeProjectionStamp(upTo))) - this.wm = upTo - } - - async discard(): Promise { - rmSync(this.dir, { recursive: true, force: true }) - } -} - -describe('reprojection doors-open — a real brain, a toy secondary projection', () => { - let brainDir: string - let projRoot: string - let brain: Brainy - const ids: string[] = [] - - const openBrain = async (dir: string): Promise => { - const b = new Brainy({ - storage: { type: 'filesystem', path: dir }, - requireSubtype: false, - silent: true, - dimensions: 384 - }) - await b.init() - return b - } - - /** - * The production wiring, callback form: the engine's `from` is an EXCLUSIVE - * lower bound, `scanFacts` bounds are inclusive — hence `from + 1`; the - * first batch is returned and the handle closed (short batches at segment - * boundaries are legal — only EMPTY means caught up). - */ - const sourceFor = (b: Brainy): FactLogSource => - new FactLogSource(async (from, limit) => { - const scan = b.scanFacts({ fromGeneration: from + 1, batchSize: limit }) - if (!scan) throw new Error('this brain hosts no fact log — cannot reproject') - const iterator = scan.batches() - try { - const first = await iterator.next() - return first.done ? [] : first.value.facts - } finally { - if (typeof iterator.return === 'function') await iterator.return(undefined) - } - }) - - beforeAll(async () => { - brainDir = mkdtempSync(join(tmpdir(), 'brainy-reproj-')) - projRoot = mkdtempSync(join(tmpdir(), 'brainy-reproj-artifacts-')) - brain = await openBrain(brainDir) - for (let i = 0; i < ROWS; i++) { - ids.push( - await brain.add({ - data: `record ${i} filed in bucket ${i % BUCKETS}`, - type: 'document', - metadata: { bucket: `b${i % BUCKETS}` } - }) - ) - } - }, 240_000) - - afterAll(async () => { - await brain?.close().catch(() => {}) - rmSync(brainDir, { recursive: true, force: true }) - rmSync(projRoot, { recursive: true, force: true }) - }) - - it('(i) folds to caught-up through the engine and matches ground-truth counts', async () => { - const projection = await BucketCountProjection.open(join(projRoot, 'i')) - const engine = new ReprojectionEngine({ source: sourceFor(brain), batchSize: 8 }) - engine.register(projection) - - const result = await engine.advance(projection.family, { budgetMs: 60_000 }) - - expect(result.status).toBe('caught-up') - expect(result.watermark).toBeGreaterThanOrEqual(ROWS) // one generation per add, at least - expect(result.applied).toBeGreaterThanOrEqual(ROWS) - expect(engine.quarantined(projection.family)).toEqual([]) - expect(projection.counts()).toEqual(GROUND_TRUTH) - // The stamp on disk is the adapter's own — stamped exactly at the fold head. - const reloaded = await BucketCountProjection.open(join(projRoot, 'i')) - expect(reloaded.watermark()).toBe(result.watermark) - expect(reloaded.counts()).toEqual(GROUND_TRUTH) - }) - - it('(ii) doors stay open mid-fold: find() and get() answer, and a bump preempts the advance', async () => { - const projection = await BucketCountProjection.open(join(projRoot, 'ii')) - const engine = new ReprojectionEngine({ source: sourceFor(brain), batchSize: 4 }) - engine.register(projection) - const head = brain.scanFacts()!.headGeneration - - const inFlight = engine.advance(projection.family, { budgetMs: 60_000 }) - // The read hook: foreground door traffic announces itself, then reads — - // both interleave with the running fold on the same event loop. - engine.doorSignal.bump() - const found = await brain.find({ query: 'record filed in bucket', limit: 3 }) - const got = await brain.get(ids[0]) - const result = await inFlight - - // The doors answered mid-fold. - expect(found.length).toBeGreaterThan(0) - expect(got).toBeTruthy() - const gotMeta = got!.metadata as Record | undefined - expect((gotMeta?.bucket ?? (gotMeta?.metadata as Record)?.bucket)).toBe('b0') - - // THE PREEMPTION PIN — mechanism, not wall-clock: the bump landed before - // the first installment boundary, so the advance yielded after exactly - // one batch (≤ batchSize facts), far short of the head. - expect(result.status).toBe('preempted') - expect(result.applied).toBeGreaterThan(0) - expect(result.applied).toBeLessThanOrEqual(4) - expect(projection.appliedGenerations.length).toBe(result.applied) - expect(projection.watermark()).not.toBeNull() - expect(projection.watermark()!).toBeLessThan(head) - - // Resuming folds the remainder; nothing was lost to the preemption. - const resumed = await engine.advance(projection.family, { budgetMs: 60_000 }) - expect(resumed.status).toBe('caught-up') - expect(projection.counts()).toEqual(GROUND_TRUTH) - }) - - it('(iii) crash mid-fold: reopen and re-advance resumes from the stamp, never refolds from zero', async () => { - const projDir = join(projRoot, 'iii') - const before = await BucketCountProjection.open(projDir) - const engine1 = new ReprojectionEngine({ source: sourceFor(brain), batchSize: 4 }) - engine1.register(before) - - // A zero budget folds exactly one guaranteed batch, then stops. - const partial = await engine1.advance(before.family, { budgetMs: 0 }) - expect(partial.status).toBe('budget-exhausted') - const stamped = before.watermark() - expect(stamped).not.toBeNull() - expect(stamped!).toBeGreaterThan(0) - - // CRASH: abandon the engine and adapter mid-fold; reopen the brain cold. - await brain.close() - brain = await openBrain(brainDir) - - const after = await BucketCountProjection.open(projDir) - expect(after.watermark()).toBe(stamped) // the stamp survived the crash - - const engine2 = new ReprojectionEngine({ source: sourceFor(brain), batchSize: 4 }) - engine2.register(after) - const resumed = await engine2.advance(after.family, { budgetMs: 60_000 }) - expect(resumed.status).toBe('caught-up') - - // NEVER REFOLDS FROM ZERO: every generation the resumed instance applied - // sits strictly above the crash stamp. - expect(after.appliedGenerations.length).toBeGreaterThan(0) - expect(Math.min(...after.appliedGenerations)).toBeGreaterThan(stamped!) - // And the combined state — durable prefix plus resumed fold — is exact. - expect(after.counts()).toEqual(GROUND_TRUTH) - }) -}) diff --git a/tests/integration/reserved-root-mint.test.ts b/tests/integration/reserved-root-mint.test.ts deleted file mode 100644 index f9577842..00000000 --- a/tests/integration/reserved-root-mint.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * @module tests/integration/reserved-root-mint - * @description THE RESERVED-ROOT MINT EXEMPTION (the release's final fix): - * existing brains mint the VFS root (the all-zeros UUID) as int 0 by - * construction at genesis — the one legitimate zero in the id space. The - * adoption path must accept it (every real depot brain refused adoption - * over this); a zero mint for ANY OTHER id remains a corrupt-mint refusal. - */ -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/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -const ROOT = '00000000-0000-0000-0000-000000000000' -const dirs: string[] = [] -const brains: Brainy[] = [] -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -type MapperBox = { - metadataIndex: { - getIdMapper(): { - uuidToInt: Map - intToUuid: Map - dirty?: boolean - } - } -} - -describe('reserved-root mint exemption', () => { - it('adoption succeeds on a brain whose VFS root carries int 0 (the depot-brain shape)', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-root0-')) - dirs.push(dir) - // Build the brain in 'defer' so we control the adoption moment. - const brain = new Brainy({ - storage: { type: 'filesystem', path: dir }, - requireSubtype: false, - logAuthority: 'defer' - }) - await brain.init() - brains.push(brain) - await brain.add({ data: 'depot row', type: NounType.Document, metadata: { k: 1 } }) - - // The genesis-era shape: the root's mint is 0 (white-box — real depot - // brains carry this in their persisted mapper). - const mapper = (brain as unknown as MapperBox).metadataIndex.getIdMapper() - const currentInt = mapper.uuidToInt.get(ROOT) - if (currentInt !== undefined) mapper.intToUuid.delete(currentInt) - mapper.uuidToInt.set(ROOT, 0) - mapper.intToUuid.set(0, ROOT) - - // THE PIN: adoption goes green — the backfill re-commits the root with - // its legitimate int 0 instead of refusing the whole brain. - const report = await brain.adoptLogAuthority() - expect(report.verdict).toBe('green') - expect(brain.logAuthority().authority).toBe('log') - // And the brain keeps serving + writing after the flip. - const fresh = await brain.add({ data: 'post-adopt', type: NounType.Document, metadata: { k: 2 } }) - expect((await brain.get(fresh))!.data).toContain('post-adopt') - }, 120000) - - it('a zero mint for a NON-root id still refuses at the mint seam, loudly and typed', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-nonroot0-')) - dirs.push(dir) - const brain = new Brainy({ - storage: { type: 'filesystem', path: dir }, - requireSubtype: false, - logAuthority: 'defer' - }) - await brain.init() - brains.push(brain) - const victim = await brain.add({ data: 'poisoned mint target', type: NounType.Document, metadata: {} }) - - // Corrupt shape: some OTHER id maps to 0. (A full update() SELF-HEALS - // this — the index cycle re-mints before the fact is written, which is - // the correct outcome — so the pin holds the guard at its real seam: - // the fact log's minter, which is what stands between a surviving zero - // and the wire.) - const mapper = (brain as unknown as MapperBox).metadataIndex.getIdMapper() - const currentInt = mapper.uuidToInt.get(victim) - if (currentInt !== undefined) mapper.intToUuid.delete(currentInt) - mapper.uuidToInt.set(victim, 0) - mapper.intToUuid.set(0, victim) - - const factLog = (brain as unknown as { - generationStore: { getFactLog(): { intMinter(kind: string, id: string): bigint } } - }).generationStore.getFactLog() - expect(() => factLog.intMinter('noun', victim)).toThrow( - /reserved for the VFS root|minted ints are positive/ - ) - // And the reserved root itself passes the same seam with 0. - mapper.uuidToInt.set(ROOT, 0) - mapper.intToUuid.set(0, ROOT) - expect(factLog.intMinter('noun', ROOT)).toBe(0n) - }, 120000) -}) diff --git a/tests/integration/rev-and-ifabsent.test.ts b/tests/integration/rev-and-ifabsent.test.ts index 3bff59f1..64b184a3 100644 --- a/tests/integration/rev-and-ifabsent.test.ts +++ b/tests/integration/rev-and-ifabsent.test.ts @@ -9,7 +9,7 @@ * - addMany({ ifAbsent: true }) applies the flag to every item */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy } from '../../src/brainy.js' import { RevisionConflictError } from '../../src/transaction/RevisionConflictError.js' import { NounType } from '../../src/types/graphTypes.js' @@ -22,10 +22,6 @@ describe('7.31.0 — _rev CAS + ifAbsent', () => { await brain.init() }) - afterEach(async () => { - await brain.close() - }) - describe('_rev initialization + surface', () => { it('initializes _rev to 1 on add()', async () => { const id = await brain.add({ data: 'hello', type: NounType.Document }) diff --git a/tests/integration/shutdown-single-owner.test.ts b/tests/integration/shutdown-single-owner.test.ts deleted file mode 100644 index 39f2ffc8..00000000 --- a/tests/integration/shutdown-single-owner.test.ts +++ /dev/null @@ -1,405 +0,0 @@ -/** - * @module tests/integration/shutdown-single-owner - * @description ONE SHUTDOWN, ONE OWNER. - * - * MEASURED IN PRODUCTION. A host that owns its own shutdown — one SIGTERM - * listener calling `close()` on every pooled store — ran head-on into the - * engine's own signal handler, which iterated every live instance, flushed its - * components in parallel, and released its writer lock in a `finally`. Two - * teardowns of the same brain at the same moment. The log shape: - * - * "Shutdown signal received - flushing pending data..." (SIGTERM) - * ...148 seconds of silence... - * "Flushed successfully (1 instance)" - * ...the host's pool close of that same store returns 1s later - * - * 149s for the one store with engine work in flight, against 24s for its six - * idle siblings. The same race in a local reproduction printed - * `Failed to flush one Brainy instance on shutdown: Writer fence lost … the - * lock file is gone` — the handler observing a lock the close it was racing - * had already released. - * - * The contract pinned here: - * (a) A host owner and the engine's hooks both live: EXACTLY ONE close runs - * per brain, no fence is lost, both durability markers are written, the - * process exits 0, and the reopen adopts rather than folding. - * (b) No host owner: the engine's handler closes every instance by the same - * `close()` path — markers written, clean exit. - * (c) `close()` is idempotent and re-entrant: concurrent callers share ONE - * execution and all of them settle. - * (d) Flush is single-flight: N kicks during a running flush arm exactly one - * follow-up, and two flush bodies never overlap. - */ - -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') -const BRAINY_SRC = join(REPO_ROOT, 'src', 'brainy.ts') - -function makeTempDir(prefix: string): string { - return mkdtempSync(join(tmpdir(), prefix)) -} - -/** The writer lock's clean-close record — written by `releaseWriterLock()`. */ -const closeRecordPath = (dir: string) => join(dir, 'locks', '_writer.close') -/** - * The generation store's clean-shutdown marker — the adopt-vs-fold gate. - * (`FileSystemStorage` gzips raw objects, so the file on disk carries `.gz`; - * both spellings are accepted so the pin survives a compression change.) - */ -const cleanShutdownWritten = (dir: string) => - existsSync(join(dir, '_system', 'clean-shutdown.json.gz')) || - existsSync(join(dir, '_system', 'clean-shutdown.json')) - -/** - * Write a child script and start it under tsx, in its OWN process group so a - * group-wide signal reaches the grandchild that actually holds the writer - * lock. (A file, not `tsx -e`: the eval form compiles to CommonJS, which has - * no top-level await.) - */ -function startChild(scriptDir: string, body: string): ReturnType { - const scriptPath = join(scriptDir, 'child-process.mts') - writeFileSync(scriptPath, body) - return spawn(TSX, [scriptPath], { - cwd: REPO_ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - detached: true - }) -} - -/** Start a child and resolve once it prints READY, collecting all its output. */ -function startAndAwaitReady( - scriptDir: string, - body: string -): Promise<{ child: ReturnType; output: () => string }> { - const child = startChild(scriptDir, body) - 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}`)) - }) - }) -} - -/** Capture console.warn/error/log lines emitted while `fn` runs. */ -async function captureConsole(fn: () => Promise): Promise<{ result: T; lines: string[] }> { - const lines: string[] = [] - const orig = { log: console.log, warn: console.warn, error: console.error } - const sink = (...args: unknown[]) => { lines.push(args.map((a) => String(a)).join(' ')) } - console.log = sink as typeof console.log - console.warn = sink as typeof console.warn - console.error = sink as typeof console.error - try { - return { result: await fn(), lines } - } finally { - console.log = orig.log - console.warn = orig.warn - console.error = orig.error - } -} - -/** - * Reopen a store and assert the open ADOPTED: no crash-recovery fold, no - * stale-lock verdict. This is the whole point of a close having run exactly - * once — a fold is measured in tens of seconds on a real store. - */ -async function expectCleanReopen(dir: string): Promise { - const { result, lines } = await captureConsole(async () => { - const next = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dir } }) - await next.init() - return next - }) - try { - expect(lines.filter((l) => /log-authority recovery|unclean shutdown detected/i.test(l))).toEqual([]) - expect(lines.filter((l) => /Overwriting stale writer lock|appears dead/i.test(l))).toEqual([]) - } finally { - await result.close() - } -} - -/** The child's counts of closes entered and close bodies run, per brain. */ -function readResult( - resultPath: string, - out: string -): { entries: Record; bodies: Record; releases: Record } { - if (!existsSync(resultPath)) throw new Error(`child wrote no result file:\n${out}`) - return JSON.parse(readFileSync(resultPath, 'utf-8')) -} - -/** - * The child-side instrumentation, shared by (a) and (b): count how many times - * `close()` is ENTERED per brain and how many times its body actually RUNS. - * The counting wrapper is an OWN property, so it shadows the prototype for - * every caller — including the engine's own signal handler, which calls - * `instance.close()`. - * - * `report()` writes SYNCHRONOUSLY to a file: it runs on the way out of the - * process (the engine's handler calls `process.exit(0)` when it is the sole - * shutdown owner), and a `console.log` to a pipe is asynchronous and can be - * dropped by that exit. - */ -function childCounters(resultPath: string): string { - return ` - const entries = {} - const bodies = {} - const releases = {} - function instrument(name, brain) { - entries[name] = 0 - bodies[name] = 0 - releases[name] = 0 - const enter = brain.close.bind(brain) - brain.close = () => { entries[name]++; return enter() } - const durable = brain.closeDurableSteps.bind(brain) - brain.closeDurableSteps = () => { bodies[name]++; return durable() } - // The writer lock is the ownership witness: the old handler released it - // in its own finally, on top of the owner's close doing the same. - const storage = brain.storage - const release = storage.releaseWriterLock.bind(storage) - storage.releaseWriterLock = () => { releases[name]++; return release() } - } - const report = () => { - __writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify({ entries, bodies, releases })) - } -` -} - -describe('shutdown has exactly one owner', () => { - let dirA: string - let dirB: string - let scriptDir: string - let resultPath: string - - beforeEach(() => { - dirA = makeTempDir('brainy-shutdown-owner-a-') - dirB = makeTempDir('brainy-shutdown-owner-b-') - scriptDir = makeTempDir('brainy-shutdown-owner-script-') - resultPath = join(scriptDir, 'result.json') - }) - - afterEach(() => { - for (const d of [dirA, dirB, scriptDir]) { - try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ } - } - }) - - it('(a) a host owner closes both brains and the engine handler steps aside', async () => { - const script = ` - import { writeFileSync as __writeFileSync } from 'node:fs' - import { Brainy } from ${JSON.stringify(BRAINY_SRC)} - const a = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: ${JSON.stringify(dirA)} } }) - const b = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: ${JSON.stringify(dirB)} } }) - await a.init() - await b.init() - await a.add({ data: 'row in brain a', type: 'concept' }) - await b.add({ data: 'row in brain b', type: 'concept' }) - ${childCounters(resultPath)} - instrument('a', a) - instrument('b', b) - // THE HOST'S OWN SHUTDOWN OWNER, registered after the engine's hooks — - // the ordinary shape: the pool was built before the signal wiring. - process.on('SIGTERM', async () => { - await Promise.all([a.close(), b.close()]) - // Stay alive a beat so the engine's deferred handler gets its turn and - // has to decide what to do about two already-closed brains. - await new Promise((r) => setTimeout(r, 1500)) - report() - process.exit(0) - }) - console.log('READY') - setInterval(() => {}, 1000) - ` - const { child, output } = await startAndAwaitReady(scriptDir, script) - - process.kill(-(child.pid as number), 'SIGTERM') - const code = await new Promise((r) => child.on('exit', (c) => r(c))) - // The tsx wrapper's exit event and the grandchild that actually held the - // locks are asynchronous with each other — let its last writes land. - await new Promise((r) => setTimeout(r, 750)) - const out = output() - - // The process shut down cleanly. - expect(code, `child output:\n${out}`).toBe(0) - - // EXACTLY ONE close per brain — entered once, body run once. A second - // entry would mean the engine's handler closed a brain its owner was - // already closing; a second body would mean close() is not single-flight. - const { entries, bodies, releases } = readResult(resultPath, out) - expect(entries).toEqual({ a: 1, b: 1 }) - expect(bodies).toEqual({ a: 1, b: 1 }) - // ...and the writer lock was given up exactly once per brain. This is the - // assertion that fails on the old handler, which released the lock in its - // own `finally` on top of the owner's close doing the same — two owners. - expect(releases).toEqual({ a: 1, b: 1 }) - - // The engine's handler ran (it announced the signal) and stepped aside for - // both brains rather than touching them. setImmediate lands in the check - // phase of the same loop turn, so a close that has begun cannot have - // finished — it is still in flight when the handler looks. - expect(out).toContain('Shutdown signal received') - expect(out).toMatch(/2 Brainy instances are already closing/) - - // Nothing was taken out from under the owner, and nothing failed. - expect(out).not.toMatch(/Writer fence lost/i) - expect(out).not.toMatch(/Failed to (flush|close) one Brainy instance/i) - - // Both durability markers, both brains: the writer lock's clean-close - // record and the generation store's clean-shutdown marker. - for (const dir of [dirA, dirB]) { - expect(existsSync(closeRecordPath(dir)), `clean-close record missing in ${dir}`).toBe(true) - expect(cleanShutdownWritten(dir), `clean-shutdown marker missing in ${dir}`).toBe(true) - } - - // And the next open adopts instead of folding. - await expectCleanReopen(dirA) - await expectCleanReopen(dirB) - }, 240_000) - - it('(b) with no host owner the engine closes every instance the same way', async () => { - const script = ` - import { writeFileSync as __writeFileSync } from 'node:fs' - import { Brainy } from ${JSON.stringify(BRAINY_SRC)} - const a = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: ${JSON.stringify(dirA)} } }) - const b = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: ${JSON.stringify(dirB)} } }) - await a.init() - await b.init() - await a.add({ data: 'row in brain a', type: 'concept' }) - await b.add({ data: 'row in brain b', type: 'concept' }) - ${childCounters(resultPath)} - instrument('a', a) - instrument('b', b) - process.on('exit', report) - console.log('READY') - setInterval(() => {}, 1000) - ` - const { child, output } = await startAndAwaitReady(scriptDir, script) - - process.kill(-(child.pid as number), 'SIGTERM') - const code = await new Promise((r) => child.on('exit', (c) => r(c))) - // The tsx wrapper's exit event and the grandchild that actually held the - // locks are asynchronous with each other — let its last writes land. - await new Promise((r) => setTimeout(r, 750)) - const out = output() - - expect(code, `child output:\n${out}`).toBe(0) - - // The engine owned this shutdown: one close per brain, through close(). - const { entries, bodies, releases } = readResult(resultPath, out) - expect(entries).toEqual({ a: 1, b: 1 }) - expect(bodies).toEqual({ a: 1, b: 1 }) - expect(releases).toEqual({ a: 1, b: 1 }) - expect(out).toContain('Shutdown signal received') - expect(out).toMatch(/Flushed successfully \(2 instances\)/) - expect(out).not.toMatch(/Writer fence lost/i) - expect(out).not.toMatch(/Failed to (flush|close) one Brainy instance/i) - - for (const dir of [dirA, dirB]) { - expect(existsSync(closeRecordPath(dir)), `clean-close record missing in ${dir}`).toBe(true) - expect(cleanShutdownWritten(dir), `clean-shutdown marker missing in ${dir}`).toBe(true) - } - - await expectCleanReopen(dirA) - await expectCleanReopen(dirB) - }, 240_000) - - it('(c) two concurrent close() callers share ONE execution, and both settle', async () => { - const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dirA } }) - await brain.init() - await brain.add({ data: 'one row', type: NounType.Concept }) - - const inner = brain as unknown as { closeDurableSteps: () => Promise } - const durable = inner.closeDurableSteps.bind(inner) - let bodies = 0 - inner.closeDurableSteps = () => { bodies++; return durable() } - - expect(brain.isClosing).toBe(false) - expect(brain.isClosed).toBe(false) - - const first = brain.close() - // The state is observable IMMEDIATELY — a signal handler that yields a - // tick and comes back must not read a stale "not yet". - expect(brain.isClosing).toBe(true) - const second = brain.close() - expect(first === second, 'concurrent callers must share the one promise').toBe(true) - - await Promise.all([first, second]) - expect(bodies).toBe(1) - expect(brain.isClosed).toBe(true) - - // A caller arriving after the close finished gets the same settled answer, - // and nothing runs again. - await brain.close() - expect(bodies).toBe(1) - - expect(existsSync(closeRecordPath(dirA))).toBe(true) - expect(cleanShutdownWritten(dirA)).toBe(true) - }, 120_000) - - it('(d) N kicks during a running flush arm exactly one follow-up, never a second flush', async () => { - const brain = new Brainy({ requireSubtype: false, storage: { type: 'filesystem', path: dirA } }) - await brain.init() - - const inner = brain as unknown as { - _flushBodyRuns: number - _flushConcurrencyPeak: number - _flushInFlight: Promise | null - _flushQueued: Promise | null - _persistBackgroundFlight: Promise | null - metadataIndex: { flush: () => Promise } - kickBackgroundFlush: (reason: 'threshold' | 'idle') => void - } - - // Widen the flush body's window so the kicks land INSIDE it — the - // production shape, where two flushes overlapped 3s apart. - const metaFlush = inner.metadataIndex.flush.bind(inner.metadataIndex) - inner.metadataIndex.flush = async () => { - await new Promise((r) => setTimeout(r, 400)) - return metaFlush() - } - - await brain.add({ data: 'a write to flush', type: NounType.Concept }) - const runsBefore = inner._flushBodyRuns - - const leader = brain.flush() - await new Promise((r) => setTimeout(r, 50)) // the leader is inside its body - expect(inner._flushInFlight, 'a flush is running').not.toBeNull() - - // The cadence kicks — the door named in the defect — plus direct callers - // (an application flush, the cross-process flush-request watcher). - for (let i = 0; i < 5; i++) inner.kickBackgroundFlush('threshold') - const direct = [brain.flush(), brain.flush(), brain.flush()] - - // EXACTLY ONE follow-up is armed, however many callers arrived. - expect(inner._flushQueued, 'the eight kicks armed one follow-up').not.toBeNull() - - await Promise.all([leader, ...direct, inner._persistBackgroundFlight ?? Promise.resolve()]) - - // One leader + one follow-up. Not nine, and never two at once. - expect(inner._flushBodyRuns - runsBefore).toBe(2) - expect(inner._flushConcurrencyPeak).toBe(1) - expect(inner._flushInFlight).toBeNull() - expect(inner._flushQueued).toBeNull() - - inner.metadataIndex.flush = metaFlush - await brain.close() - }, 120_000) -}) diff --git a/tests/integration/storage-batch-operations.test.ts b/tests/integration/storage-batch-operations.test.ts index 53547fe5..9972df1a 100644 --- a/tests/integration/storage-batch-operations.test.ts +++ b/tests/integration/storage-batch-operations.test.ts @@ -95,13 +95,7 @@ describe('Storage-Level Batch Operations v5.12.0', () => { expect(entity?.vector?.length).toBeGreaterThan(0) }) - it('should be faster than individual gets for large batches', async (ctx) => { - // Wall-clock RATIO assertion — belongs to the perf lane (npm run - // test:perf), not the correctness gate: under the exclusive release - // gate this flaked when individual gets got faster on their own - // (open-path/hydration changes), not because batchGet regressed. - ctx.skip(!process.env.BRAINY_PERF_LANE, 'timing-ratio assertion — runs only under the perf lane (npm run test:perf)') - + it('should be faster than individual gets for large batches', async () => { // Create 100 entities const ids: string[] = [] for (let i = 0; i < 100; i++) { diff --git a/tests/integration/sync-fail-compensation.test.ts b/tests/integration/sync-fail-compensation.test.ts deleted file mode 100644 index f5032e34..00000000 --- a/tests/integration/sync-fail-compensation.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * @module tests/integration/sync-fail-compensation - * @description The non-monotonic refusal-loop cure (a production adoption's - * second defect): when the at-ack covering SYNC fails AFTER a successful - * append, the counter must NOT rewind unless the appended fact is provably - * removed — rewinding while the log carries the generation re-mints the - * same number and every later append refuses non-monotonic, wedging the - * write path in a refusal loop ("writes REFUSED until it drains"). - */ -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/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -const dirs: string[] = [] -const brains: Brainy[] = [] -afterEach(async () => { - vi.restoreAllMocks() - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -describe('at-ack sync-failure compensation', () => { - it('a one-shot sync failure never wedges the write path: the next write mints a FRESH generation and succeeds', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-syncfail-')) - dirs.push(dir) - const brain = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false }) - await brain.init() // adopt-default: log authority, at-ack - brains.push(brain) - expect(brain.logAuthority().authority).toBe('log') - await brain.add({ data: 'baseline', type: NounType.Document, metadata: { n: 0 } }) - - // Fail exactly ONE covering sync (after its append lands). - // Target ensureSynced (the ACK path's covering sync) — mocking sync() - // itself gets eaten by background flushes before the victim write. - const factLog = (brain as unknown as { - generationStore: { getFactLog(): { ensureSynced(): Promise } } - }).generationStore.getFactLog() - const realEnsure = factLog.ensureSynced.bind(factLog) - let failed = false - vi.spyOn(factLog, 'ensureSynced').mockImplementation(async () => { - if (!failed) { - failed = true - throw new Error('injected sync failure (device hiccup)') - } - return realEnsure() - }) - - // The write whose sync fails: LOUD failure to the caller — never silent. - await expect( - brain.add({ data: 'sync victim', type: NounType.Document, metadata: { n: 1 } }) - ).rejects.toThrow(/sync failure/) - - // THE PIN: the very next write mints a fresh generation and SUCCEEDS — - // no non-monotonic refusal, no refusal loop, regardless of whether the - // failed write's fact was dropped or retained (both are legal outcomes; - // an equal-generation re-mint is not). - const survivor = await brain.add({ data: 'after the storm', type: NounType.Document, metadata: { n: 2 } }) - expect((await brain.get(survivor))!.data).toContain('after the storm') - await brain.flush() - expect(Number.isSafeInteger(brain.generation())).toBe(true) - - // And the log scans clean end-to-end (no torn ordering). - const scan = brain.scanFacts() - let last = 0 - if (scan) { - for await (const batch of (scan as { batches(): AsyncIterable<{ facts: Array<{ generation: number }> }> }).batches()) { - for (const f of batch.facts) { - expect(f.generation, 'strictly ascending').toBeGreaterThan(last) - last = f.generation - } - } - } - expect(last).toBeGreaterThan(0) - }, 120000) -}) diff --git a/tests/integration/transact-durability-barrier.test.ts b/tests/integration/transact-durability-barrier.test.ts index 8a670ba5..9311ce67 100644 --- a/tests/integration/transact-durability-barrier.test.ts +++ b/tests/integration/transact-durability-barrier.test.ts @@ -43,13 +43,6 @@ describe('transact durability barrier — entity writes fsync before the counter }) await brain.init() - // Drain the pending tier BEFORE instrumenting: the adopt-at-open fleet - // default re-commits the init-time baseline as a buffered single-op - // generation, and transact() flushes buffered single-ops first — that - // flush's manifest sync would otherwise be recorded ahead of the - // transact's own commit point and break the first-index ordering pins. - await brain.flush() - // Instrument the real filesystem storage: record every fsync batch in order, // and count barrier open/flush, delegating to the originals. syncCalls = [] diff --git a/tests/integration/transact-edge-delete-bigint-aliasing.test.ts b/tests/integration/transact-edge-delete-bigint-aliasing.test.ts deleted file mode 100644 index b3902571..00000000 --- a/tests/integration/transact-edge-delete-bigint-aliasing.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * @module tests/integration/transact-edge-delete-bigint-aliasing - * @description Regression for a fleet-adoption blocker: ANY edge delete - * inside `transact()` — a direct unrelate or a noun-remove's cascade — - * aborted with the metadata seam's BigInt JSON-guard error on a strict - * (native) metadata provider. - * - * The aliasing chain: `planTxUnrelate`/the remove-cascade pass the SAME verb - * object to the graph-retraction op and the metadata-retraction op. The - * metadata leg's JSON-safe wrap ran at PLAN time, when the verb was still - * clean — so it returned the same reference. At EXECUTE time the graph op - * runs first and `resolveVerbEndpointInts` mirrors BigInt - * `sourceInt`/`targetInt` onto the shared object (deliberately deferred for - * same-batch forward refs — see transact-forward-ref-graph.test.ts); the - * metadata op then crossed the seam with the polluted object. Direct - * `unrelate()` resolves ints at BUILD time, before its sanitize, which is why - * only the transact() shapes ever hit it. - * - * Fix under pin: the JSON-safe view is taken AT THE CROSSING — inside the - * metadata-index operations' execute/rollback — so no plan-vs-execute - * ordering can bypass it. The JS baseline index tolerates BigInts (it would - * mask the bug), so these pins SPY on the seam and assert what actually - * crossed, exactly as a strict native provider would judge it. - */ -import { describe, it, expect, beforeEach, 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/brainy.js' -import { NounType, VerbType } from '../../src/types/graphTypes.js' -import { - AddToMetadataIndexOperation, - RemoveFromMetadataIndexOperation -} from '../../src/transaction/operations/index.js' - -let seq = 0 -const freshId = (): string => - `00000000-0000-4000-8000-${(++seq).toString(16).padStart(12, '0')}` - -/** Top-level BigInt-valued keys of a candidate seam crossing (the guard's law). */ -const bigintKeys = (metadata: unknown): string[] => { - if (metadata === null || typeof metadata !== 'object') return [] - return Object.entries(metadata as Record) - .filter(([, v]) => typeof v === 'bigint') - .map(([k]) => k) -} - -describe('transact() edge deletes never carry BigInt across the metadata seam', () => { - let dir: string - let brain: any - let crossings: Array<{ door: string; id: string; keys: string[] }> - - beforeEach(async () => { - process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-tx-bigint-')) - brain = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - dimensions: 384, - silent: true - }) - await brain.init() - - // Spy on the seam the way a strict native provider judges it: record the - // BigInt-valued top-level keys of every metadata argument that crosses. - // The JS baseline index tolerates BigInts, so without this the baseline - // run would green a shape the native pair aborts on. - crossings = [] - const index = brain.metadataIndex - for (const door of ['addToIndex', 'removeFromIndex'] as const) { - const real = index[door].bind(index) - index[door] = (id: string, metadata: unknown, ...rest: unknown[]) => { - crossings.push({ door, id, keys: bigintKeys(metadata) }) - return real(id, metadata, ...rest) - } - } - }) - - afterEach(async () => { - await brain.close() - fs.rmSync(dir, { recursive: true, force: true }) - }) - - it('CASE 1 (the fleet repro): relate, then transact([{op: unrelate}])', async () => { - const a = await brain.add({ id: freshId(), data: 'a', type: NounType.Thing }) - const b = await brain.add({ id: freshId(), data: 'b', type: NounType.Thing }) - const verbId = await brain.relate({ from: a, to: b, type: VerbType.RelatedTo }) - - crossings.length = 0 - await brain.transact([{ op: 'unrelate', id: verbId }]) - - const polluted = crossings.filter((c) => c.keys.length > 0) - expect(polluted).toEqual([]) - expect(await brain.storage.getVerb(verbId)).toBeFalsy() - }) - - it('CASE 2 (the cascade shape): transact([{op: remove}]) cascading edge deletes', async () => { - const a = await brain.add({ id: freshId(), data: 'a', type: NounType.Thing }) - const b = await brain.add({ id: freshId(), data: 'b', type: NounType.Thing }) - const c = await brain.add({ id: freshId(), data: 'c', type: NounType.Thing }) - const ab = await brain.relate({ from: a, to: b, type: VerbType.RelatedTo }) - const ca = await brain.relate({ from: c, to: a, type: VerbType.RelatedTo }) - - crossings.length = 0 - await brain.transact([{ op: 'remove', id: a }]) - - const polluted = crossings.filter((c2) => c2.keys.length > 0) - expect(polluted).toEqual([]) - expect(await brain.get(a)).toBeFalsy() - expect(await brain.storage.getVerb(ab)).toBeFalsy() - expect(await brain.storage.getVerb(ca)).toBeFalsy() - }) - - it('CASE 3 (one batch, both legs): adds + relate + unrelate of a pre-existing edge', async () => { - const a = await brain.add({ id: freshId(), data: 'a', type: NounType.Thing }) - const b = await brain.add({ id: freshId(), data: 'b', type: NounType.Thing }) - const old = await brain.relate({ from: a, to: b, type: VerbType.RelatedTo }) - - const x = freshId() - crossings.length = 0 - await brain.transact([ - { op: 'add', id: x, data: 'x', type: NounType.Thing }, - { op: 'relate', from: a, to: x, type: VerbType.RelatedTo }, - { op: 'unrelate', id: old } - ]) - - const polluted = crossings.filter((c) => c.keys.length > 0) - expect(polluted).toEqual([]) - expect(await brain.storage.getVerb(old)).toBeFalsy() - const edges = await brain.related({ from: a }) - expect(edges.length).toBe(1) - expect(edges[0].id).not.toBe(old) - }) -}) - -describe('the metadata-index operations sanitize at the crossing, not at construction', () => { - /** A strict seam: refuses BigInts exactly as the native provider does. */ - const strictIndex = () => { - const seen: Array<{ door: string; keys: string[] }> = [] - const judge = (door: string, metadata: unknown) => { - const keys = bigintKeys(metadata) - seen.push({ door, keys }) - if (keys.length > 0) { - throw new Error( - `${door}: the metadata object violates the provider seam's JSON ` + - `contract — BigInt at ${keys.join(', ')}.` - ) - } - } - return { - seen, - addToIndex: async (_id: string, metadata: unknown) => judge('addToIndex', metadata), - removeFromIndex: async (_id: string, metadata: unknown) => judge('removeFromIndex', metadata) - } - } - - it('RemoveFromMetadataIndexOperation: entity mutated AFTER construction still crosses clean', async () => { - const index = strictIndex() - const verb: Record = { id: 'v1', sourceId: 'a', targetId: 'b' } - const op = new RemoveFromMetadataIndexOperation(index as any, 'v1', verb, () => 7n) - - // The graph leg's execute-time endpoint resolution, simulated: the shared - // object is polluted between plan and execute. - verb.sourceInt = 800_000n - verb.targetInt = 800_001n - - const rollback = await op.execute() - await rollback() - expect(index.seen.map((s) => s.keys)).toEqual([[], []]) - }) - - it('AddToMetadataIndexOperation: same law on the add leg and its rollback', async () => { - const index = strictIndex() - const verb: Record = { id: 'v2', sourceId: 'a', targetId: 'b' } - const op = new AddToMetadataIndexOperation(index as any, 'v2', verb, () => 7n) - - verb.sourceInt = 800_000n - verb.targetInt = 800_001n - - const rollback = await op.execute() - await rollback() - expect(index.seen.map((s) => s.keys)).toEqual([[], []]) - }) -}) diff --git a/tests/integration/triple-intelligence-correctness.test.ts b/tests/integration/triple-intelligence-correctness.test.ts deleted file mode 100644 index 53848d1a..00000000 --- a/tests/integration/triple-intelligence-correctness.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Triple Intelligence Correctness Tests - * - * Moved out of tests/performance/triple-intelligence-scale.test.ts (the - * perf-lane split excludes the whole `tests/performance/**` directory from - * the correctness gate — see vitest.config.ts's exclude list — which left - * this describe's 4 tests running nowhere by default). Every `expect(...)` - * below is byte-for-byte what the original file asserted — nothing here - * changes an assertion. - * - * Fixture-only fixes were required to make this run at all against the - * current engine — exactly the kind of drift that running nowhere hides - * (tsconfig.json excludes `**\/*.test.ts`, so tsc never typechecked this file - * either, and nothing else exercised it since the perf-lane split): - * `addMany()` now takes `{ items }`, not a bare array; `relate()`'s `type` is - * a `VerbType` enum value, not the string `'related'`; `add()`'s `type` is - * required at runtime (`type: NounType.Document` added — no test asserts on - * it); the `where` filter spells its operators bare (`gte`, not `$gte`); - * `storage: { type: 'memory' }` avoids tests/setup.ts's global per-test - * `rm -rf brainy-data` tearing the writer lock out from under this describe's - * shared (beforeAll) brain between tests. - * - * Two of the four tests are `it.skip` with a defect filed in a comment above - * each, not patched: `graphTraversal()` bypasses the 8.0 id-normalization law - * (a natural-key `connected.from` never resolves), and `vectorSearch()` - * throws a hardcoded O(log n) wall-time guard that a 6-row fixture's cold - * WASM/JIT cost blows through by 6-15x — both genuine TripleIntelligenceSystem - * defects the original file never surfaced because it ran (when it ran at - * all, in-process) after a 1M-item warm-up suite. See each skip's comment. - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { Brainy } from '../../src/brainy.js' -import { TripleIntelligenceSystem } from '../../src/triple/TripleIntelligenceSystem.js' -import { NounType, VerbType } from '../../src/types/graphTypes.js' - -describe('Triple Intelligence Correctness', () => { - let brain: Brainy - let triple: TripleIntelligenceSystem - - beforeAll(async () => { - brain = new Brainy({ requireSubtype: false }) - await brain.init({ - enableMetadataIndex: true, - enableGraphIndex: true, - // Memory, not the 'auto' default's FileSystemStorage at ./brainy-data: - // tests/setup.ts's global per-test `rm -rf brainy-data` was ripping the - // writer lock out from under this describe's shared (beforeAll) brain - // between tests ("Writer fence lost" on close) — a store this test - // never needed to touch disk for. - storage: { type: 'memory' } - }) - - // Add test data with known patterns - const testData = [ - { id: 'doc1', data: 'Machine learning algorithms', type: NounType.Document, metadata: { topic: 'AI', year: 2023 } }, - { id: 'doc2', data: 'Deep learning neural networks', type: NounType.Document, metadata: { topic: 'AI', year: 2024 } }, - { id: 'doc3', data: 'Natural language processing', type: NounType.Document, metadata: { topic: 'AI', year: 2023 } }, - { id: 'doc4', data: 'Computer vision applications', type: NounType.Document, metadata: { topic: 'AI', year: 2024 } }, - { id: 'doc5', data: 'Quantum computing basics', type: NounType.Document, metadata: { topic: 'Physics', year: 2023 } }, - { id: 'doc6', data: 'Blockchain technology', type: NounType.Document, metadata: { topic: 'Crypto', year: 2024 } } - ] - - await brain.addMany({ items: testData }) - - // Add relationships - await brain.relate({ from: 'doc1', to: 'doc2', type: VerbType.RelatedTo }) - await brain.relate({ from: 'doc2', to: 'doc3', type: VerbType.RelatedTo }) - await brain.relate({ from: 'doc3', to: 'doc4', type: VerbType.RelatedTo }) - - triple = brain.getTripleIntelligence() - }) - - afterAll(async () => { - await brain?.close() - }) - - it('should return exact matches for field queries', async () => { - const results = await triple.find({ - where: { topic: 'AI' }, - limit: 10 - }) - - expect(results).toHaveLength(4) - for (const result of results) { - expect(result.metadata.topic).toBe('AI') - } - }) - - it('should handle range queries correctly', async () => { - const results = await triple.find({ - where: { year: { gte: 2024 } }, - limit: 10 - }) - - expect(results).toHaveLength(3) - for (const result of results) { - expect(result.metadata.year).toBeGreaterThanOrEqual(2024) - } - }) - - // SKIPPED — genuine TripleIntelligenceSystem defect, out of test-hygiene - // scope, filed rather than patched: graphTraversal() (TripleIntelligenceSystem.ts) - // calls storage.getNoun(id) / graphIndex.getNeighbors(id) directly with the - // caller's raw `connected.from` string, bypassing the 8.0 id-normalization - // law (Brainy.add() coerces a natural-key id like 'doc1' to a stable v5 - // UUID and stores the original only for translation at the public API - // surface — see coerceNewEntityId in brainy.ts). A caller passing a - // natural-key id here gets storage.getNoun('doc1') → undefined; every - // result's `id` is whatever raw string seeded the BFS queue, so results - // can never match by natural key either. Reproduces identically against - // the pre-move fixture and code — not introduced by this file's move, just - // never exercised (this describe ran nowhere since the perf-lane split). - it.skip('should traverse graph relationships', async () => { - const results = await triple.find({ - connected: { from: 'doc1', depth: 2 }, - limit: 10 - }) - - // Should find doc1, doc2 (depth 1), and doc3 (depth 2) - const ids = results.map(r => r.id) - expect(ids).toContain('doc1') - expect(ids).toContain('doc2') - expect(ids).toContain('doc3') - - // Check depth values - const doc1Result = results.find(r => r.id === 'doc1') - const doc2Result = results.find(r => r.id === 'doc2') - const doc3Result = results.find(r => r.id === 'doc3') - - expect(doc1Result?.depth).toBe(0) - expect(doc2Result?.depth).toBe(1) - expect(doc3Result?.depth).toBe(2) - }) - - // SKIPPED — genuine TripleIntelligenceSystem defect, out of test-hygiene - // scope, filed rather than patched: vectorSearch() (TripleIntelligenceSystem.ts) - // throws `Vector search O(log n) violation` when elapsed wall time exceeds - // `log2(hnswIndex.size()) * 5 * 2` — on a 6-row fixture that bound is - // ~25.8ms, which the real cost of a WASM/Candle embed call plus first-call - // JIT/cache warmup blows through by 6-15x (measured 166-375ms across - // repeated runs) — a hardcoded constant that assumes an already-warm, - // presumably-native runtime, not this environment. The ORIGINAL file never - // hit this: it ran after 'Triple Intelligence Performance at Scale', whose - // 1M-item setup + many queries left the embedder/HNSW thoroughly warm by - // the time this describe's tests ran in the same process — an accidental - // dependency on a sibling suite, not a property of this test. Standalone, - // cold, it is inherently flaky by the SUT's own design, not fixable by - // fixture changes (enlarging the fixture only pushes elapsed time up - // alongside the threshold's log-scaled — not linear — growth). - it.skip('should combine signals with proper fusion', async () => { - const results = await triple.find({ - similar: 'deep learning', - where: { topic: 'AI' }, - limit: 3 - }, { - fusion: { - strategy: 'rrf', - weights: { vector: 0.7, field: 0.3 } - } - }) - - // doc2 should rank highest (matches both signals) - expect(results[0].id).toBe('doc2') - expect(results[0].fusionScore).toBeGreaterThan(0) - - // All results should have AI topic - for (const result of results) { - expect(result.metadata.topic).toBe('AI') - } - }) -}) diff --git a/tests/integration/txlog-origin-and-reconcile.test.ts b/tests/integration/txlog-origin-and-reconcile.test.ts deleted file mode 100644 index fbe05fcf..00000000 --- a/tests/integration/txlog-origin-and-reconcile.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * @module tests/integration/txlog-origin-and-reconcile - * @description Two consumer-driven cures, pinned together because they share - * the origin stamp: - * - * 1. TX-LOG ORIGIN — engine-originated commits stamp `origin` on their - * tx-log entry (and the commit fact's meta) so activity feeds filter on - * fact: a downstream feed showed a "double tick" because the deferred - * vector-landing commit was indistinguishable from a user save, and the - * consumer rightly refused a time-window collapse as a quiet loss. User - * writes stay UNSTAMPED (absent origin) — the pre-existing reading of - * every consumer is exact. - * - * 2. THE RECONCILE DOOR — `log-live-canonical-absent` refuses auto-cure by - * design (a legitimate lost-tombstone deletion is indistinguishable from - * canonical loss); `reconcileLogDivergence(id, {attest})` is the human's - * door: 'deleted' mints the missing tombstone, 'restore' folds the log's - * copy back, wrong-class calls refuse typed with nothing written. - */ -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/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -type RawBox = { - storage: { - readNounRaw(id: string): Promise<{ metadata: unknown; vector: unknown }> - writeNounRaw(id: string, r: { metadata: unknown; vector: unknown }): Promise - } -} - -const dirs: string[] = [] -const brains: Brainy[] = [] -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -async function fsBrain(): Promise { - const dir = mkdtempSync(join(tmpdir(), 'brainy-origin-reconcile-')) - dirs.push(dir) - const brain = new Brainy({ - storage: { type: 'filesystem', path: dir }, - requireSubtype: false - }) - await brain.init() - brains.push(brain) - return brain -} - -describe('tx-log origin stamp', () => { - it('the deferred-embed landing commit is stamped system:embed-landing; the user write is not', async () => { - const brain = await fsBrain() - await brain.add({ - data: 'a row whose vector lands later', - type: NounType.Document, - metadata: { k: 1 }, - deferEmbedding: true - }) - await brain.awaitPendingEmbeds() - await brain.flush() - - const entries = await brain.transactionLog() - const system = entries.filter((e) => (e as { origin?: string }).origin === 'system:embed-landing') - const user = entries.filter((e) => !(e as { origin?: string }).origin) - expect(system.length, 'the landing commit is stamped').toBeGreaterThanOrEqual(1) - expect(user.length, 'the user add stays unstamped').toBeGreaterThanOrEqual(1) - // The feed cure in one line: filtering !origin removes the double tick. - expect(user.length).toBeLessThan(entries.length) - }, 120000) -}) - -describe('reconcileLogDivergence — the attested door', () => { - /** Manufacture the class: a live log record whose canonical row is gone. */ - async function manufactureDivergence(brain: Brainy): Promise { - const id = await brain.add({ - data: 'pre-era row whose deletion the log never saw', - type: NounType.Document, - metadata: { era: 'pre-spine' } - }) - await brain.flush() - // Delete canonical BEHIND the log's back (raw write, no generation) — - // exactly the shape a deferred-durability-era crash left behind. - const storage = (brain as unknown as RawBox).storage - await storage.writeNounRaw(id, { metadata: null, vector: null }) - return id - } - - it("attest:'deleted' mints the missing tombstone — the oracle goes green and the commit is stamped system:reconcile", async () => { - const brain = await fsBrain() - const id = await manufactureDivergence(brain) - const before = await brain.verifyLogAuthority() - expect( - before.mismatches.some((m) => m.id === id && m.reason === 'log-live-canonical-absent'), - 'the manufactured divergence is oracle-visible as the refused class' - ).toBe(true) - - const result = await brain.reconcileLogDivergence(id, { attest: 'deleted' }) - expect(result.reconciled).toBe('tombstoned') - - const after = await brain.verifyLogAuthority() - expect(after.mismatches.some((m) => m.id === id), 'the id no longer diverges').toBe(false) - expect(await brain.get(id), 'canonical stays absent').toBeNull() - - await brain.flush() - const entries = await brain.transactionLog() - expect( - entries.some((e) => (e as { origin?: string }).origin === 'system:reconcile'), - 'the reconcile commit is origin-stamped' - ).toBe(true) - }, 120000) - - it("attest:'restore' folds the log's copy back into canonical", async () => { - const brain = await fsBrain() - const id = await manufactureDivergence(brain) - - const result = await brain.reconcileLogDivergence(id, { attest: 'restore' }) - expect(result.reconciled).toBe('restored') - - const row = await brain.get(id) - expect(row, 'the log’s only copy lives again').not.toBeNull() - expect((row!.metadata as { era: string }).era).toBe('pre-spine') - expect((await brain.verifyLogAuthority()).mismatches.some((m) => m.id === id)).toBe(false) - }, 120000) - - it('wrong-class calls refuse typed with nothing written', async () => { - const brain = await fsBrain() - const id = await brain.add({ data: 'healthy row', type: NounType.Document, metadata: { n: 1 } }) - await brain.flush() - // Canonical present + log agrees: not the class — refuse, name the state. - await expect(brain.reconcileLogDivergence(id, { attest: 'deleted' })).rejects.toThrow( - /canonical is PRESENT/ - ) - expect(await brain.get(id), 'nothing was written').not.toBeNull() - // Unknown id: no log record at all — refuse, name it. - await expect( - brain.reconcileLogDivergence('00000000-0000-7000-8000-00000000dead', { attest: 'restore' }) - ).rejects.toThrow(/no record at all/) - }, 120000) -}) diff --git a/tests/integration/update-write-granularity.test.ts b/tests/integration/update-write-granularity.test.ts deleted file mode 100644 index 234df334..00000000 --- a/tests/integration/update-write-granularity.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @module tests/integration/update-write-granularity - * @description Write-granularity law for update() (SELF-ENGINE-RESTART-GRIND, - * 2026-07-29): a metadata-only update must NEVER rewrite the noun record — - * the record carries the full vector, so an unconditional save turns every - * metadata touch into a whole-vector rewrite + fsync. Under a read-heavy - * consumer sweep bumping per-entity stats this amplified into disk saturation - * on a production deployment. Laws: - * (1) metadata-only update() → zero saveNoun calls (metadata leg only); - * (2) data/vector/type-changing update() → saveNoun runs (the vector leg and - * HNSW reindex still happen when the vector side actually changed); - * (3) the metadata-only path still lands: merged metadata readable, _rev - * bumped, find() by the new field sees the entity. - */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { Brainy } from '../../src/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' - -const stubEmbedding = async (text: string): Promise => { - const hash = text.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) - return new Array(384).fill(0).map((_, i) => Math.sin(hash + i)) -} - -describe('update() write granularity', () => { - let brain: Brainy - - beforeEach(async () => { - brain = new Brainy({ - requireSubtype: false, - storage: { type: 'memory' as const }, - embeddingFunction: stubEmbedding - }) - await brain.init() - }) - - afterEach(async () => { - await brain.close() - }) - - it('metadata-only update never rewrites the noun record (no vector rewrite)', async () => { - const id = await brain.add({ - data: 'granularity law subject', - type: NounType.Concept, - metadata: { touched: 0 } - }) - - const storage = (brain as any).storage - const saveNounSpy = vi.spyOn(storage, 'saveNoun') - - await brain.update({ id, metadata: { touched: 1 } }) - - expect(saveNounSpy).not.toHaveBeenCalled() - saveNounSpy.mockRestore() - - // The metadata leg still landed with full semantics. - const after = await brain.get(id, { includeVectors: true }) - expect(after?.metadata?.touched).toBe(1) - expect(after?._rev).toBe(2) - expect(Array.isArray(after?.vector) && after!.vector!.length).toBe(384) - - const found = await brain.find({ where: { touched: 1 } }) - expect(found.some((r: any) => r.id === id)).toBe(true) - }) - - it('confidence/weight/subtype-only updates also skip the noun record', async () => { - const id = await brain.add({ - data: 'reserved-field touch subject', - type: NounType.Concept, - metadata: {} - }) - - const storage = (brain as any).storage - const saveNounSpy = vi.spyOn(storage, 'saveNoun') - - await brain.update({ id, confidence: 0.5, weight: 2, subtype: 'note' }) - - expect(saveNounSpy).not.toHaveBeenCalled() - saveNounSpy.mockRestore() - - const after = await brain.get(id) - expect(after?.confidence).toBe(0.5) - expect(after?.subtype).toBe('note') - }) - - it('data-changing update still writes the noun record and reindexes', async () => { - const id = await brain.add({ - data: 'original embedded text', - type: NounType.Concept, - metadata: {} - }) - - const before = await brain.get(id, { includeVectors: true }) - - const storage = (brain as any).storage - const saveNounSpy = vi.spyOn(storage, 'saveNoun') - - await brain.update({ id, data: 'completely different embedded text' }) - - expect(saveNounSpy).toHaveBeenCalled() - saveNounSpy.mockRestore() - - const after = await brain.get(id, { includeVectors: true }) - expect(after?.data).toBe('completely different embedded text') - expect(after?.vector).not.toEqual(before?.vector) - }) - - it('explicit-vector update still writes the noun record', async () => { - const id = await brain.add({ - data: 'vector swap subject', - type: NounType.Concept, - metadata: {} - }) - - const storage = (brain as any).storage - const saveNounSpy = vi.spyOn(storage, 'saveNoun') - - const newVector = new Array(384).fill(0).map((_, i) => Math.cos(i)) - await brain.update({ id, vector: newVector }) - - expect(saveNounSpy).toHaveBeenCalled() - saveNounSpy.mockRestore() - - const after = await brain.get(id, { includeVectors: true }) - expect(after?.vector?.[0]).toBeCloseTo(1) // cos(0) - }) -}) diff --git a/tests/integration/vector-leg-open-build.test.ts b/tests/integration/vector-leg-open-build.test.ts deleted file mode 100644 index ea841545..00000000 --- a/tests/integration/vector-leg-open-build.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * @module tests/integration/vector-leg-open-build - * @description THE LAST RED of the two-engine release gate: a migrated - * store can hold canonical vectored nouns with NO derived vector index - * built. `open()` owns building the derived indexes (reads never build — - * see `rebuildIndexesIfNeeded`'s JSDoc); the defect this pins is the vector - * leg's decision silently skipping that build, so `find`/search served `[]` - * with no error and no narration. - * - * A downstream deployment measures this through a native vector provider - * whose own health report can legitimately say `serving: true` even while - * vector COVERAGE is honestly unledgered on its side (an unledgered - * invariant never flips `serving` — see `HealthReport`'s derivation laws). - * This repo ships only the JS engine, so the reproduction here uses the - * SAME plugin seam a native provider would (`brain.use({ activate: ctx => - * ctx.registerProvider('vector', factory) })`, the pattern - * `tests/unit/cold-open-rebuild-gate.test.ts` already established for this - * exact class of gate-decision bug) with a stub that WRAPS the real - * `JsHnswVectorIndex` — every method delegates to a genuine engine (so a - * successful rebuild restores REAL, searchable vectors), except `size()` - * (fakes 0 until rebuild runs — the "never built" posture) and - * `healthReport()` (always reports `serving: true`, `unledgered: - * ['vector-coverage']` — the "I don't track this yet" posture). This is - * "as close as the JS engine allows": the gap is reproduced at the exact - * decision the fix changes, not approximated by deleting files the JS - * engine's own cold-start heuristic already recovers from unaided (see the - * inverse pin below and cold-open-rebuild-gate.test.ts's already-pinned - * "isReady()===true, size()===0" contract, which this fix deliberately does - * NOT touch — bare isReady() has no unledgered concept to hide behind, and - * overriding it would reopen the 48-seconds-per-restart regression pinned - * there). - * - * Pins: - * (1) COVERAGE GAP FORCES THE BUILD: N vectored nouns, a provider that - * claims `serving: true` at `size()===0` — open() builds anyway (the - * ledger proves there is something to cover), and search returns real - * results, never `[]`. - * (2) THE INVERSE, HONEST EMPTY: 0 vectored nouns (every embed still - * deferred/unlanded) — open() does NOT attempt a rebuild (nothing to - * load; the old blunt "always rebuild when size()===0" heuristic wasted - * a full canonical walk here for zero benefit), and search honestly - * returns `[]` — no error, no false coverage-gap narration. - * - * SEARCH VERIFICATION NOTE: pin (1) verifies "search returns real results" - * via `find({ query: })` (semantic search — embeds the query, then - * searches), matching the pattern `tests/integration/hnsw-rebuild.test.ts` - * already uses for exactly this "post-rebuild search works" class of pin. - * A raw `find({ vector: })` / `index.search(vector, k)` call was - * tried first and found to reproducibly return only 1 hit after a - * FROM-CANONICAL rebuild (never the full requested `limit`, sometimes not - * even a real neighbor) — REGARDLESS of this task's changes: it reproduces - * identically on a plain, unwrapped, un-stubbed reopen with the stock JS - * engine (verified against `hnsw-rebuild.test.ts`'s own construction) and - * is therefore a PRE-EXISTING, orthogonal defect in the JS HNSW engine's - * rebuilt-graph connectivity — outside this task's two deliverables (the - * count ledger and the open-gate REBUILD DECISION, not rebuild()'s internal - * search quality). Left for a separate investigation; not touched here. - */ -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 { JsHnswVectorIndex } from '../../src/hnsw/hnswIndex.js' - -const tmpDirs: string[] = [] -function mkTmp(): string { - const d = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-vector-leg-open-')) - tmpDirs.push(d) - return d -} -afterEach(() => { - vi.restoreAllMocks() - for (const d of tmpDirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }) -}) - -const V = (seed: number) => - Array.from({ length: 384 }, (_, i) => Math.sin((seed + 1) * 7919 + i * 131) * 0.5 + 0.5) - -/** - * Build a store with N explicit-vector (non-deferred) nouns, flush, close. - * Each noun also carries embeddable text (`technology`/`science`, matching - * the query used below) so the semantic-search verification exercises real - * retrieval, not a coincidental match. The default JS engine builds a fully - * current store — the epoch marker is stamped current at this open's - * completion, so a later reopen's `_indexEpochStale` is honestly false and - * cannot mask the ledger-gap decision under test (nothing here manufactures - * epoch drift). - */ -async function buildVectoredStore(dir: string, n: number): Promise { - const brain: any = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - plugins: [], - silent: true, - dimensions: 384 - }) - await brain.init() - const ids: string[] = [] - for (let i = 0; i < n; i++) { - ids.push( - await brain.add({ - data: `doc ${i} about ${i % 2 === 0 ? 'technology' : 'science'}`, - type: 'document', - vector: V(i) - }) - ) - } - await brain.flush() - await brain.close() - return ids -} - -describe('vector-leg open-build (two-engine gate, last red)', () => { - it('coverage gap: a provider reporting serving:true at size()===0 is overridden by the vectored-noun ledger — open() builds, search returns real results', async () => { - const dir = mkTmp() - const ids = await buildVectoredStore(dir, 12) - - // Wrap the REAL JS engine so a successful rebuild restores genuine, - // searchable vectors — only `size()` and `healthReport()` are faked, - // simulating a native provider that has never built its own coverage of - // an unledgered invariant. - const calls = { rebuild: 0 } - const brain: any = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - plugins: [], - silent: true, - dimensions: 384 - }) - 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) - calls.rebuild++ - 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 - } - }) - await brain.init() - - // WITHOUT any find() first: open() itself must have built the leg. - expect(calls.rebuild, 'open() forced the rebuild despite serving:true').toBe(1) - const status = await brain.getIndexStatus() - expect(status.hnswIndex.size).toBeGreaterThanOrEqual(ids.length) - - // Real, searchable results — never [] (see the module doc's SEARCH - // VERIFICATION NOTE for why this is a semantic `query`, not a raw - // `vector`, call). - const results = await brain.find({ query: 'technology document', limit: 5 }) - expect(results.length).toBeGreaterThan(0) - expect(results.length).not.toBe(0) - - await brain.close() - }) - - it('the inverse: only deferred (never-landed) user nouns — the ledger is never inflated by them, and search over them honestly returns []', async () => { - // ARCHITECTURAL NOTE (updated by the zero-norm root cure): every brainy - // store carries ONE permanent VFS root noun beyond user data - // (`entities/nouns/.../00000000-0000-0000-0000-000000000000`, - // src/vfs/VirtualFileSystem.ts), created (or, on a pre-fix store, - // migrated) on every open — but it is deliberately UNVECTORED (vector - // `[]`), never a real all-zero placeholder: a zero-norm vector is not a - // vector and never crosses an engine boundary (see that file's - // doInitializeRoot() comment). It therefore contributes NOTHING to the - // vectored-noun ledger — a brand-new store's `vectors.all` floor is 0, - // not 1. This pin verifies the law the task names in the ACHIEVABLE - // form: nouns whose embed is still deferred/unlanded contribute NOTHING - // to the vectored-noun ledger either — the coverage-gap comparison sees - // exactly the baseline (the root, contributing 0), never - // baseline+deferred — and semantic search over deferred-only user - // content honestly returns `[]` (no error, no false "coverage restored" - // claim). - const dir = mkTmp() - - const build: any = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - plugins: [], - silent: true, - dimensions: 384 - }) - await build.init() - const rootOnlyLedger = await build.storage.getCanonicalCounts() - // THE NEW LAW: the root is unvectored — a brand-new store's floor is 0. - expect(rootOnlyLedger.vectors.all).toBe(0) - // Block the embedder permanently so every add below stays deferred and - // unlanded for the rest of this test (a fast deterministic embedder - // could otherwise land it before we ever observe the "still 0 extra" - // state). - vi.spyOn(build, 'embed').mockImplementation(() => new Promise(() => {})) - for (let i = 0; i < 5; i++) { - await build.add({ data: `deferred ${i}`, type: 'document', deferEmbedding: true }) - } - await build.flush() - const ledgerWithDeferred = await build.storage.getCanonicalCounts() - // The five deferred adds contributed ZERO to the vectored-noun ledger. - expect(ledgerWithDeferred.vectors.all).toBe(rootOnlyLedger.vectors.all) - await build.close() - - // Reopen (default JS engine — no stub needed): the root is the ONLY - // thing the vector leg has to load; the deferred nouns are correctly - // invisible to it. Block the embedder again BEFORE init() — reopen - // recovers the durable pending-embed markers and kicks the worker as - // part of init() itself, and an unblocked deterministic embedder could - // land all five before this test observes the open-time ledger. - const brain: any = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - plugins: [], - silent: true, - dimensions: 384 - }) - vi.spyOn(brain, 'embed').mockImplementation(() => new Promise(() => {})) - await brain.init() - // The ledger is exactly the root — the five deferred, still-unlanded - // nouns (which the rebuild above DOES insert into the graph, each with - // its stub empty vector — `index.size()` counts EVERY canonical noun's - // graph node, deferred or not, so it is not the coverage metric) never - // inflate the VECTORED count. - const ledgerAfterReopen = await brain.storage.getCanonicalCounts() - expect(ledgerAfterReopen.vectors.all).toBe(rootOnlyLedger.vectors.all) - - const results = await brain.find({ vector: V(3), limit: 5 }) - expect(results).toEqual([]) - - await brain.close() - }) -}) diff --git a/tests/integration/verb-metadata-rows.test.ts b/tests/integration/verb-metadata-rows.test.ts deleted file mode 100644 index ff08132a..00000000 --- a/tests/integration/verb-metadata-rows.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * @module tests/integration/verb-metadata-rows - * @description THE LIVE VERB PATH pins. Before this train, verb rows entered - * the metadata index ONLY via `MetadataIndexManager.rebuild()`'s canonical - * walk — every relate()/unrelate()/updateRelation() call, and every - * remove()-cascaded relationship, left the metadata index blind to verb - * writes until the next rebuild. This file pins that `relate()`, - * `unrelate()`, `updateRelation()`, `remove()`'s cascade, and their - * `transact()` mirrors now post/retract the SAME verb rows a rebuild would - * derive from canonical (ADR-007 A4: one mechanism for add/update, live and - * rebuilt). - */ -process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' - -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { Brainy } from '../../src/brainy.js' -import { NounType, VerbType } from '../../src/types/graphTypes.js' -import type { MetadataIndexManager } from '../../src/utils/metadataIndex.js' - -/** The JS metadata-index manager backing a memory-storage brain in these - * tests (feature-detected in production code via `instanceof - * MetadataIndexManager`; a narrow test-only reach-in here, matching the - * existing idiom in tests/integration/find-where-zero.test.ts and - * tests/integration/level-field-shadow.test.ts). */ -function metadataIndexOf(brain: Brainy): MetadataIndexManager { - return (brain as unknown as { metadataIndex: MetadataIndexManager }).metadataIndex -} - -describe('verb metadata rows — the live path matches the rebuild walk', () => { - let brain: Brainy - - beforeEach(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) - await brain.init() - }) - - afterEach(async () => { - await brain.close() - }) - - async function addPerson(label: string): Promise { - return brain.add({ - data: `person ${label}`, - type: NounType.Person, - metadata: { label } - }) - } - - it('(a) relate() posts a metadata-index-backed verb row a query can find', async () => { - const a = await addPerson('a') - const b = await addPerson('b') - const relId = await brain.relate({ - from: a, to: b, type: VerbType.WorksWith, metadata: { role: 'lead' } - }) - - // Read it back the SAME way a rebuild-sourced row is queried — the - // manager's own posting lookup, keyed on the custom field the caller wrote. - const index = metadataIndexOf(brain) - expect(await index.getIds('role', 'lead')).toEqual([relId]) - }) - - it('(b) unrelate() retracts the row', async () => { - const a = await addPerson('a') - const b = await addPerson('b') - const relId = await brain.relate({ - from: a, to: b, type: VerbType.WorksWith, metadata: { role: 'lead' } - }) - - const index = metadataIndexOf(brain) - expect(await index.getIds('role', 'lead')).toEqual([relId]) - - // Flush BEFORE retracting the field's only occurrence: this durably - // persists the 'role' column (a segment on disk/in the store), so the - // post-retraction query below reads "this field exists, zero live - // postings" (→ []) rather than "this field has never been written" - // (→ FIELD_NOT_INDEXED) — an orthogonal column-store characteristic - // (an unflushed field with its last live posting removed reverts to - // unknown), not a D2 behavior. - await brain.flush() - - await brain.unrelate(relId) - - expect(await index.getIds('role', 'lead')).toEqual([]) - }) - - it('(c) updateRelation({ metadata }) leaves exactly the new values', async () => { - const a = await addPerson('a') - const b = await addPerson('b') - const relId = await brain.relate({ - from: a, to: b, type: VerbType.WorksWith, metadata: { role: 'lead', team: 'core' } - }) - - const index = metadataIndexOf(brain) - expect(await index.getIds('role', 'lead')).toEqual([relId]) - - // Flush first — see (b)'s note: 'role'/'team' must be durably known - // fields before their only value is retracted, or the post-update - // "gone" checks below throw FIELD_NOT_INDEXED instead of returning []. - await brain.flush() - - await brain.updateRelation({ id: relId, metadata: { role: 'reviewer' }, merge: false }) - - // Stale values gone (the old shape AND the merge:false-dropped field)… - expect(await index.getIds('role', 'lead')).toEqual([]) - expect(await index.getIds('team', 'core')).toEqual([]) - // …only the new value serves. - expect(await index.getIds('role', 'reviewer')).toEqual([relId]) - }) - - it("(d) remove(entity) cascade retracts every incident relation's metadata row", async () => { - const a = await addPerson('a') - const b = await addPerson('b') - const c = await addPerson('c') - const rel1 = await brain.relate({ - from: a, to: b, type: VerbType.WorksWith, metadata: { tag: 'cascade-test' } - }) - const rel2 = await brain.relate({ - from: c, to: a, type: VerbType.WorksWith, metadata: { tag: 'cascade-test' } - }) - - const index = metadataIndexOf(brain) - expect((await index.getIds('tag', 'cascade-test')).sort()).toEqual([rel1, rel2].sort()) - - // Flush first — see (b)'s note. - await brain.flush() - - await brain.remove(a) // a is source of rel1, target of rel2 — both cascade - - expect(await index.getIds('tag', 'cascade-test')).toEqual([]) - }) - - it('(e) a rebuild() reproduces exactly the verb-row population the live path built', async () => { - const a = await addPerson('a') - const b = await addPerson('b') - const c = await addPerson('c') - await brain.relate({ from: a, to: b, type: VerbType.WorksWith, metadata: { tag: 'parity', label: 'ab' } }) - await brain.relate({ from: b, to: c, type: VerbType.RelatedTo, metadata: { tag: 'parity', label: 'bc' } }) - const relId3 = await brain.relate({ - from: c, to: a, type: VerbType.WorksWith, metadata: { tag: 'parity', label: 'ca' } - }) - await brain.unrelate(relId3) // exercise retraction too — the rebuild must NOT resurrect it - - const index = metadataIndexOf(brain) - const beforeIds = (await index.getIds('tag', 'parity')).slice().sort() - expect(beforeIds.length).toBe(2) - const beforeAb = await index.getIds('label', 'ab') - const beforeBc = await index.getIds('label', 'bc') - - await index.rebuild() - - const afterIds = (await index.getIds('tag', 'parity')).slice().sort() - expect(afterIds).toEqual(beforeIds) - expect(await index.getIds('label', 'ab')).toEqual(beforeAb) - expect(await index.getIds('label', 'bc')).toEqual(beforeBc) - expect(await index.getIds('label', 'ca')).toEqual([]) // the unrelated edge stays gone - }) - - it('(f) transact() relate/unrelate posts/retracts the same metadata-index rows as single-op', async () => { - const a = await addPerson('a') - const b = await addPerson('b') - const c = await addPerson('c') - const d = await addPerson('d') - - // Single-op baseline. - const singleOpId = await brain.relate({ - from: a, to: b, type: VerbType.WorksWith, metadata: { tag: 'parity-f' } - }) - - // transact() mirror. - const relateDb = await brain.transact([ - { op: 'relate', from: c, to: d, type: VerbType.WorksWith, metadata: { tag: 'parity-f' } } - ]) - const transactId = relateDb.receipt!.ids[0] - await relateDb.release() - - const index = metadataIndexOf(brain) - expect((await index.getIds('tag', 'parity-f')).sort()).toEqual([singleOpId, transactId].sort()) - - // Flush first — see (b)'s note: 'tag' must be durably known before its - // last live posting is retracted below. - await brain.flush() - - // Retract both ways — single-op unrelate() and transact() unrelate. - await brain.unrelate(singleOpId) - const unrelateDb = await brain.transact([{ op: 'unrelate', id: transactId }]) - await unrelateDb.release() - - expect(await index.getIds('tag', 'parity-f')).toEqual([]) - }) - - - it('the metadata crossing never carries BigInt endpoint ints — a cascade delete after graph resolution survives JSON', async () => { - // resolveVerbEndpointInts MIRRORS the resolved u64 ints onto the verb - // object as BigInt (verb.sourceInt/targetInt). A provider that JSON- - // serializes the metadata crossing dies on BigInt — found by the first - // joint pair gate. This pin drives the exact shape: relate (graph legs - // resolve ints), then remove the source entity (the cascade passes the - // SAME verb object to the retraction), through a provider shim that - // enforces the JSON-safety contract the way a native provider does. - const employee = await brain.add({ data: 'cascade employee', type: 'person' }) - const invoice = await brain.add({ data: 'cascade invoice', type: 'document' }) - await brain.relate({ from: employee, to: invoice, type: 'relatedTo' }) - const mgr: any = (brain as any).metadataIndex - const origRemove = mgr.removeFromIndex.bind(mgr) - const seen: unknown[] = [] - mgr.removeFromIndex = async (id: string, metadata?: unknown, generation?: bigint) => { - seen.push(metadata) - JSON.stringify(metadata) // the contract: throws on BigInt, exactly like a native crossing - return origRemove(id, metadata, generation) - } - try { - await brain.remove(employee) // cascades the relation's retraction - } finally { - mgr.removeFromIndex = origRemove - } - expect(seen.length).toBeGreaterThan(0) - for (const m of seen) { - if (m && typeof m === 'object') { - for (const [k, v] of Object.entries(m as Record)) { - expect(typeof v, `metadata key ${k} must be JSON-safe`).not.toBe('bigint') - } - } - } - }) - -}) diff --git a/tests/integration/vfs-containment-batched.test.ts b/tests/integration/vfs-containment-batched.test.ts deleted file mode 100644 index 7bbad478..00000000 --- a/tests/integration/vfs-containment-batched.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * @module tests/integration/vfs-containment-batched - * @description repairContainment costs O(edges/page) graph calls, not O(entities) (10.4.9 train). - * - * Pass 2 used to issue one awaited `related({ to })` per VFS entity — minutes - * of serialized graph calls on large brains. Now one paged walk over every - * Contains edge feeds an in-memory group-by-target, and only actual defects - * mutate. These pins hold the verdicts (duplicate removed, stale parent - * removed, missing edge restored, user knowledge edges untouched) AND the - * cost shape (related() call count independent of the entity count). - */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' -import { Brainy } from '../../src/brainy' -import { NounType, VerbType } from '../../src/types/graphTypes' - -const FILES = 60 - -describe('repairContainment: batched pass 2', () => { - let brain: Brainy - let result: { removed: number; restored: number } - let relatedCalls = 0 - - beforeAll(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - const vfs = (brain as any).vfs ?? (brain as any)._vfs - expect(vfs).toBeTruthy() - await vfs.init() - - // A directory and FILES entries under it, wired as real VFS rows. - const mkNode = async (id: string, path: string, vfsType: string): Promise => { - await brain.add({ - id, - data: `vfs node ${path}`, - type: NounType.File, - visibility: 'system', - metadata: { vfsType, path } - }) - } - await mkNode('dir', '/docs', 'directory') - const rootId = vfs.rootEntityId ?? (await vfs.initializeRoot?.()) - if (rootId) { - await brain.relate({ - from: rootId, - to: 'dir', - type: VerbType.Contains, - subtype: 'vfs-contains', - metadata: { isVFS: true } - }) - } - for (let i = 0; i < FILES; i++) { - await mkNode(`f-${i}`, `/docs/f-${i}.md`, 'file') - if (i === 0) continue // f-0: MISSING edge — must be restored - await brain.relate({ - from: 'dir', - to: `f-${i}`, - type: VerbType.Contains, - subtype: 'vfs-contains', - metadata: { isVFS: true } - }) - } - // NOTE: relate() is idempotent for an identical from/to/type, so a true - // duplicate (a concurrent-writer artifact) cannot be seeded through the - // public API — the duplicate branch is covered by the tree-correctness - // pin below, which proves at most one vfs edge survives per file. - // f-2: STALE parent edge (from a sibling file) — must be removed. - await brain.relate({ - from: 'f-3', - to: 'f-2', - type: VerbType.Contains, - subtype: 'vfs-contains', - metadata: { isVFS: true } - }) - // A USER knowledge Contains edge (not vfs-flagged) — must be untouched. - await brain.relate({ from: 'f-4', to: 'f-5', type: VerbType.Contains }) - - const spy = vi.spyOn(brain, 'related') - result = await vfs.repairContainment() - relatedCalls = spy.mock.calls.length - spy.mockRestore() - }) - - afterAll(async () => { - await brain.close() - brain = null as any - }) - - it('restores the missing edge and removes the stale parent — exactly', () => { - expect(result.restored).toBe(1) // f-0's missing edge - expect(result.removed).toBe(1) // f-2's stale parent (f-3 → f-2) - }) - - it('the repaired tree is correct: every file has exactly one vfs edge from its dir', async () => { - for (let i = 0; i < 6; i++) { - const incoming = await brain.related({ to: `f-${i}`, type: VerbType.Contains }) - const vfsEdges = incoming.filter( - (e) => e.subtype === 'vfs-contains' || (e.metadata as any)?.isVFS === true - ) - expect(vfsEdges, `f-${i}`).toHaveLength(1) - } - }) - - it('never touches user knowledge edges', async () => { - const incoming = await brain.related({ to: 'f-5', type: VerbType.Contains }) - const user = incoming.filter( - (e) => e.subtype !== 'vfs-contains' && (e.metadata as any)?.isVFS !== true - ) - expect(user).toHaveLength(1) - }) - - it('cost shape: related() calls do not scale with the entity count', () => { - // One paged type-only walk (~E/1000 pages) — with 60+ entities the old - // shape issued 60+ calls; the new one a handful. Bound generously. - expect(relatedCalls).toBeLessThanOrEqual(5) - }) -}) diff --git a/tests/integration/vfs-debug.test.ts b/tests/integration/vfs-debug.test.ts index 5eeb0ef5..7e781139 100644 --- a/tests/integration/vfs-debug.test.ts +++ b/tests/integration/vfs-debug.test.ts @@ -9,10 +9,9 @@ import * as XLSX from 'xlsx' describe('VFS Debug', () => { it('minimal VFS writeFile test', async () => { const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - try { - await brain.init() + await brain.init() - console.log('✅ Brain initialized') + console.log('✅ Brain initialized') // Get VFS and initialize const vfs = brain.vfs @@ -78,8 +77,5 @@ describe('VFS Debug', () => { // THE REAL TEST: Can we query VFS? expect(children.length).toBeGreaterThan(0) expect(rootContents.length).toBeGreaterThan(0) - } finally { - await brain.close() - } }) }) diff --git a/tests/integration/vfs-root-sweep-once.test.ts b/tests/integration/vfs-root-sweep-once.test.ts deleted file mode 100644 index cac59b70..00000000 --- a/tests/integration/vfs-root-sweep-once.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * @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 deleted file mode 100644 index 577ae7ee..00000000 --- a/tests/integration/vfs-root-zero-norm.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * @module tests/integration/vfs-root-zero-norm - * @description THE ZERO-NORM ROOT CURE — a production incident traced 150+ - * darkened rows in a downstream engine's index to the VFS root's persisted - * ALL-ZERO placeholder vector: lawful inside brainy (`cosineDistance` - * treats a zero-norm operand as MAXIMUM distance, src/utils/distance.ts) - * but a "false attractor" for an engine serving squared-euclidean distance, - * which cannot tell a real all-zero vector apart from a legitimate origin - * point. THE LAW: a zero-norm vector is not a vector — it never crosses an - * engine boundary. - * - * Three legs pinned here: - * (a) the root persists NO zeros — a brand-new store creates it with - * vector `[]` (the "unvectored" shape), absent from the HNSW index, and - * the canonical vectored-noun ledger does not count it. - * (b) a ONE-TIME migration heals an existing (pre-fix) store: an old-shape - * root (a REAL all-zero vector, genuinely indexed and ledgered — the - * 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 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). - */ -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' - -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-vfs-root-zero-norm-')) - 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 - }) -} - -describe('VFS root zero-norm cure', () => { - it('(a) a brand-new store persists the root with vector [], absent from the HNSW index, and the canonical ledger counts it unvectored', async () => { - const dir = mkTmp() - const brain = openBrain(dir) - await brain.init() - - const root = await brain.get(ROOT_ID, { includeVectors: true }) - expect(root).not.toBeNull() - expect(root.vector).toEqual([]) - - const status = await brain.getIndexStatus() - expect(status.hnswIndex.size).toBe(0) - - const ledger = await brain.storage.getCanonicalCounts() - expect(ledger.vectors.all).toBe(0) - - await brain.close() - }) - - it('(b) an old-shape store (a real all-zero placeholder root) migrates to [] exactly once on init; the ledger is decremented through the sanctioned path; a second init is a no-op', async () => { - const dir = mkTmp() - - // SESSION 1 — build the store, then hand-rewrite the root to the LEGACY - // shape: a REAL all-zero 384-dim vector, genuinely inserted into the - // vector index and genuinely counted by the vectored-noun ledger — - // reproducing exactly what a pre-fix store's root looked like on disk - // (the pre-fix add() always indexed + counted it). `index.addItem` is - // called directly (bypassing AddToVectorIndexOperation's own zero-norm - // belt, added by this same fix) precisely because the pre-fix code path - // had no such belt — this harness must match history, not the cure. - 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() - - const ledgerBeforeMigration = await brain.storage.getCanonicalCounts() - expect(ledgerBeforeMigration.vectors.all).toBe(1) - await brain.close() - - // SESSION 2 — reopen: VFS init must detect the legacy shape and migrate. - // Spy on the sanctioned migration method itself (not console output — - // `silent: true` monkey-patches `console.log` to a no-op INSIDE init(), - // which would silently swallow any pre-installed console spy too). - brain = openBrain(dir) - const migrateSpy = vi.spyOn(brain, 'unvectorNounForRootMigration') - await brain.init() - - expect(migrateSpy).toHaveBeenCalledTimes(1) - expect(migrateSpy).toHaveBeenCalledWith(ROOT_ID) - await expect(migrateSpy.mock.results[0].value).resolves.toBe(true) - - const migratedRoot = await brain.get(ROOT_ID, { includeVectors: true }) - expect(migratedRoot.vector).toEqual([]) - - const ledgerAfterMigration = await brain.storage.getCanonicalCounts() - expect(ledgerAfterMigration.vectors.all).toBe(0) - - const statusAfterMigration = await brain.getIndexStatus() - expect(statusAfterMigration.hnswIndex.size).toBe(0) - - await brain.flush() - await brain.close() - - // SESSION 3 — reopen again: the migration is a permanent no-op, not a - // one-time flag that silently re-drifts or re-fires. The zero-norm - // detection at the VFS init site never even calls the migration method - // again — the root's vector is already `[]`. - brain = openBrain(dir) - const migrateSpy2 = vi.spyOn(brain, 'unvectorNounForRootMigration') - await brain.init() - - expect(migrateSpy2).not.toHaveBeenCalled() - - const rootAfterSecondInit = await brain.get(ROOT_ID, { includeVectors: true }) - expect(rootAfterSecondInit.vector).toEqual([]) - - const ledgerAfterSecondInit = await brain.storage.getCanonicalCounts() - expect(ledgerAfterSecondInit.vectors.all).toBe(0) - - await brain.close() - }) - - 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() - - 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 — 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([]) - - // 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 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') - ) - expect(loudCall).toBeDefined() - - await brain.close() - }) - - it('(d) find() over a store whose root has been migrated never returns the root (already hidden behind visibility: system — pinned anyway)', async () => { - const dir = mkTmp() - - // Build an old-shape store (same harness as pin (b)) and let it migrate. - 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.add({ data: 'a document about technology', type: NounType.Document }) - await brain.flush() - await brain.close() - - brain = openBrain(dir) // migrates on init() - await brain.init() - - const results = await brain.find({ query: 'technology', limit: 10 }) - expect(results.some((r: any) => r.id === ROOT_ID)).toBe(false) - - // Even asking explicitly for system-tier entities must never surface the - // root as a semantic-search HIT (it carries no vector to match against). - const resultsIncludingSystem = await brain.find({ query: 'technology', limit: 10, includeSystem: true }) - expect(resultsIncludingSystem.some((r: any) => r.id === ROOT_ID)).toBe(false) - - await brain.close() - }) -}) diff --git a/tests/integration/wait-for-indexed.test.ts b/tests/integration/wait-for-indexed.test.ts deleted file mode 100644 index 711ddc99..00000000 --- a/tests/integration/wait-for-indexed.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * @module tests/integration/wait-for-indexed - * @description THE READ BARRIER — `brain.waitForIndexed(path?, opts?)`. A - * consumer that writes and then semantically recalls gets ONE honest barrier - * instead of guessing. The contract pinned here: - * - * 1. SEMANTIC LEG: a deferred add followed by `waitForIndexed('semantic')` - * resolves only after the vector landed — the row is vector-searchable - * the moment the barrier returns. - * 2. TYPED TIMEOUT: `timeoutMs` expiry REJECTS with - * WaitForIndexedTimeoutError carrying the leg + the pending count and - * naming the gauge — never a silent partial wait. - * 3. NO-ARG: every projection at the head; today that means the deferred - * embed backlog is drained. - * 4. SYNCHRONOUS LEGS: metadata/graph/aggregation resolve immediately by - * design today (they update inside the write path) — even while the - * semantic backlog is wedged. - * 5. GAUGES: getIndexStatus().projections carries the per-leg numbers, and - * the top-level pendingEmbeds compat field agrees with the semantic one. - * 6. GENERATION REFINEMENT: an empty backlog satisfies any generation - * immediately; a non-empty one falls back to the full drain. - */ -import { describe, it, expect, afterEach, vi } from 'vitest' -import { Brainy, WaitForIndexedTimeoutError } from '../../src/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -const brains: Brainy[] = [] - -async function memBrain(): Promise { - const b = new Brainy({ storage: { type: 'memory' }, requireSubtype: false }) - await b.init() - brains.push(b) - return b -} - -/** - * Abandon a poisoned in-flight embed run (its embed promise never resolves — - * production is covered by the worker's 60s hang guard; the test takes the - * white-box shortcut for speed), then drain so teardown never wedges. - */ -async function unwedge(brain: Brainy): Promise { - ;(brain as unknown as { _embedWorkerFlight: Promise | null })._embedWorkerFlight = null - await brain.awaitPendingEmbeds() -} - -afterEach(async () => { - vi.restoreAllMocks() - for (const b of brains.splice(0)) await b.close().catch(() => {}) -}) - -describe('waitForIndexed — the read barrier', () => { - it("SEMANTIC LEG: deferred add → waitForIndexed('semantic') resolves and the row is vector-searchable after", async () => { - const brain = await memBrain() - const embedSpy = vi.spyOn(brain, 'embed') - - const id = await brain.add({ - data: 'the quarterly revenue report for the northern region', - type: NounType.Document, - deferEmbedding: true, - metadata: { kind: 'report' } - }) - expect(embedSpy, 'no embed on the ack path').not.toHaveBeenCalled() - expect(brain.pendingEmbedCount()).toBeGreaterThanOrEqual(1) - - await brain.waitForIndexed('semantic') - - // The barrier's meaning: backlog drained, vector real, row searchable. - expect(brain.pendingEmbedCount(), 'barrier means drained').toBe(0) - const after = await brain.get(id, { includeVectors: true }) - expect((after!.vector as number[]).length, 'real vector after the barrier').toBeGreaterThan(0) - const hits = await brain.find({ - query: 'the quarterly revenue report for the northern region', - searchMode: 'semantic', - limit: 5 - }) - expect(hits.map((r) => r.id), 'vector-searchable after the barrier').toContain(id) - }) - - it('TYPED TIMEOUT: a hung embedder + timeoutMs rejects with the typed error naming the pending count and the gauge', async () => { - const brain = await memBrain() - const hang = vi - .spyOn(brain, 'embed') - .mockImplementation(() => new Promise(() => {})) - - await brain.add({ - data: 'never lands while the embedder hangs', - type: NounType.Document, - deferEmbedding: true, - metadata: {} - }) - expect(brain.pendingEmbedCount()).toBe(1) - - let caught: unknown - try { - await brain.waitForIndexed('semantic', { timeoutMs: 200 }) - } catch (e) { - caught = e - } - - expect(caught, 'expiry REJECTS — never a silent partial wait').toBeInstanceOf( - WaitForIndexedTimeoutError - ) - const err = caught as WaitForIndexedTimeoutError - expect(err.path).toBe('semantic') - expect(err.timeoutMs).toBe(200) - expect(err.pendingEmbeds).toBeGreaterThanOrEqual(1) - // The message names what was still pending and the gauge to check. - expect(err.message).toContain(`${err.pendingEmbeds} deferred embed`) - expect(err.message).toContain('getIndexStatus().projections.semantic.pendingEmbeds') - - hang.mockRestore() - await unwedge(brain) - expect(brain.pendingEmbedCount()).toBe(0) - }) - - it('NO-ARG: waitForIndexed() waits on the pending-embed drain (every projection at the head)', async () => { - const brain = await memBrain() - await brain.add({ - data: 'a deferred capture that the bare barrier must cover', - type: NounType.Document, - deferEmbedding: true, - metadata: {} - }) - expect(brain.pendingEmbedCount()).toBeGreaterThanOrEqual(1) - - await brain.waitForIndexed() - - expect( - brain.pendingEmbedCount(), - 'the bare barrier drained the only asynchronous projection' - ).toBe(0) - }) - - it('SYNCHRONOUS LEGS: metadata/graph/aggregation resolve immediately — even while the semantic backlog is wedged', async () => { - const brain = await memBrain() - - // Quiet brain first: all three legs resolve on a brain with no backlog. - await brain.add({ data: 'quiet row', type: NounType.Document, metadata: { q: 1 } }) - await brain.awaitPendingEmbeds() - await brain.waitForIndexed('metadata') - await brain.waitForIndexed('graph') - await brain.waitForIndexed('aggregation') - - // The stronger pin: these projections update inside the write path today, - // so their leg resolves immediately BY DESIGN — independent of a wedged - // semantic backlog. (If any of them incorrectly delegated to the embed - // drain, this test would hang.) - const hang = vi - .spyOn(brain, 'embed') - .mockImplementation(() => new Promise(() => {})) - await brain.add({ - data: 'wedged deferred row', - type: NounType.Document, - deferEmbedding: true, - metadata: {} - }) - expect(brain.pendingEmbedCount()).toBe(1) - - await brain.waitForIndexed('metadata') - await brain.waitForIndexed('graph') - await brain.waitForIndexed('aggregation') - - hang.mockRestore() - await unwedge(brain) - }) - - it('GAUGES: getIndexStatus().projections carries the per-leg shape, and the compat field agrees', async () => { - const brain = await memBrain() - await brain.add({ data: 'gauge row', type: NounType.Document, metadata: { g: 1 } }) - await brain.awaitPendingEmbeds() - - const status = await brain.getIndexStatus() - expect(status.projections).toEqual({ - semantic: { pendingEmbeds: 0 }, - metadata: { synchronous: true }, - graph: { synchronous: true }, - aggregation: { pendingBackfills: 0, pendingCatchUps: 0 } - }) - // Compat: the existing top-level gauge stays and agrees. - expect(status.pendingEmbeds).toBe(0) - - // The semantic gauge is honest while a backlog exists. - const hang = vi - .spyOn(brain, 'embed') - .mockImplementation(() => new Promise(() => {})) - await brain.add({ - data: 'backlogged row', - type: NounType.Document, - deferEmbedding: true, - metadata: {} - }) - const busy = await brain.getIndexStatus() - expect(busy.projections.semantic.pendingEmbeds).toBeGreaterThanOrEqual(1) - expect(busy.pendingEmbeds).toBe(busy.projections.semantic.pendingEmbeds) - - hang.mockRestore() - await unwedge(brain) - }) - - it('GENERATION REFINEMENT: an empty backlog satisfies any generation immediately; a non-empty one falls back to the full drain', async () => { - const brain = await memBrain() - await brain.add({ data: 'generation row', type: NounType.Document, metadata: {} }) - await brain.awaitPendingEmbeds() - - // Empty backlog: the semantic watermark is at the head — >= any committed G. - await brain.waitForIndexed('semantic', { generation: 1 }) - - // Non-empty backlog: the conservative full drain (a superset of the - // requested wait, never a partial one). - await brain.add({ - data: 'second generation row', - type: NounType.Document, - deferEmbedding: true, - metadata: {} - }) - await brain.waitForIndexed('semantic', { generation: 1 }) - expect(brain.pendingEmbedCount(), 'the fallback is the full drain').toBe(0) - }) -}) diff --git a/tests/integration/watermark-adopt-reopen.test.ts b/tests/integration/watermark-adopt-reopen.test.ts deleted file mode 100644 index d0eb1182..00000000 --- a/tests/integration/watermark-adopt-reopen.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @module tests/integration/watermark-adopt-reopen - * @description End-to-end LC1 watermark adoption: a clean flush+close stamps - * every projection at the committed generation; the reopen verdicts all read - * 'adopt' — a same-version reopen owes ZERO rebuild work, provably, via the - * stamps rather than via absence of complaint. - */ -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/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -const dirs: string[] = [] -const brains: Brainy[] = [] -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -describe('watermark stamps ride the flush fan-out', () => { - it('flush stamps all three projections at the committed generation; reopen adopts', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-wm-')) - dirs.push(dir) - let brain = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false }) - await brain.init() - brains.push(brain) - await brain.add({ data: 'stamped row', type: NounType.Document, metadata: { k: 1 } }) - await brain.flush() - - const committed = (brain as unknown as { - storage: { committedGeneration(): number } - }).storage.committedGeneration() - const mi = (brain as unknown as { metadataIndex: { watermark(): number | null } }).metadataIndex - expect(mi.watermark(), 'metadata stamp = committed').toBe(committed) - await brain.close() - brains.pop() - - brain = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false }) - await brain.init() - brains.push(brain) - const mi2 = (brain as unknown as { - metadataIndex: { watermarkVerdict(): string | null } - }).metadataIndex - expect(mi2.watermarkVerdict(), 'clean reopen adopts').toBe('adopt') - // And the brain serves. - expect((await brain.find({ where: { k: 1 }, limit: 5 })).length).toBe(1) - }, 60000) -}) diff --git a/tests/integration/write-flow-production-shape.test.ts b/tests/integration/write-flow-production-shape.test.ts deleted file mode 100644 index f33cdc88..00000000 --- a/tests/integration/write-flow-production-shape.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * @module tests/integration/write-flow-production-shape - * @description The production-shaped WRITE-FLOW gate leg. A downstream - * deployment's release gate went all-green on snapshots and rehearsal reads - * while two write-path defects (pad-frame constructibility, a counter rewind - * after a successful append) waited in ordinary WRITE flows — deferred - * embedding retries plus background history-flush concurrency wearing the - * stacks. This leg runs that exact shape, permanently: - * - * - concurrent mixed writes (adds, deferred-embed adds, updates, removes) - * - racing explicit flushes (the history tier's group commit, mid-traffic) - * - then the three laws: every ack is readable truth, the fact log is - * STRICTLY ascending end-to-end, and no write is ever refused. - * - * Part two crashes the brain mid-traffic (no close — RAM discarded) and - * requires every acked write back after reopen: the at-ack contract under - * the same production shape, not under a synthetic single write. - */ -import { describe, it, expect, afterEach } from 'vitest' -import * as fs from 'node:fs' -import { Brainy } from '../../src/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' -import { - abandonAsCrashed, - factGenerations, - makeTempDir, - openBrain -} from '../helpers/durabilityKillMatrix.js' - -describe('write-flow production shape — the pair gate leg from a consumer-reported miss', () => { - const dirs: string[] = [] - const liveBrains: Brainy[] = [] - afterEach(async () => { - for (const b of liveBrains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }) - }) - function trackDir(): string { - const dir = makeTempDir() - dirs.push(dir) - return dir - } - - async function runTrafficWave( - brain: Brainy, - wave: number, - perWave: number - ): Promise<{ kept: string[]; removed: string[] }> { - const kept: string[] = [] - const removed: string[] = [] - const work: Promise[] = [] - for (let i = 0; i < perWave; i++) { - const n = wave * perWave + i - if (i % 4 === 0) { - // Deferred-embed add — the retry-marker flow that wore the defect. - work.push( - brain - .add({ data: `deferred payload ${n}`, type: NounType.Document, metadata: { n, defer: true }, deferEmbedding: true }) - .then((id) => void kept.push(id)) - ) - } else if (i % 4 === 1) { - // Add, then update it in the same wave (two generations, same id). - work.push( - brain.add({ data: `versioned payload ${n}`, type: NounType.Document, metadata: { n, v: 1 } }).then(async (id) => { - kept.push(id) - await brain.update({ id, metadata: { n, v: 2 } }) - }) - ) - } else if (i % 4 === 2) { - // Add, then remove — a durable tombstone is an ack too. - work.push( - brain.add({ data: `ephemeral payload ${n}`, type: NounType.Document, metadata: { n } }).then(async (id) => { - await brain.remove(id) - removed.push(id) - }) - ) - } else { - work.push( - brain.add({ data: `plain payload ${n}`, type: NounType.Document, metadata: { n } }).then((id) => void kept.push(id)) - ) - } - // Race the history tier's group commit against live traffic. - if (i % 5 === 3) work.push(brain.flush()) - } - // NO REFUSALS: every promise must resolve — a single rejection here is - // the refusal-loop costume this leg exists to catch. - await Promise.all(work) - return { kept, removed } - } - - it('three waves of mixed traffic with racing flushes: every ack is truth, the log is strictly ascending, nothing refused', async () => { - const dir = trackDir() - const brain = await openBrain(dir, { logAuthority: 'adopt' }) - liveBrains.push(brain) - expect(brain.logAuthority().authority).toBe('log') - - const kept: string[] = [] - const removed: string[] = [] - for (let wave = 0; wave < 3; wave++) { - const result = await runTrafficWave(brain, wave, 20) - kept.push(...result.kept) - removed.push(...result.removed) - } - await brain.flush() - - for (const id of kept) { - expect(await brain.get(id), `acked write ${id} must be readable truth`).not.toBeNull() - } - for (const id of removed) { - expect(await brain.get(id), `acked remove ${id} must hold`).toBeNull() - } - - const gens = await factGenerations(brain) - expect(gens.length).toBeGreaterThan(0) - for (let i = 1; i < gens.length; i++) { - expect(gens[i], 'fact log strictly ascending end-to-end').toBeGreaterThan(gens[i - 1]) - } - - // Clean reopen: the same truth survives a restart. - await liveBrains.pop()!.close() - const reopened = await openBrain(dir, { logAuthority: 'adopt' }) - liveBrains.push(reopened) - for (const id of kept.slice(0, 10)) { - expect(await reopened.get(id)).not.toBeNull() - } - }, 240000) - - it('crash mid-traffic: every acked write survives the reopen (the at-ack law under the production shape)', async () => { - const dir = trackDir() - const brain = await openBrain(dir, { logAuthority: 'adopt' }) - liveBrains.push(brain) - - const { kept, removed } = await runTrafficWave(brain, 0, 24) - // No close, no flush — the process "dies" holding its RAM. - await abandonAsCrashed(liveBrains.pop()!) - - const reopened = await openBrain(dir, { logAuthority: 'adopt' }) - liveBrains.push(reopened) - for (const id of kept) { - expect(await reopened.get(id), `acked write ${id} must survive the crash`).not.toBeNull() - } - for (const id of removed) { - expect(await reopened.get(id), `acked remove ${id} must survive the crash`).toBeNull() - } - const gens = await factGenerations(reopened) - for (let i = 1; i < gens.length; i++) { - expect(gens[i], 'fact log strictly ascending after recovery').toBeGreaterThan(gens[i - 1]) - } - }, 240000) -}) diff --git a/tests/integration/writer-lock-clean-close.test.ts b/tests/integration/writer-lock-clean-close.test.ts deleted file mode 100644 index 7d9c59d6..00000000 --- a/tests/integration/writer-lock-clean-close.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * @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/writer-lock-fencing.test.ts b/tests/integration/writer-lock-fencing.test.ts deleted file mode 100644 index d5b82c30..00000000 --- a/tests/integration/writer-lock-fencing.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * @module tests/integration/writer-lock-fencing - * @description The writer-lock fencing cures, from a production dev-store - * split-brain (two live writers alternating a store's id-mapper between two - * internally-consistent truths). Three laws, each pinned: - * - * 1. A LIVE writer is never auto-evicted — staleness requires PID-death. - * (The old rule evicted on heartbeat age alone, so a >60s event-loop - * stall — debugger, GC — handed the lock to a second opener while the - * first kept writing.) - * 2. A DEAD writer's lock still self-clears with narration (venue's ask). - * 3. THE FENCE: an evicted writer (force-takeover or removed lock) fails - * LOUDLY at its next commit barrier — typed BRAINY_WRITER_FENCED — and - * never advances the store. - */ -import { describe, it, expect, afterEach } from 'vitest' -import * as fs from 'node:fs' -import * as os from 'node:os' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Brainy } from '../../src/index.js' -import { NounType } from '../../src/types/graphTypes.js' - -const dirs: string[] = [] -const brains: Brainy[] = [] -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -function lockPath(dir: string): string { - return join(dir, 'locks', '_writer.lock') -} - -async function fsBrain(dir: string): Promise { - const brain = new Brainy({ - storage: { type: 'filesystem', path: dir }, - requireSubtype: false - }) - await brain.init() - brains.push(brain) - return brain -} - -describe('writer-lock fencing', () => { - it('a LIVE writer with an ancient heartbeat is NOT evicted — the second opener refuses typed', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-fence-live-')) - dirs.push(dir) - await fsBrain(dir) - - // Manufacture the trigger shape: a DIFFERENT process's lock (pid 1 — - // always alive, never ours, EPERM proves liveness) with a >60s-old - // heartbeat — the blocked-event-loop costume that used to get evicted. - const lp = lockPath(dir) - const lock = JSON.parse(fs.readFileSync(lp, 'utf-8')) - lock.pid = 1 - lock.lastHeartbeat = new Date(Date.now() - 10 * 60_000).toISOString() - fs.writeFileSync(lp, JSON.stringify(lock)) - - // Old rule: heartbeat-age eviction → silent takeover → split brain. - // New rule: live PID = live writer; the second opener throws typed. - const second = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false }) - brains.push(second) - await expect(second.init()).rejects.toMatchObject({ code: 'BRAINY_WRITER_LOCKED' }) - }, 120000) - - it("a DEAD writer's lock self-clears and the new opener proceeds", async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-fence-dead-')) - dirs.push(dir) - const first = await fsBrain(dir) - await first.close() - brains.pop() - - // Manufacture a crashed holder: a lock naming a PID that cannot exist. - fs.mkdirSync(join(dir, 'locks'), { recursive: true }) - fs.writeFileSync( - lockPath(dir), - JSON.stringify({ - pid: 2 ** 22 + 12345, // beyond pid_max on any default Linux - hostname: os.hostname(), - startedAt: new Date().toISOString(), - lastHeartbeat: new Date().toISOString(), - version: 'test', - rootDir: dir - }) - ) - const brain = await fsBrain(dir) // must not throw - const id = await brain.add({ data: 'post-takeover write', type: NounType.Document, metadata: {} }) - expect(await brain.get(id)).not.toBeNull() - }, 120000) - - it('the fence does NOT fire on a same-process re-open — the documented warn-and-take-over contract stays benign', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-fence-samepid-')) - dirs.push(dir) - const first = await fsBrain(dir) - await first.add({ data: 'first instance write', type: NounType.Document, metadata: { n: 1 } }) - - // A second instance in the SAME process takes the lock over (fresh - // startedAt) — the pattern server-restart tests use. The first - // instance's background flushes must keep working: same pid + same - // hostname IS ownership. (The plant's integration lane caught the - // startedAt-strict fence latching exactly this shape dead.) - const second = await fsBrain(dir) - await second.add({ data: 'second instance write', type: NounType.Document, metadata: { n: 2 } }) - await expect(first.flush()).resolves.toBeUndefined() - await expect(second.flush()).resolves.toBeUndefined() - }, 120000) - - it('THE FENCE: a forced-out writer fails its next flush typed and advances nothing', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-fence-evict-')) - dirs.push(dir) - const victim = await fsBrain(dir) - await victim.add({ data: 'pre-eviction write', type: NounType.Document, metadata: { n: 1 } }) - await victim.flush() - const genBefore = victim.generation() - - // A successor takes the lock behind the victim's back (the force-takeover - // shape: different pid + startedAt). - fs.writeFileSync( - lockPath(dir), - JSON.stringify({ - pid: process.pid + 1, - hostname: os.hostname(), - startedAt: new Date(Date.now() + 1).toISOString(), - lastHeartbeat: new Date().toISOString(), - version: 'test-successor', - rootDir: dir - }) - ) - - // The victim's next commit barrier must refuse, typed — never write on. - await victim.add({ data: 'post-eviction write', type: NounType.Document, metadata: { n: 2 } }) - await expect(victim.flush()).rejects.toMatchObject({ code: 'BRAINY_WRITER_FENCED' }) - expect(victim.generation(), 'committed watermark never advanced past the fence') - .toBeGreaterThanOrEqual(genBefore) - - // Transact leg: the barrier fences there too, and rolls back cleanly. - await expect( - victim.transact([ - { op: 'add', id: '00000000-0000-7000-8000-0000000fence', type: NounType.Document, data: 'fenced', metadata: {} } - ]) - ).rejects.toMatchObject({ code: 'BRAINY_WRITER_FENCED' }) - - // Silence the fenced instance's close-time release (it no longer owns the lock). - brains.pop() - await victim.close().catch(() => {}) - }, 120000) -}) diff --git a/tests/integration/zero-norm-unvector-door.test.ts b/tests/integration/zero-norm-unvector-door.test.ts deleted file mode 100644 index d3290010..00000000 --- a/tests/integration/zero-norm-unvector-door.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -/** - * @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/README.md b/tests/lifecycle/README.md deleted file mode 100644 index ebe0e61d..00000000 --- a/tests/lifecycle/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# The Lifecycle Lane - -One brain, driven through founding, a working day, a clean restart, a -crash, a repair, and a second life, checked chapter by chapter against an -independent shadow-model referee (`biographyHarness.ts`). It catches -COMPOSITION regressions unit tests miss — a store fine in one process but -broken across a restart/crash/repair. Runs on the plain JS engine, so it -gates every commit. - -Run it: `npx vitest run tests/lifecycle --pool=forks` - -A red names the chapter label, the id, and expected-vs-actual — diagnosable -from the message alone. `biography.test.ts` is split into two `it` blocks -(Ch1-3, then Ch4-6) purely for reporting; it is still ONE fixed-order story. -Chapters must never be reordered, skipped, or made conditional, and a -failing chapter's assertion must never be weakened to force green. - -Lab notes (hard-won, keep): -- `git reset --hard` does NOT remove untracked files — a "clean" tree can still - carry stray test stores; use `git clean -fd tests/lifecycle-tmp` equivalents. -- `silent: true` patches `console` process-wide — never assert narration through - `console` spies in this lane; the engine's always-on channel is `prodLog`. diff --git a/tests/lifecycle/biography.test.ts b/tests/lifecycle/biography.test.ts deleted file mode 100644 index 274f0ef0..00000000 --- a/tests/lifecycle/biography.test.ts +++ /dev/null @@ -1,418 +0,0 @@ -/** - * @module tests/lifecycle/biography - * @description THE LIFECYCLE LANE — see `tests/lifecycle/README.md` for what - * this proves and how to run it. One scenario, "the working store": a single - * brain driven through founding, a working day, a clean restart, a crash, a - * repair, and a second life, verified chapter by chapter against an - * independent shadow-model referee (`biographyHarness.ts`). - * - * Split into two `it` blocks so a currently-failing later chapter (see the - * second block's header comment — a live engine finding, not a defect in - * this lane) never hides the earlier chapters' passing coverage. The two - * blocks share one brain's directory and one shadow model, run in the SAME - * fixed order the single scenario always has (`describe.sequential` below - * exists to say so explicitly, though vitest's own default is sequential - * within a file) — this is a split for REPORTING clarity, not a reordering - * or conditional skip of any chapter. - */ -import { describe, it, expect } from 'vitest' -import * as fs from 'node:fs' -import { NounType, VerbType } from '../../src/types/graphTypes.js' -import type { Brainy } from '../../src/brainy.js' -import type { AddParams, RelateParams, UpdateParams, UpdateRelationParams } from '../../src/index.js' -import { abandonAsCrashed, makeTempDir, openBrain, uid } from '../helpers/durabilityKillMatrix.js' -import { - createModel, - getCanonicalCountsFor, - modelAdd, - modelDelete, - modelRelate, - modelUpdate, - modelUpdateRelation, - recordVfsFileWrite, - snapshotVfsBaseline, - verifyChapter, - type HubCheck, - type ShadowModel -} from './biographyHarness.js' - -const STATUSES = ['active', 'pending', 'closed', 'archived'] as const - -/** Cycle a status value to the next one in the fixed rotation — used so - * Ch2's 40 updates provably MOVE entities across find() buckets rather than - * risking a no-op reassignment of the same value. */ -function nextStatus(current: unknown): (typeof STATUSES)[number] { - const currentStr = typeof current === 'string' ? current : STATUSES[0] - const idx = STATUSES.indexOf(currentStr as (typeof STATUSES)[number]) - return STATUSES[(idx < 0 ? 0 : idx + 1) % STATUSES.length] -} - -// --------------------------------------------------------------------------- -// Shared biography state — set up by the first `it`, consumed by the second. -// The two blocks are one continuous story told in two named pieces; nothing -// here resets or diverges between them. -// --------------------------------------------------------------------------- -let dir: string -let model: ShadowModel -let brain: Brainy -let hubs: HubCheck[] -let employees: string[] -let customers: string[] -let invoices: string[] -let tasks: string[] -let projects: string[] -let nonHub: string[] - -// ---- Wrappers: every call to the real brain updates the shadow model in -// the same statement, so the two can never drift apart by construction. -// Defined once, closing over the `let` bindings above so both `it` blocks -// (and any future reopen inside them) operate on the current brain/model. -async function doAdd(label: string, params: Omit): Promise { - const id = uid(label) - await brain.add({ ...params, id }) - modelAdd(model, id, { - type: params.type, - subtype: params.subtype, - metadata: params.metadata ?? {}, - visibility: params.visibility - }) - return id -} - -async function doUpdate(id: string, patch: Omit): Promise { - await brain.update({ ...patch, id }) - modelUpdate(model, id, { metadata: patch.metadata, merge: patch.merge, visibility: patch.visibility }) -} - -async function doRemove(id: string): Promise { - await brain.remove(id) - modelDelete(model, id) -} - -async function doRelate(params: RelateParams): Promise { - const id = await brain.relate(params) - modelRelate(model, id, { - from: params.from, - to: params.to, - type: params.type, - subtype: params.subtype, - metadata: params.metadata - }) - return id -} - -async function doUpdateRelation(id: string, patch: Omit): Promise { - await brain.updateRelation({ ...patch, id }) - modelUpdateRelation(model, id, { metadata: patch.metadata, merge: patch.merge }) -} - -async function doVfsWrite(path: string, content: string): Promise { - await brain.vfs.writeFile(path, content) - recordVfsFileWrite(model) -} - -describe.sequential('lifecycle — the working store', () => { - it( - 'Ch1 FOUNDING -> Ch2 A WORKING DAY -> Ch3 CLEAN RESTART: every read serves truth', - async () => { - process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' - dir = makeTempDir() - model = createModel() - - // logAuthority: 'adopt' from the first open, mirrored across every - // reopen — see write-flow-production-shape.test.ts, which the later - // crash chapter's at-ack law is pinned against. - brain = await openBrain(dir, { logAuthority: 'adopt' }) - - // ================================================================= - // CHAPTER 1 — FOUNDING - // ================================================================= - // Baseline MUST be snapshotted before any biography act — it is the - // VFS root's own system-tier footprint, measured, never hardcoded. - await snapshotVfsBaseline(brain, model) - - employees = [] - for (let i = 0; i < 20; i++) { - employees.push( - await doAdd(`emp-${i}`, { - data: `employee record ${i}`, - type: NounType.Person, - subtype: 'employee', - metadata: { status: STATUSES[i % STATUSES.length], department: ['engineering', 'sales', 'support'][i % 3] } - }) - ) - } - customers = [] - for (let i = 0; i < 20; i++) { - customers.push( - await doAdd(`cust-${i}`, { - data: `customer record ${i}`, - type: NounType.Person, - subtype: 'customer', - metadata: { status: STATUSES[i % STATUSES.length], tier: i % 2 === 0 ? 'gold' : 'standard' } - }) - ) - } - invoices = [] - for (let i = 0; i < 30; i++) { - invoices.push( - await doAdd(`inv-${i}`, { - data: `invoice record ${i}`, - type: NounType.Document, - subtype: 'invoice', - metadata: { status: STATUSES[i % STATUSES.length], amount: 100 + i * 17 } - }) - ) - } - tasks = [] - for (let i = 0; i < 25; i++) { - tasks.push( - await doAdd(`task-${i}`, { - data: `task record ${i}`, - type: NounType.Task, - subtype: 'milestone', - metadata: { status: STATUSES[i % STATUSES.length], priority: (i % 5) + 1 } - }) - ) - } - projects = [] - for (let i = 0; i < 25; i++) { - projects.push( - await doAdd(`proj-${i}`, { - data: `project record ${i}`, - type: NounType.Project, - metadata: { status: STATUSES[i % STATUSES.length], budget: 1000 * (i + 1) } - }) - ) - } - expect(employees.length + customers.length + invoices.length + tasks.length + projects.length).toBe(120) - - // Five hubs (proj-0..proj-4) fan out to tasks (Contains) and employees - // (WorksWith); a residual band of invoice->customer RelatedTo edges is - // unrelated to any hub. Hubs are never touched again for the rest of - // the biography, so they stay valid adjacency samples in every chapter. - const hubIds = projects.slice(0, 5) - for (let h = 0; h < 5; h++) { - for (let k = 0; k < 15; k++) { - const taskIdx = (h * 5 + k) % tasks.length - await doRelate({ from: hubIds[h], to: tasks[taskIdx], type: VerbType.Contains, subtype: 'delivers' }) - } - for (let k = 0; k < 10; k++) { - const empIdx = (h * 4 + k) % employees.length - await doRelate({ from: hubIds[h], to: employees[empIdx], type: VerbType.WorksWith }) - } - } - for (let j = 0; j < 25; j++) { - await doRelate({ from: invoices[j], to: customers[j % customers.length], type: VerbType.RelatedTo, subtype: 'billed-to' }) - } - expect(model.relations.size).toBe(150) - - // A handful of VFS files. - for (let i = 0; i < 5; i++) { - await doVfsWrite(`/report-${i}.txt`, `founding report ${i}`) - } - - await brain.flush() - - hubs = hubIds.map((id) => ({ id, typeFilters: [VerbType.Contains, VerbType.WorksWith] })) - await verifyChapter(brain, model, 'Ch1 FOUNDING', { hubs, bucketField: 'status' }) - - // ================================================================= - // CHAPTER 2 — A WORKING DAY - // ================================================================= - // Non-hub pool for every mutation below. - nonHub = [...employees, ...customers, ...invoices, ...tasks, ...projects.slice(5)] - - // 40 updates that provably MOVE entities across find() status buckets. - const updateTargets = nonHub.slice(0, 40) - for (const id of updateTargets) { - const current = model.entities.get(id)!.metadata.status - await doUpdate(id, { metadata: { status: nextStatus(current) } }) - } - - // 10 visibility flips (public -> internal). - const visibilityTargets = nonHub.slice(40, 50) - for (const id of visibilityTargets) { - await doUpdate(id, { visibility: 'internal' }) - } - - // 15 deletes — some hub members (their edges cascade away), 3 of them - // earmarked for Ch6's resurrection. - const resurrectIds = [tasks[0], tasks[1], employees[0]] - const otherDeletes = [ - tasks[2], tasks[3], tasks[4], tasks[5], tasks[6], - employees[1], employees[2], employees[3], - customers[0], customers[1], customers[2], customers[3] - ] - const ch2DeleteTargets = [...resurrectIds, ...otherDeletes] - expect(ch2DeleteTargets.length).toBe(15) - for (const id of ch2DeleteTargets) { - await doRemove(id) - } - - // 20 new adds. - const ch2NewTypes = [NounType.Person, NounType.Document, NounType.Task] - for (let i = 0; i < 20; i++) { - await doAdd(`ch2-new-${i}`, { - data: `working-day addition ${i}`, - type: ch2NewTypes[i % ch2NewTypes.length], - subtype: 'ad-hoc', - metadata: { status: STATUSES[i % STATUSES.length] } - }) - } - - // 10 updateRelation metadata patches — read AFTER the deletes above, - // so only relations the cascade left alive are ever targeted. - const survivingRelationIds = [...model.relations.keys()].slice(0, 10) - expect(survivingRelationIds.length).toBe(10) - for (const relId of survivingRelationIds) { - await doUpdateRelation(relId, { metadata: { reviewed: true } }) - } - - await brain.flush() - await verifyChapter(brain, model, 'Ch2 A WORKING DAY', { hubs, bucketField: 'status' }) - - // ================================================================= - // CHAPTER 3 — CLEAN RESTART - // ================================================================= - await brain.close() - brain = await openBrain(dir, { logAuthority: 'adopt' }) - await verifyChapter(brain, model, 'Ch3 CLEAN RESTART', { hubs, bucketField: 'status' }) - - // Leave the brain closed and the directory intact for the next `it` - // (the biography continues there) — do NOT remove `dir` here. - await brain.close() - }, - 300000 - ) - - it( - 'Ch4 CRASH -> Ch5 REPAIR -> Ch6 SECOND LIFE: continues the Ch3 store', - async () => { - try { - brain = await openBrain(dir, { logAuthority: 'adopt' }) - - // =============================================================== - // CHAPTER 4 — CRASH - // =============================================================== - const ch4Types = [NounType.Person, NounType.Document, NounType.Task, NounType.Project] - for (let i = 0; i < 10; i++) { - await doAdd(`ch4-new-${i}`, { - data: `crash-window addition ${i}`, - type: ch4Types[i % ch4Types.length], - metadata: { status: STATUSES[i % STATUSES.length] } - }) - } - const ch4UpdateTargets = nonHub.slice(50, 55) // invoices[10..14] — untouched so far - for (const id of ch4UpdateTargets) { - await doUpdate(id, { metadata: { status: 'active' } }) - } - // NO flush — abandon exactly the way process death would (the - // at-ack law: every write already awaited above must survive). - await abandonAsCrashed(brain) - brain = await openBrain(dir, { logAuthority: 'adopt' }) - await verifyChapter(brain, model, 'Ch4 CRASH', { hubs, bucketField: 'status' }) - - // =============================================================== - // CHAPTER 5 — REPAIR - // =============================================================== - const report = await brain.repairIndex() - for (const family of report.families) { - const accounted = - family.checked === true || (family.checked === false && typeof family.skipped === 'string' && family.skipped.length > 0) - expect( - accounted, - `[Ch5 REPAIR] family '${family.family}' must be checked or explicitly skipped with a reason; got ${JSON.stringify(family)}` - ).toBe(true) - } - // A healthy store: repair must change nothing the model doesn't - // already expect — verifyChapter against the UNCHANGED model proves it. - await verifyChapter(brain, model, 'Ch5 REPAIR', { hubs, bucketField: 'status' }) - - // =============================================================== - // CHAPTER 6 — SECOND LIFE - // =============================================================== - const ch6Types = [NounType.Person, NounType.Document, NounType.Task, NounType.Project] - for (let i = 0; i < 10; i++) { - await doAdd(`ch6-new-${i}`, { - data: `second-life addition ${i}`, - type: ch6Types[i % ch6Types.length], - metadata: { status: STATUSES[i % STATUSES.length] } - }) - } - const ch6UpdateTargets = nonHub.slice(55, 65) // invoices[15..24] — untouched so far - expect(ch6UpdateTargets.every((id) => model.entities.get(id)!.alive)).toBe(true) - for (const id of ch6UpdateTargets) { - await doUpdate(id, { metadata: { status: 'closed' } }) - } - const ch6DeleteTargets = nonHub - .slice(65, 90) // invoices[25..29] + tasks[0..19] (some already dead — filtered below) - .filter((id) => model.entities.get(id)!.alive) - .slice(0, 7) - expect(ch6DeleteTargets.length).toBe(7) - for (const id of ch6DeleteTargets) { - await doRemove(id) - } - - // Resurrection: the SAME three ids Ch2 deleted, reinserted with - // BRAND-NEW metadata — the model expects the new metadata only. - await doAdd('task-0', { data: 'resurrected task 0', type: NounType.Task, subtype: 'milestone', metadata: { status: 'active', resurrected: true } }) - await doAdd('task-1', { data: 'resurrected task 1', type: NounType.Task, subtype: 'milestone', metadata: { status: 'pending', resurrected: true } }) - await doAdd('emp-0', { data: 'resurrected employee 0', type: NounType.Person, subtype: 'employee', metadata: { status: 'active', resurrected: true } }) - expect(tasks[0]).toBe(uid('task-0')) // same id as Ch1/Ch2 — the resurrection-adjacent shape - - await brain.close() - brain = await openBrain(dir, { logAuthority: 'adopt' }) - await verifyChapter(brain, model, 'Ch6 SECOND LIFE', { hubs, bucketField: 'status' }) - - // Final, standalone getCanonicalCounts() exactness check (beyond - // verifyChapter's own (f) leg) — the whole ledger, in one shot. - const finalCounts = await getCanonicalCountsFor(brain) - const aliveEntities = [...model.entities.values()].filter((e) => e.alive) - const alivePublicEntities = aliveEntities.filter((e) => (e.visibility ?? 'public') === 'public') - const aliveVerbs = model.relations.size - expect(finalCounts, 'final getCanonicalCounts() exactness — Ch6 SECOND LIFE').toEqual({ - nouns: { - counted: alivePublicEntities.length + model.vfsFileNouns, - all: aliveEntities.length + model.vfsFileNouns + model.vfsBaselineNouns - }, - verbs: { - counted: aliveVerbs + model.vfsContainsVerbs, - all: aliveVerbs + model.vfsContainsVerbs + model.vfsBaselineVerbs - }, - // 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 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 - }, - suspect: false - }) - } finally { - await brain.close().catch(() => {}) - // Best-effort, retried: a still-draining background persistence - // write (e.g. count/index write-through) can race a single rmSync - // and leave a partial directory behind — retry a couple of times - // rather than let this temp dir leak. - for (let attempt = 0; attempt < 3; attempt++) { - try { - fs.rmSync(dir, { recursive: true, force: true }) - if (!fs.existsSync(dir)) break - } catch { - // ignore and retry - } - await new Promise((resolve) => setTimeout(resolve, 100)) - } - } - }, - 300000 - ) -}) diff --git a/tests/lifecycle/biographyHarness.ts b/tests/lifecycle/biographyHarness.ts deleted file mode 100644 index ca15b36a..00000000 --- a/tests/lifecycle/biographyHarness.ts +++ /dev/null @@ -1,389 +0,0 @@ -/** - * @module tests/lifecycle/biographyHarness - * @description The referee for the LIFECYCLE LANE (see `biography.test.ts`): - * a plain in-memory SHADOW MODEL of a brain's contents, updated by every act - * the biography performs (add/update/remove/relate/updateRelation/vfs writes), - * plus `verifyChapter()`, which asserts the live brain agrees with the model - * after every chapter. No engine code runs inside the model — it is an - * independent ledger, not a mirror of the implementation under test. - * - * COUNT SEMANTICS this harness encodes (verified against the live engine, - * not assumed — see the module-level comments below for how each was - * confirmed): - * - * - `getNounCount()` / `getVerbCount()` count PUBLIC-tier alive records only - * (visibility absent or `'public'`) — `'internal'` and `'system'` are both - * excluded. `storage.getCanonicalCounts()` mirrors that same PUBLIC-only - * scalar as `counted`, and additionally reports `all` — every tier, - * unfiltered — as the coverage-ledger denominator (see - * tests/integration/canonical-count-ledger.test.ts). - * - `brain.vfs.writeFile()` for a brand-new file at a path directly under the - * VFS root creates exactly ONE new File noun plus ONE new `Contains` verb - * (root -> file), and BOTH are ordinary PUBLIC records (no visibility - * field is set) — so they count toward `getNounCount()`/`getVerbCount()` - * as well as the canonical `all` scalars. Only the VFS ROOT entity itself - * is `'system'`-tier (created once, at `init()`, before any biography - * chapter runs) — that lone record is the only hidden-tier footprint the - * model does not construct explicitly, so it is captured empirically via - * `snapshotVfsBaseline()` immediately after `init()` rather than hardcoded. - * - `related()` filters edges by the RELATION's own visibility tier, not by - * the visibility of the entities the edge connects — flipping an entity to - * `'internal'` does not hide its edges from `related()`. This lane never - * sets relation visibility, so every relation the model tracks is exactly - * as reachable as its presence in `model.relations` implies. - * - `remove()` cascades: every relation touching the removed entity (as - * `from` or `to`) is hard-deleted along with it. The model mirrors this by - * deleting the relation entirely from `model.relations` (no relation - * "alive" flag — presence in the map IS aliveness). - */ -import { expect } from 'vitest' -import type { Brainy } from '../../src/brainy.js' -import type { NounType, VerbType } from '../../src/types/graphTypes.js' -import type { EntityVisibility, StorageAdapter } from '../../src/coreTypes.js' - -/** - * One entity's complete lifecycle-relevant state, as the biography's acts - * leave it. `alive: false` means the model believes the id has been removed - * — the entry is KEPT (never deleted from the map) so `verifyChapter` can - * assert the negative half of the contract: a dead id must read as `null`. - */ -export interface ShadowEntity { - type: NounType - subtype?: string - metadata: Record - visibility?: EntityVisibility - alive: boolean -} - -/** - * One relation's complete lifecycle-relevant state. There is no `alive` - * flag here — presence in {@link ShadowModel.relations} IS aliveness, - * mirroring the engine's hard delete of the canonical verb record on - * cascade (see the module header). - */ -export interface ShadowRelation { - from: string - to: string - type: VerbType - subtype?: string - metadata: Record -} - -/** - * The independent truth ledger the biography updates on every act it - * performs. `verifyChapter` checks the live brain against this — never the - * other way around. - */ -export interface ShadowModel { - entities: Map - relations: Map - /** - * `getCanonicalCounts()` nouns.all / verbs.all captured right after - * `init()`, before chapter 1 — the VFS root's own system-tier footprint. - * Set once via {@link snapshotVfsBaseline}; never hardcoded. - */ - vfsBaselineNouns: number - vfsBaselineVerbs: number - /** - * Public nouns/verbs created by `vfs.writeFile()` for a brand-new file at - * a flat top-level path: exactly one File noun + one Contains verb per - * call (see the module header). Bumped by {@link recordVfsFileWrite}. - */ - vfsFileNouns: number - vfsContainsVerbs: number -} - -/** A fresh, empty shadow model — call once before chapter 1. */ -export function createModel(): ShadowModel { - return { - entities: new Map(), - relations: new Map(), - vfsBaselineNouns: 0, - vfsBaselineVerbs: 0, - vfsFileNouns: 0, - vfsContainsVerbs: 0 - } -} - -/** Narrow, documented private-storage access (the same style already used by - * `tests/helpers/durabilityKillMatrix.ts`'s `storeOf()`), needed because - * `getCanonicalCounts()` lives on the storage adapter, not on `Brainy`. */ -function storageOf(brain: Brainy): StorageAdapter { - return (brain as unknown as { storage: StorageAdapter }).storage -} - -/** Public wrapper around the private-storage `getCanonicalCounts()` read, so - * callers never need their own private-access cast — used internally by - * {@link snapshotVfsBaseline} and {@link verifyChapter}, and by - * `biography.test.ts` for its final standalone exactness check. */ -export async function getCanonicalCountsFor(brain: Brainy): ReturnType> { - const storage = storageOf(brain) - if (!storage.getCanonicalCounts) { - throw new Error( - 'lifecycle lane: the storage adapter under test has no getCanonicalCounts() — the canonical-count-exactness leg of this lane is unrepresentable without it.' - ) - } - return storage.getCanonicalCounts() -} - -/** - * Snapshot the VFS root's own hidden-tier footprint. Call exactly once, - * immediately after `init()` and before chapter 1 does anything — this is - * the ONE baseline offset the model does not construct by hand (see the - * module header for why: the root is `'system'`-tier plumbing the biography - * never explicitly creates). - */ -export async function snapshotVfsBaseline(brain: Brainy, model: ShadowModel): Promise { - const counts = await getCanonicalCountsFor(brain) - model.vfsBaselineNouns = counts.nouns.all - model.vfsBaselineVerbs = counts.verbs.all -} - -/** - * Record one `brain.vfs.writeFile()` call for a brand-new file at a flat - * top-level path (no intermediate directories). Bumps both the noun and verb - * VFS counters by one, matching the engine's actual write path exactly (see - * the module header) — never call this for an overwrite of an existing path, - * a nested path (which would also vivify intermediate directory nouns/edges, - * a different, unmodeled shape), or the biography loses its exactness. - */ -export function recordVfsFileWrite(model: ShadowModel): void { - model.vfsFileNouns += 1 - model.vfsContainsVerbs += 1 -} - -/** Record a fresh `add()` (or a Ch6 resurrection — `Map.set` fully replaces - * whatever a prior dead entry held, which is exactly the "new metadata only" - * contract a resurrection must honor). */ -export function modelAdd( - model: ShadowModel, - id: string, - entity: { type: NounType; subtype?: string; metadata: Record; visibility?: EntityVisibility } -): void { - model.entities.set(id, { - type: entity.type, - subtype: entity.subtype, - metadata: { ...entity.metadata }, - visibility: entity.visibility, - alive: true - }) -} - -/** Record an `update()` — merges metadata by default, matching the engine's - * `merge: true` default; pass `merge: false` to mirror a full replace. */ -export function modelUpdate( - model: ShadowModel, - id: string, - patch: { metadata?: Record; merge?: boolean; visibility?: EntityVisibility } -): void { - const existing = model.entities.get(id) - if (!existing || !existing.alive) { - throw new Error(`shadow model: update() targeted ${id}, which the model does not have alive — biography sequencing bug`) - } - if (patch.metadata) { - existing.metadata = patch.merge === false ? { ...patch.metadata } : { ...existing.metadata, ...patch.metadata } - } - if (patch.visibility !== undefined) { - existing.visibility = patch.visibility - } -} - -/** Record a `remove()` — marks the entity dead (entry retained, per - * {@link ShadowEntity}) and cascades: every relation touching it, in either - * direction, is hard-deleted from the model too (matching the engine). */ -export function modelDelete(model: ShadowModel, id: string): void { - const existing = model.entities.get(id) - if (!existing || !existing.alive) { - throw new Error(`shadow model: remove() targeted ${id}, which the model does not have alive — biography sequencing bug`) - } - existing.alive = false - for (const [relId, rel] of model.relations) { - if (rel.from === id || rel.to === id) model.relations.delete(relId) - } -} - -/** Record a `relate()` — `id` is the relation id the real call returned. */ -export function modelRelate( - model: ShadowModel, - id: string, - relation: { from: string; to: string; type: VerbType; subtype?: string; metadata?: Record } -): void { - model.relations.set(id, { - from: relation.from, - to: relation.to, - type: relation.type, - subtype: relation.subtype, - metadata: { ...(relation.metadata ?? {}) } - }) -} - -/** Record an `updateRelation()` metadata patch — merges by default. */ -export function modelUpdateRelation( - model: ShadowModel, - id: string, - patch: { metadata?: Record; merge?: boolean } -): void { - const existing = model.relations.get(id) - if (!existing) { - throw new Error(`shadow model: updateRelation() targeted ${id}, which the model does not have — biography sequencing bug`) - } - if (patch.metadata) { - existing.metadata = patch.merge === false ? { ...patch.metadata } : { ...existing.metadata, ...patch.metadata } - } -} - -/** Order-independent structural equality for plain JSON-shaped metadata. */ -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true - if (typeof a !== typeof b) return false - if (a === null || b === null) return a === b - if (typeof a !== 'object') return false - const aKeys = Object.keys(a as Record) - const bKeys = Object.keys(b as Record) - if (aKeys.length !== bKeys.length) return false - for (const k of aKeys) { - if (!deepEqual((a as Record)[k], (b as Record)[k])) return false - } - return true -} - -/** One hub entity to sample for the `related()` adjacency check, plus the - * verb type(s) it is known (by biography construction) to have OUT-edges - * of, so the type-filtered variant is exercised too. */ -export interface HubCheck { - id: string - typeFilters: VerbType[] -} - -/** Options steering one `verifyChapter()` call. */ -export interface VerifyOptions { - /** Hub entities to sample for the `related()` adjacency check. */ - hubs: HubCheck[] - /** The metadata field `find()` bucket-checks against (a bare string field - * every alive entity may or may not carry — distinct values present among - * ALIVE model entities are discovered automatically each call, so a - * chapter that moves entities across buckets is re-checked exactly). */ - bucketField: string -} - -/** - * Assert the live brain agrees with the model, in full, after one chapter. - * Every failure message names the chapter `label`, the id (where - * applicable), and expected-vs-actual — a red here must be diagnosable from - * the assertion message alone, with no need to re-read this file. - */ -export async function verifyChapter(brain: Brainy, model: ShadowModel, label: string, opts: VerifyOptions): Promise { - // (a) + (b): every alive entity reads back exactly as modeled; every dead - // entity reads as null. - for (const [id, entity] of model.entities) { - const live = await brain.get(id) - if (entity.alive) { - expect(live, `[${label}] alive entity ${id} (type=${entity.type}) must be readable via get(), got null`).not.toBeNull() - const e = live! - expect(e.type, `[${label}] entity ${id} .type mismatch: expected ${entity.type}, got ${e.type}`).toBe(entity.type) - expect(e.subtype, `[${label}] entity ${id} .subtype mismatch: expected ${JSON.stringify(entity.subtype)}, got ${JSON.stringify(e.subtype)}`).toBe(entity.subtype) - expect( - e.visibility, - `[${label}] entity ${id} .visibility mismatch: expected ${JSON.stringify(entity.visibility)}, got ${JSON.stringify(e.visibility)}` - ).toBe(entity.visibility) - const metaMatches = deepEqual(e.metadata ?? {}, entity.metadata) - expect( - metaMatches, - `[${label}] entity ${id} .metadata mismatch: expected ${JSON.stringify(entity.metadata)}, got ${JSON.stringify(e.metadata)}` - ).toBe(true) - } else { - expect(live, `[${label}] dead entity ${id} (type=${entity.type}) must read as null, got ${JSON.stringify(live)}`).toBeNull() - } - } - - // (c) find({ where: { : value } }) returns exactly the - // model's matching alive set, per distinct value currently present. - const bucketValues = new Set() - for (const entity of model.entities.values()) { - if (!entity.alive) continue - const v = entity.metadata[opts.bucketField] - if (typeof v === 'string') bucketValues.add(v) - } - for (const value of bucketValues) { - const expectedIds = [...model.entities.entries()] - .filter(([, e]) => e.alive && e.metadata[opts.bucketField] === value) - .map(([id]) => id) - .sort() - const results = await brain.find({ - where: { [opts.bucketField]: value } as Record, - includeInternal: true, - limit: 100000 - }) - const actualIds = results.map((r) => r.id).sort() - expect( - actualIds, - `[${label}] find({ where: { ${opts.bucketField}: ${JSON.stringify(value)} } }) mismatch: expected ${expectedIds.length} ids ${JSON.stringify(expectedIds)}, got ${actualIds.length} ids ${JSON.stringify(actualIds)}` - ).toEqual(expectedIds) - } - - // (d) related(id) / related(id, { type }) for the hub sample matches the - // model's adjacency exactly (out-edges — related(id) is shorthand for - // { from: id }). - for (const hub of opts.hubs) { - const expectedAll = [...model.relations.entries()] - .filter(([, r]) => r.from === hub.id) - .map(([id]) => id) - .sort() - const liveAll = await brain.related({ from: hub.id, limit: 100000 }) - const actualAllIds = liveAll.map((r) => r.id).sort() - expect( - actualAllIds, - `[${label}] related(${hub.id}) mismatch: expected ${expectedAll.length} ids ${JSON.stringify(expectedAll)}, got ${actualAllIds.length} ids ${JSON.stringify(actualAllIds)}` - ).toEqual(expectedAll) - - for (const typeFilter of hub.typeFilters) { - const expectedTyped = [...model.relations.entries()] - .filter(([, r]) => r.from === hub.id && r.type === typeFilter) - .map(([id]) => id) - .sort() - const liveTyped = await brain.related({ from: hub.id, type: typeFilter, limit: 100000 }) - const actualTypedIds = liveTyped.map((r) => r.id).sort() - expect( - actualTypedIds, - `[${label}] related(${hub.id}, { type: '${typeFilter}' }) mismatch: expected ${expectedTyped.length} ids ${JSON.stringify(expectedTyped)}, got ${actualTypedIds.length} ids ${JSON.stringify(actualTypedIds)}` - ).toEqual(expectedTyped) - } - } - - // (e) getNounCount() / getVerbCount(): PUBLIC-tier alive records - // (visibility absent/'public'; 'internal' and 'system' both excluded — see - // the module header) plus the VFS's own public contributions. - const alivePublicNouns = [...model.entities.values()].filter((e) => e.alive && (e.visibility ?? 'public') === 'public').length - const aliveVerbs = model.relations.size - const expectedNounCount = alivePublicNouns + model.vfsFileNouns - const expectedVerbCount = aliveVerbs + model.vfsContainsVerbs - expect( - await brain.getNounCount(), - `[${label}] getNounCount() mismatch: expected ${expectedNounCount} (alive public entities ${alivePublicNouns} + vfs file nouns ${model.vfsFileNouns})` - ).toBe(expectedNounCount) - expect( - await brain.getVerbCount(), - `[${label}] getVerbCount() mismatch: expected ${expectedVerbCount} (alive relations ${aliveVerbs} + vfs contains verbs ${model.vfsContainsVerbs})` - ).toBe(expectedVerbCount) - - // (f) getCanonicalCounts(): ALL-visibility scalars (every tier) equal the - // model's alive totals including hidden tiers, plus the VFS's own - // contributions (both file nouns/verbs AND the once-measured root - // baseline). suspect must be false — every delete in this biography goes - // through brain.remove(), which always proves the record it decrements. - const ledger = await getCanonicalCountsFor(brain) - const aliveAllNouns = [...model.entities.values()].filter((e) => e.alive).length - const expectedNounsAll = aliveAllNouns + model.vfsFileNouns + model.vfsBaselineNouns - const expectedVerbsAll = aliveVerbs + model.vfsContainsVerbs + model.vfsBaselineVerbs - expect( - ledger.nouns.all, - `[${label}] getCanonicalCounts().nouns.all mismatch: expected ${expectedNounsAll} (alive incl. internal ${aliveAllNouns} + vfs file nouns ${model.vfsFileNouns} + vfs root baseline ${model.vfsBaselineNouns})` - ).toBe(expectedNounsAll) - expect( - ledger.verbs.all, - `[${label}] getCanonicalCounts().verbs.all mismatch: expected ${expectedVerbsAll} (alive relations ${aliveVerbs} + vfs contains verbs ${model.vfsContainsVerbs} + vfs root baseline ${model.vfsBaselineVerbs})` - ).toBe(expectedVerbsAll) - expect(ledger.nouns.counted, `[${label}] getCanonicalCounts().nouns.counted mismatch (should mirror getNounCount())`).toBe(expectedNounCount) - expect(ledger.verbs.counted, `[${label}] getCanonicalCounts().verbs.counted mismatch (should mirror getVerbCount())`).toBe(expectedVerbCount) - expect(ledger.suspect, `[${label}] getCanonicalCounts().suspect must be false — every delete in this biography proves its record`).toBe(false) -} diff --git a/tests/performance/triple-intelligence-scale.test.ts b/tests/performance/triple-intelligence-scale.test.ts index 1db7fc80..6687decd 100644 --- a/tests/performance/triple-intelligence-scale.test.ts +++ b/tests/performance/triple-intelligence-scale.test.ts @@ -352,8 +352,106 @@ describe('Triple Intelligence Performance at Scale', () => { }) }) -// The former 'Triple Intelligence Correctness' describe (4 tests, no timing -// assertions) moved to tests/integration/triple-intelligence-correctness.test.ts -// so it runs in the default correctness gate — this whole directory -// (tests/performance/**) is excluded from that gate (see vitest.config.ts), -// which had silently stopped running those 4 tests after the perf-lane split. \ No newline at end of file +describe('Triple Intelligence Correctness', () => { + let brain: Brainy + let triple: TripleIntelligenceSystem + + beforeAll(async () => { + brain = new Brainy({ requireSubtype: false }) + await brain.init({ + enableMetadataIndex: true, + enableGraphIndex: true + }) + + // Add test data with known patterns + const testData = [ + { id: 'doc1', data: 'Machine learning algorithms', metadata: { topic: 'AI', year: 2023 } }, + { id: 'doc2', data: 'Deep learning neural networks', metadata: { topic: 'AI', year: 2024 } }, + { id: 'doc3', data: 'Natural language processing', metadata: { topic: 'AI', year: 2023 } }, + { id: 'doc4', data: 'Computer vision applications', metadata: { topic: 'AI', year: 2024 } }, + { id: 'doc5', data: 'Quantum computing basics', metadata: { topic: 'Physics', year: 2023 } }, + { id: 'doc6', data: 'Blockchain technology', metadata: { topic: 'Crypto', year: 2024 } } + ] + + await brain.addMany(testData) + + // Add relationships + await brain.relate({ from: 'doc1', to: 'doc2', type: 'related' }) + await brain.relate({ from: 'doc2', to: 'doc3', type: 'related' }) + await brain.relate({ from: 'doc3', to: 'doc4', type: 'related' }) + + triple = brain.getTripleIntelligence() + }) + + afterAll(async () => { + await brain?.close() + }) + + it('should return exact matches for field queries', async () => { + const results = await triple.find({ + where: { topic: 'AI' }, + limit: 10 + }) + + expect(results).toHaveLength(4) + for (const result of results) { + expect(result.metadata.topic).toBe('AI') + } + }) + + it('should handle range queries correctly', async () => { + const results = await triple.find({ + where: { year: { $gte: 2024 } }, + limit: 10 + }) + + expect(results).toHaveLength(3) + for (const result of results) { + expect(result.metadata.year).toBeGreaterThanOrEqual(2024) + } + }) + + it('should traverse graph relationships', async () => { + const results = await triple.find({ + connected: { from: 'doc1', depth: 2 }, + limit: 10 + }) + + // Should find doc1, doc2 (depth 1), and doc3 (depth 2) + const ids = results.map(r => r.id) + expect(ids).toContain('doc1') + expect(ids).toContain('doc2') + expect(ids).toContain('doc3') + + // Check depth values + const doc1Result = results.find(r => r.id === 'doc1') + const doc2Result = results.find(r => r.id === 'doc2') + const doc3Result = results.find(r => r.id === 'doc3') + + expect(doc1Result?.depth).toBe(0) + expect(doc2Result?.depth).toBe(1) + expect(doc3Result?.depth).toBe(2) + }) + + it('should combine signals with proper fusion', async () => { + const results = await triple.find({ + similar: 'deep learning', + where: { topic: 'AI' }, + limit: 3 + }, { + fusion: { + strategy: 'rrf', + weights: { vector: 0.7, field: 0.3 } + } + }) + + // doc2 should rank highest (matches both signals) + expect(results[0].id).toBe('doc2') + expect(results[0].fusionScore).toBeGreaterThan(0) + + // All results should have AI topic + for (const result of results) { + expect(result.metadata.topic).toBe('AI') + } + }) +}) \ No newline at end of file diff --git a/tests/performance/typeAware.bench.test.ts b/tests/performance/typeAware.bench.test.ts index b1153662..72d96fe5 100644 --- a/tests/performance/typeAware.bench.test.ts +++ b/tests/performance/typeAware.bench.test.ts @@ -17,7 +17,7 @@ * - Note limitations and edge cases */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy } from '../../src/brainy.js' import { TypeAwareStorageAdapter } from '../../src/storage/adapters/typeAwareStorageAdapter.js' import { FileSystemStorage } from '../../src/storage/adapters/fileSystemStorage.js' @@ -67,10 +67,6 @@ describe('TypeAware Performance Benchmarks', () => { } }) - afterEach(async () => { - await brainMemory.close() - }) - it('should measure type-based query performance', async () => { // MEASURED: Query for one type (200 entities) const start = performance.now() diff --git a/tests/regression/metadata-field-typing.unit.test.ts b/tests/regression/metadata-field-typing.unit.test.ts deleted file mode 100644 index 910d4f2a..00000000 --- a/tests/regression/metadata-field-typing.unit.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * @module metadata-field-typing.unit.test - * @description Regression: a metadata field that holds more than one value - * KIND stays fully filterable on every kind it holds. - * - * The defect this pins, reproduced on the released engine: the metadata index - * fixed a field's value type from the FIRST value it saw, and every later value - * of a different type was coerced to that type or, when coercion failed, - * dropped from the index in silence. Writing `category: 'electronics'` rows and - * then `category: 5` rows left `find({ where: { category: 5 } })` returning - * nothing — while the same rows in a numbers-only corpus answered correctly. - * The rows themselves were never lost: they stayed readable by id and by vector - * search, and only ever went missing from equality filters on that one field, - * which is what made it so quiet. - * - * Order is the whole point of these cases. Neither writer owns the field, so - * strings-then-numbers and numbers-then-strings must give the same answers. - */ - -import { describe, it, expect } from 'vitest' -import { Brainy } from '../../src/brainy.js' -import { NounType } from '../../src/types/graphTypes.js' - -/** A brain over memory storage, with a corpus written in the given order. */ -async function brainWith( - rows: Array<{ label: string; category: unknown }> -): Promise { - const brainy = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brainy.init() - for (const row of rows) { - await brainy.add({ - data: `item ${row.label}`, - type: NounType.Thing, - metadata: { label: row.label, category: row.category } - }) - } - return brainy -} - -const labelsOf = (results: Array<{ metadata?: Record }>): string[] => - results.map((r) => String(r.metadata?.label)).sort() - -describe('regression: a mixed-kind metadata field filters on every kind', { timeout: 180_000 }, () => { - it('finds number rows written after string rows', async () => { - const brainy = await brainWith([ - { label: 'e1', category: 'electronics' }, - { label: 'f1', category: 'furniture' }, - { label: 'n1', category: 5 }, - { label: 'n2', category: 5 }, - { label: 'n3', category: 7 } - ]) - try { - expect(labelsOf(await brainy.find({ where: { category: 5 }, limit: 100 }))).toEqual(['n1', 'n2']) - expect(labelsOf(await brainy.find({ where: { category: 7 }, limit: 100 }))).toEqual(['n3']) - expect(labelsOf(await brainy.find({ where: { category: 'electronics' }, limit: 100 }))).toEqual(['e1']) - expect(labelsOf(await brainy.find({ where: { category: 'furniture' }, limit: 100 }))).toEqual(['f1']) - } finally { - await brainy.close() - } - }) - - it('finds string rows written after number rows', async () => { - const brainy = await brainWith([ - { label: 'n1', category: 5 }, - { label: 'n2', category: 5 }, - { label: 'e1', category: 'electronics' }, - { label: 'e2', category: 'electronics' } - ]) - try { - expect(labelsOf(await brainy.find({ where: { category: 'electronics' }, limit: 100 }))).toEqual(['e1', 'e2']) - expect(labelsOf(await brainy.find({ where: { category: 5 }, limit: 100 }))).toEqual(['n1', 'n2']) - } finally { - await brainy.close() - } - }) - - it('keeps `5` and `\'5\'` apart — a kind is part of the value, not a formatting detail', async () => { - const brainy = await brainWith([ - { label: 'num', category: 5 }, - { label: 'str', category: '5' } - ]) - try { - expect(labelsOf(await brainy.find({ where: { category: 5 }, limit: 100 }))).toEqual(['num']) - expect(labelsOf(await brainy.find({ where: { category: '5' }, limit: 100 }))).toEqual(['str']) - } finally { - await brainy.close() - } - }) - - it('serves booleans mixed into a field that already holds strings', async () => { - const brainy = await brainWith([ - { label: 's1', category: 'yes' }, - { label: 'b1', category: true }, - { label: 'b2', category: false } - ]) - try { - expect(labelsOf(await brainy.find({ where: { category: true }, limit: 100 }))).toEqual(['b1']) - expect(labelsOf(await brainy.find({ where: { category: false }, limit: 100 }))).toEqual(['b2']) - expect(labelsOf(await brainy.find({ where: { category: 'yes' }, limit: 100 }))).toEqual(['s1']) - } finally { - await brainy.close() - } - }) - - it('ranges over the numeric part of a mixed field', async () => { - const brainy = await brainWith([ - { label: 'unpriced', category: 'on request' }, - { label: 'cheap', category: 100 }, - { label: 'mid', category: 500 }, - { label: 'dear', category: 900 } - ]) - try { - const found = await brainy.find({ - where: { category: { greaterThan: 200 } }, - limit: 100 - }) - expect(labelsOf(found)).toEqual(['dear', 'mid']) - } finally { - await brainy.close() - } - }) -}) diff --git a/tests/regression/metadata-index-cleanup.unit.test.ts b/tests/regression/metadata-index-cleanup.unit.test.ts index 3746e833..0984d727 100644 --- a/tests/regression/metadata-index-cleanup.unit.test.ts +++ b/tests/regression/metadata-index-cleanup.unit.test.ts @@ -244,10 +244,7 @@ describe('Metadata index cleanup after remove / removeMany', () => { const noConfidenceId = await addEntity({ type: 'thing' }) const withConfidenceId = await addEntity({ type: 'thing', confidence: 0.9 }) - // system.confidence — confidence is an engine scalar (an add() param), - // never a metadata field; bare 'confidence' now addresses the user's - // own metadata bag under the sealed field-addressing law. - const results = await brain.find({ where: { 'system.confidence': { exists: true } } }) + const results = await brain.find({ where: { confidence: { exists: true } } }) const ids = results.map(r => r.id) expect(ids).toContain(withConfidenceId) @@ -258,8 +255,7 @@ describe('Metadata index cleanup after remove / removeMany', () => { const noWeightId = await addEntity({ type: 'thing' }) const withWeightId = await addEntity({ type: 'thing', weight: 0.5 }) - // system.weight — same reasoning as system.confidence above. - const results = await brain.find({ where: { 'system.weight': { exists: true } } }) + const results = await brain.find({ where: { weight: { exists: true } } }) const ids = results.map(r => r.id) expect(ids).toContain(withWeightId) @@ -273,12 +269,11 @@ describe('Metadata index cleanup after remove / removeMany', () => { const id = await addEntity({ type: 'thing' }) await brain.remove(id) - // Entity must not appear in any confidence query. system.confidence — - // same addressing as the two tests above. - const existsTrue = await brain.find({ where: { 'system.confidence': { exists: true } } }) + // Entity must not appear in any confidence query + const existsTrue = await brain.find({ where: { confidence: { exists: true } } }) expect(existsTrue.map(r => r.id)).not.toContain(id) - const existsFalse = await brain.find({ where: { 'system.confidence': { exists: false } } }) + const existsFalse = await brain.find({ where: { confidence: { exists: false } } }) expect(existsFalse.map(r => r.id)).not.toContain(id) }) }) diff --git a/tests/transaction/TransactionManager.unit.test.ts b/tests/transaction/TransactionManager.unit.test.ts index 29e7f7ae..86b1692c 100644 --- a/tests/transaction/TransactionManager.unit.test.ts +++ b/tests/transaction/TransactionManager.unit.test.ts @@ -5,6 +5,7 @@ * - High-level transaction API * - Statistics tracking * - Error handling + * - Result wrapping */ import { describe, it, expect, beforeEach } from 'vitest' @@ -83,6 +84,42 @@ describe('TransactionManager', () => { }) }) + describe('executeTransactionWithResult', () => { + it('should return detailed result', async () => { + const result = await manager.executeTransactionWithResult(async (tx) => { + tx.addOperation({ + execute: async () => { + await new Promise(resolve => setTimeout(resolve, 1)) + return async () => {} + } + }) + tx.addOperation({ execute: async () => undefined }) + return 'success' + }) + + expect(result.value).toBe('success') + expect(result.operationCount).toBe(2) + expect(result.executionTimeMs).toBeGreaterThanOrEqual(0) + }) + + it('should measure execution time', async () => { + const result = await manager.executeTransactionWithResult(async (tx) => { + tx.addOperation({ + execute: async () => { + await new Promise(resolve => setTimeout(resolve, 25)) + return async () => {} + } + }) + return 'done' + }) + + // Timer coalescing can fire a setTimeout up to a few ms EARLY under + // load, so assert well below the sleep — this tests that time is + // MEASURED, not the OS timer's precision. + expect(result.executionTimeMs).toBeGreaterThanOrEqual(20) + }) + }) + describe('Statistics Tracking', () => { it('should track total transactions', async () => { await manager.executeTransaction(async (tx) => { diff --git a/tests/unit/aggregation/aggregation-provider-rebuild.test.ts b/tests/unit/aggregation/aggregation-provider-rebuild.test.ts deleted file mode 100644 index efd7b4bb..00000000 --- a/tests/unit/aggregation/aggregation-provider-rebuild.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * @module tests/unit/aggregation/aggregation-provider-rebuild - * @description Pins for SELF-ENGINE-LIFECYCLE-SPRINT asks (c) + (d): - * (c) the native provider's parallel `rebuildAggregate` — on the provider - * contract since 8.x but NEVER invoked (the JS walk streamed per-entity - * FFI calls instead) — is now the backfill walk's preferred door; - * (d) a write-path hook that cannot see its entity (before-image-less - * delete) flags an exact rescan LOUDLY instead of silently skipping the - * decrement (the skip let counts drift upward forever). - */ -import { describe, it, expect, vi } from 'vitest' -import { AggregationIndex } from '../../../src/aggregation/AggregationIndex.js' -import { NounType } from '../../../src/types/graphTypes.js' -import type { AggregationProvider, AggregateGroupState } from '../../../src/types/brainy.types.js' - -const DEF = { - name: 'by_subtype', - source: { type: NounType.Document }, - groupBy: ['system.subtype'] as string[], - metrics: { count: { op: 'count' as const } } -} - -/** Minimal in-memory storage double for the index's persistence surface. */ -function memStorage() { - const store = new Map() - return { - saveMetadata: async (k: string, v: unknown) => void store.set(k, v), - getMetadata: async (k: string) => store.get(k) ?? null - } as never -} - -function providerDouble(): AggregationProvider & { rebuildAggregate: ReturnType } { - return { - defineAggregate: vi.fn(), - removeAggregate: vi.fn(), - incrementalUpdate: vi.fn(() => []), - computeGroupKey: vi.fn(() => ({})), - rebuildAggregate: vi.fn((): Map => { - return new Map([ - [ - 'system.subtype=invoice', - { - groupKey: { 'system.subtype': 'invoice' }, - metrics: { count: { sum: 0, count: 2, min: Infinity, max: -Infinity, m2: 0 } } - } as AggregateGroupState - ] - ]) - }), - queryAggregate: vi.fn(() => []) - } as never -} - -describe('ask (c) — the native parallel rebuild is invoked, never dead code', () => { - it('rebuildWithProvider hands SOURCE-MATCHED entities to the provider once and swaps state in', () => { - const provider = providerDouble() - const index = new AggregationIndex(memStorage(), provider) - index.defineAggregate(DEF) - - expect(index.hasProviderRebuild()).toBe(true) - - const entities = [ - { type: NounType.Document, subtype: 'invoice', metadata: {} }, - { type: NounType.Document, subtype: 'invoice', metadata: {} }, - // Source-filter mismatch: a different noun type must be filtered OUT - // before the provider sees the batch. - { type: NounType.Person, subtype: 'invoice', metadata: {} } - ] - const handled = index.rebuildWithProvider(DEF.name, entities) - - expect(handled).toBe(true) - expect(provider.rebuildAggregate).toHaveBeenCalledTimes(1) - const [defArg, entArg] = provider.rebuildAggregate.mock.calls[0] - expect(defArg.name).toBe(DEF.name) - expect(entArg).toHaveLength(2) - - // The rebuilt state serves — and the aggregate is no longer pending. - expect(index.getPendingBackfills()).not.toContain(DEF.name) - }) - - it('returns false without a provider rebuild — the caller streams the JS walk', () => { - const index = new AggregationIndex(memStorage()) - index.defineAggregate(DEF) - expect(index.hasProviderRebuild()).toBe(false) - expect(index.rebuildWithProvider(DEF.name, [])).toBe(false) - }) -}) - -describe('ask (d) — the before-image-less delete is LOUD, never a silent skip', () => { - it('flagAllForRescan puts every defined aggregate back on the backfill list', () => { - const index = new AggregationIndex(memStorage()) - index.defineAggregate(DEF) - index.defineAggregate({ ...DEF, name: 'second' }) - // Simulate settled state: nothing pending. - for (const n of index.getPendingBackfills()) { - index.beginBackfill(n) - index.finishBackfill(n) - } - expect(index.getPendingBackfills()).toEqual([]) - - index.flagAllForRescan('delete of X carried no before-image metadata') - - expect(index.getPendingBackfills().sort()).toEqual(['by_subtype', 'second']) - }) -}) - -describe('reconcileEntity — the exact delta algebra at the catch-up boundary', () => { - it('before-only removes, after-only adds, both reconciles a group move', () => { - const index = new AggregationIndex(memStorage()) - index.defineAggregate(DEF) - for (const n of index.getPendingBackfills()) { - index.beginBackfill(n) - index.finishBackfill(n) - } - const doc = (subtype: string) => ({ type: NounType.Document, subtype, metadata: {} }) - - // Pre-window state, applied through the LIVE hooks (as adoption would - // have counted it): c and seed exist as drafts, x1 as an invoice. - index.onEntityAdded('c', doc('draft')) - index.onEntityAdded('seed', doc('draft')) - index.onEntityAdded('x1', doc('invoice')) - - // The window's reconciliation: two adds, one group move, one delete. - index.reconcileEntity(DEF.name, 'a', null, doc('invoice')) - index.reconcileEntity(DEF.name, 'b', null, doc('invoice')) - index.reconcileEntity(DEF.name, 'c', doc('draft'), doc('invoice')) - index.reconcileEntity(DEF.name, 'seed', doc('draft'), null) - - const rows = index.queryAggregate({ name: DEF.name }) - const count = (st: string) => - Number(rows.find(r => r.groupKey['system.subtype'] === st)?.metrics.count ?? 0) - expect(count('invoice')).toBe(4) // x1 + a + b + moved c - expect(count('draft')).toBe(0) // c moved out, seed deleted - }) -}) diff --git a/tests/unit/brainy-core.unit.test.ts b/tests/unit/brainy-core.unit.test.ts index 0488057d..eb6614e4 100644 --- a/tests/unit/brainy-core.unit.test.ts +++ b/tests/unit/brainy-core.unit.test.ts @@ -5,7 +5,7 @@ * No mocks, no fakes, real implementation */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy } from '../../src/brainy.js' import { NounType } from '../../src/types/graphTypes.js' @@ -21,10 +21,6 @@ describe('Brainy 3.0 Core (Unit Tests)', () => { await brain.init() }) - afterEach(async () => { - await brain.close() - }) - describe('CRUD Operations', () => { it('should create items with add', async () => { const id = await brain.add({ diff --git a/tests/unit/brainy/add.test.ts b/tests/unit/brainy/add.test.ts index 7203690c..c017862c 100644 --- a/tests/unit/brainy/add.test.ts +++ b/tests/unit/brainy/add.test.ts @@ -335,24 +335,15 @@ describe('Brainy.add()', () => { }) describe('edge cases', () => { - it('should accept an empty string as real (empty) data', async () => { - // Arrange — '' is legitimate content (e.g. an empty file's first - // write), not a missing field. Only null/undefined data (with no - // vector either) is "missing" — see the separate - // 'data and vector are both missing' test above. + it('should reject empty string as data', async () => { + // Arrange const params = createAddParams({ data: '', type: 'thing' }) - - // Act - const id = await brain.add(params) - - // Assert — stored and readable back as empty, not rejected - expect(id).toBeDefined() - const entity = await brain.get(id) - expect(entity).not.toBeNull() - expect(entity!.data).toBe('') + + // Act & Assert - Empty string is not valid data + await expect(brain.add(params)).rejects.toThrow('Invalid add() parameters: Missing required field \'data\'') }) it('should handle very long text content', async () => { @@ -461,11 +452,9 @@ describe('Brainy.add()', () => { }) // Act & Assert - // order-of-magnitude guard: worst honest-iron measurement 105ms - // (5% over the old 100ms budget), 3x headroom on the overage class await assertCompletesWithin( () => brain.add(params), - 300, + 100, // Should complete within 100ms 'Add operation' ) }) diff --git a/tests/unit/brainy/batch-operations.test.ts b/tests/unit/brainy/batch-operations.test.ts index 16f0f93d..58b25744 100644 --- a/tests/unit/brainy/batch-operations.test.ts +++ b/tests/unit/brainy/batch-operations.test.ts @@ -113,12 +113,7 @@ describe('Brainy Batch Operations', () => { items: Array.from({ length: 100 }, (_, i) => ({ data: `Bulk ${i}`, type: NounType.Thing, - metadata: { counter: 0 }, - // This test exercises updateMany's batching, not embedding — the - // sanctioned "unvectored" `[]` shape (see - // tests/integration/index-skips-unvectored.test.ts) skips the - // real embedder entirely. - vector: [] + metadata: { counter: 0 } })) }) const manyIds = manyResult.successful @@ -279,12 +274,7 @@ describe('Brainy Batch Operations', () => { const manyResult = await brain.addMany({ items: Array.from({ length: 100 }, (_, i) => ({ data: `Bulk Delete ${i}`, - type: NounType.Thing, - // This test exercises removeMany's batching, not embedding — the - // sanctioned "unvectored" `[]` shape (see - // tests/integration/index-skips-unvectored.test.ts) skips the - // real embedder entirely. - vector: [] + type: NounType.Thing })) }) const manyIds = manyResult.successful @@ -466,9 +456,7 @@ describe('Brainy Batch Operations', () => { // Verify batch operation completed successfully // Note: Performance can vary based on system load and embedding generation expect(batchIds).toHaveLength(itemCount) - // order-of-magnitude guard: worst honest-iron measurement 11.9s (CPU-only - // inference, 32-core box), 3x headroom for 50-item batch - expect(batchTime).toBeLessThan(40000) + expect(batchTime).toBeLessThan(5000) // Reasonable timeout for 50 items console.log(`Individual: ${individualTime}ms, Batch: ${batchTime}ms`) if (batchTime < individualTime) { @@ -522,9 +510,7 @@ describe('Brainy Batch Operations', () => { const totalTime = Date.now() - startTime - // order-of-magnitude guard: worst honest-iron measurement 6652ms - // (mixed batch under CPU-only inference), 3x headroom - expect(totalTime).toBeLessThan(20000) + expect(totalTime).toBeLessThan(3000) // v5.4.0: Type-first storage takes longer // Verify final state const remaining = await brain.get(initialIds[0]) @@ -555,18 +541,10 @@ describe('Brainy Batch Operations', () => { it('should validate batch size limits', async () => { // Try to add a large batch (reduced from 10000 to 1000 for reasonable test time) - // This test validates the batch SIZE law, not embeddings — items carry - // the sanctioned "unvectored" `[]` shape (see - // tests/integration/index-skips-unvectored.test.ts) so addMany's batch - // embedder is never invoked; 1000 real embeddings under the root - // vitest config (which does not mock the embedder) is a 60-180s - // budget flake waiting to happen, not a defect in what this test - // actually asserts. const largeCount = 1000 const largeItems = Array.from({ length: largeCount }, (_, i) => ({ data: `Large ${i}`, - type: NounType.Thing, - vector: [] + type: NounType.Thing })) try { @@ -578,7 +556,7 @@ describe('Brainy Batch Operations', () => { // Might throw if there's a limit expect(error).toBeDefined() } - }) + }, 60000) it('should provide meaningful error messages', async () => { try { diff --git a/tests/unit/brainy/degraded-reads-surfaced.test.ts b/tests/unit/brainy/degraded-reads-surfaced.test.ts index 004adeaa..29a8a77c 100644 --- a/tests/unit/brainy/degraded-reads-surfaced.test.ts +++ b/tests/unit/brainy/degraded-reads-surfaced.test.ts @@ -19,19 +19,13 @@ import { prodLog } from '../../../src/utils/logger.js' const UUID = (suffix: string): string => `00000000-0000-4000-8000-0000000000${suffix}` describe('Finding 10 — degraded derived-index state is surfaced on reads', () => { - const opened: Brainy[] = [] - beforeEach(() => { process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' }) - afterEach(async () => { - vi.restoreAllMocks() - for (const b of opened.splice(0)) await b.close().catch(() => {}) - }) + afterEach(() => vi.restoreAllMocks()) it('checkHealth() reports adopt-forward degraded ids as unhealthy', async () => { const brain = new Brainy({ storage: { type: 'memory' }, dimensions: 384, requireSubtype: false }) - opened.push(brain) await brain.init() ;(brain as any)._indexDegradedIds.add(UUID('de')) @@ -43,7 +37,6 @@ describe('Finding 10 — degraded derived-index state is surfaced on reads', () it('find()/get() warn loudly while degraded, ONCE, then repairIndex() clears it', async () => { const warn = vi.spyOn(prodLog, 'warn').mockImplementation(() => {}) const brain = new Brainy({ storage: { type: 'memory' }, dimensions: 384, requireSubtype: false }) - opened.push(brain) await brain.init() await brain.add({ id: UUID('a1'), data: 'x', type: NounType.Document }) ;(brain as any)._indexRebuildFailed = new Error('rebuild boom') @@ -66,7 +59,6 @@ describe('Finding 10 — degraded derived-index state is surfaced on reads', () it('persistSingleOp records receipt.degraded (widened return type, not dropped)', async () => { const brain = new Brainy({ storage: { type: 'memory' }, dimensions: 384, requireSubtype: false }) - opened.push(brain) await brain.init() // Simulate a degraded receipt by wrapping the generation store's commitSingleOp. const gs: any = (brain as any).generationStore diff --git a/tests/unit/brainy/find-complement-operators.test.ts b/tests/unit/brainy/find-complement-operators.test.ts index 710fbbbf..76fbb017 100644 --- a/tests/unit/brainy/find-complement-operators.test.ts +++ b/tests/unit/brainy/find-complement-operators.test.ts @@ -7,7 +7,7 @@ * soft-delete semantic: `field !== value` MUST include entities that have no * such field at all. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy } from '../../../src/brainy' import { NounType } from '../../../src/types/graphTypes' @@ -26,10 +26,6 @@ describe('find() complement operators (ne / exists:false / missing:true)', () => ids.noField2 = await brain.add({ data: 'n2', type: NounType.Thing, metadata: { other: 2 } }) }) - afterEach(async () => { - await brain.close() - }) - it('ne returns everything except the matching value — INCLUDING entities without the field', async () => { const rows = await brain.find({ where: { status: { ne: 'active' } }, limit: 100 }) const got = new Set(rows.map((r) => r.id)) diff --git a/tests/unit/brainy/find-index-integrity-guard.test.ts b/tests/unit/brainy/find-index-integrity-guard.test.ts index 3e63d790..30cfdf1b 100644 --- a/tests/unit/brainy/find-index-integrity-guard.test.ts +++ b/tests/unit/brainy/find-index-integrity-guard.test.ts @@ -12,7 +12,7 @@ * returns an id whose record matches NEITHER the type nor the where filter) and * assert the phantom is dropped while the genuine matches survive. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy } from '../../../src/brainy' import { NounType } from '../../../src/types/graphTypes' @@ -48,10 +48,6 @@ describe('find() index-integrity guard (phantom row class)', () => { }) }) - afterEach(async () => { - await brain.close() - }) - it('healthy index: the discriminant query returns only the staff Person', async () => { const rows = await brain.find({ type: NounType.Person, where: { entityType: 'staff' }, limit: 100 }) expect(rows.map((r) => r.id)).toEqual([staffId]) diff --git a/tests/unit/brainy/find-orderby-pagek.test.ts b/tests/unit/brainy/find-orderby-pagek.test.ts index 49fccb02..9a453f8d 100644 --- a/tests/unit/brainy/find-orderby-pagek.test.ts +++ b/tests/unit/brainy/find-orderby-pagek.test.ts @@ -42,8 +42,7 @@ describe('find({ where, orderBy }) bounds the sort to the page (CTX-BR-FIND-ORDE return real(f, ob, o, topK) } - // system.createdAt — entity age, not a user metadata field named 'createdAt'. - const results = await brain.find({ where: { bucket: 'x' }, orderBy: 'system.createdAt', order: 'desc', limit: 5 }) + const results = await brain.find({ where: { bucket: 'x' }, orderBy: 'createdAt', order: 'desc', limit: 5 }) expect(results).toHaveLength(5) // Page-bounded: ~ limit (5) + a small hidden-tier over-fetch — NOT all 50 matches. diff --git a/tests/unit/brainy/find.test.ts b/tests/unit/brainy/find.test.ts index 59601456..c9b36e64 100644 --- a/tests/unit/brainy/find.test.ts +++ b/tests/unit/brainy/find.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy } from '../../../src/brainy' import { createAddParams } from '../../helpers/test-factory' import { NounType } from '../../../src/types/graphTypes' @@ -12,11 +12,7 @@ describe('Brainy.find()', () => { }) await brain.init() }) - - afterEach(async () => { - await brain.close() - }) - + describe('success paths', () => { it('should find entities by text query', async () => { // Arrange @@ -379,13 +375,11 @@ describe('Brainy.find()', () => { limit: 10 }) const duration = Date.now() - start - + // Assert - // order-of-magnitude guard: worst honest-iron measurement 106ms - // (6% over the old 100ms budget), 3x headroom on the overage class - expect(duration).toBeLessThan(300) + expect(duration).toBeLessThan(100) }) - + it('should handle large result sets efficiently', async () => { // Arrange - Add many entities await Promise.all( diff --git a/tests/unit/brainy/flush-single-flight.test.ts b/tests/unit/brainy/flush-single-flight.test.ts deleted file mode 100644 index 49d93ea8..00000000 --- a/tests/unit/brainy/flush-single-flight.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * @module tests/unit/brainy/flush-single-flight - * @description THE FLUSH GATE NEVER STRANDS A WAITER. - * - * The gate serialises flushes: one body runs, at most one waits. The failure - * mode that shape invites is a promise CYCLE — a queued follow-up expressed as - * `leader.then(() => this.flush())` is settled only by resolving the promise - * the leader is being awaited through, so anything that awaits `flush()` from - * inside a flush body closes the graph on itself and nobody ever resolves. - * That is an unbounded hang, not a slow flush, and it presents exactly like a - * test timing out inside a bulk write. - * - * The gate therefore settles its waiter from the MACHINE (a bare deferred - * promoted in the leader's `finally`), never from a chain. The laws pinned - * here, each on a path that must settle the waiter: - * - * (a) many callers during one running flush → one body, one follow-up, and - * EVERY caller resolves within a bound; - * (b) the leader REJECTS → its own caller rejects, and the queued caller is - * still run and still settled; - * (c) the promoted follow-up itself rejects → its waiter rejects (settled, - * not stranded) and the gate is left open for the next flush; - * (d) the leader's promise does not wait for its follower. - */ - -import { describe, it, expect, afterEach } from 'vitest' -import { Brainy } from '../../../src/brainy' -import { NounType } from '../../../src/types/graphTypes' - -type GateInternals = { - _flushInFlight: Promise | null - _flushQueued: Promise | null - _flushBodyRuns: number - _flushConcurrencyPeak: number - _flushSteps: () => Promise - kickBackgroundFlush: (reason: 'threshold' | 'idle') => void -} - -/** Fail loudly rather than hanging the suite: a stranded waiter never settles. */ -function withinBound(p: Promise, ms: number, what: string): Promise { - let timer: ReturnType - return Promise.race([ - p, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`${what} did not settle within ${ms}ms`)), ms) - }) - ]).finally(() => clearTimeout(timer)) as Promise -} - -describe('the flush gate settles every waiter', () => { - const brains: Brainy[] = [] - - afterEach(async () => { - for (const b of brains.splice(0)) { - try { await b.close() } catch { /* already closed */ } - } - }) - - async function openBrain(): Promise> { - const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - brains.push(brain) - await brain.init() - await brain.add({ data: 'a write, so a flush has work', type: NounType.Thing }) - return brain - } - - it('(a) every caller arriving during one flush resolves, and only one follows', async () => { - const brain = await openBrain() - const inner = brain as unknown as GateInternals - - const realSteps = inner._flushSteps.bind(inner) - inner._flushSteps = async () => { - await new Promise((r) => setTimeout(r, 120)) - return realSteps() - } - - const runsBefore = inner._flushBodyRuns - const leader = brain.flush() - await new Promise((r) => setTimeout(r, 20)) - - const joiners = [brain.flush(), brain.flush(), brain.flush(), brain.flush()] - for (let i = 0; i < 4; i++) inner.kickBackgroundFlush('threshold') - expect(inner._flushQueued, 'exactly one waiter is queued').not.toBeNull() - - await withinBound(Promise.all([leader, ...joiners]), 15_000, 'the flush callers') - - expect(inner._flushBodyRuns - runsBefore).toBe(2) - expect(inner._flushConcurrencyPeak).toBe(1) - expect(inner._flushQueued).toBeNull() - }) - - it('(b) a leader that REJECTS still runs and settles the queued waiter', async () => { - const brain = await openBrain() - const inner = brain as unknown as GateInternals - - const realSteps = inner._flushSteps.bind(inner) - let call = 0 - inner._flushSteps = async () => { - call++ - await new Promise((r) => setTimeout(r, 80)) - if (call === 1) throw new Error('injected: the leader flush failed') - return realSteps() - } - - const leader = brain.flush() - await new Promise((r) => setTimeout(r, 20)) - const queued = brain.flush() - - await expect(leader).rejects.toThrow(/injected: the leader flush failed/) - // The waiter is NOT collateral damage of the leader's failure: it gets its - // own run, and it settles. - await withinBound(queued, 15_000, 'the queued waiter after a failed leader') - expect(call).toBe(2) - expect(inner._flushQueued).toBeNull() - expect(inner._flushInFlight).toBeNull() - }) - - it('(c) a promoted follow-up that rejects settles its waiter and opens the gate', async () => { - const brain = await openBrain() - const inner = brain as unknown as GateInternals - - const realSteps = inner._flushSteps.bind(inner) - let call = 0 - inner._flushSteps = async () => { - call++ - await new Promise((r) => setTimeout(r, 80)) - if (call === 2) throw new Error('injected: the follow-up flush failed') - return realSteps() - } - - const leader = brain.flush() - await new Promise((r) => setTimeout(r, 20)) - const queued = brain.flush() - - await withinBound(leader, 15_000, 'the leader') - await withinBound( - expect(queued).rejects.toThrow(/injected: the follow-up flush failed/), - 15_000, - 'the rejected follow-up' - ) - // The gate is open: a later flush still runs. - inner._flushSteps = realSteps - await brain.add({ data: 'another write', type: NounType.Thing }) - await withinBound(brain.flush(), 15_000, 'the flush after a failed follow-up') - expect(inner._flushInFlight).toBeNull() - expect(inner._flushQueued).toBeNull() - }) - - it('(d) the leader does not wait for its follower', async () => { - const brain = await openBrain() - const inner = brain as unknown as GateInternals - - const realSteps = inner._flushSteps.bind(inner) - let call = 0 - inner._flushSteps = async () => { - call++ - // The follow-up is deliberately far slower than the leader. - await new Promise((r) => setTimeout(r, call === 1 ? 60 : 600)) - return realSteps() - } - - const leader = brain.flush() - await new Promise((r) => setTimeout(r, 20)) - const queued = brain.flush() - - const t0 = Date.now() - await withinBound(leader, 15_000, 'the leader') - const leaderWall = Date.now() - t0 - // If the leader awaited its follower it could not return before the - // follower's own 600ms body had run. - expect(leaderWall).toBeLessThan(500) - - await withinBound(queued, 15_000, 'the follower') - }) -}) diff --git a/tests/unit/brainy/get.test.ts b/tests/unit/brainy/get.test.ts index 97a19125..b39bf2e1 100644 --- a/tests/unit/brainy/get.test.ts +++ b/tests/unit/brainy/get.test.ts @@ -5,8 +5,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../../src/brainy' -import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError' -import { +import { createAddParams, generateTestVector, createTestConfig, @@ -269,75 +268,32 @@ describe('Brainy.get()', () => { expect(entity!.id).toBe(id) }) - // THE INDEXABLE-ARRAY BOUND, from get()'s side. This case used to park a - // 1000-element array in the metadata bag and assert it came back. That - // shape is refused at the write door now — an array field mints one - // posting per element, so an unbounded array is an unbounded write — so - // the case pins BOTH halves of the law that replaced it: a large SCALAR - // payload still round-trips whole, and an array over the bound refuses by - // name. Every length derives from MAX_INDEXED_ARRAY_LENGTH so the pin - // follows the constant wherever it moves. - it('should get an entity with a large scalar metadata payload', async () => { - // Arrange — large in every dimension EXCEPT array length: a long string, - // many fields, deep nesting, and an array sitting exactly ON the bound. + it('should get entity with very large metadata', async () => { + // Arrange const largeMetadata = { - atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`), + bigArray: new Array(1000).fill('item'), bigObject: Object.fromEntries( Array.from({ length: 100 }, (_, i) => [`key${i}`, `value${i}`]) ), - longString: 'x'.repeat(10_000), deepNesting: Array(10).fill(null).reduce( (acc) => ({ nested: acc }), { value: 'deep' } ) } - + const id = await brain.add(createAddParams({ data: 'Large metadata', type: 'thing', metadata: largeMetadata })) - + // Act const entity = await brain.get(id) - - // Assert — the payload comes back whole, first element to last + + // Assert expect(entity).not.toBeNull() - expect(entity!.metadata.atTheBound).toHaveLength(MAX_INDEXED_ARRAY_LENGTH) - expect(entity!.metadata.atTheBound[0]).toBe('item0') - expect(entity!.metadata.atTheBound[MAX_INDEXED_ARRAY_LENGTH - 1]) - .toBe(`item${MAX_INDEXED_ARRAY_LENGTH - 1}`) + expect(entity!.metadata.bigArray).toHaveLength(1000) expect(Object.keys(entity!.metadata.bigObject)).toHaveLength(100) - expect(entity!.metadata.longString).toHaveLength(10_000) - - // ...including the deep nest, walked to the bottom. - let cursor: any = entity!.metadata.deepNesting - for (let depth = 0; depth < 10; depth++) cursor = cursor.nested - expect(cursor.value).toBe('deep') - }) - - it('should refuse a metadata array over the indexing bound, by name', async () => { - // Arrange - const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1 - - // Act - const err = await brain - .add(createAddParams({ - data: 'Large metadata', - type: 'thing', - metadata: { bigArray: new Array(overTheBound).fill('item') } - })) - .catch((e: any) => e) - - // Assert — the field, the length and the bound, on the error and in the - // message, so a handler can report or repair without parsing prose. - expect(err).toBeInstanceOf(MetadataArrayTooLargeError) - expect(err.field).toBe('bigArray') - expect(err.length).toBe(overTheBound) - expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH) - expect(err.message).toContain('bigArray') - expect(err.message).toContain(String(overTheBound)) - expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH)) }) }) diff --git a/tests/unit/brainy/lazy-notready-honor.test.ts b/tests/unit/brainy/lazy-notready-honor.test.ts deleted file mode 100644 index e53be4a6..00000000 --- a/tests/unit/brainy/lazy-notready-honor.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * @module tests/unit/brainy/lazy-notready-honor - * @description THE SILENT-EMPTY TRAP pin (found during a fleet adoption, - * SELF-ENGINE-PAIR-STANDARD): under `disableAutoRebuild: true`, the OLD lazy - * first-query path (`ensureIndexesLoaded`) assessed ONLY the vector index's - * readiness — a native METADATA provider reporting not-ready (its strand - * report) never blocked the completion latch, so the promised lazy rebuild - * never fired and every `find()` silently returned `[]` on a populated store - * (measured: 52 entities durable-but-unqueryable, first query 0ms/0 rows). - * - * RE-POINTED to the health-gate law (a read never builds; a rebuild runs - * entirely at open): `ensureIndexesLoaded()` is now a pure CHECK. A not-ready - * report from ANY provider — metadata, vector, or graph — makes it THROW the - * matching typed `*NotReadyError` rather than silently letting the read - * proceed, and it NEVER calls `rebuildIndexesIfNeeded` (that is entirely - * open()'s job now — see the second describe block below). The spirit is - * unchanged: a not-ready report from any single provider can never be - * shadowed into a silent empty result. - * - * White-box provider-double pattern per tests/unit/brainy/migration-deference. - */ -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, MetadataIndexNotReadyError } from '../../../src/index.js' -import { NounType } from '../../../src/types/graphTypes.js' -import { createTestConfig } from '../../helpers/test-factory.js' - -interface BrainInternals { - index: { size(): number } - metadataIndex: { isReady?: () => boolean } - ensureIndexesLoaded(): void - rebuildIndexesIfNeeded(force?: boolean): Promise -} - -const brains: Brainy[] = [] -const dirs: string[] = [] - -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) - vi.restoreAllMocks() -}) - -async function warmBrain(): Promise<{ brain: Brainy; internals: BrainInternals }> { - const brain = new Brainy(createTestConfig({ disableAutoRebuild: true })) - await brain.init() - brains.push(brain) - for (let i = 0; i < 3; i++) { - await brain.add({ data: `row ${i}`, type: NounType.Document, metadata: { i } }) - } - const internals = brain as unknown as BrainInternals - return { brain, internals } -} - -describe('the read gate honors EVERY provider’s not-ready report', () => { - it('a not-ready METADATA provider refuses loudly — it never lets a read proceed, and it never rebuilds', async () => { - const { internals } = await warmBrain() - - // The trap's shape: vector side looks fine (populated), metadata - // provider says NOT ready — the OLD gate silently latched complete here. - // The new gate refuses loudly instead; a read never triggers a rebuild. - internals.metadataIndex.isReady = () => false - const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined) - - expect(() => internals.ensureIndexesLoaded()).toThrow(MetadataIndexNotReadyError) - expect(rebuildSpy, 'a read NEVER triggers a rebuild — building is entirely open()\'s job now').not.toHaveBeenCalled() - }) - - it('control: all providers ready/unknown+populated → the gate lets the read through, no rebuild', async () => { - const { internals } = await warmBrain() - internals.metadataIndex.isReady = () => true - const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined) - - expect(() => internals.ensureIndexesLoaded()).not.toThrow() - expect(rebuildSpy).not.toHaveBeenCalled() - }) -}) - -describe('the open-time build honors the same law: a needed rebuild runs at open, never deferred to a read', () => { - it('disableAutoRebuild:true does not defer a needed rebuild past open() on a reopened, populated store', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-lazy-notready-honor-')) - dirs.push(dir) - - const writer = new Brainy(createTestConfig({ disableAutoRebuild: true, storage: { type: 'filesystem', path: dir } })) - await writer.init() - for (let i = 0; i < 3; i++) { - await writer.add({ data: `row ${i}`, type: NounType.Document, metadata: { i } }) - } - await writer.flush() - await writer.close() - - // Fresh instance over the same store: its derived indexes start empty in - // memory, so open()'s rebuildIndexesIfNeeded MUST fire (and complete) - // before init() returns — even though disableAutoRebuild is true, there - // is no first-query lazy path left to defer to. - const reader = new Brainy(createTestConfig({ disableAutoRebuild: true, storage: { type: 'filesystem', path: dir } })) - const internals = reader as unknown as { rebuildIndexesIfNeeded(force?: boolean): Promise } - const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded') - - await reader.init() - brains.push(reader) - - expect(rebuildSpy).toHaveBeenCalledTimes(1) - const rows = await reader.find({ where: { i: 1 } }) - expect(rows.length).toBe(1) - }, 30000) -}) diff --git a/tests/unit/brainy/maintenance-debt.test.ts b/tests/unit/brainy/maintenance-debt.test.ts deleted file mode 100644 index 4026d655..00000000 --- a/tests/unit/brainy/maintenance-debt.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * @module tests/unit/brainy/maintenance-debt - * @description Coverage for `brain.maintenanceDebt()` (8.10.1) — the - * observability seam so an operator sees a provider's outstanding background - * maintenance work (compaction, deferred writes, a build-new→verify→swap in - * flight, ...) BEFORE it grinds a transaction into a budget-busting op, the - * same failure class documented on `TransactionTimeoutError` - * (src/transaction/errors.ts). Sibling to tests/unit/brainy/warm.test.ts, - * which establishes this file's technique: shape the probe points brain.ts - * reads (`typeof provider.maintenanceDebt === 'function'`) directly on the - * REAL, live provider instances rather than hand-rolling full fakes for the - * larger `MetadataIndexProvider` / `GraphIndexProvider` interfaces. - * - * `brain.maintenanceDebt()` is a PURE PASSTHROUGH: no thresholds, no - * polling, no JS-side estimation — these tests pin exactly that by asserting - * the returned payload is the provider's object, verbatim. - */ -import { describe, it, expect } from 'vitest' -import { Brainy } from '../../../src/brainy.js' -import { NounType } from '../../../src/types/graphTypes.js' -import type { ProviderMaintenanceDebt } from '../../../src/plugin.js' - -// Brainy's ValidationConfig fixes vectors at exactly 384 dimensions -// (src/utils/paramValidation.ts) — match it so add() doesn't reject test data. -const DIM = 384 -const V = (seed = 1): number[] => Array.from({ length: DIM }, (_, i) => Math.sin(seed + i)) - -async function freshBrain(): Promise> { - const brain = new Brainy({ - requireSubtype: false, - storage: { type: 'memory' }, - silent: true - }) - await brain.init() - await brain.add({ data: 'a', type: NounType.Thing, vector: V(1) }) - return brain -} - -describe('brain.maintenanceDebt()', () => { - it('reports "unavailable" for every surface when no active provider implements maintenanceDebt() (the built-in JS stack today)', async () => { - const brain = await freshBrain() - - const report = await brain.maintenanceDebt() - - expect(report.vector.outcome).toBe('unavailable') - expect(report.vector.debt).toBeUndefined() - expect(report.metadata.outcome).toBe('unavailable') - expect(report.metadata.debt).toBeUndefined() - expect(report.graph.outcome).toBe('unavailable') - expect(report.graph.debt).toBeUndefined() - - await brain.close() - }) - - it('reports "reported" + the exact payload when the active provider implements maintenanceDebt() (verbatim passthrough, no thresholding)', async () => { - const brain = await freshBrain() - - const vectorDebt: ProviderMaintenanceDebt = { - pendingBytes: 4_096, - pendingItems: 12, - lastPassCompletedAt: 1_700_000_000_000, - lastPassOutcome: 'completed', - converging: true - } - ;(brain as any).index.maintenanceDebt = async () => vectorDebt - - const report = await brain.maintenanceDebt() - - expect(report.vector.outcome).toBe('reported') - // Verbatim passthrough — the exact object, not a re-derived copy. - expect(report.vector.debt).toBe(vectorDebt) - // Untouched surfaces stay honestly 'unavailable'. - expect(report.metadata.outcome).toBe('unavailable') - expect(report.graph.outcome).toBe('unavailable') - - await brain.close() - }) - - it('mixed surfaces: each surface\'s outcome depends ONLY on its OWN active provider — one surface reporting never leaks into another', async () => { - const brain = await freshBrain() - - const metadataDebt: ProviderMaintenanceDebt = { - pendingItems: 3, - lastPassOutcome: 'partial', - converging: false - } - const graphDebt: ProviderMaintenanceDebt = { - pendingBytes: 0, - converging: true - } - ;(brain as any).metadataIndex.maintenanceDebt = async () => metadataDebt - ;(brain as any).graphIndex.maintenanceDebt = async () => graphDebt - // Vector is deliberately left unpatched. - - const report = await brain.maintenanceDebt() - - expect(report.vector.outcome).toBe('unavailable') - expect(report.vector.debt).toBeUndefined() - - expect(report.metadata.outcome).toBe('reported') - expect(report.metadata.debt).toBe(metadataDebt) - - expect(report.graph.outcome).toBe('reported') - expect(report.graph.debt).toBe(graphDebt) - - await brain.close() - }) - - it('an empty ProviderMaintenanceDebt object (every field omitted) is still honestly "reported" — presence of the hook, not the payload\'s richness, drives the outcome', async () => { - const brain = await freshBrain() - - const emptyDebt: ProviderMaintenanceDebt = {} - ;(brain as any).graphIndex.maintenanceDebt = async () => emptyDebt - - const report = await brain.maintenanceDebt() - - expect(report.graph.outcome).toBe('reported') - expect(report.graph.debt).toEqual({}) - - await brain.close() - }) -}) diff --git a/tests/unit/brainy/metadata-provider-contract.test.ts b/tests/unit/brainy/metadata-provider-contract.test.ts index 466fc654..7e690978 100644 --- a/tests/unit/brainy/metadata-provider-contract.test.ts +++ b/tests/unit/brainy/metadata-provider-contract.test.ts @@ -1,28 +1,25 @@ /** * @module tests/unit/brainy/metadata-provider-contract - * @description Brainy-side wiring of the metadata-provider contract. + * @description Brainy-side wiring of the two metadata-provider contract additions + * confirmed with cor for the lockstep: * - * `getIdsForFilter(filter, opts?)` — brainy passes a page bound on the UNSORTED - * `find({ type, where, limit })` path so a native provider can early-stop. The JS - * index ignores `opts`. - * - * RETIRED (health-gate law): `probeConsistency()` / `ensureMetadataConsistencyProbed()` - * — a read-time consistency probe that launches `detectAndRepairCorruption()` on - * `false` was exactly the read-triggered dark rebuild the law forbids (a read must - * never start a store walk or a rebuild). The probe's diagnostic value lives on in - * `validateIndexConsistency()` / `repairIndex()`, which remain explicit, operator-invoked - * calls. The pin below confirms the retirement: `probeConsistency()` is never called by - * a read, even when a provider exposes it. + * 1. `probeConsistency()` — an OPTIONAL O(1) cold-open consistency sampler. On the + * first read, brainy calls it once; on `false` it self-heals via + * `detectAndRepairCorruption()` (the metadata counterpart of the graph cold-load + * guard). The native provider implements it; the JS index omits it (no-op). + * 2. `getIdsForFilter(filter, opts?)` — brainy passes a page bound on the UNSORTED + * `find({ type, where, limit })` path so a native provider can early-stop. The JS + * index ignores `opts`. * * These are unit tests of brainy's CALL behaviour (the real end-to-end honoring is * exercised by cor's combined matrix); they inject probe/spy hooks onto the live JS * metadata index, which has neither method by default. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy } from '../../../src/brainy' import { NounType } from '../../../src/types/graphTypes' -describe('metadata-provider contract wiring (getIdsForFilter opts)', () => { +describe('metadata-provider contract wiring (probeConsistency + getIdsForFilter opts)', () => { let brain: Brainy let mi: any @@ -32,27 +29,47 @@ describe('metadata-provider contract wiring (getIdsForFilter opts)', () => { await brain.add({ data: 'a', type: NounType.Thing, metadata: { kind: 'x' } }) await brain.add({ data: 'b', type: NounType.Thing, metadata: { kind: 'y' } }) mi = (brain as any).metadataIndex + ;(brain as any)._metadataConsistencyProbed = false // reset the one-shot guard }) - afterEach(async () => { - await brain.close() - }) - - it('RETIRED: a read never calls probeConsistency() / self-heals via detectAndRepairCorruption — that is the read-triggered dark rebuild the health-gate law forbids', async () => { + it('calls probeConsistency once on cold open and self-heals via detectAndRepairCorruption on false', async () => { let probes = 0 let repairs = 0 - mi.probeConsistency = async () => { probes++; return false } // would-be corrupt signal + mi.probeConsistency = async () => { probes++; return false } // corrupt → must repair const origRepair = mi.detectAndRepairCorruption.bind(mi) mi.detectAndRepairCorruption = async () => { repairs++; return origRepair() } await brain.find({ where: { kind: 'x' } }) + expect(probes).toBe(1) + expect(repairs).toBe(1) + + // Second read must NOT re-probe (once per brain). await brain.find({ where: { kind: 'y' } }) + expect(probes).toBe(1) + expect(repairs).toBe(1) + }) - expect(probes).toBe(0) // no read-time probe exists anymore - expect(repairs).toBe(0) // and therefore no read-triggered self-heal either + it('does NOT repair when the probe reports healthy', async () => { + let repairs = 0 + mi.probeConsistency = async () => true // clean + const origRepair = mi.detectAndRepairCorruption.bind(mi) + mi.detectAndRepairCorruption = async () => { repairs++; return origRepair() } - delete mi.probeConsistency - mi.detectAndRepairCorruption = origRepair + await brain.find({ where: { kind: 'x' } }) + expect(repairs).toBe(0) + }) + + it('a probe failure never breaks the read (best-effort, retried next time)', async () => { + let probes = 0 + mi.probeConsistency = async () => { probes++; throw new Error('probe boom') } + + // The read still succeeds despite the throwing probe. + const rows = await brain.find({ where: { kind: 'x' } }) + expect(rows.length).toBe(1) + expect(probes).toBe(1) + // Guard reset on failure → the next read retries the probe. + await brain.find({ where: { kind: 'y' } }) + expect(probes).toBe(2) }) it('passes a page bound to getIdsForFilter on the unsorted find path (offset 0, brainy re-windows)', async () => { diff --git a/tests/unit/brainy/migration-deference.test.ts b/tests/unit/brainy/migration-deference.test.ts index b5817c3d..6471b4ef 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 `@soulcraftlabs/brainy/brain-format` + * - Hook 3: the marker module is re-exported at `@soulcraft/brainy/brain-format` * so cor reads the SAME `EXPECTED_INDEX_EPOCH` / `CURRENT_DATA_FORMAT` constants * (single source of truth, no duplicated value). * @@ -25,7 +25,7 @@ */ import { describe, it, expect, afterEach, vi } from 'vitest' -import { Brainy, VectorIndexNotReadyError } from '../../../src/index.js' +import { Brainy } from '../../../src/index.js' import { NounType } from '../../../src/types/graphTypes.js' import { createTestConfig } from '../../helpers/test-factory.js' import { BaseStorage } from '../../../src/storage/baseStorage.js' @@ -43,8 +43,9 @@ interface BrainInternals { metadataIndex: { rebuild(...a: unknown[]): Promise } graphIndex: { size(): number; rebuild(...a: unknown[]): Promise } _indexEpochStale: boolean + lazyRebuildCompleted: boolean rebuildIndexesIfNeeded(force?: boolean): Promise - ensureIndexesLoaded(): void + ensureIndexesLoaded(): Promise storage: { readRawObject(p: string): Promise } } @@ -180,40 +181,40 @@ describe('rc.8 no-freeze migration deference (isMigrating / stampBrainFormat / b expect(idxSpy).toHaveBeenCalledTimes(1) }) - // --- Hook 1: read-gate deference (RE-POINTED — the health-gate law retired - // the first-query lazy force-rebuild entirely: ensureIndexesLoaded() is now - // a pure CHECK that never calls rebuildIndexesIfNeeded, migrating or not. - // What survives from the original law is the DEFERENCE itself: a migrating - // provider's report is never judged by the gate — it neither throws nor - // rebuilds — while the exact same not-ready report on a NON-migrating - // provider throws the typed error instead of ever rebuilding.) ------------ + // --- Hook 1: large-path first-query lazy force-rebuild deference ---------- - it('the read gate defers to a migrating vector provider — a not-ready report neither throws nor rebuilds', async () => { + it('lazy first-query force-rebuild is SKIPPED when the vector provider isMigrating()', async () => { + // disableAutoRebuild routes first queries through ensureIndexesLoaded() (the + // large-brain lazy path that would otherwise force a blocking rebuild). const brain = await makeWarmBrain(2, { disableAutoRebuild: true }) const internals = internalsOf(brain) const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined) - // Simulate a not-ready live vector index (cor is mid-swap, serving canonical). - ;(internals.index as unknown as { isReady?: () => boolean }).isReady = () => false + // Simulate a cold/empty live vector index (cor is mid-swap, serving canonical). + vi.spyOn(internals.index, 'size').mockReturnValue(0) + internals.lazyRebuildCompleted = false setMigrating(internals.index, true) - expect(() => internals.ensureIndexesLoaded()).not.toThrow() - // A query during cor's background swap must not trigger brainy's own - // rebuild — reads never rebuild in any case, migrating or not. + await internals.ensureIndexesLoaded() + + // A query during cor's background swap must not trigger brainy's blocking rebuild. expect(rebuildSpy).toHaveBeenCalledTimes(0) }) - it('the read gate THROWS for the same not-ready vector provider once migration clears (control)', async () => { + it('lazy first-query force-rebuild STILL fires when the vector provider is not migrating (control)', async () => { const brain = await makeWarmBrain(2, { disableAutoRebuild: true }) const internals = internalsOf(brain) const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined) - ;(internals.index as unknown as { isReady?: () => boolean }).isReady = () => false + vi.spyOn(internals.index, 'size').mockReturnValue(0) + internals.lazyRebuildCompleted = false // No isMigrating → not deferring. - expect(() => internals.ensureIndexesLoaded()).toThrow(VectorIndexNotReadyError) - // Still never rebuilds — the gate refuses loudly instead. - expect(rebuildSpy).toHaveBeenCalledTimes(0) + await internals.ensureIndexesLoaded() + + // Without deference, the cold empty index drives the lazy force-rebuild. + expect(rebuildSpy).toHaveBeenCalledTimes(1) + expect(rebuildSpy).toHaveBeenCalledWith(true) }) // --- Hook 2: public stampBrainFormat() ----------------------------------- @@ -242,12 +243,9 @@ 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 '@soulcraftlabs/brainy/brain-format' (Hook 3) so both + // cor imports these from '@soulcraft/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 - // frozen keys at first open. (Epoch 2 same day: `level` indexability.) - expect(EXPECTED_INDEX_EPOCH).toBe(3) + expect(EXPECTED_INDEX_EPOCH).toBe(1) expect(CURRENT_DATA_FORMAT).toBe('8.0') }) }) diff --git a/tests/unit/brainy/migration-gate-family-scoped.test.ts b/tests/unit/brainy/migration-gate-family-scoped.test.ts index ce510a4e..b71c3899 100644 --- a/tests/unit/brainy/migration-gate-family-scoped.test.ts +++ b/tests/unit/brainy/migration-gate-family-scoped.test.ts @@ -8,7 +8,7 @@ * gate that hung getStats / readdir / readFile behind an unrelated family's * migration until the wait timed out. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy } from '../../../src/brainy.js' import { MigrationInProgressError } from '../../../src/errors/brainyError.js' @@ -38,19 +38,12 @@ const jam = (provider: unknown) => { } describe('migration LOCK is family-scoped', () => { - const opened: Brainy[] = [] - beforeEach(() => { process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' }) - afterEach(async () => { - for (const b of opened.splice(0)) await b.close().catch(() => {}) - }) - it('a stuck VECTOR migration does not block canonical or graph/metadata reads', async () => { const brain = await seed() - opened.push(brain) const childId = ( (await brain.vfs.readdir('/notes', { withFileTypes: true })) as Array<{ entityId: string }> )[0].entityId @@ -67,7 +60,6 @@ describe('migration LOCK is family-scoped', () => { it('a stuck VECTOR migration STILL blocks a read that needs the vector family', async () => { const brain = await seed() - opened.push(brain) jam((brain as any).index) // A semantic query consults the vector index — it must wait, and (bounded by @@ -78,7 +70,6 @@ describe('migration LOCK is family-scoped', () => { it('a stuck GRAPH migration blocks traversal but not vector/canonical reads', async () => { const brain = await seed() - opened.push(brain) const childId = ( (await brain.vfs.readdir('/notes', { withFileTypes: true })) as Array<{ entityId: string }> )[0].entityId @@ -96,7 +87,6 @@ describe('migration LOCK is family-scoped', () => { it('with no migration in flight, every read serves (the fast path is a no-op)', async () => { const brain = await seed() - opened.push(brain) await expect(brain.getStats()).resolves.toBeDefined() await expect(brain.find({ query: 'doc' })).resolves.toBeDefined() await expect(brain.vfs.readdir('/notes')).resolves.toHaveLength(1) diff --git a/tests/unit/brainy/open-path.test.ts b/tests/unit/brainy/open-path.test.ts deleted file mode 100644 index 3556d7a5..00000000 --- a/tests/unit/brainy/open-path.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * OPEN-PATH tests: init() must never gate on the embedding model, the VFS - * root bootstrap must never touch the embedding engine, and a slow open - * must narrate its phases. - * - * Background: a production restart storm measured 90,017ms for a single - * brain init vs 1,117ms quiet — an ~80x contention multiplier — traced to - * every writer's init() eagerly awaiting the process-global WASM embedding - * engine before the VFS root even existed. See src/brainy.ts performInit() - * and src/vfs/VirtualFileSystem.ts doInitializeRoot(). - */ - -import { describe, it, expect, vi } from 'vitest' -import { Brainy } from '../../../src/brainy' -import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage' -import { embeddingManager } from '../../../src/embeddings/EmbeddingManager' -import { createTestConfig } from '../../helpers/test-factory' - -/** - * The four signals `isDeterministicEmbedMode()` checks (see - * src/embeddings/deterministicEmbedMode.ts). The global unit-test setup - * (tests/setup-unit.ts) sets some of these for the whole file/run in some - * vitest configurations; other configurations leave them unset and run the - * real WASM engine instead. The background-warm tests below need the - * "not unit-test mode" branch of performInit() to actually execute, so they - * save/clear/restore all four explicitly — deterministic regardless of - * which config invoked this file, never relying on ambient state. - */ -function withRealEmbedderBranch(fn: () => Promise): Promise { - const savedEnvDeterministic = process.env.BRAINY_DETERMINISTIC_EMBEDDINGS - const savedEnvUnitTest = process.env.BRAINY_UNIT_TEST - const g = globalThis as Record - const savedGlobalDeterministic = g.__BRAINY_DETERMINISTIC_EMBED__ - const savedGlobalUnitTest = g.__BRAINY_UNIT_TEST__ - - delete process.env.BRAINY_DETERMINISTIC_EMBEDDINGS - delete process.env.BRAINY_UNIT_TEST - delete g.__BRAINY_DETERMINISTIC_EMBED__ - delete g.__BRAINY_UNIT_TEST__ - - const restore = () => { - if (savedEnvDeterministic !== undefined) process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = savedEnvDeterministic - if (savedEnvUnitTest !== undefined) process.env.BRAINY_UNIT_TEST = savedEnvUnitTest - if (savedGlobalDeterministic !== undefined) g.__BRAINY_DETERMINISTIC_EMBED__ = savedGlobalDeterministic - if (savedGlobalUnitTest !== undefined) g.__BRAINY_UNIT_TEST__ = savedGlobalUnitTest - } - - return fn().finally(restore) -} - -/** - * A MemoryStorage whose init() takes an artificially long time — a - * controllable fake seam (not a wall-clock race) that reliably pushes - * performInit()'s "storage-init" phase (and therefore the total open time) - * past the 2000ms narration threshold, without touching the filesystem or - * relying on real contention. - */ -class SlowMemoryStorage extends MemoryStorage { - override async init(): Promise { - await new Promise((resolve) => setTimeout(resolve, 2200)) - await super.init() - } -} - -describe('OPEN-PATH: init() never gates on the embedding model', () => { - it('bootstrapping a fresh store never calls the embedding engine (VFS root add is engine-untouched)', async () => { - const embedSpy = vi.spyOn(embeddingManager, 'embed') - const brain = new Brainy(createTestConfig()) - try { - await brain.init() - - // The VFS root's add() must never have reached the embedding engine — - // it carries an explicit placeholder vector instead (see - // VirtualFileSystem.doInitializeRoot()). - expect(embedSpy).not.toHaveBeenCalled() - - // Sanity: the VFS is genuinely usable afterwards. - const files = await brain.vfs.readdir('/') - expect(files).toEqual([]) - } finally { - await brain.close() - embedSpy.mockRestore() - } - }) - - it('starts the embedding-engine warm in the BACKGROUND — init() resolves before the warm does', async () => { - await withRealEmbedderBranch(async () => { - const events: string[] = [] - let releaseWarm!: () => void - const warmGate = new Promise((resolve) => { - releaseWarm = resolve - }) - - const initSpy = vi.spyOn(embeddingManager, 'init').mockImplementation(async () => { - events.push('warm-start') - await warmGate - events.push('warm-resolve') - }) - - const brain = new Brainy(createTestConfig()) - try { - await brain.init() - events.push('init-resolved') - - // init() started the warm but returned WITHOUT waiting for it. - expect(initSpy).toHaveBeenCalledTimes(1) - expect(events).toEqual(['warm-start', 'init-resolved']) - - // Now let the fake warm finish and confirm it lands strictly after. - releaseWarm() - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(events).toEqual(['warm-start', 'init-resolved', 'warm-resolve']) - } finally { - await brain.close() - initSpy.mockRestore() - } - }) - }) - - it('narrates a background warm FAILURE loudly instead of losing it silently', async () => { - await withRealEmbedderBranch(async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const initSpy = vi - .spyOn(embeddingManager, 'init') - .mockRejectedValue(new Error('simulated cold-compile failure')) - - const brain = new Brainy(createTestConfig()) - try { - // init() itself must still resolve — a failed background warm is - // never fatal to open(). - await expect(brain.init()).resolves.toBeUndefined() - - // Give the background .catch() a microtask/macrotask to run. - await new Promise((resolve) => setTimeout(resolve, 0)) - - const failureLine = warnSpy.mock.calls - .map((args) => args.map(String).join(' ')) - .find((line) => line.includes('background embedding-engine warm FAILED')) - expect(failureLine).toBeDefined() - expect(failureLine).toContain('simulated cold-compile failure') - } finally { - await brain.close() - initSpy.mockRestore() - warnSpy.mockRestore() - } - }) - }) - - it('eagerEmbeddings: false starts no warm at all', async () => { - await withRealEmbedderBranch(async () => { - const initSpy = vi.spyOn(embeddingManager, 'init') - const brain = new Brainy({ ...createTestConfig(), eagerEmbeddings: false }) - try { - await brain.init() - expect(initSpy).not.toHaveBeenCalled() - } finally { - await brain.close() - initSpy.mockRestore() - } - }) - }) - - it('narrates a slow open with a per-phase ms breakdown once total time exceeds 2000ms', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const brain = new Brainy({ ...createTestConfig(), storage: new SlowMemoryStorage() }) - try { - await brain.init() - - const slowOpenLine = warnSpy.mock.calls - .map((args) => args.map(String).join(' ')) - .find((line) => line.includes('[Brainy] slow open:')) - - expect(slowOpenLine).toBeDefined() - expect(slowOpenLine).toContain('storage-init=') - expect(slowOpenLine).toContain('generation-store-open-fold=') - expect(slowOpenLine).toContain('index-init-gate=') - expect(slowOpenLine).toContain('vfs-bootstrap=') - expect(slowOpenLine).toContain('embedding-warm-started=') - } finally { - await brain.close() - warnSpy.mockRestore() - } - }, 20000) - - it('stays silent about phase timing when open is fast (under 2000ms)', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const brain = new Brainy(createTestConfig()) - try { - await brain.init() - const slowOpenLine = warnSpy.mock.calls - .map((args) => args.map(String).join(' ')) - .find((line) => line.includes('[Brainy] slow open:')) - expect(slowOpenLine).toBeUndefined() - } finally { - await brain.close() - warnSpy.mockRestore() - } - }) -}) diff --git a/tests/unit/brainy/persistence-policy.test.ts b/tests/unit/brainy/persistence-policy.test.ts deleted file mode 100644 index 92bb4e3c..00000000 --- a/tests/unit/brainy/persistence-policy.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * @module tests/unit/brainy/persistence-policy - * @description THE ENGINE-OWNED FLUSH CADENCE pins (A4, - * SELF-ENGINE-LIFECYCLE-SPRINT, David-directed: callers NEVER call flush() - * in hot paths). The production disease: 829 caller-scheduled per-write - * flushes convoying into 45–66 second write walls — cadence hand-rolled a - * layer above the only layer that can see dirty state and IO pressure. - * - * Pinned here: (1) the write-count trigger fires a BACKGROUND flush without - * any caller flush(); (2) the idle trigger; (3) `'manual'` restores - * caller-owned cadence exactly; (4) THE ACK LAW — a write acknowledges - * without awaiting any background flush, even one that never resolves. - */ -import { describe, it, expect, afterEach, vi } from 'vitest' -import { Brainy } from '../../../src/index.js' -import { NounType } from '../../../src/types/graphTypes.js' - -const brains: Brainy[] = [] - -async function mk(persistence?: { - policy?: 'auto' | 'manual' - flushEveryWrites?: number - flushIntervalMs?: number - flushOnIdleMs?: number -}): Promise { - const b = new Brainy({ - storage: { type: 'memory' }, - requireSubtype: false, - ...(persistence && { persistence }) - }) - await b.init() - brains.push(b) - return b -} - -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - vi.restoreAllMocks() -}) - -describe('persistence policy — the engine owns its flush cadence', () => { - it('write-count trigger: N committed writes fire ONE background flush, no caller flush()', async () => { - const brain = await mk({ flushEveryWrites: 5, flushOnIdleMs: 60_000, flushIntervalMs: 600_000 }) - const flushSpy = vi.spyOn(brain, 'flush') - - for (let i = 0; i < 5; i++) { - await brain.add({ data: `w${i}`, type: NounType.Document, metadata: { i } }) - } - - await vi.waitFor(() => expect(flushSpy).toHaveBeenCalled(), { timeout: 5000 }) - // Single-flight: the threshold crossing kicks exactly one. - expect(flushSpy.mock.calls.length).toBe(1) - }) - - it('idle trigger: a quiet store with dirty writes flushes itself', async () => { - const brain = await mk({ flushEveryWrites: 10_000, flushIntervalMs: 600_000, flushOnIdleMs: 60 }) - const flushSpy = vi.spyOn(brain, 'flush') - - await brain.add({ data: 'lone write', type: NounType.Document, metadata: {} }) - - await vi.waitFor(() => expect(flushSpy).toHaveBeenCalled(), { timeout: 5000 }) - }) - - it('idle debounce under load: slow writes never fire a flush per inter-write gap', async () => { - // The contended-disk amplifier: writes slower than the idle window make - // every gap look idle — without the spacing floor this fired a full - // flush per write (measured 15 background flushes in 100 contended adds - // on a production-shaped box). The floor (min(interval, 10×idle)) caps - // idle fires; deferred, never dropped. - const brain = await mk({ flushEveryWrites: 10_000, flushIntervalMs: 600_000, flushOnIdleMs: 50 }) - const flushSpy = vi.spyOn(brain, 'flush') - - // Six writes spaced wider than the idle window (50ms) with the whole - // span inside ~one floor window (500ms): the old behavior fires ~an - // idle flush per gap (≈6); the debounced behavior fires at most two - // (one immediate boot-window fire + one at the floor boundary). - for (let i = 0; i < 6; i++) { - await brain.add({ data: `slow ${i}`, type: NounType.Document, metadata: {} }) - await new Promise((r) => setTimeout(r, 70)) - } - expect(flushSpy.mock.calls.length, 'no flush-per-gap amplifier').toBeLessThanOrEqual(2) - - // Deferred, never dropped: the dirty writes still persist once the - // floor elapses on the now-quiet store. - await vi.waitFor(() => expect(flushSpy).toHaveBeenCalled(), { timeout: 5000 }) - }) - - it("'manual' policy: the engine NEVER flushes on its own", async () => { - const brain = await mk({ policy: 'manual', flushEveryWrites: 2, flushOnIdleMs: 30 }) - const flushSpy = vi.spyOn(brain, 'flush') - - for (let i = 0; i < 6; i++) { - await brain.add({ data: `m${i}`, type: NounType.Document, metadata: { i } }) - } - await new Promise((r) => setTimeout(r, 150)) - - expect(flushSpy).not.toHaveBeenCalled() - }) - - it('THE ACK LAW: writes acknowledge without awaiting the background flush — even a hung one', async () => { - const brain = await mk({ flushEveryWrites: 2, flushOnIdleMs: 60_000, flushIntervalMs: 600_000 }) - // A flush that NEVER resolves: if any write ack awaited it, the test - // would time out. (The engine's background flight must be fire-and-log.) - vi.spyOn(brain, 'flush').mockImplementation(() => new Promise(() => {})) - - for (let i = 0; i < 6; i++) { - const id = await brain.add({ data: `a${i}`, type: NounType.Document, metadata: { i } }) - expect(id).toBeTruthy() - } - // All six writes acked while the "flush" hangs forever. - const rows = await brain.find({ type: NounType.Document, limit: 10 }) - expect(rows.length).toBe(6) - - // Un-hang before afterEach close(): restore the method AND drop the - // never-resolving in-flight promise (close() awaits the flight — with a - // real flush that is correct; here it is the test's own artifact). - vi.restoreAllMocks() - ;(brain as unknown as { _persistBackgroundFlight: Promise | null })._persistBackgroundFlight = - null - }) -}) diff --git a/tests/unit/brainy/relate-duplicate-optimization.test.ts b/tests/unit/brainy/relate-duplicate-optimization.test.ts index 910d057d..8bcb7c7a 100644 --- a/tests/unit/brainy/relate-duplicate-optimization.test.ts +++ b/tests/unit/brainy/relate-duplicate-optimization.test.ts @@ -18,7 +18,7 @@ describe('Duplicate Check Optimization', () => { }) afterEach(async () => { - await brain.close() + // Cleanup is automatic with memory storage }) it('should detect duplicate relationships using GraphAdjacencyIndex', async () => { diff --git a/tests/unit/brainy/relate.test.ts b/tests/unit/brainy/relate.test.ts index bea35ba3..eb1a036e 100644 --- a/tests/unit/brainy/relate.test.ts +++ b/tests/unit/brainy/relate.test.ts @@ -5,8 +5,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../../src/brainy' -import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError' -import { +import { createAddParams, createTestConfig, } from '../../helpers/test-factory' @@ -249,23 +248,16 @@ describe('Brainy.relate()', () => { expect(matches.length).toBe(1) // Only one relationship should exist }) - // THE INDEXABLE-ARRAY BOUND, from relate()'s side. This case used to pass a - // 100-element array through relate() and assert it came back — a length - // hardcoded either side of a bound it never named, so it read green or red - // purely by where the constant happened to sit. Both halves of the law are - // pinned here instead, and every length derives from - // MAX_INDEXED_ARRAY_LENGTH so the pin follows the constant. - it('should handle a large scalar metadata payload on a relation', async () => { - // Arrange — large in every dimension EXCEPT array length: a long string, - // many fields, and an array sitting exactly ON the bound. + it('should handle very long metadata', async () => { + // Arrange const largeMetadata = { - atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`), + bigArray: new Array(100).fill('item'), bigObject: Object.fromEntries( Array.from({ length: 50 }, (_, i) => [`key${i}`, `value${i}`]) ), - longString: 'x'.repeat(10_000) + longString: 'x'.repeat(1000) } - + // Act await brain.relate({ from: entity1Id, @@ -273,46 +265,12 @@ describe('Brainy.relate()', () => { type: 'relatedTo', metadata: largeMetadata }) - - // Assert — the payload comes back whole, first element to last + + // Assert const relations = await brain.related({ from: entity1Id }) const relation = relations.find(r => r.to === entity2Id) expect(relation).toBeDefined() - expect(relation!.metadata?.atTheBound).toHaveLength(MAX_INDEXED_ARRAY_LENGTH) - expect(relation!.metadata?.atTheBound[0]).toBe('item0') - expect(relation!.metadata?.atTheBound[MAX_INDEXED_ARRAY_LENGTH - 1]) - .toBe(`item${MAX_INDEXED_ARRAY_LENGTH - 1}`) - expect(Object.keys(relation!.metadata?.bigObject)).toHaveLength(50) - expect(relation!.metadata?.longString).toHaveLength(10_000) - }) - - it('should refuse a relation metadata array over the indexing bound, by name', async () => { - // Arrange - const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1 - - // Act - const err = await brain - .relate({ - from: entity1Id, - to: entity3Id, - type: 'relatedTo', - metadata: { bigArray: new Array(overTheBound).fill('item') } - }) - .catch((e: any) => e) - - // Assert — the field, the length and the bound, on the error and in the - // message, so a handler can report or repair without parsing prose. - expect(err).toBeInstanceOf(MetadataArrayTooLargeError) - expect(err.field).toBe('bigArray') - expect(err.length).toBe(overTheBound) - expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH) - expect(err.message).toContain('bigArray') - expect(err.message).toContain(String(overTheBound)) - expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH)) - - // Refused means not written: no relation of this shape exists. - const relations = await brain.related({ from: entity1Id }) - expect(relations.some(r => r.to === entity3Id && r.metadata?.bigArray)).toBe(false) + expect(relation!.metadata?.bigArray).toHaveLength(100) }) it('should handle special characters in metadata', async () => { diff --git a/tests/unit/brainy/reserved-field-policy.test.ts b/tests/unit/brainy/reserved-field-policy.test.ts new file mode 100644 index 00000000..c5f37af4 --- /dev/null +++ b/tests/unit/brainy/reserved-field-policy.test.ts @@ -0,0 +1,251 @@ +/** + * @module tests/unit/brainy/reserved-field-policy + * @description The 8.0 `reservedFieldPolicy` matrix — what happens when an + * untyped (JavaScript) caller smuggles a Brainy-reserved field INSIDE the + * `metadata` bag of a write call, past the compile-time guard. + * + * 8.0 is a clean break with no silent failures. The decided contract: + * - `'throw'` (DEFAULT): a reserved key in the bag throws a clear Error naming + * the offending key(s) and the correct write path. No remap, no data loss. + * - `'warn'`: legacy remap PLUS a one-shot (per method+field, per process) + * warning for EVERY reserved key found. + * - `'remap'`: the pre-8.0 silent remap, no warning. + * + * The deep correctness of the remap itself (top-level precedence, system-managed + * drops, transact()/with() mirrors, read-side splitting) lives in + * tests/unit/brainy/update-reserved-metadata-remap.test.ts (which now runs under + * `reservedFieldPolicy: 'remap'`). This file pins the POLICY SELECTION and the + * throw/warn behaviors. + * + * Compile-time callers can't write these shapes at all (see + * tests/unit/types/reserved-metadata-keys.test-d.ts); the `as object` widenings + * below simulate untyped callers. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { Brainy } from '../../../src/index.js' +import { NounType, VerbType } from '../../../src/types/graphTypes.js' +import { createTestConfig } from '../../helpers/test-factory.js' +import { prodLog } from '../../../src/utils/logger.js' + +describe('reservedFieldPolicy', () => { + describe("default policy is 'throw'", () => { + let brain: Brainy + + beforeEach(async () => { + // No reservedFieldPolicy override → resolves to 'throw'. + brain = new Brainy(createTestConfig()) + await brain.init() + }) + + afterEach(async () => { + await brain.close() + }) + + it('add() throws naming the offending key and the correct write path', async () => { + await expect( + brain.add({ + type: NounType.Concept, + subtype: 'general', + data: 'x', + metadata: { confidence: 0.8 } as object + }) + ).rejects.toThrow(/metadata\.confidence is a reserved field/) + + // The error names the right param and the reserved list for discoverability. + await expect( + brain.add({ + type: NounType.Concept, + subtype: 'general', + data: 'x', + metadata: { confidence: 0.8 } as object + }) + ).rejects.toThrow(/'confidence' param.*RESERVED_ENTITY_FIELDS/s) + }) + + it('add() lists EVERY offending key when several are present', async () => { + const err = await brain + .add({ + type: NounType.Person, + data: 'multi', + metadata: { confidence: 0.5, weight: 0.6, subtype: 'employee' } as object + }) + .catch((e) => e as Error) + expect(err).toBeInstanceOf(Error) + expect(err.message).toMatch(/confidence/) + expect(err.message).toMatch(/weight/) + expect(err.message).toMatch(/subtype/) + }) + + it('update() throws on a reserved key in the patch', async () => { + const id = await brain.add({ type: NounType.Concept, subtype: 'general', data: 'y' }) + await expect( + brain.update({ id, metadata: { confidence: 0.3 } as object }) + ).rejects.toThrow(/metadata\.confidence is a reserved field/) + }) + + it('relate() throws on a reserved key in the bag', async () => { + const a = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'A' }) + const b = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'B' }) + await expect( + brain.relate({ + from: a, + to: b, + type: VerbType.RelatedTo, + subtype: 'colleague', + metadata: { confidence: 0.4 } as object + }) + ).rejects.toThrow(/metadata\.confidence is a reserved field.*RESERVED_RELATION_FIELDS/s) + }) + + it('updateRelation() throws on a reserved key in the patch', async () => { + const a = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'A' }) + const b = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'B' }) + const relId = await brain.relate({ + from: a, + to: b, + type: VerbType.ReportsTo, + subtype: 'direct' + }) + await expect( + brain.updateRelation({ id: relId, metadata: { weight: 0.2 } as object }) + ).rejects.toThrow(/metadata\.weight is a reserved field/) + }) + + it('transact() add op throws on a reserved key in the bag', async () => { + await expect( + brain.transact([ + { + op: 'add', + type: NounType.Concept, + subtype: 'general', + data: 'tx', + metadata: { confidence: 0.7 } as object + } + ]) + ).rejects.toThrow(/metadata\.confidence is a reserved field/) + }) + + it('a custom (non-reserved) key in the bag does NOT throw', async () => { + const id = await brain.add({ + type: NounType.Concept, + subtype: 'general', + data: 'ok', + metadata: { status: 'draft', rating: 4 } + }) + const entity = await brain.get(id) + expect(entity?.metadata).toEqual({ status: 'draft', rating: 4 }) + }) + }) + + describe("'remap' policy remaps silently (no warning)", () => { + let brain: Brainy + let warnSpy: ReturnType + + beforeEach(async () => { + warnSpy = vi.spyOn(prodLog, 'warn').mockImplementation(() => {}) + brain = new Brainy(createTestConfig({ reservedFieldPolicy: 'remap' })) + await brain.init() + }) + + afterEach(async () => { + await brain.close() + warnSpy.mockRestore() + }) + + it('lifts user-mutable reserved fields to top-level without warning', async () => { + const id = await brain.add({ + type: NounType.Person, + data: 'remap lift', + metadata: { confidence: 0.8, weight: 0.6, subtype: 'employee', dept: 'eng' } as object + }) + const entity = await brain.get(id) + expect(entity?.confidence).toBe(0.8) + expect(entity?.weight).toBe(0.6) + expect(entity?.subtype).toBe('employee') + expect(entity?.metadata).toEqual({ dept: 'eng' }) + // 'remap' is silent about reserved fields (unrelated storage logs may fire, + // so assert specifically that no reserved-field warning was emitted). + const reservedWarned = warnSpy.mock.calls.some((c) => + String(c[0]).includes('reserved field') + ) + expect(reservedWarned).toBe(false) + }) + + it('preserves _originalId on natural-key ids through the remap path', async () => { + // A speculative view applies the same normalization and maps a natural-key + // id to a stable UUID, preserving the caller's original string. + const base = await brain.now() + const speculative = await base.with([ + { + op: 'add', + id: 'remap-spec-entity', + type: NounType.Concept, + subtype: 'general', + data: 'spec', + metadata: { confidence: 0.65, custom: 'spec' } as object + } + ]) + const entity = await speculative.get('remap-spec-entity') + expect(entity?.confidence).toBe(0.65) + expect(entity?.metadata).toEqual({ custom: 'spec', _originalId: 'remap-spec-entity' }) + await speculative.release() + await base.release() + }) + }) + + describe("'warn' policy remaps AND warns once per key", () => { + let brain: Brainy + let warnSpy: ReturnType + + beforeEach(async () => { + warnSpy = vi.spyOn(prodLog, 'warn').mockImplementation(() => {}) + brain = new Brainy(createTestConfig({ reservedFieldPolicy: 'warn' })) + await brain.init() + }) + + afterEach(async () => { + await brain.close() + warnSpy.mockRestore() + }) + + it('remaps the value (same as remap) and emits a warning naming the field', async () => { + // Use a method+field combo unique to this test so the per-process one-shot + // registry has not already consumed it. + const id = await brain.add({ + type: NounType.Person, + data: 'warn lift', + // weight is user-mutable → remapped; this is the only 'warn'-policy + // add({ weight }) in the suite, so the one-shot warning fires here. + metadata: { weight: 0.42, dept: 'eng' } as object + }) + const entity = await brain.get(id) + // Value is honored (remap still happens under 'warn'). + expect(entity?.weight).toBe(0.42) + expect(entity?.metadata).toEqual({ dept: 'eng' }) + // And a warning was emitted naming the reserved field. + expect(warnSpy).toHaveBeenCalled() + const warned = warnSpy.mock.calls.some((c) => + String(c[0]).includes("'weight'") + ) + expect(warned).toBe(true) + }) + + it('warns for system-managed keys too (closes the historical gap)', async () => { + // Pre-8.0 only system-managed fields warned; 'warn' warns for every key. + // 'createdBy' (system-managed on update) is unique to this test. + const id = await brain.add({ type: NounType.Concept, subtype: 'general', data: 'sys' }) + warnSpy.mockClear() + await brain.update({ id, metadata: { createdBy: 'nope', keep: 'me' } as object }) + const entity = await brain.get(id) + // System-managed key dropped; custom field merged. + expect((entity?.metadata as Record)?.createdBy).toBeUndefined() + expect((entity?.metadata as Record)?.keep).toBe('me') + // A warning was emitted for the dropped system-managed key. + const warned = warnSpy.mock.calls.some((c) => + String(c[0]).includes("'createdBy'") + ) + expect(warned).toBe(true) + }) + }) +}) diff --git a/tests/unit/brainy/update-reserved-metadata-remap.test.ts b/tests/unit/brainy/update-reserved-metadata-remap.test.ts new file mode 100644 index 00000000..31713f99 --- /dev/null +++ b/tests/unit/brainy/update-reserved-metadata-remap.test.ts @@ -0,0 +1,403 @@ +/** + * @module tests/unit/brainy/update-reserved-metadata-remap + * @description Regression tests for the reserved-field metadata-bag trap, + * ported from the 7.x fix and extended to the full 8.0 contract. + * + * History: `add({metadata: {confidence}})` lifted reserved fields to their + * canonical top-level location, but `update({metadata: {confidence}})` + * silently dropped the same shape — the patch value survived the merge and + * was then clobbered by the preserve-existing spread. A production + * consumer's confidence-evolution writes no-oped for weeks before being + * caught by reading values back. + * + * These tests pin the LEGACY REMAP behavior, which in 8.0 is opt-in via + * `reservedFieldPolicy: 'remap'` (the default is `'throw'` — see the policy + * matrix in tests/unit/brainy/reserved-field-policy.test.ts). The brain in + * every test below is constructed with `reservedFieldPolicy: 'remap'` so these + * deep correctness assertions about the remap path stay exercised. + * + * Remap contract under test (every write path, entities AND relationships): + * - user-mutable reserved fields (`confidence`, `weight`, `subtype` — plus + * `service`/`createdBy` at add()/relate() time) remap from the metadata + * bag to their dedicated top-level param, with top-level winning when both + * are present; + * - system-managed reserved fields (`createdAt`, `_rev`, `noun`/`verb`, + * `data`, …) are dropped from the bag; + * - the same normalization applies to `transact()` operations and `with()` + * speculative views; + * - reads NEVER echo a reserved field inside `metadata`. + * + * TypeScript callers can't write these shapes at all (compile-time guard on + * the metadata param types — see tests/unit/types/reserved-metadata-keys.test-d.ts); + * these tests simulate untyped (JavaScript) callers, hence the `as object` + * widenings on the metadata literals. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Brainy } from '../../../src/index.js' +import { NounType, VerbType } from '../../../src/types/graphTypes.js' +import { createTestConfig } from '../../helpers/test-factory.js' + +describe('reserved-field metadata remap (8.0 legacy remap path)', () => { + let brain: Brainy + + beforeEach(async () => { + // The remap path is opt-in in 8.0 (default policy is 'throw'). + brain = new Brainy(createTestConfig({ reservedFieldPolicy: 'remap' })) + await brain.init() + }) + + afterEach(async () => { + await brain.close() + }) + + describe('update() — the ported 7.x regression', () => { + it('remaps metadata.confidence to the top-level field (the production repro)', async () => { + const id = await brain.add({ + type: NounType.Concept, + subtype: 'general', + data: 'x', + metadata: { confidence: 0.8 } as object + }) + + // Top-level write works (always did) + await brain.update({ id, confidence: 0.42 }) + let entity = await brain.get(id) + expect(entity?.confidence).toBe(0.42) + + // Metadata-patch write — silently dropped pre-fix, remapped now + await brain.update({ id, metadata: { confidence: 0.33 } as object }) + entity = await brain.get(id) + expect(entity?.confidence).toBe(0.33) + // The reserved key must not linger inside the metadata bag + expect((entity?.metadata as Record)?.confidence).toBeUndefined() + }) + + it('remaps metadata.weight and metadata.subtype the same way', async () => { + const id = await brain.add({ + type: NounType.Concept, + subtype: 'general', + data: 'y', + metadata: {} + }) + + await brain.update({ id, metadata: { weight: 0.7, subtype: 'specialized' } as object }) + const entity = await brain.get(id) + expect(entity?.weight).toBe(0.7) + expect(entity?.subtype).toBe('specialized') + expect((entity?.metadata as Record)?.weight).toBeUndefined() + expect((entity?.metadata as Record)?.subtype).toBeUndefined() + }) + + it('top-level param wins when both top-level and metadata-patch carry the field', async () => { + const id = await brain.add({ + type: NounType.Concept, + subtype: 'general', + data: 'z', + metadata: { confidence: 0.5 } as object + }) + + await brain.update({ id, confidence: 0.9, metadata: { confidence: 0.1 } as object }) + const entity = await brain.get(id) + expect(entity?.confidence).toBe(0.9) + }) + + it('drops system-managed fields from patches without corrupting the entity', async () => { + const id = await brain.add({ + type: NounType.Concept, + subtype: 'general', + data: 'w', + metadata: { keep: 'me' } + }) + const before = await brain.get(id) + + await brain.update({ + id, + metadata: { createdAt: 1, _rev: 999, noun: 'organization', other: 'applied' } as object + }) + const after = await brain.get(id) + + expect(after?.createdAt).toBe(before?.createdAt) // immutable + expect(after?.type).toBe('concept') // noun patch ignored + expect(after?._rev).toBe((before?._rev ?? 1) + 1) // _rev patch ignored; normal bump applied + expect((after?.metadata as Record)?.other).toBe('applied') // custom fields still merge + expect((after?.metadata as Record)?.keep).toBe('me') + expect((after?.metadata as Record)?._rev).toBeUndefined() + expect((after?.metadata as Record)?.createdAt).toBeUndefined() + expect((after?.metadata as Record)?.noun).toBeUndefined() + }) + + it('custom (non-reserved) metadata patches are unaffected by the remap', async () => { + const id = await brain.add({ + type: NounType.Concept, + subtype: 'general', + data: 'v', + metadata: { status: 'draft' } + }) + + await brain.update({ id, metadata: { status: 'reviewed', rating: 4.5 } }) + const entity = await brain.get(id) + expect((entity?.metadata as Record)?.status).toBe('reviewed') + expect((entity?.metadata as Record)?.rating).toBe(4.5) + }) + }) + + describe('add() — explicit lift, identical contract', () => { + it('lifts confidence/weight/subtype out of the bag to top level', async () => { + const id = await brain.add({ + type: NounType.Person, + data: 'lift check', + metadata: { confidence: 0.8, weight: 0.6, subtype: 'employee', dept: 'eng' } as object + }) + + const entity = await brain.get(id) + expect(entity?.confidence).toBe(0.8) + expect(entity?.weight).toBe(0.6) + expect(entity?.subtype).toBe('employee') + expect(entity?.metadata).toEqual({ dept: 'eng' }) + }) + + it('lifts service (settable at add time) and lets the top-level param win', async () => { + const lifted = await brain.add({ + type: NounType.Person, + subtype: 'employee', + data: 'service lift', + metadata: { service: 'orders' } as object + }) + expect((await brain.get(lifted))?.service).toBe('orders') + + const topLevelWins = await brain.add({ + type: NounType.Person, + subtype: 'employee', + data: 'service precedence', + service: 'billing', + metadata: { service: 'orders' } as object + }) + const entity = await brain.get(topLevelWins) + expect(entity?.service).toBe('billing') + expect((entity?.metadata as Record)?.service).toBeUndefined() + }) + + it('a remapped subtype satisfies subtype enforcement like a top-level one', async () => { + brain.requireSubtype(NounType.Document) + + // Top-level missing, but the bag carries it — must not throw. + const id = await brain.add({ + type: NounType.Document, + data: 'enforcement via remap', + metadata: { subtype: 'invoice' } as object + }) + expect((await brain.get(id))?.subtype).toBe('invoice') + + // Neither place carries it — must throw. + await expect( + brain.add({ type: NounType.Document, data: 'no subtype anywhere' }) + ).rejects.toThrow(/subtype/) + }) + }) + + describe('transact() — same remap on add and update ops', () => { + it('normalizes reserved fields in transact add + update ops', async () => { + const db1 = await brain.transact([ + { + op: 'add', + type: NounType.Concept, + subtype: 'general', + data: 'tx', + metadata: { confidence: 0.7, custom: 'a' } as object + } + ]) + const id = db1.receipt!.ids[0] + + let entity = await brain.get(id) + expect(entity?.confidence).toBe(0.7) + expect(entity?.metadata).toEqual({ custom: 'a' }) + + await brain.transact([ + { op: 'update', id, metadata: { confidence: 0.25, custom: 'b' } as object } + ]) + entity = await brain.get(id) + expect(entity?.confidence).toBe(0.25) + expect(entity?.metadata).toEqual({ custom: 'b' }) + expect((entity?.metadata as Record)?.confidence).toBeUndefined() + }) + + it('historical asOf() reads surface reserved fields ONLY top-level', async () => { + const db1 = await brain.transact([ + { + op: 'add', + type: NounType.Concept, + subtype: 'general', + data: 'historical', + metadata: { confidence: 0.9, custom: 'past' } as object + } + ]) + const id = db1.receipt!.ids[0] + + // Move the world forward so generation db1 is historical. + await brain.transact([{ op: 'update', id, confidence: 0.1, metadata: { custom: 'now' } }]) + + const past = await brain.asOf(db1.generation) + const historical = await past.get(id) + expect(historical?.confidence).toBe(0.9) + expect(historical?.metadata).toEqual({ custom: 'past' }) + await past.release() + }) + + it('with() speculative views apply the same normalization', async () => { + const base = await brain.now() + const speculative = await base.with([ + { + op: 'add', + id: 'spec-entity', + type: NounType.Concept, + subtype: 'general', + data: 'spec', + metadata: { confidence: 0.65, custom: 'spec' } as object + } + ]) + + const entity = await speculative.get('spec-entity') + expect(entity?.confidence).toBe(0.65) + // 8.0 id normalization: a natural-key id is mapped to a stable UUID and + // the caller's original string is preserved under _originalId — surfaced + // here exactly as the durable transact()/add() paths do. + expect(entity?.metadata).toEqual({ custom: 'spec', _originalId: 'spec-entity' }) + await speculative.release() + await base.release() + }) + }) + + describe('read paths never echo reserved fields inside metadata', () => { + it('find() (storage pagination path) returns custom-only metadata with reserved fields top-level', async () => { + const id = await brain.add({ + type: NounType.Person, + subtype: 'employee', + data: 'pagination echo check', + confidence: 0.8, + weight: 0.6, + metadata: { dept: 'eng' } + }) + + // No query/filter → served by the direct storage pagination path + // (getNounsWithPagination), which historically echoed the full flat + // record (noun/subtype/createdAt/… inside metadata). + const results = await brain.find({ limit: 50 }) + const result = results.find((r) => r.id === id) + expect(result).toBeDefined() + expect(result?.entity.metadata).toEqual({ dept: 'eng' }) + expect(result?.entity.type).toBe(NounType.Person) + expect(result?.entity.subtype).toBe('employee') + expect(result?.entity.confidence).toBe(0.8) + expect(result?.entity.weight).toBe(0.6) + expect(typeof result?.entity.createdAt).toBe('number') + expect(result?.entity._rev).toBe(1) + }) + + it('related() by target surfaces reserved fields top-level, custom-only metadata', async () => { + const a = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'src' }) + const b = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'tgt' }) + const relId = await brain.relate({ + from: a, + to: b, + type: VerbType.ReportsTo, + subtype: 'direct', + confidence: 0.9, + weight: 0.5, + service: 'orders', + metadata: { note: 'target path' } + }) + + const relations = await brain.related({ to: b }) + const rel = relations.find((r) => r.id === relId) + expect(rel).toBeDefined() + expect(rel?.metadata).toEqual({ note: 'target path' }) + expect(rel?.subtype).toBe('direct') + expect(rel?.confidence).toBe(0.9) + expect(rel?.weight).toBe(0.5) + expect(rel?.service).toBe('orders') + expect(typeof rel?.createdAt).toBe('number') + }) + }) + + describe('relationships — relate() / updateRelation() mirror', () => { + let a: string + let b: string + + beforeEach(async () => { + a = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'A' }) + b = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'B' }) + }) + + it('relate() persists the top-level confidence and service params', async () => { + const relId = await brain.relate({ + from: a, + to: b, + type: VerbType.ReportsTo, + subtype: 'direct', + confidence: 0.77, + service: 'orders' + }) + + const relations = await brain.related({ from: a }) + const rel = relations.find((r) => r.id === relId) + expect(rel?.confidence).toBe(0.77) + expect(rel?.service).toBe('orders') + }) + + it('relate() remaps reserved fields out of the metadata bag', async () => { + const relId = await brain.relate({ + from: a, + to: b, + type: VerbType.RelatedTo, + subtype: 'colleague', + metadata: { confidence: 0.4, weight: 0.3, role: 'peer' } as object + }) + + const relations = await brain.related({ from: a }) + const rel = relations.find((r) => r.id === relId) + expect(rel?.confidence).toBe(0.4) + expect(rel?.weight).toBe(0.3) + expect(rel?.metadata).toEqual({ role: 'peer' }) + }) + + it('relation.metadata never echoes the verb type key', async () => { + const relId = await brain.relate({ + from: a, + to: b, + type: VerbType.RelatedTo, + subtype: 'colleague', + metadata: { note: 'no echo' } + }) + + const relations = await brain.related({ from: a }) + const rel = relations.find((r) => r.id === relId) + expect(rel?.type).toBe(VerbType.RelatedTo) + expect((rel?.metadata as Record)?.verb).toBeUndefined() + expect(rel?.metadata).toEqual({ note: 'no echo' }) + }) + + it('updateRelation() remaps the user-mutable trio and preserves service', async () => { + const relId = await brain.relate({ + from: a, + to: b, + type: VerbType.ReportsTo, + subtype: 'direct', + service: 'orders', + metadata: { keep: 'me' } + }) + + await brain.updateRelation({ + id: relId, + metadata: { confidence: 0.55, subtype: 'dotted-line', extra: 'applied' } as object + }) + + const relations = await brain.related({ from: a }) + const rel = relations.find((r) => r.id === relId) + expect(rel?.confidence).toBe(0.55) + expect(rel?.subtype).toBe('dotted-line') + expect(rel?.service).toBe('orders') // fixed at relate() time, never erased by updates + expect(rel?.metadata).toEqual({ keep: 'me', extra: 'applied' }) + }) + }) +}) diff --git a/tests/unit/brainy/update.test.ts b/tests/unit/brainy/update.test.ts index ec5f3fff..19fdad19 100644 --- a/tests/unit/brainy/update.test.ts +++ b/tests/unit/brainy/update.test.ts @@ -5,8 +5,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../../src/brainy' -import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError' -import { +import { createAddParams, createTestConfig, } from '../../helpers/test-factory' @@ -356,88 +355,36 @@ describe('Brainy.update()', () => { expect(final!.metadata.counter).toBeLessThanOrEqual(10) }) - // THE INDEXABLE-ARRAY BOUND, from update()'s side. This case used to write - // a 1000-element array through update() and assert it came back. That - // shape is refused at the write door now — an array field mints one - // posting per element, so an unbounded array is an unbounded write — so - // the case pins BOTH halves of the law that replaced it. Every length - // derives from MAX_INDEXED_ARRAY_LENGTH so the pin follows the constant. - it('should handle a large scalar metadata update', async () => { + it('should handle very large metadata updates', async () => { // Arrange const id = await brain.add(createAddParams({ data: 'Large metadata test', type: 'thing' })) - - // Large in every dimension EXCEPT array length: a long string, many - // fields, deep nesting, and an array sitting exactly ON the bound. + const largeMetadata = { - atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`), + bigArray: new Array(1000).fill('item'), bigObject: Object.fromEntries( Array.from({ length: 100 }, (_, i) => [`key${i}`, `value${i}`]) ), - longString: 'x'.repeat(10_000), deepNesting: Array(10).fill(null).reduce( (acc) => ({ nested: acc }), { value: 'deep' } ) } - + // Act await brain.update({ id, metadata: largeMetadata, merge: false }) - - // Assert — the payload comes back whole, first element to last + + // Assert const updated = await brain.get(id) expect(updated).not.toBeNull() - expect(updated!.metadata.atTheBound).toHaveLength(MAX_INDEXED_ARRAY_LENGTH) - expect(updated!.metadata.atTheBound[0]).toBe('item0') - expect(updated!.metadata.atTheBound[MAX_INDEXED_ARRAY_LENGTH - 1]) - .toBe(`item${MAX_INDEXED_ARRAY_LENGTH - 1}`) + expect(updated!.metadata.bigArray).toHaveLength(1000) expect(Object.keys(updated!.metadata.bigObject)).toHaveLength(100) - expect(updated!.metadata.longString).toHaveLength(10_000) - - // ...including the deep nest, walked to the bottom. - let cursor: any = updated!.metadata.deepNesting - for (let depth = 0; depth < 10; depth++) cursor = cursor.nested - expect(cursor.value).toBe('deep') - }) - - it('should refuse an update whose metadata array is over the indexing bound, by name', async () => { - // Arrange - const id = await brain.add(createAddParams({ - data: 'Large metadata test', - type: 'thing', - metadata: { keep: 'me' } - })) - const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1 - - // Act - const err = await brain - .update({ - id, - metadata: { bigArray: new Array(overTheBound).fill('item') }, - merge: false - }) - .catch((e: any) => e) - - // Assert — the field, the length and the bound, on the error and in the - // message, so a handler can report or repair without parsing prose. - expect(err).toBeInstanceOf(MetadataArrayTooLargeError) - expect(err.field).toBe('bigArray') - expect(err.length).toBe(overTheBound) - expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH) - expect(err.message).toContain('bigArray') - expect(err.message).toContain(String(overTheBound)) - expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH)) - - // Refused means unchanged: the row still carries what it had before. - const unchanged = await brain.get(id) - expect(unchanged!.metadata.keep).toBe('me') - expect(unchanged!.metadata.bigArray).toBeUndefined() }) it('should preserve entity ID during update', async () => { diff --git a/tests/unit/brainy/visibility.test.ts b/tests/unit/brainy/visibility.test.ts index dd4540d7..a5a02422 100644 --- a/tests/unit/brainy/visibility.test.ts +++ b/tests/unit/brainy/visibility.test.ts @@ -198,47 +198,60 @@ describe('visibility (8.0 reserved field)', () => { expect(entity?.visibility).toBeUndefined() }) - it('metadata.visibility is the USER’s field (field-addressing law) — stored verbatim, never lifted to the engine tier', async () => { - const id = await brain.add({ - type: NounType.Concept, - data: 'y', - metadata: { visibility: 'internal', tag: 't' } as object - }) - const entity = await brain.get(id) - // The user's field lives in the bag, verbatim… - expect((entity?.metadata as Record)?.visibility).toBe('internal') - expect((entity?.metadata as Record)?.tag).toBe('t') - // …and the ENGINE tier is untouched: absent === public, so the entity - // stays visible on default reads (the engine tier is set only via the - // dedicated visibility param and reads at system.visibility). - expect(entity?.visibility).toBeUndefined() - const visible = await brain.find({ type: NounType.Concept, limit: 20 }) - expect(visible.map((r) => r.id)).toContain(id) + it('an untyped caller passing visibility inside metadata is normalized under reservedFieldPolicy:"remap" (lifted to top-level)', async () => { + // Simulate a JavaScript caller smuggling the reserved key past the compile-time guard. + // The legacy remap behavior is now opt-in (8.0 default is 'throw'). + const remapBrain = new Brainy(createTestConfig({ reservedFieldPolicy: 'remap' })) + await remapBrain.init() + try { + const id = await remapBrain.add({ + type: NounType.Concept, + data: 'y', + metadata: { visibility: 'internal', tag: 't' } as object + }) + const entity = await remapBrain.get(id) + // Lifted to the top-level field… + expect(entity?.visibility).toBe('internal') + // …and stripped from the metadata bag. + expect((entity?.metadata as Record)?.visibility).toBeUndefined() + expect((entity?.metadata as Record)?.tag).toBe('t') + // It is excluded from the default count, exactly like a top-level internal write. + expect(await remapBrain.getNounCount()).toBe(0) + } finally { + await remapBrain.close() + } }) - it('a user field valued "system" cannot smuggle the Brainy-only tier — it is just user data', async () => { - const id = await brain.add({ - type: NounType.Concept, - data: 'z', - metadata: { visibility: 'system' } as object - }) - const entity = await brain.get(id) - // Engine tier unaffected → entity stays public (counted, visible); - // the string 'system' is ordinary user data in the bag. - expect(entity?.visibility).toBeUndefined() - expect((entity?.metadata as Record)?.visibility).toBe('system') - const found = await brain.find({ type: NounType.Concept, limit: 10 }) - expect(found.map((r) => r.id)).toContain(id) + it('a "system" value smuggled through metadata is dropped under reservedFieldPolicy:"remap", not honored', async () => { + // 'system' is Brainy-only; an untyped caller must not be able to set it. + const remapBrain = new Brainy(createTestConfig({ reservedFieldPolicy: 'remap' })) + await remapBrain.init() + try { + const id = await remapBrain.add({ + type: NounType.Concept, + data: 'z', + metadata: { visibility: 'system' } as object + }) + const entity = await remapBrain.get(id) + // The smuggled 'system' was dropped → entity stays public (counted, visible). + expect(entity?.visibility).toBeUndefined() + expect(await remapBrain.getNounCount()).toBe(1) + const found = await remapBrain.find({ type: NounType.Concept, limit: 10 }) + expect(found.map((r) => r.id)).toContain(id) + } finally { + await remapBrain.close() + } }) - it('a forged system.visibility key in metadata refuses loudly at the write door', async () => { + it('an untyped caller passing visibility inside metadata throws under the default policy', async () => { + // 8.0 default: no silent remap — a reserved key in the bag is a loud error. await expect( brain.add({ type: NounType.Concept, data: 'throws', - metadata: { 'system.visibility': 'internal' } as object + metadata: { visibility: 'internal', tag: 't' } as object }) - ).rejects.toThrow(/system\./) + ).rejects.toThrow(/visibility.*reserved field/) }) }) }) diff --git a/tests/unit/brainy/warm.test.ts b/tests/unit/brainy/warm.test.ts index 8a0bb4da..ce213696 100644 --- a/tests/unit/brainy/warm.test.ts +++ b/tests/unit/brainy/warm.test.ts @@ -307,92 +307,4 @@ describe('brain.warm()', () => { expect(report.totalDurationMs).toBeGreaterThanOrEqual(0) await brain.close() }) - - // --- Metadata leg routes through the ACTIVE provider (8.10.1) ----------- - // - // `MetadataIndexProvider` is a ~50-method interface (src/plugin.ts) — far - // too large to hand-write a compliant fake class the way `FakeVectorProvider` - // fakes the ~8-method `VectorIndexProvider` above. Test (c) already - // establishes this file's pattern for the metadata leg: exercise the REAL - // `MetadataIndexManager` instance and shape just the probe points brain.ts - // reads (`typeof provider.warm === 'function'` / - // `typeof provider.hydrateAll === 'function'`) directly on that instance. - // Shadowing an own property on the live object stands in for "a different - // provider implementation" without needing a hand-rolled full fake — the - // rest of the real manager (used by add()/init() above) is untouched. - describe('metadata leg — warm() routes through the active provider', () => { - it('(f) calls the ACTIVE metadata provider\'s warm() when present and reports "warmed", never falling back to hydrateAll', async () => { - const brain = new Brainy({ - requireSubtype: false, - storage: { type: 'memory' }, - silent: true - }) - await brain.init() - await brain.add({ data: 'a', type: NounType.Thing, vector: V(1) }) - - const metadataIndex = (brain as any).metadataIndex - let warmCalls = 0 - let hydrateAllCalls = 0 - const origHydrateAll = metadataIndex.hydrateAll.bind(metadataIndex) - metadataIndex.hydrateAll = async (...args: unknown[]) => { - hydrateAllCalls++ - return origHydrateAll(...args) - } - // Simulates a native metadata provider declaring the optional `warm()` - // hook added to `MetadataIndexProvider` (src/plugin.ts) in 8.10.1. - metadataIndex.warm = async () => { - warmCalls++ - } - - const report = await brain.warm() - - expect(warmCalls).toBe(1) - expect(hydrateAllCalls).toBe(0) // warm() ran — no hydrateAll fallback - expect(report.metadata.outcome).toBe('warmed') - await brain.close() - }) - - it('reports "unavailable" when the active metadata provider implements neither warm() nor hydrateAll() (the honest branch a native provider without either hook must hit)', async () => { - const brain = new Brainy({ - requireSubtype: false, - storage: { type: 'memory' }, - silent: true - }) - await brain.init() - await brain.add({ data: 'a', type: NounType.Thing, vector: V(1) }) - - const metadataIndex = (brain as any).metadataIndex - // Shadow away BOTH optional hooks — models a genuinely native provider - // that (unlike the built-in JS manager) offers neither seam. This must - // never fall back to calling init() as a stand-in for warmth. - metadataIndex.warm = undefined - metadataIndex.hydrateAll = undefined - - const report = await brain.warm() - - expect(report.metadata.outcome).toBe('unavailable') - await brain.close() - }) - - it('the built-in JS manager (no warm()) still reports "warmed" via its existing hydrateAll() duck-type — unchanged by the new provider hook', async () => { - const brain = new Brainy({ - requireSubtype: false, - storage: { type: 'memory' }, - silent: true - }) - await brain.init() - await brain.add({ data: 'a', type: NounType.Thing, vector: V(1) }) - - // No patching at all — the default built-in MetadataIndexManager has - // hydrateAll() but no warm(), exactly as it did before this change. - const metadataIndex = (brain as any).metadataIndex - expect(typeof metadataIndex.warm).not.toBe('function') - expect(typeof metadataIndex.hydrateAll).toBe('function') - - const report = await brain.warm() - - expect(report.metadata.outcome).toBe('warmed') - await brain.close() - }) - }) }) diff --git a/tests/unit/db/bounded-chains.test.ts b/tests/unit/db/bounded-chains.test.ts index d356277d..034bc663 100644 --- a/tests/unit/db/bounded-chains.test.ts +++ b/tests/unit/db/bounded-chains.test.ts @@ -477,17 +477,14 @@ describe('materializeAtGeneration — bounded & deadlock-free (GA #33)', () => { const store = (brain as any).generationStore const N = 400 - // Relative, not absolute: under the adopt-at-open default the open-time - // baseline backfill takes a generation of its own, so the first add is - // NOT generation 1 — pin the deep generation to the first add's commit. - let deepGen = 0 for (let i = 0; i < N; i++) { await brain.add({ data: `doc ${i}`, type: NounType.Document, subtype: 'note', metadata: { i }, vector: VEC }) - if (i === 0) deepGen = brain.generation() } const R = brain.generation() // ≈ N (each add is its own generation) expect(R).toBeGreaterThanOrEqual(N) + const deepGen = 1 + // Count getDelta invocations during the materialize. const realGetDelta = store.getDelta.bind(store) let getDeltaCalls = 0 @@ -512,8 +509,7 @@ describe('materializeAtGeneration — bounded & deadlock-free (GA #33)', () => { expect(getDeltaCalls).toBeLessThan(R * 5) expect(getDeltaCalls).toBeLessThan(N * N) // the regression guard - // The materialized brain at the first add's generation holds exactly the - // one user entity that existed. + // The materialized at-gen-1 brain holds exactly the one entity that existed. const atGen1 = await handle.find({ limit: N + 10 }) expect(atGen1.length).toBe(1) await handle.close() diff --git a/tests/unit/db/db-portable-graph.test.ts b/tests/unit/db/db-portable-graph.test.ts index 1de9983d..abb89553 100644 --- a/tests/unit/db/db-portable-graph.test.ts +++ b/tests/unit/db/db-portable-graph.test.ts @@ -9,7 +9,7 @@ * subtype-required default. */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { randomUUID } from 'node:crypto' import * as fs from 'node:fs/promises' import * as os from 'node:os' @@ -19,7 +19,6 @@ import { createTestConfig } from '../../helpers/test-factory' import { NounType, VerbType } from '../../../src/types/graphTypes' import { validatePortableGraph } from '../../../src/db/portableGraph' import type { PortableGraph } from '../../../src/db/portableGraph' -import { CanonicalEnumerationUnavailableError } from '../../../src/db/errors' describe('8.0 portable graph export/import (PortableGraph v1)', () => { let brain: Brainy @@ -294,257 +293,3 @@ describe('8.0 export includeContent (VFS blobs, filesystem)', () => { } }) }) - -describe('8.0 export enumeration:"canonical" — canon-complete against index blindness', () => { - let brain: Brainy - - beforeEach(async () => { - brain = new Brainy(createTestConfig()) - await brain.init() - }) - - afterEach(async () => { - await brain.close() - }) - - it('(i) equals the index-based export when the index is healthy — same entity ids, relations, vectors', async () => { - const a = await brain.add({ data: 'Alice', type: NounType.Person, subtype: 'employee' }) - const b = await brain.add({ data: 'Bob', type: NounType.Person, subtype: 'employee' }) - const c = await brain.add({ data: 'Acme', type: NounType.Organization, subtype: 'vendor' }) - await brain.relate({ from: a, to: b, type: VerbType.FriendOf, subtype: 'close' }) - await brain.relate({ from: a, to: c, type: VerbType.WorksWith, subtype: 'full-time' }) - - const indexExport = await brain.export({}, { includeVectors: true, enumeration: 'index' }) - const canonicalExport = await brain.export({}, { includeVectors: true, enumeration: 'canonical' }) - - expect(canonicalExport.entities.map((e) => e.id).sort()).toEqual( - indexExport.entities.map((e) => e.id).sort() - ) - expect(canonicalExport.relations.map((r) => r.id).sort()).toEqual( - indexExport.relations.map((r) => r.id).sort() - ) - expect(canonicalExport.entities.map((e) => e.id).sort()).toEqual([a, b, c].sort()) - for (const e of canonicalExport.entities) { - expect(e.vector?.length).toBeGreaterThan(0) - } - expect(canonicalExport.drift).toBeUndefined() // reportIndexDrift not requested - }) - - it('(ii) survives simulated metadata-index blindness; the index export misses the record; drift names it canonicalOnly', async () => { - const staff = await brain.add({ - data: 'Staff', - type: NounType.Person, - subtype: 'employee', - metadata: { role: 'staff' } - }) - const other = await brain.add({ - data: 'Other', - type: NounType.Person, - subtype: 'employee', - metadata: { role: 'staff' } - }) - - // Surgically poison the metadata index (the lowest-level seam the existing - // find() phantom-row guard tests use — see find-index-integrity-guard.test.ts, - // which does the mirror-image ADD case) so the predicate query - // enumeration:'index' issues (find({ type: Person })) never returns `staff` — - // a real canonical record the index has lost track of, the exact - // canon-present/index-missing state canonical mode exists to survive. - const mi = (brain as any).metadataIndex - const original = mi.getIdsForFilter.bind(mi) - mi.getIdsForFilter = async (filter: any, opts?: any): Promise => { - const ids: string[] = await original(filter, opts) - return ids.filter((id: string) => id !== staff) - } - - try { - const indexExport = await brain.export({ type: NounType.Person }, { enumeration: 'index' }) - expect(indexExport.entities.map((e) => e.id)).not.toContain(staff) - expect(indexExport.entities.map((e) => e.id)).toContain(other) - - const canonicalExport = await brain.export( - { type: NounType.Person }, - { enumeration: 'canonical', reportIndexDrift: true } - ) - expect(canonicalExport.entities.map((e) => e.id)).toContain(staff) - expect(canonicalExport.entities.map((e) => e.id)).toContain(other) - expect(canonicalExport.drift?.canonicalOnly).toEqual([staff]) - expect(canonicalExport.drift?.indexOnly).toEqual([]) - } finally { - mi.getIdsForFilter = original - } - }) - - it('(iii) drift report shape + loud console.warn only when nonzero', async () => { - const staff = await brain.add({ data: 'Staff', type: NounType.Person, subtype: 'employee' }) - await brain.add({ data: 'Other', type: NounType.Person, subtype: 'employee' }) - - const mi = (brain as any).metadataIndex - const original = mi.getIdsForFilter.bind(mi) - mi.getIdsForFilter = async (filter: any, opts?: any): Promise => { - const ids: string[] = await original(filter, opts) - return ids.filter((id: string) => id !== staff) - } - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const drifted = await brain.export( - { type: NounType.Person }, - { enumeration: 'canonical', reportIndexDrift: true } - ) - expect(drifted.drift).toEqual({ canonicalOnly: [staff], indexOnly: [] }) - expect(warnSpy).toHaveBeenCalledTimes(1) - expect(warnSpy.mock.calls[0].join(' ')).toMatch(/drift/i) - } finally { - mi.getIdsForFilter = original - warnSpy.mockClear() - } - - // Healthy index: drift is reported (both lists present) but never warned about. - try { - const healthy = await brain.export( - { type: NounType.Person }, - { enumeration: 'canonical', reportIndexDrift: true } - ) - expect(healthy.drift).toEqual({ canonicalOnly: [], indexOnly: [] }) - expect(warnSpy).not.toHaveBeenCalled() - } finally { - warnSpy.mockRestore() - } - }) - - it('(iv) throws CanonicalEnumerationUnavailableError on a historical asOf() view and a speculative with() overlay', async () => { - const a = '22222222-2222-4222-8222-222222222222' - const b = '33333333-3333-4333-8333-333333333333' - await brain.transact([{ op: 'add', id: a, data: 'First', type: NounType.Thing, subtype: 'x' }]) - const g1 = brain.generation() - await brain.transact([{ op: 'add', id: b, data: 'Second', type: NounType.Thing, subtype: 'x' }]) - - const past = await brain.asOf(g1) - try { - await expect(past.export({}, { enumeration: 'canonical' })).rejects.toThrow( - CanonicalEnumerationUnavailableError - ) - // The default (index) mode is unaffected — still a valid time-travel export. - const backup = await past.export() - expect(backup.entities.map((e) => e.id)).toContain(a) - } finally { - await past.release() - } - - const speculativeId = '11111111-1111-4111-8111-111111111111' - const view = await brain.now().with([ - { op: 'add', id: speculativeId, data: 'Speculative', type: NounType.Thing, subtype: 'x' } - ]) - try { - await expect(view.export({}, { enumeration: 'canonical' })).rejects.toThrow( - CanonicalEnumerationUnavailableError - ) - } finally { - await view.release() - } - }) - - it('throws a plain Error when enumeration:"canonical" has no storage adapter to walk', async () => { - const { exportGraph } = await import('../../../src/db/portableGraph') - const readerOnly = { get: async () => null, find: async () => [], related: async () => [] } - await expect( - exportGraph(readerOnly as any, undefined, {}, { enumeration: 'canonical' }) - ).rejects.toThrow(/enumeration:'canonical' requires a storage adapter/) - }) -}) - -describe('8.0 export includeHidden — every visibility tier for migration-grade canon completeness', () => { - // The fixed-id VFS root Brainy.init() always creates is the one 'system'-visibility - // entity a consumer can rely on existing (visibility:'system' is not settable via the - // public add() API — "intentionally not accepted", per AddParams.visibility's doc). - const VFS_ROOT_ID = '00000000-0000-0000-0000-000000000000' - - let brain: Brainy - - beforeEach(async () => { - brain = new Brainy(createTestConfig()) - await brain.init() - }) - - afterEach(async () => { - await brain.close() - }) - - it('canonical + includeHidden carries an internal row AND the system row; round-trips through import', async () => { - const publicId = await brain.add({ data: 'Public', type: NounType.Thing, subtype: 'x' }) - const internalId = await brain.add({ - data: 'Internal', - type: NounType.Thing, - subtype: 'x', - visibility: 'internal' - }) - - const migrationExport = await brain.export({}, { enumeration: 'canonical', includeHidden: true }) - const ids = migrationExport.entities.map((e) => e.id) - expect(ids).toContain(publicId) - expect(ids).toContain(internalId) - expect(ids).toContain(VFS_ROOT_ID) - expect(migrationExport.entities.find((e) => e.id === internalId)?.visibility).toBe('internal') - expect(migrationExport.entities.find((e) => e.id === VFS_ROOT_ID)?.visibility).toBe('system') - - const target = new Brainy(createTestConfig()) - await target.init() - try { - const result = await target.import(migrationExport) - expect(result.errors).toHaveLength(0) - expect((await target.get(internalId))?.visibility).toBe('internal') - } finally { - await target.close() - } - }) - - it('default export (includeHidden omitted) still excludes both hidden tiers — pins today\'s behavior', async () => { - const publicId = await brain.add({ data: 'Public', type: NounType.Thing, subtype: 'x' }) - const internalId = await brain.add({ - data: 'Internal', - type: NounType.Thing, - subtype: 'x', - visibility: 'internal' - }) - - for (const opts of [{ enumeration: 'index' as const }, { enumeration: 'canonical' as const }]) { - const backup = await brain.export({}, opts) - const ids = backup.entities.map((e) => e.id) - expect(ids).toContain(publicId) - expect(ids).not.toContain(internalId) - expect(ids).not.toContain(VFS_ROOT_ID) - } - }) - - it('index mode + includeHidden also reaches both tiers — find() takes includeInternal + includeSystem in one pass', async () => { - const publicId = await brain.add({ data: 'Public', type: NounType.Thing, subtype: 'x' }) - const internalId = await brain.add({ - data: 'Internal', - type: NounType.Thing, - subtype: 'x', - visibility: 'internal' - }) - - const indexExport = await brain.export({}, { enumeration: 'index', includeHidden: true }) - const canonicalExport = await brain.export({}, { enumeration: 'canonical', includeHidden: true }) - - const indexIds = indexExport.entities.map((e) => e.id).sort() - const canonicalIds = canonicalExport.entities.map((e) => e.id).sort() - expect(indexIds).toEqual(canonicalIds) - expect(indexIds).toContain(publicId) - expect(indexIds).toContain(internalId) - expect(indexIds).toContain(VFS_ROOT_ID) - }) - - it('drift stays pure under includeHidden — no tier-policy noise when the index is healthy', async () => { - await brain.add({ data: 'Public', type: NounType.Thing, subtype: 'x' }) - await brain.add({ data: 'Internal', type: NounType.Thing, subtype: 'x', visibility: 'internal' }) - - const audited = await brain.export( - {}, - { enumeration: 'canonical', includeHidden: true, reportIndexDrift: true } - ) - expect(audited.drift).toEqual({ canonicalOnly: [], indexOnly: [] }) - }) -}) diff --git a/tests/unit/db/fact-log-group-sync.test.ts b/tests/unit/db/fact-log-group-sync.test.ts deleted file mode 100644 index 401aa3e4..00000000 --- a/tests/unit/db/fact-log-group-sync.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * @module tests/unit/db/fact-log-group-sync - * @description Group commit on the fact log — the covering guarantee behind - * durable-at-ack: concurrent callers of ensureSynced() share ONE covering - * fsync (running + queued slots), a caller appending during a running sync - * joins a sync that STARTS after its append (never the possibly-stale running - * one), a solo writer syncs immediately, and at the brain level an at-ack - * ack resolving means the write's fact is on disk. - * - * The final pin holds the at-ack durability contract END TO END: an acked - * write's fact survives a crash-shaped reopen. This was a `.fails` known - * gap (FactLog.open() truncated every fact beyond the committed watermark, - * which only advances at the pending-tier flush) — CURED by the 10.0.0 - * adopt-at-open fleet default: a fresh brain stores the log-authority - * artifact at open, and under 'log' authority recovery REPLAYS intact - * facts above the manifest instead of truncating them. - */ -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/index.js' -import { FileSystemStorage } from '../../../src/storage/adapters/fileSystemStorage.js' -import { - FactLog, - storageSupportsFactLog, - type CommitFact, - type FactLogStorage -} from '../../../src/db/factLog.js' - -const UUID = (n: number): string => - `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` - -const fact = (generation: number): CommitFact => ({ - generation, - timestamp: 1_700_000_000_000 + generation, - ops: [ - { - kind: 'noun', - id: UUID(generation), - record: { metadata: { noun: 'document', title: `doc ${generation}` }, vector: { v: [1, 2] } } - } - ] -}) - -/** Scan every fact from a FRESH reader log over the same directory. */ -async function readBack(dir: string, committedHead: number): Promise { - const storage: any = new FileSystemStorage(dir) - await storage.init() - const reader = new FactLog(storage as FactLogStorage) - await reader.open(committedHead) - const facts: CommitFact[] = [] - const scan = reader.scanFacts() - for await (const batch of scan.batches()) facts.push(...batch.facts) - return facts -} - -describe('fact log group commit — the covering fsync', () => { - let dir: string - let storage: any - let log: FactLog - - beforeEach(async () => { - dir = mkdtempSync(join(tmpdir(), 'brainy-group-sync-')) - storage = new FileSystemStorage(dir) - await storage.init() - expect(storageSupportsFactLog(storage)).toBe(true) - log = new FactLog(storage as FactLogStorage) - await log.open(0) - }) - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }) - }) - - it('many concurrent ensureSynced() callers share one covering fsync — every caller resolves, batching happened', async () => { - for (let g = 1; g <= 10; g++) await log.append(fact(g)) - - // Count REAL fsync batches at the storage boundary, with a small delay so - // the concurrent callers genuinely overlap the running sync. - let fsyncBatches = 0 - const origSync = storage.syncRawObjects.bind(storage) - storage.syncRawObjects = async (paths: string[]) => { - fsyncBatches++ - await new Promise((r) => setTimeout(r, 15)) - return origSync(paths) - } - - const callers = Array.from({ length: 10 }, () => log.ensureSynced()) - await Promise.all(callers) // every caller resolves — no lost writer - - expect(fsyncBatches, 'callers shared a covering fsync').toBeLessThan(10) - expect(fsyncBatches).toBeGreaterThanOrEqual(1) - - // Durable: a fresh reader over the same directory sees all 10 facts. - const facts = await readBack(dir, 10) - expect(facts.map((f) => f.generation)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) - }) - - it('an append during a RUNNING sync is covered by a sync that starts after it — never the stale running one', async () => { - for (let g = 1; g <= 3; g++) await log.append(fact(g)) - - // Gate the FIRST fsync so a sync is provably in flight. - let fsyncBatches = 0 - let releaseGate!: () => void - const gate = new Promise((r) => { - releaseGate = r - }) - let gated = true - const origSync = storage.syncRawObjects.bind(storage) - storage.syncRawObjects = async (paths: string[]) => { - fsyncBatches++ - if (gated) { - gated = false - await gate - } - return origSync(paths) - } - - const p1 = log.ensureSynced() // sync A: snapshots gens 1..3, blocks in fsync - await new Promise((r) => setTimeout(r, 10)) - expect(fsyncBatches, 'sync A is in flight').toBe(1) - - await log.append(fact(4)) // lands AFTER sync A snapshotted - let p2Resolved = false - const p2 = log.ensureSynced().then(() => { - p2Resolved = true - }) - - // The covering guarantee: p2 must NOT resolve off the running sync (it - // may have snapshotted before the append) — it waits for the queued one. - await new Promise((r) => setTimeout(r, 25)) - expect(p2Resolved, 'p2 never joins the possibly-stale running sync').toBe(false) - - releaseGate() - await p1 - await p2 - expect(p2Resolved).toBe(true) - expect(fsyncBatches, 'the queued covering sync ran after the running one').toBe(2) - - // The late append is durable once p2 resolved. - const facts = await readBack(dir, 4) - expect(facts.map((f) => f.generation)).toEqual([1, 2, 3, 4]) - }) - - it('a solo writer syncs immediately — one fsync, and a dirty-free ensureSynced adds none', async () => { - // Count only covering syncs: the first append itself fsyncs the tail - // manifest (the manifest-first flip), so instrument AFTER it. - await log.append(fact(1)) - let fsyncBatches = 0 - const origSync = storage.syncRawObjects.bind(storage) - storage.syncRawObjects = async (paths: string[]) => { - fsyncBatches++ - return origSync(paths) - } - - await log.ensureSynced() - expect(fsyncBatches).toBe(1) - - // Nothing new appended: the covering sync finds nothing dirty. - await log.ensureSynced() - expect(fsyncBatches).toBe(1) - }) -}) - -describe('durable-at-ack through the brain (group commit end-to-end)', () => { - const dirs: string[] = [] - const brains: any[] = [] - - const openBrain = async (dir?: string): Promise<{ brain: any; dir: string }> => { - process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' - const d = dir ?? mkdtempSync(join(tmpdir(), 'brainy-at-ack-')) - if (!dir) dirs.push(d) - const brain: any = new Brainy({ - storage: { type: 'filesystem', path: d }, - requireSubtype: false, - silent: true, - dimensions: 384 - }) - brains.push(brain) - await brain.init() - return { brain, dir: d } - } - - afterEach(async () => { - for (const b of brains.splice(0)) await b.close?.().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) - }) - - it('at-ack: N concurrent add() acks all resolve, every ack was covered by a log sync, and every fact is on disk after reopen', async () => { - const { brain, dir } = await openBrain() - // The 10.0.0 fleet default already adopted log authority at open, so - // the brain is at-ack; the white-box engage stays so this pin holds the - // durability MACHINERY itself independent of the open-time posture. - brain.generationStore.setLogDurability('at-ack') - - const factLog = brain.generationStore.getFactLog() - expect(factLog).not.toBeNull() - let syncs = 0 - const origSync = factLog.sync.bind(factLog) - factLog.sync = async () => { - syncs++ - return origSync() - } - - const ids: string[] = await Promise.all( - Array.from({ length: 10 }, (_, i) => - brain.add({ data: `concurrent write ${i}`, type: 'document', metadata: { i } }) - ) - ) - expect(new Set(ids).size, 'every ack resolved with a distinct id').toBe(10) - // Honest pin: single-op acks serialize under the commit mutex (append + - // covering sync run inside it), so concurrent add() acks do not currently - // share one fsync — cross-writer batching is the FactLog-layer property - // pinned above. What must hold here: at least one covering sync ran, and - // no ack resolved without the machinery engaged. - expect(syncs).toBeGreaterThanOrEqual(1) - expect(syncs).toBeLessThanOrEqual(10) - - await brain.close() - const { brain: reopened } = await openBrain(dir) - const scan = reopened.scanFacts() - expect(scan).not.toBeNull() - const liveFactIds = new Set() - for await (const batch of scan!.batches()) { - for (const f of batch.facts) { - for (const op of f.ops) if (op.kind === 'noun' && op.record !== null) liveFactIds.add(op.id) - } - } - for (const id of ids) { - expect(liveFactIds.has(id), `fact for acked write ${id} survives reopen`).toBe(true) - } - }) - - // THE AT-ACK CONTRACT, HELD (was a `.fails` known gap): an acked write's - // fact survives a crash-shaped reopen. Fixed by the 10.0.0 adopt-at-open - // fleet default — this brain adopted LOG authority at open (artifact - // stored, durable-at-ack live), and under 'log' authority FactLog - // recovery REPLAYS intact facts above the committed watermark at the next - // open instead of truncating them back. Durable-at-ack now survives the - // very crash it exists for. - it('at-ack CONTRACT: acked facts survive a crash-shaped reopen (no flush ever ran)', async () => { - const { brain, dir } = await openBrain() - expect(brain.logAuthority().authority, 'the fleet default adopted at open').toBe('log') - expect(brain.generationStore.logDurability).toBe('at-ack') - // Crash simulation: the pending-tier durability flush never happens - // (every trigger routes through flushPendingSingleOps), and the brain is - // abandoned without close() — exactly the power-loss shape at-ack is for. - brain.generationStore.flushPendingSingleOps = async () => {} - - const ids: string[] = [] - for (let i = 0; i < 5; i++) { - ids.push(await brain.add({ data: `acked write ${i}`, type: 'document', metadata: { i } })) - } - - // No flush, no close — reopen the directory as a new session. - const { brain: reopened } = await openBrain(dir) - const scan = reopened.scanFacts() - expect(scan).not.toBeNull() - const liveFactIds = new Set() - for await (const batch of scan!.batches()) { - for (const f of batch.facts) { - for (const op of f.ops) if (op.kind === 'noun' && op.record !== null) liveFactIds.add(op.id) - } - } - for (const id of ids) { - expect(liveFactIds.has(id), `acked fact ${id} survives the crash-shaped reopen`).toBe(true) - } - }) -}) diff --git a/tests/unit/db/fact-log.test.ts b/tests/unit/db/fact-log.test.ts index f1c226cc..abce2dc9 100644 --- a/tests/unit/db/fact-log.test.ts +++ b/tests/unit/db/fact-log.test.ts @@ -186,52 +186,4 @@ describe('fact log — round-trip, framing, reconcile, rotation, scan', () => { await log.sync() expect(log.segmentPaths()).toEqual([]) // only a tail exists — nothing sealed }) - - describe('scanFacts liveness contract (Stage-2 D1)', () => { - it('a wedged store fails LOUDLY within the first-batch bound — never a silent hang', async () => { - // Force a sealed segment (tiny rotateBytes) so the scan must READ from - // storage, then wedge that read: the exact production shape (a - // backlogged brain whose segment read never returned). - const mem: any = new MemoryStorage() - await mem.init() - const wedgeable = new FactLog(mem, { rotateBytes: 1 }) - await wedgeable.open(0) - await wedgeable.append(fact(1)) - await wedgeable.append(fact(2)) // second append rotates → seg 1 sealed - await wedgeable.sync() - - const realRead = mem.readRawBytes.bind(mem) - mem.readRawBytes = (p: string) => - p.includes('facts/seg-') ? new Promise(() => {}) : realRead(p) // hangs forever - - const scan = wedgeable.scanFacts({ firstBatchTimeoutMs: 200 }) - const started = Date.now() - await expect(scan.batches().next()).rejects.toThrow(/no first batch within 200ms/) - expect(Date.now() - started).toBeLessThan(5_000) // bound held, not a hang - }) - - it('a healthy scan is unaffected — first batch well inside the bound, all facts delivered', async () => { - for (let g = 1; g <= 5; g++) await log.append(fact(g)) - await log.sync() - const scan = log.scanFacts({ batchSize: 2 }) - const all: CommitFact[] = [] - for await (const b of scan.batches()) all.push(...b.facts) - expect(all.map((f) => f.generation)).toEqual([1, 2, 3, 4, 5]) - expect(scan.summary().factsYielded).toBe(5) - }) - - it('consumer think-time between pulls never counts against the producer', async () => { - for (let g = 1; g <= 4; g++) await log.append(fact(g)) - await log.sync() - // Bound tighter than the consumer's pause: only the FIRST pull is - // raced, so a slow consumer after batch 1 must not trip the deadline. - const gen = log.scanFacts({ batchSize: 2, firstBatchTimeoutMs: 150 }).batches() - const first = await gen.next() - expect(first.done).toBe(false) - await new Promise((r) => setTimeout(r, 400)) // dawdle past the bound - const second = await gen.next() - expect(second.done).toBe(false) - expect((await gen.next()).done).toBe(true) - }) - }) }) diff --git a/tests/unit/db/factLogFormat.test.ts b/tests/unit/db/factLogFormat.test.ts deleted file mode 100644 index 0c507b41..00000000 --- a/tests/unit/db/factLogFormat.test.ts +++ /dev/null @@ -1,808 +0,0 @@ -/** - * @module tests/unit/db/factLogFormat - * @description Fact-log format v2 (record envelope + sector seals) pinned at - * the byte level: every record type round-trips field-exact (bigint ints, - * bin16 uuids, float-exact vectors), headers read v1 AND v2, unknown record - * types/versions refuse loudly with the typed error, the reserved crypto - * envelope (cipherFlag/keyId — plaintext-only this release) refuses anything - * nonzero/non-nil with the same typed error, genesis width mismatches - * refuse naming both widths, sealed groups align to the sector size with - * invisible pads, vector refs are writer-enforced single-hop, and torn tails - * truncate to the intact prefix at EVERY byte offset. This module is the - * reference implementation of a two-implementation contract — golden byte - * vectors here are frozen; a change that breaks them is a format change. - */ -import { describe, it, expect } from 'vitest' -import { encode, decode } from '@msgpack/msgpack' -import { - encodeFactV2, - decodeFact, - decodeGroupV2, - encodeSegmentHeaderV2, - parseSegmentHeader, - sealGroup, - framePayload, - encodePadFrame, - minPadFrameBytes, - UnknownLogRecordError, - GenesisWidthMismatchError, - LOG_RECORD_TYPES, - LOG_RECORD_VERSION, - LOG_RECORD_CIPHER_PLAINTEXT, - FACT_LOG_FORMAT_V1, - FACT_LOG_FORMAT_V2, - SEGMENT_HEADER_BYTES, - DEFAULT_SEAL_SIZE, - type CommitFactV2, - type LogRecord, - type VectorRef -} from '../../../src/db/factLogFormat.js' - -const UUID = (n: number): string => - `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` -const HASH_A = 'ab'.repeat(32) -const HASH_B = '0123456789abcdef'.repeat(4) - -/** uuid string → bin16 (test-local mirror of the wire helper). */ -const uuidBytes = (id: string): Uint8Array => { - const hex = id.replace(/-/g, '') - const bytes = new Uint8Array(16) - for (let i = 0; i < 16; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) - return bytes -} - -const hex = (bytes: Uint8Array): string => Buffer.from(bytes).toString('hex') - -/** Encode → strip frame → decode; the standard round-trip. */ -const roundTrip = ( - fact: CommitFactV2, - encOpts?: Parameters[1], - decOpts?: { expectedIdSpaceWidth?: 32 | 64 } -): CommitFactV2 => decodeFact(framePayload(encodeFactV2(fact, encOpts)), 2, decOpts) - -/** A single-record fact around `record`, canonical shape for strict equality. */ -const factOf = (generation: number, record: LogRecord): CommitFactV2 => ({ - generation, - timestamp: 1_700_000_000_000 + generation, - records: [record] -}) - -/** - * Build a fact frame of EXACTLY `totalBytes` (projection.note binary filler), - * for engineering precise seal-boundary scenarios. - */ -function frameOfExactly(totalBytes: number, generation: number): Uint8Array { - let fillerLength = Math.max(0, totalBytes - 60) - for (let i = 0; i < 12; i++) { - const frame = encodeFactV2({ - generation, - timestamp: 1, - records: [{ type: 'projection.note', note: { fill: new Uint8Array(fillerLength) } }] - }) - const diff = totalBytes - frame.length - if (diff === 0) return frame - fillerLength += diff - if (fillerLength < 0) throw new Error(`no frame of ${totalBytes} bytes is constructible`) - } - throw new Error('frame sizing did not converge') -} - -describe('fact-log format v2 — record round-trips (field-exact)', () => { - it('noun.afterImage: bin16 uuid, u64-as-bigint beyond 2^53, metadata, inline vector', () => { - const fact = factOf(1, { - type: 'noun.afterImage', - id: UUID(1), - entityInt: (1n << 60n) + 3n, // provably beyond Number territory - metadata: { - noun: 'document', - title: 'doc 1', - nested: { tags: ['a', 'b'], score: 0.25 }, - big: Number.MAX_SAFE_INTEGER, - negative: -42, - flag: true, - missing: null - }, - vectorLeg: [0.1, -2.5, 3, 1e-7] - }) - expect(roundTrip(fact)).toStrictEqual(fact) - }) - - it('noun.tombstone: body-less removal', () => { - const fact = factOf(2, { type: 'noun.tombstone', id: UUID(2) }) - expect(roundTrip(fact)).toStrictEqual(fact) - }) - - it('verb.afterImage: both endpoints, three u64 handles, verb name', () => { - const fact = factOf(3, { - type: 'verb.afterImage', - id: UUID(3), - verbInt: 18_446_744_073_709_551_615n, // u64 max - metadata: { verb: 'contains', weight: 0.5 }, - vectorLeg: null, - verb: 'contains', - sourceId: UUID(31), - sourceInt: 7n, - targetId: UUID(32), - targetInt: (1n << 53n) + 1n - }) - expect(roundTrip(fact)).toStrictEqual(fact) - }) - - it('verb.tombstone: body-less removal', () => { - const fact = factOf(4, { type: 'verb.tombstone', id: UUID(4) }) - expect(roundTrip(fact)).toStrictEqual(fact) - }) - - it('batch.meta: one metadata map per fact', () => { - const fact = factOf(5, { type: 'batch.meta', meta: { source: 'import', count: 12 } }) - expect(roundTrip(fact)).toStrictEqual(fact) - }) - - it('embed.pending: id + enqueue time', () => { - const fact = factOf(6, { type: 'embed.pending', id: UUID(6), enqueuedAt: 1_700_000_000_777 }) - expect(roundTrip(fact)).toStrictEqual(fact) - }) - - it('embed.landed: inline vector, float-exact', () => { - const fact = factOf(7, { - type: 'embed.landed', - id: UUID(7), - vector: [0.30000000000000004, -1.5, 2 ** 31 + 0.5] - }) - expect(roundTrip(fact)).toStrictEqual(fact) - }) - - it('blob.manifest: bin32 hash, size, mimeType, both refOps', () => { - const add = factOf(8, { - type: 'blob.manifest', - hash: HASH_A, - size: 1_048_576, - mimeType: 'image/png', - refOp: 'add' - }) - expect(roundTrip(add)).toStrictEqual(add) - const release = factOf(9, { - type: 'blob.manifest', - hash: HASH_B, - size: 0, - mimeType: 'application/octet-stream', - refOp: 'release' - }) - expect(roundTrip(release)).toStrictEqual(release) - }) - - it('projection.note: opaque map rides untouched', () => { - const fact = factOf(10, { - type: 'projection.note', - note: { consumer: 'reserved', payload: { depth: [1, 2, 3] } } - }) - expect(roundTrip(fact)).toStrictEqual(fact) - }) - - it('bootstrap.baseline: kind flag, metadata, vector leg — both kinds', () => { - const noun = factOf(11, { - type: 'bootstrap.baseline', - id: UUID(11), - kind: 'noun', - metadata: { noun: 'person' }, - vectorLeg: [1, 2, 3] - }) - expect(roundTrip(noun)).toStrictEqual(noun) - const verb = factOf(12, { - type: 'bootstrap.baseline', - id: UUID(12), - kind: 'verb', - metadata: null, - vectorLeg: null - }) - expect(roundTrip(verb)).toStrictEqual(verb) - }) - - it('log.genesis: width, brainId, createdAt — both widths', () => { - for (const idSpaceWidth of [32, 64] as const) { - const fact = factOf(1, { - type: 'log.genesis', - idSpaceWidth, - brainId: UUID(999), - createdAt: 1_700_000_000_000 - }) - expect(roundTrip(fact, undefined, { expectedIdSpaceWidth: idSpaceWidth })).toStrictEqual(fact) - } - }) - - it('a combined fact: genesis-first, all record types, fact meta, duplicate blobHashes', () => { - const fact: CommitFactV2 = { - generation: 1, - timestamp: 1_700_000_000_001, - records: [ - { type: 'log.genesis', idSpaceWidth: 64, brainId: UUID(999), createdAt: 1_699_999_999_999 }, - { type: 'noun.afterImage', id: UUID(1), entityInt: 1n, metadata: { a: 1 }, vectorLeg: [0.5] }, - { type: 'noun.tombstone', id: UUID(2) }, - { - type: 'verb.afterImage', - id: UUID(3), - verbInt: 3n, - metadata: null, - vectorLeg: null, - verb: 'relatedTo', - sourceId: UUID(31), - sourceInt: 1n, - targetId: UUID(32), - targetInt: 2n - }, - { type: 'verb.tombstone', id: UUID(4) }, - { type: 'batch.meta', meta: { origin: 'unit' } }, - { type: 'embed.pending', id: UUID(6), enqueuedAt: 5 }, - { type: 'embed.landed', id: UUID(7), vector: [0.1] }, - { type: 'blob.manifest', hash: HASH_A, size: 9, mimeType: 'text/plain', refOp: 'add' }, - { type: 'projection.note', note: {} }, - { type: 'bootstrap.baseline', id: UUID(11), kind: 'noun', metadata: null, vectorLeg: null } - ], - meta: { source: 'unit' }, - blobHashes: [HASH_A, HASH_A] // multiset — duplicates preserved - } - expect(roundTrip(fact, undefined, { expectedIdSpaceWidth: 64 })).toStrictEqual(fact) - }) -}) - -describe('fact-log format v2 — golden byte vectors (frozen contract)', () => { - it('v2 segment header bytes are pinned', () => { - expect(hex(encodeSegmentHeaderV2(7, 4096))).toBe( - '4246414354530000020000000700000000000000001000000000000000000000' - ) - }) - - it('a noun.tombstone frame is pinned byte-for-byte', () => { - const frame = encodeFactV2({ - generation: 3, - timestamp: 1_700_000_000_123, - records: [{ type: 'noun.tombstone', id: '00000000-0000-4000-8000-000000000042' }] - }) - expect(hex(frame)).toBe( - '2d00000048e4d43695cf0000000000000003cf0000018bcfe5687b9195020100c0' + - 'c41000000000000040008000000000000042c0c0' - ) - }) - - it('u64 registry fields ride as fixed 8-byte msgpack uint64 (0xcf)', () => { - const payload = framePayload( - encodeFactV2(factOf(1, { type: 'embed.pending', id: UUID(1), enqueuedAt: 2 })) - ) - // positions 0 and 1 (generation, timestamp) and enqueuedAt are all 0xcf - expect(payload[1]).toBe(0xcf) - expect(payload[10]).toBe(0xcf) - }) -}) - -describe('fact-log format v2 — segment headers (v1 AND v2)', () => { - const v1Header = (): Uint8Array => { - const header = new Uint8Array(SEGMENT_HEADER_BYTES) - header.set(new Uint8Array([0x42, 0x46, 0x41, 0x43, 0x54, 0x53, 0x00, 0x00]), 0) - const view = new DataView(header.buffer) - view.setUint32(8, FACT_LOG_FORMAT_V1, true) - view.setBigUint64(12, 42n, true) - return header - } - - it('a v2 header round-trips with its sealSize', () => { - const header = encodeSegmentHeaderV2(123_456, 512) - expect(header.length).toBe(SEGMENT_HEADER_BYTES) - expect(parseSegmentHeader(header)).toStrictEqual({ - formatVersion: FACT_LOG_FORMAT_V2, - firstGeneration: 123_456, - sealSize: 512 - }) - // default sealSize - expect(parseSegmentHeader(encodeSegmentHeaderV2(1)).sealSize).toBe(DEFAULT_SEAL_SIZE) - }) - - it('a v1 header parses: version 1, sealSize absent (undefined)', () => { - const parsed = parseSegmentHeader(v1Header()) - expect(parsed).toStrictEqual({ formatVersion: FACT_LOG_FORMAT_V1, firstGeneration: 42 }) - expect(parsed.sealSize).toBeUndefined() - }) - - it('corrupted magic throws', () => { - const header = encodeSegmentHeaderV2(1) - header[0] = 0x58 - expect(() => parseSegmentHeader(header)).toThrow(/bad magic/) - }) - - it('non-zero reserved bytes throw — v1 (offset 20+) and v2 (offset 22+)', () => { - const v1 = v1Header() - v1[21] = 1 - expect(() => parseSegmentHeader(v1)).toThrow(/non-zero reserved/) - - const v2 = encodeSegmentHeaderV2(1, 4096) - v2[25] = 1 - expect(() => parseSegmentHeader(v2)).toThrow(/non-zero reserved/) - }) - - it('the v2 sealSize bytes are NOT reserved bytes in v2 (but ARE in v1)', () => { - // sealSize 512 puts a non-zero byte at offset 21 — legal in v2 only. - const v2 = encodeSegmentHeaderV2(1, 512) - expect(parseSegmentHeader(v2).sealSize).toBe(512) - const v1 = v1Header() - v1[20] = 0x00 - v1[21] = 0x02 // same bytes a v2 sealSize=512 would carry - expect(() => parseSegmentHeader(v1)).toThrow(/non-zero reserved/) - }) - - it('an unknown header version and a short buffer throw', () => { - const header = encodeSegmentHeaderV2(1) - new DataView(header.buffer).setUint32(8, 3, true) - expect(() => parseSegmentHeader(header)).toThrow(/formatVersion 3/) - expect(() => parseSegmentHeader(header.subarray(0, 31))).toThrow(/32 bytes/) - }) - - it('header writer refuses out-of-range inputs', () => { - expect(() => encodeSegmentHeaderV2(-1)).toThrow(/non-negative/) - expect(() => encodeSegmentHeaderV2(1, 32)).toThrow(/sealSize/) - expect(() => encodeSegmentHeaderV2(1, 65_536)).toThrow(/sealSize/) - }) -}) - -describe('fact-log format v2 — decoder law (typed refusals, never skip)', () => { - it('unknown record type 12 throws UnknownLogRecordError naming type 12', () => { - const payload = encode([1, 1, [[12, 1]], null, null]) - expect(() => decodeFact(payload, 2)).toThrow(UnknownLogRecordError) - try { - decodeFact(payload, 2) - expect.unreachable('decode must throw') - } catch (error) { - const typed = error as UnknownLogRecordError - expect(typed).toBeInstanceOf(UnknownLogRecordError) - expect(typed.recordType).toBe(12) - expect(typed.recordVersion).toBe(1) - expect(typed.message).toMatch(/type 12/) - expect(typed.message).toMatch(/newer reader/) - } - }) - - it('recordVersion 2 on a known type throws the same class naming the version', () => { - const payload = encode([1, 1, [[LOG_RECORD_TYPES.NOUN_TOMBSTONE, 2, new Uint8Array(16)]], null, null]) - try { - decodeFact(payload, 2) - expect.unreachable('decode must throw') - } catch (error) { - const typed = error as UnknownLogRecordError - expect(typed).toBeInstanceOf(UnknownLogRecordError) - expect(typed.recordType).toBe(LOG_RECORD_TYPES.NOUN_TOMBSTONE) - expect(typed.recordVersion).toBe(2) - expect(typed.message).toMatch(/version 2/) - expect(typed.message).toMatch(/newer reader/) - } - }) - - it('a fact mixing known and unknown records still refuses (no partial reads)', () => { - const known = [LOG_RECORD_TYPES.NOUN_TOMBSTONE, 1, 0, null, uuidBytes(UUID(1))] - const payload = encode([1, 1, [known, [200, 1]], null, null]) - expect(() => decodeFact(payload, 2)).toThrow(UnknownLogRecordError) - }) - - it('a nonzero cipherFlag refuses with the typed error — encrypted records need a newer reader', () => { - const payload = encode( - [1, 1, [[LOG_RECORD_TYPES.NOUN_TOMBSTONE, 1, 1, null, uuidBytes(UUID(1))]], null, null] - ) - try { - decodeFact(payload, 2) - expect.unreachable('decode must throw') - } catch (error) { - const typed = error as UnknownLogRecordError - expect(typed).toBeInstanceOf(UnknownLogRecordError) - expect(typed.recordType).toBe(LOG_RECORD_TYPES.NOUN_TOMBSTONE) - expect(typed.recordVersion).toBe(1) - expect(typed.message).toMatch(/cipherFlag 1/) - expect(typed.message).toMatch(/encrypted records need a newer reader/) - } - }) - - it('a non-nil keyId refuses the same way, even with cipherFlag 0', () => { - const payload = encode( - [ - 1, - 1, - [[LOG_RECORD_TYPES.NOUN_TOMBSTONE, 1, 0, uuidBytes(UUID(9)), uuidBytes(UUID(1))]], - null, - null - ] - ) - expect(() => decodeFact(payload, 2)).toThrow(UnknownLogRecordError) - expect(() => decodeFact(payload, 2)).toThrow(/encrypted records need a newer reader/) - }) - - it('the encoder always writes the plaintext envelope: cipherFlag 0, keyId nil', () => { - const payload = framePayload(encodeFactV2(factOf(1, { type: 'noun.tombstone', id: UUID(1) }))) - const raw = decode(payload) as unknown[] - const record = (raw[2] as unknown[][])[0] - expect(record[2]).toBe(LOG_RECORD_CIPHER_PLAINTEXT) - expect(record[3]).toBeNull() - expect(LOG_RECORD_CIPHER_PLAINTEXT).toBe(0) - }) - - it('an unknown segment format version has no decode path', () => { - const payload = framePayload(encodeFactV2(factOf(1, { type: 'noun.tombstone', id: UUID(1) }))) - expect(() => decodeFact(payload, 3)).toThrow(/reads 1 and 2/) - }) -}) - -describe('fact-log format v2 — log.genesis width law', () => { - const genesisFact = (width: 32 | 64): CommitFactV2 => - factOf(1, { type: 'log.genesis', idSpaceWidth: width, brainId: UUID(9), createdAt: 1 }) - - it('expectedWidth 32 vs a 64-width genesis refuses, naming both widths', () => { - const payload = framePayload(encodeFactV2(genesisFact(64))) - expect(() => decodeFact(payload, 2, { expectedIdSpaceWidth: 32 })).toThrow( - GenesisWidthMismatchError - ) - try { - decodeFact(payload, 2, { expectedIdSpaceWidth: 32 }) - expect.unreachable('decode must throw') - } catch (error) { - const typed = error as GenesisWidthMismatchError - expect(typed.expectedWidth).toBe(32) - expect(typed.actualWidth).toBe(64) - expect(typed.message).toMatch(/32-bit/) - expect(typed.message).toMatch(/64-bit/) - } - }) - - it('a matching width (and no expectation at all) decodes cleanly', () => { - const payload = framePayload(encodeFactV2(genesisFact(64))) - expect(decodeFact(payload, 2, { expectedIdSpaceWidth: 64 }).records[0]).toMatchObject({ - idSpaceWidth: 64 - }) - expect(decodeFact(payload, 2).records[0]).toMatchObject({ idSpaceWidth: 64 }) - }) - - it('genesis anywhere but record 0 refuses — encode AND decode', () => { - const late: CommitFactV2 = { - generation: 1, - timestamp: 1, - records: [ - { type: 'noun.tombstone', id: UUID(1) }, - { type: 'log.genesis', idSpaceWidth: 64, brainId: UUID(9), createdAt: 1 } - ] - } - expect(() => encodeFactV2(late)).toThrow(/first record/) - const crafted = encode([ - 1, - 1, - [ - [LOG_RECORD_TYPES.NOUN_TOMBSTONE, 1, 0, null, uuidBytes(UUID(1))], - [LOG_RECORD_TYPES.LOG_GENESIS, 1, 0, null, 64, uuidBytes(UUID(9)), 1] - ], - null, - null - ]) - expect(() => decodeFact(crafted, 2)).toThrow(/first record/) - }) - - it('an invalid genesis width on the wire is malformed, not a mismatch', () => { - const crafted = encode( - [1, 1, [[LOG_RECORD_TYPES.LOG_GENESIS, 1, 0, null, 48, uuidBytes(UUID(9)), 1]], null, null] - ) - expect(() => decodeFact(crafted, 2)).toThrow(/32 or 64/) - }) -}) - -describe('fact-log format v2 — vector legs (single-hop law)', () => { - it('inline vectors round-trip float-exact', () => { - const vector = [0.1 + 0.2, -0.0000001, 3.141592653589793, 2 ** 40 + 0.25] - const fact = factOf(1, { - type: 'noun.afterImage', - id: UUID(1), - entityInt: 1n, - metadata: null, - vectorLeg: vector - }) - const decoded = roundTrip(fact) - expect((decoded.records[0] as { vectorLeg: number[] }).vectorLeg).toStrictEqual(vector) - }) - - it('a ref round-trips when the validator vouches for the target generation', () => { - const fact = factOf(6, { - type: 'noun.afterImage', - id: UUID(1), - entityInt: 1n, - metadata: null, - vectorLeg: { sameAsGeneration: 5 } - }) - const viaSet = roundTrip(fact, { inlineVectorGenerations: new Set([5]) }) - expect((viaSet.records[0] as { vectorLeg: VectorRef }).vectorLeg).toStrictEqual({ - sameAsGeneration: 5 - }) - const viaCallback = roundTrip(fact, { inlineVectorGenerations: (g) => g === 5 }) - expect(viaCallback).toStrictEqual(fact) - }) - - it('the encoder REFUSES a ref the validator rejects', () => { - const fact = factOf(6, { - type: 'noun.afterImage', - id: UUID(1), - entityInt: 1n, - metadata: null, - vectorLeg: { sameAsGeneration: 5 } - }) - expect(() => encodeFactV2(fact, { inlineVectorGenerations: new Set([4]) })).toThrow( - /single-hop/ - ) - expect(() => encodeFactV2(fact, { inlineVectorGenerations: () => false })).toThrow( - /generation 5/ - ) - }) - - it('the encoder REFUSES a ref when no validator was provided at all', () => { - const fact = factOf(6, { - type: 'noun.afterImage', - id: UUID(1), - entityInt: 1n, - metadata: null, - vectorLeg: { sameAsGeneration: 5 } - }) - expect(() => encodeFactV2(fact)).toThrow(/unverifiable ref/) - }) - - it('embed.landed is inline-only: encode refuses non-arrays, decode refuses wire refs', () => { - const bad = factOf(7, { - type: 'embed.landed', - id: UUID(7), - vector: null as unknown as number[] - }) - expect(() => encodeFactV2(bad)).toThrow(/INLINE/) - const craftedRef = encode( - [1, 1, [[LOG_RECORD_TYPES.EMBED_LANDED, 1, 0, null, uuidBytes(UUID(7)), ['ref', 5]]], null, null] - ) - expect(() => decodeFact(craftedRef, 2)).toThrow(/INLINE/) - }) -}) - -describe('fact-log format v2 — sector seals', () => { - const facts = [1, 2, 3].map((g) => - factOf(g, { - type: 'noun.afterImage', - id: UUID(g), - entityInt: BigInt(g), - metadata: { title: `doc ${g}` }, - vectorLeg: [g + 0.5] - }) - ) - const frames = facts.map((f) => encodeFactV2(f)) - - it('sealGroup output is sector-aligned and decodes to exactly the input facts', () => { - const sealed = sealGroup(frames, 4096) - expect(sealed.length % 4096).toBe(0) - const { facts: decoded, validBytes } = decodeGroupV2(sealed) - expect(decoded).toStrictEqual(facts) // pads invisible - expect(validBytes).toBe(sealed.length) - }) - - it('an already-aligned group gets NO pad (byte-identical passthrough)', () => { - const exact = frameOfExactly(4096, 1) - const sealed = sealGroup([exact], 4096) - expect(sealed.length).toBe(4096) - expect(Buffer.compare(Buffer.from(sealed), Buffer.from(exact))).toBe(0) - expect(decodeGroupV2(sealed).facts).toHaveLength(1) - }) - - it('a normal gap gets ONE exact-fit pad frame', () => { - const sealed = sealGroup([frameOfExactly(2000, 1), frameOfExactly(1996, 2)], 4096) // gap 100 - expect(sealed.length).toBe(4096) - expect(decodeGroupV2(sealed).facts.map((f) => f.generation)).toEqual([1, 2]) - }) - - it('a gap too small for any frame (the <12-byte remainder and friends) pads through one extra sector', () => { - for (const gap of [1, 8, 11, 16, 32]) { - const sealed = sealGroup([frameOfExactly(4096 - gap, 1)], 4096) - expect(sealed.length % 4096).toBe(0) - expect(sealed.length).toBe(8192) // gap + one full sector, still aligned - const { facts: decoded, validBytes } = decodeGroupV2(sealed) - expect(decoded.map((f) => f.generation)).toEqual([1]) - expect(validBytes).toBe(8192) - } - // the smallest constructible pad frame fits exactly — no overshoot at 33 - const sealed33 = sealGroup([frameOfExactly(4096 - 33, 1)], 4096) - expect(sealed33.length).toBe(4096) - expect(decodeGroupV2(sealed33).facts.map((f) => f.generation)).toEqual([1]) - }) - - it('seals honor a custom sealSize (device-probed sizes are the caller business)', () => { - const sealed = sealGroup(frames, 512) - expect(sealed.length % 512).toBe(0) - expect(decodeGroupV2(sealed).facts).toStrictEqual(facts) - }) - - it('pad frame bytes are pinned (golden vector, sealSize 64)', () => { - const tomb = encodeFactV2({ - generation: 3, - timestamp: 1_700_000_000_123, - records: [{ type: 'noun.tombstone', id: '00000000-0000-4000-8000-000000000042' }] - }) - const sealed = sealGroup([tomb], 64) // 53 bytes → gap 11 → overshoot → 75-byte pad - expect(sealed.length).toBe(128) - expect(hex(sealed.subarray(tomb.length))).toBe( - // frame prefix + [0, 0, [[0, 1, bin8(40 zero bytes)]], nil, nil] - '4300000088b4c8fa95cf0000000000000000cf000000000000000091930001c428' + - '0'.repeat(80) + - 'c0c0' - ) - }) - - it('encodePadFrame builds exact-size pads for streaming writers; refuses sub-minimum sizes', () => { - // Pads are envelope-exempt (skipped wholesale), so the smallest pad frame - // is byte-stable across the crypto-envelope change. - expect(minPadFrameBytes()).toBe(33) - for (const size of [minPadFrameBytes(), 64, 4096]) { - const pad = encodePadFrame(size) - expect(pad.length).toBe(size) - const { facts: decoded, validBytes } = decodeGroupV2(pad) - expect(decoded).toEqual([]) // invisible to readers - expect(validBytes).toBe(size) - } - expect(() => encodePadFrame(minPadFrameBytes() - 1)).toThrow(/at least/) - }) - - it('sealGroup refuses garbage: empty groups, malformed frames, bad seal sizes', () => { - expect(() => sealGroup([], 4096)).toThrow(/at least one frame/) - expect(() => sealGroup([new Uint8Array([1, 2, 3])], 4096)).toThrow(/not a well-formed frame/) - const corrupted = encodeFactV2(facts[0]) - corrupted[corrupted.length - 1] ^= 0xff - expect(() => sealGroup([corrupted], 4096)).toThrow(/not a well-formed frame/) - expect(() => sealGroup(frames, 32)).toThrow(/sealSize/) - }) -}) - -describe('fact-log format v2 — torn-tail discipline', () => { - it('truncating a sealed group at EVERY byte offset of the tail yields the intact prefix, never an uncontrolled throw', () => { - const frames = [frameOfExactly(600, 1), frameOfExactly(700, 2), frameOfExactly(800, 3)] - const sealed = sealGroup(frames, 4096) - expect(sealed.length).toBe(4096) - const f3End = 600 + 700 + 800 - - for (let cut = 600 + 700; cut < sealed.length; cut++) { - const { facts: decoded, validBytes } = decodeGroupV2(sealed.subarray(0, cut)) - const expected = cut < f3End ? [1, 2] : [1, 2, 3] - expect(decoded.map((f) => f.generation)).toEqual(expected) - expect(validBytes).toBe(cut < f3End ? 600 + 700 : f3End) - } - }) - - it('a flipped payload byte (not just truncation) also terminates the walk at the damage', () => { - const frames = [frameOfExactly(600, 1), frameOfExactly(700, 2)] - const sealed = sealGroup(frames, 4096) - const damaged = sealed.slice() - damaged[600 + 100] ^= 0xff // inside frame 2's payload - const { facts: decoded, validBytes } = decodeGroupV2(damaged) - expect(decoded.map((f) => f.generation)).toEqual([1]) - expect(validBytes).toBe(600) - }) -}) - -describe('fact-log format v2 — writer refusals (loud, never silent)', () => { - const tombstone = (g: number): CommitFactV2 => factOf(g, { type: 'noun.tombstone', id: UUID(g) }) - - it('accepts empty records (an all-deduped batch is a real generation); refuses generation 0 and a second batch.meta', () => { - // Contract change with the live cutover: v1 always encoded op-less - // commits (a batch whose relates dedupe away still mints a generation); - // v2 must not fork commit semantics — empty records round-trip. - const empty = decodeFact(framePayload(encodeFactV2({ generation: 1, timestamp: 1, records: [] })), 2) - expect(empty.records).toEqual([]) - expect(() => encodeFactV2({ ...tombstone(1), generation: 0 })).toThrow(/positive integer/) - expect(() => - encodeFactV2({ - generation: 1, - timestamp: 1, - records: [ - { type: 'batch.meta', meta: { a: 1 } }, - { type: 'batch.meta', meta: { b: 2 } } - ] - }) - ).toThrow(/at most one batch.meta/) - }) - - it('refuses pad records — filler belongs to sealGroup, not to writers', () => { - const fact = { - generation: 1, - timestamp: 1, - records: [{ type: 'pad' } as unknown as LogRecord] - } - expect(() => encodeFactV2(fact)).toThrow(/cannot encode record type pad/) - }) - - it('refuses malformed field values: non-uuid ids, bad hashes, out-of-range u64s', () => { - expect(() => - encodeFactV2(factOf(1, { type: 'noun.tombstone', id: 'not-a-uuid' })) - ).toThrow(/not a uuid/) - expect(() => - encodeFactV2( - factOf(1, { type: 'blob.manifest', hash: 'abc', size: 1, mimeType: 'x', refOp: 'add' }) - ) - ).toThrow(/64 hex chars/) - expect(() => - encodeFactV2( - factOf(1, { - type: 'noun.afterImage', - id: UUID(1), - entityInt: -1n, - metadata: null, - vectorLeg: null - }) - ) - ).toThrow(/u64 range/) - expect(() => - encodeFactV2( - factOf(1, { - type: 'noun.afterImage', - id: UUID(1), - entityInt: 1n << 64n, - metadata: null, - vectorLeg: null - }) - ) - ).toThrow(/u64 range/) - }) -}) - -describe('fact-log format — the v1 decode path stays readable forever', () => { - it('decodeFact(payload, 1) reads the v1 ops shape (positional, bin16, tombstones)', () => { - // Crafted exactly as the v1 writer frames facts: default msgpack, ops at - // position 2 as [kind u8, id bin16, [metadata, vector] | nil]. - const payload = encode([ - 4, - 1_700_000_000_004, - [ - [0, uuidBytes(UUID(41)), [{ noun: 'document', title: 'doc 41' }, { v: [1, 2] }]], - [1, uuidBytes(UUID(42)), null] // verb tombstone - ], - { source: 'v1' }, - ['abc123'] - ]) - const fact = decodeFact(payload, 1) - expect(fact).toStrictEqual({ - generation: 4, - timestamp: 1_700_000_000_004, - ops: [ - { - kind: 'noun', - id: UUID(41), - record: { metadata: { noun: 'document', title: 'doc 41' }, vector: { v: [1, 2] } } - }, - { kind: 'verb', id: UUID(42), record: null } - ], - meta: { source: 'v1' }, - blobHashes: ['abc123'] - }) - }) -}) - -describe('fact-log format v2 — frame envelope helper', () => { - it('framePayload verifies exact length and crc32c', () => { - const frame = encodeFactV2(factOf(1, { type: 'noun.tombstone', id: UUID(1) })) - expect(() => framePayload(frame)).not.toThrow() - - const shortFrame = frame.subarray(0, frame.length - 1) - expect(() => framePayload(shortFrame)).toThrow(/declares/) - - const corrupted = frame.slice() - corrupted[corrupted.length - 1] ^= 0xff - expect(() => framePayload(corrupted)).toThrow(/crc32c/) - }) - - it('the record-type registry and version constants are the frozen wire codes', () => { - expect(LOG_RECORD_TYPES).toStrictEqual({ - PAD: 0, - NOUN_AFTER_IMAGE: 1, - NOUN_TOMBSTONE: 2, - VERB_AFTER_IMAGE: 3, - VERB_TOMBSTONE: 4, - BATCH_META: 5, - EMBED_PENDING: 6, - EMBED_LANDED: 7, - BLOB_MANIFEST: 8, - PROJECTION_NOTE: 9, - BOOTSTRAP_BASELINE: 10, - LOG_GENESIS: 11 - }) - expect(LOG_RECORD_VERSION).toBe(1) - }) -}) diff --git a/tests/unit/db/fault-injection-shim.test.ts b/tests/unit/db/fault-injection-shim.test.ts deleted file mode 100644 index a6d4109e..00000000 --- a/tests/unit/db/fault-injection-shim.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * @module tests/unit/db/fault-injection-shim - * @description The fault-injection storage wrapper proven in isolation: a - * torn write persists a decodable prefix (the crash shape durability tests - * replay), a dropped sync is observable (armed → the inner adapter never sees - * it; journaled), a failed append throws without writing a byte, knobs are - * one-shot, and unarmed operation is a transparent passthrough. The full - * commit-path fault matrix lives with the log's ack work — this file proves - * the SHIM itself. - */ -import { describe, it, expect, beforeEach } from 'vitest' -import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js' -import { - FactLog, - storageSupportsFactLog, - type CommitFact, - type FactLogStorage -} from '../../../src/db/factLog.js' -import { - FaultInjectionStorage, - FaultInjectedError -} from '../../../src/db/faultInjectionStorage.js' -import { - encodeFactV2, - encodeSegmentHeaderV2, - decodeGroupV2, - parseSegmentHeader, - SEGMENT_HEADER_BYTES, - type CommitFactV2 -} from '../../../src/db/factLogFormat.js' - -const UUID = (n: number): string => - `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` - -const factV2 = (generation: number): CommitFactV2 => ({ - generation, - timestamp: 1_700_000_000_000 + generation, - records: [{ type: 'noun.tombstone', id: UUID(generation) }] -}) - -const factV1 = (generation: number): CommitFact => ({ - generation, - timestamp: 1_700_000_000_000 + generation, - ops: [ - { - kind: 'noun', - id: UUID(generation), - record: { metadata: { noun: 'document' }, vector: null } - } - ] -}) - -describe('fault-injection storage wrapper', () => { - let inner: FactLogStorage & { syncRawObjects: (paths: string[]) => Promise } - let shim: FaultInjectionStorage - let innerSyncCalls: string[][] - - beforeEach(async () => { - const mem: any = new MemoryStorage() - await mem.init() - innerSyncCalls = [] - const realSync = mem.syncRawObjects.bind(mem) - mem.syncRawObjects = async (paths: string[]) => { - innerSyncCalls.push([...paths]) - return realSync(paths) - } - inner = mem - shim = new FaultInjectionStorage(inner) - }) - - it('satisfies the fact-log storage surface (drop-in wrapper)', () => { - expect(storageSupportsFactLog(shim)).toBe(true) - }) - - it('unarmed, every operation is a transparent passthrough', async () => { - await shim.writeRawBytes('seg', new Uint8Array([1, 2, 3])) - await shim.appendRawBytes('seg', new Uint8Array([4, 5])) - expect(Array.from((await shim.readRawBytes('seg'))!)).toEqual([1, 2, 3, 4, 5]) - expect(await shim.rawByteSize('seg')).toBe(5) - expect(Array.from((await inner.readRawBytes('seg'))!)).toEqual([1, 2, 3, 4, 5]) - - await shim.writeRawObject('obj.json', { a: 1 }) - expect(await shim.readRawObject('obj.json')).toEqual({ a: 1 }) - await shim.deleteRawObject('obj.json') - expect(await shim.readRawObject('obj.json')).toBeNull() - - await shim.syncRawObjects(['seg']) - expect(innerSyncCalls).toEqual([['seg']]) - expect(shim.injectedFaults).toEqual([]) - }) - - describe('tearWriteAtByte — a torn write produces a decodable-prefix segment', () => { - it('persists only the first N bytes of the next append; the prefix decodes intact', async () => { - const path = 'facts/seg-test.bfl' - const frame1 = encodeFactV2(factV2(1)) - const frame2 = encodeFactV2(factV2(2)) - - await shim.appendRawBytes(path, encodeSegmentHeaderV2(1, 4096)) - await shim.appendRawBytes(path, frame1) - shim.tearWriteAtByte(frame2.length - 5) // crash 5 bytes before the frame lands - await shim.appendRawBytes(path, frame2) // reports success — the tear is silent - - const bytes = (await inner.readRawBytes(path))! - expect(bytes.length).toBe(SEGMENT_HEADER_BYTES + frame1.length + frame2.length - 5) - - // The "crash": reopen from storage and read what actually survived. - const header = parseSegmentHeader(bytes) - expect(header).toStrictEqual({ formatVersion: 2, firstGeneration: 1, sealSize: 4096 }) - const { facts, validBytes } = decodeGroupV2(bytes.subarray(SEGMENT_HEADER_BYTES)) - expect(facts.map((f) => f.generation)).toEqual([1]) // fact 2's torn frame is invisible - expect(validBytes).toBe(frame1.length) - - expect(shim.injectedFaults).toEqual([ - { - kind: 'torn-write', - path, - requestedBytes: frame2.length, - writtenBytes: frame2.length - 5 - } - ]) - }) - - it('a tear inside the frame prefix (first bytes) leaves the earlier facts intact too', async () => { - const path = 'facts/seg-prefix.bfl' - const frame1 = encodeFactV2(factV2(1)) - await shim.appendRawBytes(path, encodeSegmentHeaderV2(1, 4096)) - await shim.appendRawBytes(path, frame1) - shim.tearWriteAtByte(3) - await shim.appendRawBytes(path, encodeFactV2(factV2(2))) - - const bytes = (await inner.readRawBytes(path))! - const { facts } = decodeGroupV2(bytes.subarray(SEGMENT_HEADER_BYTES)) - expect(facts.map((f) => f.generation)).toEqual([1]) - }) - - it('a tear at byte 0 writes nothing at all', async () => { - shim.tearWriteAtByte(0) - await shim.appendRawBytes('empty.bfl', new Uint8Array([1, 2, 3])) - expect(await inner.readRawBytes('empty.bfl')).toBeNull() - expect(shim.injectedFaults[0]).toMatchObject({ kind: 'torn-write', writtenBytes: 0 }) - }) - - it('is one-shot: the append after the torn one lands whole', async () => { - shim.tearWriteAtByte(1) - await shim.appendRawBytes('seg', new Uint8Array([1, 2, 3, 4])) - await shim.appendRawBytes('seg', new Uint8Array([5, 6])) - expect(Array.from((await inner.readRawBytes('seg'))!)).toEqual([1, 5, 6]) - }) - - it('refuses a negative tear offset', () => { - expect(() => shim.tearWriteAtByte(-1)).toThrow(/non-negative/) - }) - }) - - describe('dropNextSync — a dropped sync is observable', () => { - it('the armed sync never reaches the inner adapter and is journaled', async () => { - shim.dropNextSync() - await shim.syncRawObjects(['a.bfl', 'b.bfl']) - expect(innerSyncCalls).toEqual([]) // the device never saw it - expect(shim.injectedFaults).toEqual([{ kind: 'dropped-sync', paths: ['a.bfl', 'b.bfl'] }]) - }) - - it('is one-shot: the following sync passes through', async () => { - shim.dropNextSync() - await shim.syncRawObjects(['x']) - await shim.syncRawObjects(['y']) - expect(innerSyncCalls).toEqual([['y']]) - }) - }) - - describe('failNextAppend — a failed append throws without writing a byte', () => { - it('throws the typed error, writes nothing, and journals the fault', async () => { - await shim.appendRawBytes('seg', new Uint8Array([1])) - shim.failNextAppend() - await expect(shim.appendRawBytes('seg', new Uint8Array([2, 3]))).rejects.toThrow( - FaultInjectedError - ) - expect(Array.from((await inner.readRawBytes('seg'))!)).toEqual([1]) // untouched - expect(shim.injectedFaults).toEqual([{ kind: 'failed-append', path: 'seg' }]) - // one-shot: the next append succeeds - await shim.appendRawBytes('seg', new Uint8Array([4])) - expect(Array.from((await inner.readRawBytes('seg'))!)).toEqual([1, 4]) - }) - - it('carries the operation and path for programmatic assertions', async () => { - shim.failNextAppend() - try { - await shim.appendRawBytes('some/path.bfl', new Uint8Array([1])) - expect.unreachable('append must throw') - } catch (error) { - const typed = error as FaultInjectedError - expect(typed).toBeInstanceOf(FaultInjectedError) - expect(typed.operation).toBe('append') - expect(typed.path).toBe('some/path.bfl') - } - }) - - it('wins over a simultaneously-armed tear; the tear stays pending for the next append', async () => { - shim.failNextAppend() - shim.tearWriteAtByte(2) - await expect(shim.appendRawBytes('seg', new Uint8Array([1, 2, 3]))).rejects.toThrow( - FaultInjectedError - ) - expect(await inner.readRawBytes('seg')).toBeNull() - await shim.appendRawBytes('seg', new Uint8Array([9, 8, 7])) - expect(Array.from((await inner.readRawBytes('seg'))!)).toEqual([9, 8]) // torn at 2 - expect(shim.injectedFaults.map((f) => f.kind)).toEqual(['failed-append', 'torn-write']) - }) - }) - - describe('composed with the real fact log (v1 surface)', () => { - it('a torn append is truncated away on reopen — the log heals to the intact prefix', async () => { - const log = new FactLog(shim) - await log.open(0) - await log.append(factV1(1)) - await log.sync() - - shim.tearWriteAtByte(10) // fact 2's frame lands 10 bytes long — torn - await log.append(factV1(2)) - await log.sync() - - // The crash: abandon the instance, reopen from what storage actually holds. - const reopened = new FactLog(inner) - await reopened.open(2) // generation 2 committed elsewhere — but its fact is torn - expect(reopened.headGeneration()).toBe(1) - const all: CommitFact[] = [] - for await (const batch of reopened.scanFacts().batches()) all.push(...batch.facts) - expect(all.map((f) => f.generation)).toEqual([1]) - }) - }) -}) diff --git a/tests/unit/db/fieldAddressing.test.ts b/tests/unit/db/fieldAddressing.test.ts deleted file mode 100644 index 04110992..00000000 --- a/tests/unit/db/fieldAddressing.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * @module tests/unit/db/fieldAddressing - * @description Unit pins for the one field-addressing law (ruled 2026-08-03). - * These pin the PURE half of the law — parsing, the ruled maps, plumbing - * invisibility, refusal text — including the RELATION map, which cannot be - * pinned through the public query API today (related() carries no - * field-addressing options): the verb mirror is contract-tested here at the - * module level so the two engines cannot drift on it. - */ -import { describe, it, expect } from 'vitest' -import { - SYSTEM_ENTITY_SCALARS, - SYSTEM_RELATION_SCALARS, - PLUMBING_FIELDS, - parseFieldAddress, - buildUnresolvableMessage, - InvalidFieldAddressError -} from '../../../src/db/fieldAddressing.js' - -describe('field-addressing law — pure module pins', () => { - it('the entity system map is EXACTLY the ruled ten scalars', () => { - expect([...SYSTEM_ENTITY_SCALARS].sort()).toEqual( - [ - 'confidence', - 'createdAt', - 'createdBy', - 'id', - 'service', - 'subtype', - 'type', - 'updatedAt', - 'visibility', - 'weight' - ].sort() - ) - }) - - it('the relation system map is the ruled verb mirror', () => { - expect([...SYSTEM_RELATION_SCALARS].sort()).toEqual( - [ - 'verb', - 'sourceId', - 'targetId', - 'confidence', - 'createdAt', - 'createdBy', - 'service', - 'subtype', - 'updatedAt', - 'visibility', - 'weight' - ].sort() - ) - }) - - it('plumbing is exactly the ruled five, and none of it leaks into a system map', () => { - expect([...PLUMBING_FIELDS].sort()).toEqual( - ['_rev', 'connections', 'data', 'level', 'vector'].sort() - ) - for (const field of PLUMBING_FIELDS) { - expect(SYSTEM_ENTITY_SCALARS.has(field)).toBe(false) - expect(SYSTEM_RELATION_SCALARS.has(field)).toBe(false) - } - }) - - it('bare names address user metadata — even when the name matches a system scalar', () => { - expect(parseFieldAddress('level', 'entity')).toEqual({ - scope: 'metadata', - field: 'level', - raw: 'level' - }) - expect(parseFieldAddress('confidence', 'entity').scope).toBe('metadata') - expect(parseFieldAddress('createdAt', 'entity').scope).toBe('metadata') - expect(parseFieldAddress('verb', 'relation').scope).toBe('metadata') - }) - - it('metadata.-prefix is the explicit spelling of the bare form', () => { - expect(parseFieldAddress('metadata.level', 'entity')).toEqual({ - scope: 'metadata', - field: 'level', - raw: 'metadata.level' - }) - }) - - it('system.-prefix reaches exactly the map — entity and relation', () => { - for (const field of SYSTEM_ENTITY_SCALARS) { - expect(parseFieldAddress(`system.${field}`, 'entity')).toEqual({ - scope: 'system', - field, - raw: `system.${field}` - }) - } - for (const field of SYSTEM_RELATION_SCALARS) { - expect(parseFieldAddress(`system.${field}`, 'relation').scope).toBe('system') - } - // The structural relation members are NOT entity scalars. - expect(() => parseFieldAddress('system.verb', 'entity')).toThrow(InvalidFieldAddressError) - expect(() => parseFieldAddress('system.sourceId', 'entity')).toThrow(InvalidFieldAddressError) - }) - - it('plumbing refuses in the system spelling, on both record kinds', () => { - for (const field of PLUMBING_FIELDS) { - expect(() => parseFieldAddress(`system.${field}`, 'entity')).toThrow( - InvalidFieldAddressError - ) - expect(() => parseFieldAddress(`system.${field}`, 'relation')).toThrow( - InvalidFieldAddressError - ) - } - }) - - it('refusal text carries the whole valid map — the fix lives in the message', () => { - try { - parseFieldAddress('system.level', 'entity') - expect.unreachable('should have thrown') - } catch (e) { - const msg = (e as Error).message - for (const field of SYSTEM_ENTITY_SCALARS) { - expect(msg).toContain(`system.${field}`) - } - expect(msg).toContain('plumbing') - } - }) - - it('malformed addresses refuse: empty name, bare metadata. prefix', () => { - expect(() => parseFieldAddress('', 'entity')).toThrow(InvalidFieldAddressError) - expect(() => parseFieldAddress('metadata.', 'entity')).toThrow(InvalidFieldAddressError) - }) - - it('the did-you-mean names BOTH candidates for a system-colliding bare name', () => { - const msg = buildUnresolvableMessage('createdAt', 'entity') - expect(msg).toContain('system.createdAt') - expect(msg).toContain('metadata.createdAt') - }) - - it('a non-colliding unknown bare name names both spellings — system. explicitly as NOT valid', () => { - // Cross-engine pin (cor's suite greps for both spellings in every - // refusal): the metadata candidate is the fix; the system spelling is - // named but HONESTLY marked invalid, never offered as a candidate. - const msg = buildUnresolvableMessage('scoore', 'entity') - expect(msg).toContain('metadata.scoore') - expect(msg).toContain('system.scoore') - expect(msg).toContain('NOT valid') - }) -}) diff --git a/tests/unit/db/generation-segments.test.ts b/tests/unit/db/generation-segments.test.ts deleted file mode 100644 index f16e67b3..00000000 --- a/tests/unit/db/generation-segments.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -/** - * @module tests/unit/db/generation-segments - * @description The generation-segment store (Stage-2 D1+D3 file format). - * Laws: (1) fold → read round-trips deltas and records byte-faithfully via - * sidecar point-reads; (2) the manifest is the ONLY discovery path — reopen - * reads one file, never a listing; (3) a lost/corrupt sidecar rebuilds from - * its segment loudly, a damaged SEGMENT fails loudly (never silent wrong - * data); (4) D3 reclaim drops whole segments only and bumps compactedBelow; - * (5) the packed digest is deterministic across reopen; (6) immutability — - * fold refuses overlap with sealed ranges. - */ -import { describe, it, expect, beforeEach } from 'vitest' -import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js' -import { - GenerationSegmentStore, - SEGMENTS_PREFIX, - type FoldGeneration -} from '../../../src/db/generationSegments.js' - -const UUID = (n: number): string => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` - -const gen = (g: number, recordCount = 2): FoldGeneration => ({ - generation: g, - timestamp: 1_700_000_000_000 + g, - delta: { generation: g, nouns: [UUID(g)], verbs: [], bytes: 123 + g }, - records: Array.from({ length: recordCount }, (_, i) => ({ - kind: (i % 2 === 0 ? 'noun' : 'verb') as 'noun' | 'verb', - id: UUID(g * 100 + i), - record: { metadata: { noun: 'document', v: g }, vector: { v: [g, i] } } - })) -}) - -describe('db/GenerationSegmentStore — the D1+D3 packed tier', () => { - let storage: MemoryStorage - let store: GenerationSegmentStore - - beforeEach(async () => { - storage = new MemoryStorage() - await storage.init() - store = new GenerationSegmentStore(storage as any) - await store.open() - }) - - it('fold → read round-trips deltas and records via sidecar point-reads', async () => { - const meta = await store.fold([gen(1), gen(2), gen(3)]) - expect(meta).toMatchObject({ firstGeneration: 1, lastGeneration: 3, frames: 3 }) - expect(meta.checksum).toBeGreaterThan(0) - - expect(store.hasGeneration(2)).toBe(true) - expect(store.hasGeneration(4)).toBe(false) - - const d2 = await store.readDelta(2) - expect(d2?.delta).toEqual({ generation: 2, nouns: [UUID(2)], verbs: [], bytes: 125 }) - expect(d2?.timestamp).toBe(1_700_000_000_002) - - const records = await store.readRecords(3) - expect(records).toHaveLength(2) - expect(records![0]).toEqual({ - kind: 'noun', - id: UUID(300), - record: { metadata: { noun: 'document', v: 3 }, vector: { v: [3, 0] } } - }) - // Point read by id, both kinds. - expect(await store.readRecord(3, 'verb', UUID(301))).toEqual({ - metadata: { noun: 'document', v: 3 }, - vector: { v: [3, 1] } - }) - expect(await store.readRecord(3, 'noun', UUID(999))).toBeNull() - }) - - it('reopen discovers everything from the manifest alone — no listing', async () => { - await store.fold([gen(1), gen(2)]) - await store.fold([gen(3), gen(4)]) - - const reopened = new GenerationSegmentStore(storage as any) - await reopened.open() - expect(reopened.segments()).toHaveLength(2) - expect(reopened.hasGeneration(4)).toBe(true) - expect((await reopened.readDelta(1))?.timestamp).toBe(1_700_000_000_001) - }) - - it('a lost sidecar rebuilds from its segment; a damaged segment fails LOUDLY', async () => { - const meta = await store.fold([gen(1), gen(2)]) - const idxPath = `${SEGMENTS_PREFIX}/seg-${String(1).padStart(20, '0')}.idx` - await storage.deleteRawObject(idxPath) - - const reopened = new GenerationSegmentStore(storage as any) - await reopened.open() - // Rebuild path: still serves correct data. - expect((await reopened.readRecords(2))!).toHaveLength(2) - - // Now damage the SEGMENT itself: flip a payload byte → CRC mismatch, loud. - const segPath = `${SEGMENTS_PREFIX}/${meta.file}` - const bytes = (await storage.readRawBytes(segPath))! - bytes[bytes.length - 3] ^= 0xff - await storage.writeRawBytes(segPath, bytes) - const damaged = new GenerationSegmentStore(storage as any) - await damaged.open() - ;(damaged as any).sidecars.clear() - await storage.deleteRawObject(idxPath) // force the sequential rebuild over damaged bytes - await expect(damaged.readRecords(2)).rejects.toThrow(/CRC mismatch|damaged/) - }) - - it('D3 reclaim drops whole segments only and bumps compactedBelow', async () => { - await store.fold([gen(1), gen(2)]) - await store.fold([gen(3), gen(4)]) - await store.fold([gen(5), gen(6)]) - - // Horizon mid-segment-2 (below 4): only segment 1 is FULLY below → drops. - const r1 = await store.dropSegmentsBelow(4) - expect(r1).toEqual({ dropped: 1, compactedBelow: 3 }) - expect(store.hasGeneration(1)).toBe(false) - expect(store.hasGeneration(3)).toBe(true) // partial segment survives whole - - // Bytes actually gone. - expect(await storage.readRawBytes(`${SEGMENTS_PREFIX}/seg-${String(1).padStart(20, '0')}.bgs`)).toBeNull() - - // Horizon past everything: the rest drop; compactedBelow is durable. - const r2 = await store.dropSegmentsBelow(7) - expect(r2.dropped).toBe(2) - const reopened = new GenerationSegmentStore(storage as any) - await reopened.open() - expect(reopened.compactedBelow()).toBe(7) - expect(reopened.segments()).toHaveLength(0) - }) - - it('the packed digest is deterministic across reopen and changes with history', async () => { - await store.fold([gen(1), gen(2), gen(3)]) - const atSeal = await store.digestThroughPacked(3) - const midSegment = await store.digestThroughPacked(2) - expect(atSeal).not.toBeNull() - expect(midSegment).not.toBeNull() - expect(midSegment).not.toBe(atSeal) - - const reopened = new GenerationSegmentStore(storage as any) - await reopened.open() - expect(await reopened.digestThroughPacked(3)).toBe(atSeal) - expect(await reopened.digestThroughPacked(2)).toBe(midSegment) - - await reopened.fold([gen(4)]) - expect(await reopened.digestThroughPacked(4)).not.toBe(atSeal) - }) - - it('sealed segments are immutable — fold refuses overlap, requires ascending input', async () => { - await store.fold([gen(1), gen(2)]) - await expect(store.fold([gen(2), gen(3)])).rejects.toThrow(/overlaps the 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/ - ) - }) -}) diff --git a/tests/unit/db/generationStore-commit-guard.test.ts b/tests/unit/db/generationStore-commit-guard.test.ts deleted file mode 100644 index d449f8ef..00000000 --- a/tests/unit/db/generationStore-commit-guard.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * @module tests/unit/db/generationStore-commit-guard - * @description Pins the commit-order guard on - * `GenerationStore.commitTransaction()` (`src/db/generationStore.ts`). - * - * `reservedGensAsc()`'s own doc comment states an invariant it never - * enforced: pending single-op generations are always greater than every - * committed one, because the store's only two sanctioned callers — - * `Brainy.transact()` and `Brainy.compactHistory()` — flush the pending tier - * before committing. Nothing stopped a caller from invoking - * `commitTransaction()` directly while single-ops were still buffered: the - * fresh commit would land in `committedRanges` ABOVE those lower, - * still-pending generations, so the committed-then-pending concatenation - * `reservedGensAsc()` yields is no longer ascending — and `resolveManyAt` - * (which walks committed ranges before pending ones) would silently report a - * WRONG before-image for a point-in-time read. `commitTransaction()` now - * refuses loudly (`PendingSingleOpsUnflushedError`) instead of assuming. - * - * Four pins: - * 1. A direct `commitTransaction()` call while single-ops are pending throws - * and commits NOTHING. - * 2. The same commit succeeds once the pending tier is flushed first. - * 3. `Brainy.transact()` — which already flushes first — is unaffected - * (mirrors `tests/unit/db/generation-chain.test.ts`'s `seedX()`/`bumpX()` - * transact pin: add, then transact-update, generation advances by one - * each time, the update lands). - * 4. `reservedGensAsc()` stays ascending across a real add+transact+delete - * workload — proven by point-in-time reads (`asOf`) staying correct - * throughout, which is exactly what an ordering break would corrupt. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js' -import { - GenerationStore, - GENERATIONS_PREFIX, - MANIFEST_PATH -} from '../../../src/db/generationStore.js' -import { PendingSingleOpsUnflushedError } from '../../../src/db/errors.js' -import { Brainy } from '../../../src/index.js' -import { NounType } from '../../../src/types/graphTypes.js' -import { createTestConfig, generateTestVector } from '../../helpers/test-factory.js' - -/** Precomputed embedding so Brainy-level adds skip the (slow) embedding model — - * these tests exercise the generation layer, not semantics. */ -const VEC = generateTestVector() - -// Entity ids must be UUID-shaped (the sharded storage layout derives the -// shard from the UUID hex) — same fixture convention as generationStore.test.ts. -const ID_A = '00000000-0000-4000-8000-0000000000aa' -const ID_B = '00000000-0000-4000-8000-0000000000bb' - -/** Stored-metadata fixture in the canonical shape the live write paths use - * (matches generationStore.test.ts's fixture exactly). */ -function metadataFixture(version: number): Record { - return { - noun: NounType.Document, - subtype: 'note', - data: `payload-v${version}`, - version, - createdAt: 1000, - updatedAt: 1000 + version, - _rev: version - } -} - -describe('db/GenerationStore — commitTransaction pending-tier guard (store level)', () => { - let storage: MemoryStorage - let store: GenerationStore - - beforeEach(async () => { - storage = new MemoryStorage() - await storage.init() - store = new GenerationStore(storage) - await store.open() - }) - - /** Buffer one single-op generation via commitSingleOp WITHOUT flushing — - * the pending tier that must be drained before commitTransaction(). */ - async function pendingSingleOp(id: string, version: number): Promise { - const { generation } = await store.commitSingleOp({ - touched: { nouns: [id] }, - execute: async () => { - await storage.saveNounMetadata(id, metadataFixture(version)) - } - }) - return generation - } - - /** A direct transact commit — exactly what a caller bypassing - * Brainy.transact()'s flush-first step would issue. */ - function directCommit(id: string, version: number): Promise<{ generation: number; timestamp: number }> { - return store.commitTransaction({ - touched: { nouns: [id], verbs: [] }, - execute: async () => { - await storage.saveNounMetadata(id, metadataFixture(version)) - } - }) - } - - it('PIN 1: refuses a direct commitTransaction() while single-ops are pending, and commits NOTHING', async () => { - const g1 = await pendingSingleOp(ID_A, 1) - expect(g1).toBe(1) - expect(store.committedGeneration()).toBe(0) // nothing flushed to disk yet - - let caught: unknown - try { - await directCommit(ID_B, 1) - expect.unreachable('should have thrown PendingSingleOpsUnflushedError') - } catch (err) { - caught = err - } - expect(caught).toBeInstanceOf(PendingSingleOpsUnflushedError) - expect((caught as PendingSingleOpsUnflushedError).pendingCount).toBe(1) - - // Nothing committed: the head + committed ranges are unchanged, and the - // counter never advanced for the refused attempt (the guard fires before - // a generation is even reserved). - expect(store.committedGeneration()).toBe(0) - expect(store.generation()).toBe(1) // still just the pending single-op's gen - expect(await storage.readRawObject(MANIFEST_PATH)).toBeNull() - // The guard fires BEFORE a generation is reserved (`gen = ++this.counter` - // never runs), so the refused attempt's would-be directory (generation 2, - // the next number after the pending single-op's 1) was never created. - expect(await storage.listRawObjects(`${GENERATIONS_PREFIX}/2`)).toEqual([]) - - // The refused write never touched canonical storage. - expect((await storage.readNounRaw(ID_B)).metadata).toBeNull() - - // The pending tier itself is untouched by the refused attempt — flushing - // now still commits the ORIGINAL single-op cleanly. - await store.flushPendingSingleOps() - expect(store.committedGeneration()).toBe(1) - const atG0 = await store.resolveAt('noun', ID_A, 0) - expect(atG0).toEqual({ source: 'absent' }) // the create sentinel before g1's write - }) - - it('PIN 2: the same commit succeeds once the pending tier is flushed first', async () => { - await pendingSingleOp(ID_A, 1) - await expect(directCommit(ID_B, 1)).rejects.toBeInstanceOf(PendingSingleOpsUnflushedError) - - await store.flushPendingSingleOps() - expect(store.committedGeneration()).toBe(1) - - const { generation } = await directCommit(ID_B, 1) - expect(generation).toBe(2) - expect(store.committedGeneration()).toBe(2) - expect((await storage.readNounRaw(ID_B)).metadata).toMatchObject({ version: 1 }) - }) -}) - -describe('Brainy public API — commitTransaction pending-tier guard is behavior-neutral', () => { - let brain: Brainy - - beforeEach(async () => { - brain = new Brainy(createTestConfig()) - await brain.init() - }) - afterEach(async () => { - await brain.close() - }) - - it('PIN 3: Brainy.transact() still commits normally over pending single-ops (mirrors generation-chain.test.ts\'s seedX()/bumpX() transact pin)', async () => { - const store = (brain as any).generationStore as GenerationStore - // Relative, not absolute: under the adopt-at-open default the open-time - // baseline backfill takes a generation of its own (see - // bounded-chains.test.ts's identical note), so the first user add is not - // necessarily generation 1. - const baseGen = brain.generation() - const baseCommitted = store.committedGeneration() - - const id = await brain.add({ - data: 'x', - type: NounType.Document, - subtype: 'note', - metadata: { v: 1 }, - vector: VEC - }) - // The add is a pending single-op generation — NOT yet flushed. - expect(brain.generation()).toBe(baseGen + 1) - expect(store.committedGeneration()).toBe(baseCommitted) - - // Brainy.transact() flushes the pending tier FIRST (src/brainy.ts: - // `await this.generationStore.flushPendingSingleOps()`, immediately - // before its `generationStore.commitTransaction()` call), so the guard - // never fires on this path — same shape as generation-chain.test.ts's - // seedX() (add) → bumpX() (transact update) → generation advances by one. - const db = await brain.transact([{ op: 'update', id, metadata: { v: 2 } }]) - await db.release() - - expect(brain.generation()).toBe(baseGen + 2) - expect(store.committedGeneration()).toBe(baseGen + 2) // the flushed add + the transact update - const entity = (await brain.get(id)) as any - expect(entity.metadata.v).toBe(2) - }) - - it('PIN 4: reservedGensAsc() stays ascending across a real add+transact+delete workload — point-in-time reads stay correct', async () => { - const store = (brain as any).generationStore as GenerationStore - const baseGen = brain.generation() - const baseCommitted = store.committedGeneration() - - const idX = await brain.add({ - data: 'x', - type: NounType.Document, - subtype: 'note', - metadata: { v: 1 }, - vector: VEC - }) - expect(brain.generation()).toBe(baseGen + 1) // pending (un-flushed) - - const idY = await brain.add({ - data: 'y', - type: NounType.Document, - subtype: 'note', - metadata: { v: 1 }, - vector: VEC - }) - // Pin right after BOTH adds — before the transact update — so X reads v1 - // and Y still exists at this pin, unlike the live head after the rest of - // the workload runs. - const pinAfterBothAdds = brain.generation() - expect(pinAfterBothAdds).toBe(baseGen + 2) // ALSO pending — two un-flushed single-ops - expect(store.committedGeneration()).toBe(baseCommitted) - - // A transact() flushes baseGen+1 and baseGen+2 first, then commits its - // own update as baseGen+3. If committed-vs-pending ordering ever broke, - // this is exactly the step that would land a commit ABOVE still-pending - // generations. - const db = await brain.transact([{ op: 'update', id: idX, metadata: { v: 3 } }]) - await db.release() - expect(brain.generation()).toBe(baseGen + 3) - expect(store.committedGeneration()).toBe(baseGen + 3) - - // A single-op delete, pending again (un-flushed). - await brain.remove(idY) - expect(brain.generation()).toBe(baseGen + 4) - - // A point-in-time read pinned right after the two adds (before the - // transact update) must see X's PRE-update value and Y still present. - // This is precisely what resolveManyAt/resolveAt get WRONG if committed - // and pending generations were ever interleaved out of ascending order. - const past = await brain.asOf(pinAfterBothAdds) - const xAtPin = (await past.get(idX)) as any - expect(xAtPin?.metadata?.v).toBe(1) - const yAtPin = (await past.get(idY)) as any - expect(yAtPin?.metadata?.v).toBe(1) // not yet removed, as of this pin - await past.release() - - // Live state reflects every later write, in the right order. - const xNow = (await brain.get(idX)) as any - expect(xNow.metadata.v).toBe(3) - expect(await brain.get(idY)).toBeNull() - }) -}) diff --git a/tests/unit/db/log-authority-oracle-verbs.test.ts b/tests/unit/db/log-authority-oracle-verbs.test.ts deleted file mode 100644 index 68da1867..00000000 --- a/tests/unit/db/log-authority-oracle-verbs.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * @module tests/unit/db/log-authority-oracle-verbs - * @description The verification oracle's VERB legs — module-level pins with - * doubles (the brain-level wiring rides the owner's call site): - * 1. Wired verb legs diff verbs exactly like nouns (pre-log / state-differs / - * tombstone-vs-present / log-live-absent). - * 2. UNWIRED verb legs = an HONEST PARTIAL verdict: verbsChecked stays 0 — - * the oracle never claims scope it did not scan. - */ -import { describe, it, expect } from 'vitest' -import { runLogCompletenessOracle, recordDigest } from '../../../src/db/logAuthority.js' -import type { FactScanHandle } from '../../../src/db/factLog.js' - -type Op = { kind: 'noun' | 'verb'; id: string; record: { metadata: unknown; vector: unknown } | null } - -function scanOf(facts: Array<{ generation: number; ops: Op[] }>): () => FactScanHandle | null { - return () => - ({ - batches: async function* () { - yield { facts: facts.map((f) => ({ ...f, timestamp: 0 })) } - } - }) as unknown as FactScanHandle -} - -function pagedList(rows: string[]) { - return async ({ pagination }: { pagination: { limit: number; offset?: number } }) => { - const start = pagination.offset ?? 0 - const items = rows.slice(start, start + pagination.limit).map((id) => ({ id })) - return { items, hasMore: start + pagination.limit < rows.length } - } -} - -const rec = (v: number) => ({ metadata: { v }, vector: null }) - -describe('oracle verb legs', () => { - it('wired: verbs diff by digest — clean log goes green over nouns AND verbs', async () => { - const report = await runLogCompletenessOracle({ - storage: { getNouns: pagedList(['n1']) } as never, - scanFacts: scanOf([ - { generation: 1, ops: [{ kind: 'noun', id: 'n1', record: rec(1) }] }, - { generation: 2, ops: [{ kind: 'verb', id: 'v1', record: rec(7) }] } - ]), - canonicalNounDigest: async () => recordDigest(rec(1)), - factRecordDigest: recordDigest, - canonicalVerbDigest: async () => recordDigest(rec(7)), - getVerbs: pagedList(['v1']) - }) - expect(report.verdict).toBe('green') - expect(report.nounsChecked).toBe(1) - expect(report.verbsChecked).toBe(1) - expect(report.matched).toBe(2) - }) - - it('wired: every verb divergence class is NAMED', async () => { - const report = await runLogCompletenessOracle({ - storage: { getNouns: pagedList([]) } as never, - scanFacts: scanOf([ - { - generation: 1, - ops: [ - { kind: 'verb', id: 'v-differs', record: rec(1) }, - { kind: 'verb', id: 'v-tomb', record: null }, - { kind: 'verb', id: 'v-orphan', record: rec(3) } - ] - } - ]), - canonicalNounDigest: async () => null, - factRecordDigest: recordDigest, - canonicalVerbDigest: async (id) => - id === 'v-differs' ? recordDigest(rec(999)) : id === 'v-tomb' ? recordDigest(rec(2)) : null, - // canonical enumerates: v-differs (drifted), v-tomb (log says deleted), - // v-prelog (never logged); v-orphan is log-live but canonical-absent. - getVerbs: pagedList(['v-differs', 'v-tomb', 'v-prelog']) - }) - expect(report.verdict).toBe('red') - const by = (id: string) => report.mismatches.find((m) => m.id === id) - expect(by('v-differs')).toMatchObject({ kind: 'verb', reason: 'state-differs' }) - expect(by('v-tomb')).toMatchObject({ kind: 'verb', reason: 'log-tombstone-canonical-present' }) - expect(by('v-prelog')).toMatchObject({ kind: 'verb', reason: 'pre-log-record' }) - expect(by('v-orphan')).toMatchObject({ kind: 'verb', reason: 'log-live-canonical-absent' }) - }) - - it('unwired: verbsChecked stays 0 — honest partial scope, never a silent claim', async () => { - const report = await runLogCompletenessOracle({ - storage: { getNouns: pagedList(['n1']) } as never, - scanFacts: scanOf([ - { generation: 1, ops: [{ kind: 'noun', id: 'n1', record: rec(1) }] }, - { generation: 2, ops: [{ kind: 'verb', id: 'v1', record: rec(7) }] } - ]), - canonicalNounDigest: async () => recordDigest(rec(1)), - factRecordDigest: recordDigest - }) - expect(report.verbsChecked).toBe(0) - expect(report.nounsChecked).toBe(1) - }) -}) diff --git a/tests/unit/db/pad-frame-total.test.ts b/tests/unit/db/pad-frame-total.test.ts deleted file mode 100644 index a5c73d19..00000000 --- a/tests/unit/db/pad-frame-total.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @module tests/unit/db/pad-frame-total - * @description Pad-frame construction is TOTAL: every size from the minimum - * through 4096+257 is constructible byte-exact (a production adoption found - * the msgpack class-boundary hole at 291 bytes — sync died whole, and the - * failure cascaded into a counter rewind after a successful append). Every - * constructed pad decodes as skip-by-definition filler. - */ -import { describe, it, expect } from 'vitest' -import { encodePadFrame, minPadFrameBytes, decodeGroupV2 } from '../../../src/db/factLogFormat.js' - -describe('pad frames are constructible at EVERY size', () => { - it('exact construction from the minimum through a full sector + boundary spill', () => { - const min = minPadFrameBytes() - for (let size = min; size <= 4096 + 257; size++) { - const frame = encodePadFrame(size) - expect(frame.length, `size ${size}`).toBe(size) - } - }) - - it('the production case (291) and its class-boundary siblings decode as invisible filler', () => { - for (const size of [291, minPadFrameBytes(), 300, 511, 512, 513, 4096]) { - const frame = encodePadFrame(size) - const group = decodeGroupV2(frame) - expect(group.facts, `size ${size} is reader-invisible`).toEqual([]) - expect(group.validBytes).toBe(size) - } - }) -}) diff --git a/tests/unit/db/torn-open-guards.test.ts b/tests/unit/db/torn-open-guards.test.ts deleted file mode 100644 index 77c1b8b4..00000000 --- a/tests/unit/db/torn-open-guards.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * @module tests/unit/db/torn-open-guards - * @description Power-cut throw-site cures (brainy-alone fault-injection - * findings, both release-gating): - * 1. A torn generation manifest/counter (NaN/garbage where a generation - * belongs) DISCARDS with narration and re-derives — never a RangeError - * killing the open. - * 2. A manifest-listed-but-unloadable column segment QUARANTINES at - * discovery with narration; the field serves its remaining segments - * DEGRADED — never a raw throw killing every query on the field. - */ -import { describe, it, expect, afterEach } from 'vitest' -import { mkdtempSync, rmSync, readdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { gzipSync } from 'node:zlib' -import { Brainy } from '../../../src/index.js' -import { NounType } from '../../../src/types/graphTypes.js' - -const dirs: string[] = [] -const brains: Brainy[] = [] -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) - for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) -}) - -async function open(dir: string): Promise { - const b = new Brainy({ storage: { type: 'filesystem', path: dir }, requireSubtype: false }) - await b.init() - brains.push(b) - return b -} - -describe('torn-open guards', () => { - it('a torn generation manifest (NaN) opens with narrated discard — never a RangeError', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-torn-gen-')) - dirs.push(dir) - let brain = await open(dir) - const id = await brain.add({ data: 'survivor row', type: NounType.Document, metadata: { k: 1 } }) - await brain.flush() - await brain.close() - brains.pop() - - // The power-cut shape: the manifest's generation field is garbage. - const sys = join(dir, '_system') - const manifestPath = ['manifest.json', 'manifest.json.gz'] - .map((f) => join(sys, f)) - .find((p) => existsSync(p))! - const torn = { version: 1, generation: 'NaN-garbage', committedAt: 'x', horizon: null } - if (manifestPath.endsWith('.gz')) writeFileSync(manifestPath, gzipSync(JSON.stringify(torn))) - else writeFileSync(manifestPath, JSON.stringify(torn)) - - // Open MUST succeed (narrated discard + recovery re-derivation), and the - // durable row must still serve (log-authority replay recovers it). - brain = await open(dir) - expect((await brain.get(id))!.data).toContain('survivor row') - // Writes continue with a sane monotonic generation. - await brain.add({ data: 'post-recovery', type: NounType.Document, metadata: { k: 2 } }) - expect(Number.isSafeInteger(brain.generation())).toBe(true) - }, 120000) - - it('a torn column segment quarantines at discovery; the field serves remaining segments degraded — never a raw throw', async () => { - const dir = mkdtempSync(join(tmpdir(), 'brainy-torn-seg-')) - dirs.push(dir) - let brain = await open(dir) - for (let i = 0; i < 6; i++) { - await brain.add({ data: `row ${i}`, type: NounType.Document, metadata: { bucket: i % 2 } }) - } - await brain.flush() - await brain.close() - brains.pop() - - // Tear ONE column segment's bytes on disk (manifest keeps listing it) — - // the QUERIED field's own segment, so the quarantine path provably - // engages. Column segments live under the raw-blob root: - // `/_blobs/_column_index//L-.bin`. - const segDir = join(dir, '_blobs', '_column_index', 'bucket') - let tornOne = false - if (existsSync(segDir)) { - for (const f of readdirSync(segDir, { withFileTypes: true })) { - if (!f.isDirectory() && /^L\d+-.*\.bin$/.test(f.name)) { - writeFileSync(join(segDir, f.name), Buffer.from([0x00, 0x01, 0x02])) // garbage - tornOne = true - break - } - } - } - expect(tornOne, 'found a segment file to tear (layout probe)').toBe(true) - - // Queries on the field MUST NOT throw — degraded-announced service. - brain = await open(dir) - const rows = await brain.find({ where: { bucket: 0 }, limit: 10 }) - expect(Array.isArray(rows), 'query survives the torn segment').toBe(true) - // Full completeness is NOT asserted (the torn segment's rows may be - // absent — that is the documented degraded contract until heal). - }, 120000) -}) diff --git a/tests/unit/db/whereMatcher.test.ts b/tests/unit/db/whereMatcher.test.ts index 6c0252d6..2223117c 100644 --- a/tests/unit/db/whereMatcher.test.ts +++ b/tests/unit/db/whereMatcher.test.ts @@ -32,7 +32,7 @@ function entity(overrides: Partial = {}): Entity { } describe('db/whereMatcher — resolveEntityField', () => { - it('system. resolves the entity scalar; bare/metadata. reads the metadata bag only (sealed 2026-08-03)', () => { + it('resolves standard top-level fields', () => { const e = entity({ subtype: 'invoice', service: 'billing', @@ -41,32 +41,17 @@ describe('db/whereMatcher — resolveEntityField', () => { _rev: 3, data: 'payload' }) - - // system. is the ONLY spelling that reaches an entity scalar. - expect(resolveEntityField(e, 'system.id')).toBe('e-1') - expect(resolveEntityField(e, 'system.type')).toBe(NounType.Document) - expect(resolveEntityField(e, 'system.subtype')).toBe('invoice') - expect(resolveEntityField(e, 'system.service')).toBe('billing') - expect(resolveEntityField(e, 'system.confidence')).toBe(0.9) - expect(resolveEntityField(e, 'system.weight')).toBe(0.5) - expect(resolveEntityField(e, 'system.createdAt')).toBe(1000) - expect(resolveEntityField(e, 'system.updatedAt')).toBe(2000) - - // Plumbing (_rev, data) is invisible even via system. — not in the - // ten-scalar map, so this internal resolver reads it as absent (the typed - // refusal for these lives one layer up, at the query-surface parser). - expect(resolveEntityField(e, 'system._rev')).toBeUndefined() - expect(resolveEntityField(e, 'system.data')).toBeUndefined() - - // Bare names are ALWAYS the user's metadata field — even when they share - // a spelling with an engine scalar, or with the now-dead 'noun' alias. - // This entity's metadata bag is empty, so every bare name below reads - // absent rather than silently falling back to the entity scalar. - expect(resolveEntityField(e, 'id')).toBeUndefined() - expect(resolveEntityField(e, 'type')).toBeUndefined() - expect(resolveEntityField(e, 'noun')).toBeUndefined() // legacy alias is dead - expect(resolveEntityField(e, 'subtype')).toBeUndefined() - expect(resolveEntityField(e, 'createdAt')).toBeUndefined() + expect(resolveEntityField(e, 'id')).toBe('e-1') + expect(resolveEntityField(e, 'type')).toBe(NounType.Document) + expect(resolveEntityField(e, 'noun')).toBe(NounType.Document) // alias + expect(resolveEntityField(e, 'subtype')).toBe('invoice') + expect(resolveEntityField(e, 'service')).toBe('billing') + expect(resolveEntityField(e, 'confidence')).toBe(0.9) + expect(resolveEntityField(e, 'weight')).toBe(0.5) + expect(resolveEntityField(e, '_rev')).toBe(3) + expect(resolveEntityField(e, 'createdAt')).toBe(1000) + expect(resolveEntityField(e, 'updatedAt')).toBe(2000) + expect(resolveEntityField(e, 'data')).toBe('payload') }) it('resolves custom fields from the metadata bag', () => { diff --git a/tests/unit/get-index-status-readiness.test.ts b/tests/unit/get-index-status-readiness.test.ts index 5e283bc8..7f82ec5d 100644 --- a/tests/unit/get-index-status-readiness.test.ts +++ b/tests/unit/get-index-status-readiness.test.ts @@ -7,7 +7,7 @@ * _indexRebuildFailed / _indexDegradedIds degraded states (mirroring * validateIndexConsistency / checkHealth). */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy, NounType } from '../../src/index.js' describe('getIndexStatus honest readiness (Finding 9)', () => { @@ -20,10 +20,6 @@ describe('getIndexStatus honest readiness (Finding 9)', () => { await brain.flush() }) - afterEach(async () => { - await brain.close() - }) - it('a not-ready provider makes populated honest (false) and exposes ready:false', async () => { brain.index.isReady = () => false // count present, serving structure NOT loaded const status = await brain.getIndexStatus() diff --git a/tests/unit/graph/graph-adjacency-watermark.test.ts b/tests/unit/graph/graph-adjacency-watermark.test.ts deleted file mode 100644 index 8298277e..00000000 --- a/tests/unit/graph/graph-adjacency-watermark.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * @module tests/unit/graph/graph-adjacency-watermark - * @description Watermark-stamp pins for the graph-adjacency projection. - * - * THE LAW under test: the persisted adjacency artifact (the two verb-id LSM - * trees' SSTables + manifests) carries a stamp asserting "this state - * reflects every committed generation ≤ W and nothing above W" — written - * AFTER both trees' flushes complete — and init() computes the three-way - * verdict: stamped==committed → 'adopt' · stampedcommitted OR unstamped → 'rescan', LOUDLY. - * - * The verdict is COMPUTED AND EXPOSED only — cold-load recovery and rebuild - * triggers are unchanged. - */ -import { describe, it, expect, vi, afterEach } from 'vitest' -import { v4 as uuidv4 } from 'uuid' -import { - GraphAdjacencyIndex, - GRAPH_ADJACENCY_STAMP_KEY -} from '../../../src/graph/graphAdjacencyIndex.js' -import { EntityIdMapper } from '../../../src/utils/entityIdMapper.js' -import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js' -import { VerbType } from '../../../src/types/graphTypes.js' -import type { GraphVerb } from '../../../src/coreTypes.js' -import { prodLog } from '../../../src/utils/logger.js' - -function makeVerb(id: string, sourceId: string, targetId: string): GraphVerb { - return { - id, - sourceId, - targetId, - vector: [], - type: VerbType.RelatedTo, - verb: VerbType.RelatedTo - } -} - -async function makeStorage(committed: number | null): Promise { - const storage = new MemoryStorage() - await storage.init() - if (committed !== null) { - vi.spyOn(storage, 'committedGeneration').mockReturnValue(committed) - } - return storage -} - -function setCommitted(storage: MemoryStorage, committed: number): void { - vi.spyOn(storage, 'committedGeneration').mockReturnValue(committed) -} - -/** Session 1: index verbs, optionally stamp, flush + close — the artifact. */ -async function writeArtifact(storage: MemoryStorage, stamp: number | null): Promise { - const idMapper = new EntityIdMapper({ storage, storageKey: 'test:graph:idMapper' }) - await idMapper.init() - const index = new GraphAdjacencyIndex(storage, {}, idMapper) - const a = uuidv4() - const b = uuidv4() - const aInt = BigInt(idMapper.getOrAssign(a)) - const bInt = BigInt(idMapper.getOrAssign(b)) - await index.addVerb(makeVerb(uuidv4(), a, b), aInt, bInt, 1n) - if (stamp !== null) index.stampWatermark(stamp) - await index.flush() - await index.close() -} - -/** Session 2: reopen on the same storage via the cold-load path. */ -async function reopen(storage: MemoryStorage): Promise { - const index = new GraphAdjacencyIndex(storage) - await index.init() - return index -} - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('graph adjacency index — watermark stamp + three-way load verdict', () => { - it("save-with-stamp then reopen at the same committed generation → 'adopt'", async () => { - const storage = await makeStorage(5) - await writeArtifact(storage, 5) - - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('adopt') - expect(index.watermark()).toBe(5) - expect(index.watermarkGap()).toBeNull() - await index.close() - }) - - it("stamp BEHIND the committed generation → 'catchup' with the exact gap reported", async () => { - const storage = await makeStorage(5) - await writeArtifact(storage, 5) - - setCommitted(storage, 11) - - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('catchup') - expect(index.watermark()).toBe(5) - expect(index.watermarkGap()).toEqual({ from: 5, to: 11 }) - await index.close() - }) - - it("stamp ABOVE the committed generation → 'rescan', said out loud", async () => { - const storage = await makeStorage(9) - await writeArtifact(storage, 9) - - setCommitted(storage, 4) - - const warnSpy = vi.spyOn(prodLog, 'warn') - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('rescan') - expect(index.watermarkGap()).toBeNull() - const said = warnSpy.mock.calls.map(c => String(c[0])).join('\n') - expect(said).toContain('RESCAN') - expect(said).toContain('ABOVE') - await index.close() - }) - - it("legacy unstamped artifact on a stamped store → 'rescan', LOUD — never a silent adopt", async () => { - const storage = await makeStorage(3) - await writeArtifact(storage, null) // pre-stamp adjacency: SSTables, no stamp - - expect(await storage.getMetadata(GRAPH_ADJACENCY_STAMP_KEY)).toBeNull() - - const warnSpy = vi.spyOn(prodLog, 'warn') - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('rescan') - expect(index.watermark()).toBeNull() - const said = warnSpy.mock.calls.map(c => String(c[0])).join('\n') - expect(said).toContain('RESCAN') - expect(said).toContain('unstamped') - await index.close() - }) - - it("a store with no committed-generation capability keeps pre-stamp behavior → 'adopt'", async () => { - const storage = await makeStorage(null) - await writeArtifact(storage, null) - - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('adopt') - expect(index.watermark()).toBeNull() - await index.close() - }) - - it('STAMP-AFTER-DATA: the stamp is the last saveMetadata of the flush, after both trees’ SSTable + manifest writes', async () => { - const storage = await makeStorage(2) - const idMapper = new EntityIdMapper({ storage, storageKey: 'test:graph:idMapper' }) - await idMapper.init() - const index = new GraphAdjacencyIndex(storage, {}, idMapper) - const a = uuidv4() - const b = uuidv4() - await index.addVerb( - makeVerb(uuidv4(), a, b), - BigInt(idMapper.getOrAssign(a)), - BigInt(idMapper.getOrAssign(b)), - 1n - ) - - const keys: string[] = [] - const originalSave = storage.saveMetadata.bind(storage) - vi.spyOn(storage, 'saveMetadata').mockImplementation(async (id, metadata) => { - keys.push(id) - return originalSave(id, metadata) - }) - - index.stampWatermark(2) - await index.flush() - - const stampAt = keys.indexOf(GRAPH_ADJACENCY_STAMP_KEY) - expect(stampAt, 'stamp record was written').toBeGreaterThanOrEqual(0) - expect(stampAt, 'stamp is the FINAL metadata write of the flush').toBe(keys.length - 1) - // Both trees flushed durable bytes before the stamp landed. - expect( - keys.slice(0, stampAt).some(k => k.startsWith('graph-lsm-verbs-source')), - 'verbs-by-source tree wrote before the stamp' - ).toBe(true) - expect( - keys.slice(0, stampAt).some(k => k.startsWith('graph-lsm-verbs-target')), - 'verbs-by-target tree wrote before the stamp' - ).toBe(true) - - const record = (await storage.getMetadata(GRAPH_ADJACENCY_STAMP_KEY)) as { - watermark: number - formatVersion: number - stampedAt: number - } - expect(record.watermark).toBe(2) - expect(record.formatVersion).toBe(1) - expect(typeof record.stampedAt).toBe('number') - - await index.close() - }) - - it('a pending stamp also lands on the close() shutdown path, after the final tree flushes', async () => { - const storage = await makeStorage(6) - const idMapper = new EntityIdMapper({ storage, storageKey: 'test:graph:idMapper' }) - await idMapper.init() - const index = new GraphAdjacencyIndex(storage, {}, idMapper) - const a = uuidv4() - const b = uuidv4() - await index.addVerb( - makeVerb(uuidv4(), a, b), - BigInt(idMapper.getOrAssign(a)), - BigInt(idMapper.getOrAssign(b)), - 1n - ) - - index.stampWatermark(6) - await index.close() // no explicit flush — close() flushes, then stamps - - const record = (await storage.getMetadata(GRAPH_ADJACENCY_STAMP_KEY)) as { watermark: number } - expect(record?.watermark).toBe(6) - }) -}) diff --git a/tests/unit/graph/graph-fastpath-honest-readiness.test.ts b/tests/unit/graph/graph-fastpath-honest-readiness.test.ts index 95a6c0c4..46d318b4 100644 --- a/tests/unit/graph/graph-fastpath-honest-readiness.test.ts +++ b/tests/unit/graph/graph-fastpath-honest-readiness.test.ts @@ -8,7 +8,7 @@ * scan; and a one-shot probe self-heals a no-isReady provider whose adjacency * did not cold-load. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy, NounType, VerbType } from '../../../src/index.js' describe('graph fast-path honest readiness (Finding 2)', () => { @@ -33,10 +33,6 @@ describe('graph fast-path honest readiness (Finding 2)', () => { await storage.getVerbsBySource(a) }) - afterEach(async () => { - await brain.close() - }) - it('not-ready provider → shard scan returns the REAL edges, not a silent []', async () => { const gi = storage.graphIndex // Simulate a cold native provider: count/manifest loaded (isInitialized) but diff --git a/tests/unit/hnsw/hnsw-watermark.test.ts b/tests/unit/hnsw/hnsw-watermark.test.ts deleted file mode 100644 index bf8b8510..00000000 --- a/tests/unit/hnsw/hnsw-watermark.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * @module tests/unit/hnsw/hnsw-watermark - * @description Watermark-stamp pins for the JS HNSW vector projection. - * - * THE LAW under test: the persisted HNSW artifact (per-node records + the - * entryPoint/maxLevel system record) carries a stamp asserting "this state - * reflects every committed generation ≤ W and nothing above W" — written - * AFTER every byte it certifies is durable — and rebuild() computes the - * three-way verdict: stamped==committed → 'adopt' · stampedcommitted OR unstamped → 'rescan', - * LOUDLY. Vector-bearing stamps carry the model identity this module can - * honestly assert: dimensions only (no embedding-model id is reachable from - * the index module). - * - * The verdict is COMPUTED AND EXPOSED only — no rebuild trigger changed. - */ -import { describe, it, expect, vi, afterEach } from 'vitest' -import { v4 as uuidv4 } from 'uuid' -import { JsHnswVectorIndex, HNSW_INDEX_STAMP_KEY } from '../../../src/hnsw/hnswIndex.js' -import { euclideanDistance } from '../../../src/utils/index.js' -import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js' -import { prodLog } from '../../../src/utils/logger.js' - -const DIM = 8 - -function randomVector(dim: number): number[] { - return Array.from({ length: dim }, () => Math.random() * 2 - 1) -} - -async function makeStorage(committed: number | null): Promise { - const storage = new MemoryStorage() - await storage.init() - if (committed !== null) { - vi.spyOn(storage, 'committedGeneration').mockReturnValue(committed) - } - return storage -} - -function setCommitted(storage: MemoryStorage, committed: number): void { - vi.spyOn(storage, 'committedGeneration').mockReturnValue(committed) -} - -function makeIndex(storage: MemoryStorage): JsHnswVectorIndex { - return new JsHnswVectorIndex( - { M: 4, efConstruction: 50, efSearch: 20 }, - euclideanDistance, - { useParallelization: false, storage, persistMode: 'deferred' } - ) -} - -/** Session 1: insert nodes, optionally stamp, flush — the durable artifact. */ -async function writeArtifact(storage: MemoryStorage, stamp: number | null): Promise { - const index = makeIndex(storage) - for (let i = 0; i < 3; i++) { - await index.addItem({ id: uuidv4(), vector: randomVector(DIM) }) - } - if (stamp !== null) index.stampWatermark(stamp) - await index.flush() -} - -/** Session 2: reopen on the same storage via the load path (rebuild). */ -async function reopen(storage: MemoryStorage): Promise { - const index = makeIndex(storage) - await index.rebuild() - return index -} - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('JS HNSW index — watermark stamp + three-way load verdict', () => { - it("save-with-stamp then reopen at the same committed generation → 'adopt'", async () => { - const storage = await makeStorage(5) - await writeArtifact(storage, 5) - - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('adopt') - expect(index.watermark()).toBe(5) - expect(index.watermarkGap()).toBeNull() - }) - - it("stamp BEHIND the committed generation → 'catchup' with the exact gap reported", async () => { - const storage = await makeStorage(5) - await writeArtifact(storage, 5) - - setCommitted(storage, 9) - - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('catchup') - expect(index.watermark()).toBe(5) - expect(index.watermarkGap()).toEqual({ from: 5, to: 9 }) - }) - - it("stamp ABOVE the committed generation → 'rescan', said out loud", async () => { - const storage = await makeStorage(9) - await writeArtifact(storage, 9) - - setCommitted(storage, 4) - - const warnSpy = vi.spyOn(prodLog, 'warn') - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('rescan') - expect(index.watermarkGap()).toBeNull() - const said = warnSpy.mock.calls.map(c => String(c[0])).join('\n') - expect(said).toContain('RESCAN') - expect(said).toContain('ABOVE') - }) - - it("legacy unstamped artifact on a stamped store → 'rescan', LOUD — never a silent adopt", async () => { - const storage = await makeStorage(3) - await writeArtifact(storage, null) // pre-stamp index: data flushed, no stamp - - expect(await storage.getMetadata(HNSW_INDEX_STAMP_KEY)).toBeNull() - - const warnSpy = vi.spyOn(prodLog, 'warn') - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('rescan') - expect(index.watermark()).toBeNull() - const said = warnSpy.mock.calls.map(c => String(c[0])).join('\n') - expect(said).toContain('RESCAN') - expect(said).toContain('unstamped') - }) - - it("a store with no committed-generation capability keeps pre-stamp behavior → 'adopt'", async () => { - const storage = await makeStorage(null) - await writeArtifact(storage, null) - - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('adopt') - expect(index.watermark()).toBeNull() - }) - - it('STAMP-AFTER-DATA: the stamp lands after every node record and the system record', async () => { - const storage = await makeStorage(2) - const index = makeIndex(storage) - for (let i = 0; i < 3; i++) { - await index.addItem({ id: uuidv4(), vector: randomVector(DIM) }) - } - - // One shared op log across all three write surfaces pins global order. - const ops: string[] = [] - const origNode = storage.saveVectorIndexData.bind(storage) - vi.spyOn(storage, 'saveVectorIndexData').mockImplementation(async (id, data) => { - ops.push(`node:${id}`) - return origNode(id, data) - }) - const origSystem = storage.saveHNSWSystem.bind(storage) - vi.spyOn(storage, 'saveHNSWSystem').mockImplementation(async data => { - ops.push('system') - return origSystem(data) - }) - const origMeta = storage.saveMetadata.bind(storage) - vi.spyOn(storage, 'saveMetadata').mockImplementation(async (id, metadata) => { - ops.push(`meta:${id}`) - return origMeta(id, metadata) - }) - - index.stampWatermark(2) - await index.flush() - - const stampAt = ops.indexOf(`meta:${HNSW_INDEX_STAMP_KEY}`) - expect(stampAt, 'stamp record was written').toBeGreaterThanOrEqual(0) - expect(stampAt, 'stamp is the FINAL write of the flush').toBe(ops.length - 1) - expect(ops.filter(o => o.startsWith('node:')).length).toBeGreaterThan(0) - expect(ops.indexOf('system')).toBeLessThan(stampAt) - }) - - it('the stamp record carries {watermark, formatVersion, stampedAt} + modelIdentity (dims only)', async () => { - const storage = await makeStorage(7) - await writeArtifact(storage, 7) - - const record = (await storage.getMetadata(HNSW_INDEX_STAMP_KEY)) as { - watermark: number - formatVersion: number - stampedAt: number - modelIdentity: { embedModelId?: string; dimensions: number | null } - } - expect(record.watermark).toBe(7) - expect(record.formatVersion).toBe(1) - expect(typeof record.stampedAt).toBe('number') - // The JS index never sees the embedder — dimensions are the only vector- - // space identity it can honestly assert. - expect(record.modelIdentity).toEqual({ dimensions: DIM }) - }) - - it('a pending stamp still lands when nothing is dirty (already-durable bytes, stamp-after-data trivially holds)', async () => { - const storage = await makeStorage(4) - const index = makeIndex(storage) - await index.addItem({ id: uuidv4(), vector: randomVector(DIM) }) - await index.flush() // data durable, no stamp yet - - index.stampWatermark(4) - await index.flush() // nothing dirty — the stamp must still be written - - const record = (await storage.getMetadata(HNSW_INDEX_STAMP_KEY)) as { watermark: number } - expect(record?.watermark).toBe(4) - expect(index.watermark()).toBe(4) - }) -}) diff --git a/tests/unit/hnsw/update-item-atomic.test.ts b/tests/unit/hnsw/update-item-atomic.test.ts deleted file mode 100644 index f8798949..00000000 --- a/tests/unit/hnsw/update-item-atomic.test.ts +++ /dev/null @@ -1,366 +0,0 @@ -/** - * @module tests/unit/hnsw/update-item-atomic - * @description Guard for the atomic vector-index update: a row must NEVER be - * absent from vector search during an update. The historical update path - * staged a remove followed by an add as two separately-awaited transaction - * operations — between them the row was in NEITHER index (dark to semantic - * recall while perfectly visible to metadata reads; observed as seconds-long - * flicker in a production deployment). The structural cure verified here: - * - * 1. `JsHnswVectorIndex.updateItem` — same vector (element-wise) is a pure - * no-op (the production flicker shape: a type-only update re-indexing an - * UNCHANGED vector); a changed vector swaps in place, the node never - * leaving the map (white-box probe at the first internal step after the - * synchronous swap), including when the node IS the entry point. - * 2. `ReplaceInVectorIndexOperation` — one transaction leg that prefers the - * provider's in-place `updateItem`, with a remove+add-ADJACENT fallback - * for providers that have not shipped it; rollback restores the declared - * before-vector on both branches. - * 3. The brain's update path — with the JS index carrying `updateItem`, - * `removeItem` is never called during `brain.update()`, for the - * type-only shape AND for a genuine vector change. - */ -import { describe, it, expect, vi } from 'vitest' -import { JsHnswVectorIndex } from '../../../src/hnsw/hnswIndex.js' -import { ReplaceInVectorIndexOperation } from '../../../src/transaction/operations/IndexOperations.js' -import type { VectorIndexProvider } from '../../../src/plugin.js' -import type { Vector, VectorDocument } from '../../../src/coreTypes.js' -import { euclideanDistance } from '../../../src/utils/index.js' -import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js' -import { Brainy } from '../../../src/brainy' -import { createAddParams, createTestConfig } from '../../helpers/test-factory' - -const DIM = 8 - -function seededRand(seed: number): () => number { - let s = seed >>> 0 - return () => { - s = (s + 0x6d2b79f5) | 0 - let t = Math.imul(s ^ (s >>> 15), 1 | s) - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t - return ((t ^ (t >>> 14)) >>> 0) / 4294967296 - } -} - -/** A deterministic vector pointing in a pseudo-random direction (well-connected graph). */ -function vec(idx: number): number[] { - const rand = seededRand(idx + 1) - return Array.from({ length: DIM }, () => rand() * 2 - 1) -} - -type Noun = { id: string; vector: number[]; connections: Map>; level: number } - -function nounsOf(index: JsHnswVectorIndex): Map { - return (index as unknown as { nouns: Map }).nouns -} - -/** Flatten a reverse index to sorted `target|level|source` triples. */ -function triplesFromIncoming(inc: Map>>): string[] { - const out: string[] = [] - for (const [target, byLevel] of inc) { - for (const [level, sources] of byLevel) { - for (const source of sources) out.push(`${target}|${level}|${source}`) - } - } - return out.sort() -} - -/** Derive the ground-truth reverse index directly from the live forward adjacency. */ -function triplesFromAdjacency(nouns: Map): string[] { - const out: string[] = [] - for (const [nodeId, node] of nouns) { - for (const [level, targets] of node.connections) { - for (const target of targets) out.push(`${target}|${level}|${nodeId}`) - } - } - return out.sort() -} - -function assertReverseIndexConsistent(index: JsHnswVectorIndex): void { - const live = ( - index as unknown as { ensureIncoming: () => Map>> } - ).ensureIncoming() - expect(triplesFromIncoming(live)).toEqual(triplesFromAdjacency(nounsOf(index))) -} - -function assertNoSelfLoops(index: JsHnswVectorIndex, id: string): void { - const node = nounsOf(index).get(id)! - for (const [level, targets] of node.connections) { - expect(targets.has(id), `self-loop at level ${level}`).toBe(false) - } -} - -function makeIndex(M = 16): JsHnswVectorIndex { - return new JsHnswVectorIndex( - { M, efConstruction: 200, efSearch: 64, ml: 16 }, - euclideanDistance, - { useParallelization: false, storage: new MemoryStorage() } - ) -} - -async function fillIndex(index: JsHnswVectorIndex, count: number): Promise { - for (let i = 0; i < count; i++) { - await index.addItem({ id: `n-${i}`, vector: vec(i) }) - } -} - -describe('JsHnswVectorIndex.updateItem — atomic in-place vector update', () => { - it('same vector (element-wise equal, fresh array) is a pure no-op: no remove, no relink, still searchable', async () => { - const index = makeIndex() - await fillIndex(index, 30) - - const target = 'n-7' - const sameVector = [...vec(7)] // fresh array, identical elements - - const before = await index.search(vec(7), 1) - expect(before[0][0]).toBe(target) - - const removeSpy = vi.spyOn(index, 'removeItem') - const nodeBefore = nounsOf(index).get(target)! - const connectionsBefore = nodeBefore.connections // reference — a relink replaces it - - await index.updateItem({ id: target, vector: sameVector }) - - expect(removeSpy).not.toHaveBeenCalled() - expect(index.size()).toBe(30) - // No relink happened: the connections map is the SAME object, untouched. - expect(nounsOf(index).get(target)!.connections).toBe(connectionsBefore) - - const after = await index.search(vec(7), 1) - expect(after[0][0]).toBe(target) - expect(after[0][1]).toBeCloseTo(0, 10) - - removeSpy.mockRestore() - }) - - it('changed vector: node never leaves the map (probe fires after the synchronous swap), removeItem never called, findable by the NEW vector', async () => { - const index = makeIndex() - await fillIndex(index, 40) - - const target = 'n-5' - const newVector = vec(500) - - // White-box probe: ensureIncoming is the FIRST internal step of the unlink - // walk, i.e. the first thing updateItem does after the synchronous vector - // swap. At that instant the node must (a) still be in the map and (b) - // already carry the NEW vector — the visibility-atomic ordering. - const inner = index as unknown as { - nouns: Map - ensureIncoming: () => Map>> - } - const origEnsure = inner.ensureIncoming.bind(index) - let probed = false - let presentDuring = false - let swappedFirst = false - ;(index as any).ensureIncoming = function () { - if (!probed) { - probed = true - presentDuring = inner.nouns.has(target) - swappedFirst = inner.nouns.get(target)?.vector === newVector - } - return origEnsure() - } - - const removeSpy = vi.spyOn(index, 'removeItem') - await index.updateItem({ id: target, vector: newVector }) - delete (index as any).ensureIncoming // restore the prototype method - - expect(probed).toBe(true) - expect(presentDuring).toBe(true) - expect(swappedFirst).toBe(true) - expect(removeSpy).not.toHaveBeenCalled() - expect(index.size()).toBe(40) - expect(nounsOf(index).has(target)).toBe(true) - - // Findable by search with the NEW vector, at distance ~0. - const got = await index.search(newVector, 1) - expect(got[0][0]).toBe(target) - expect(got[0][1]).toBeCloseTo(0, 10) - - // The relink left the graph bookkeeping exactly consistent. - assertNoSelfLoops(index, target) - assertReverseIndexConsistent(index) - - removeSpy.mockRestore() - }) - - it('keeps the node at its existing level (never releveled by an update)', async () => { - const index = makeIndex() - await fillIndex(index, 30) - - const target = 'n-3' - const levelBefore = nounsOf(index).get(target)!.level - - await index.updateItem({ id: target, vector: vec(600) }) - - expect(nounsOf(index).get(target)!.level).toBe(levelBefore) - expect(index.getMaxLevel()).toBeGreaterThanOrEqual(levelBefore) - }) - - it('updating the ENTRY POINT in place keeps it valid — entry id and maxLevel unchanged, graph never stranded', async () => { - const index = makeIndex() - await fillIndex(index, 40) - - const entryId = index.getEntryPointId()! - const maxLevelBefore = index.getMaxLevel() - const newVector = vec(700) - - await index.updateItem({ id: entryId, vector: newVector }) - - // Entry-point bookkeeping must not regress. - expect(index.getEntryPointId()).toBe(entryId) - expect(index.getMaxLevel()).toBe(maxLevelBefore) - expect(index.size()).toBe(40) - - // The entry point itself is findable by its new vector... - const gotEntry = await index.search(newVector, 1) - expect(gotEntry[0][0]).toBe(entryId) - - // ...and the REST of the graph is still reachable through it (a stranded, - // edgeless entry point would make every other node invisible). - const otherId = [...nounsOf(index).keys()].find((id) => id !== entryId)! - const otherIdx = Number(otherId.slice(2)) - const gotOther = await index.search(vec(otherIdx), 1) - expect(gotOther[0][0]).toBe(otherId) - - assertNoSelfLoops(index, entryId) - assertReverseIndexConsistent(index) - }) - - it('absent id delegates to addItem (plain insert)', async () => { - const index = makeIndex() - await fillIndex(index, 10) - - await index.updateItem({ id: 'fresh', vector: vec(900) }) - - expect(index.size()).toBe(11) - const got = await index.search(vec(900), 1) - expect(got[0][0]).toBe('fresh') - }) -}) - -describe('ReplaceInVectorIndexOperation — one atomic transaction leg', () => { - it('uses the provider updateItem path and rolls back to the old vector in place', async () => { - const index = makeIndex() - await fillIndex(index, 30) - - const target = 'n-9' - const oldVector = vec(9) - const newVector = vec(800) - - const removeSpy = vi.spyOn(index, 'removeItem') - const op = new ReplaceInVectorIndexOperation(index, target, oldVector, newVector) - expect(op.name).toBe('ReplaceInVectorIndex(hnsw-js)') - - const rollback = await op.execute() - expect(removeSpy).not.toHaveBeenCalled() - expect((await index.search(newVector, 1))[0][0]).toBe(target) - - await rollback() - expect(removeSpy).not.toHaveBeenCalled() - expect(index.size()).toBe(30) - - // Old vector restored, element-wise, and searchable again. - const restored = nounsOf(index).get(target)!.vector - expect(restored.length).toBe(oldVector.length) - for (let i = 0; i < oldVector.length; i++) { - expect(restored[i]).toBe(oldVector[i]) - } - const back = await index.search(oldVector, 1) - expect(back[0][0]).toBe(target) - expect(back[0][1]).toBeCloseTo(0, 10) - - removeSpy.mockRestore() - }) - - it('falls back to remove+add ADJACENT within the single op for a provider without updateItem, and rolls back the same way', async () => { - // A provider that has not shipped updateItem — the temporary seam: the - // pair stays adjacent inside ONE op (no other transaction operation can - // interleave), until the provider ships its own in-place updateItem. - const calls: string[] = [] - const store = new Map() - const legacyProvider = { - name: 'legacy-native', - addItem: async (item: VectorDocument) => { - calls.push(`add:${item.id}`) - store.set(item.id, item.vector) - return item.id - }, - removeItem: async (id: string) => { - calls.push(`remove:${id}`) - return store.delete(id) - }, - search: async () => [], - size: () => store.size, - clear: () => store.clear(), - rebuild: async () => {}, - flush: async () => 0, - getPersistMode: () => 'immediate' as const - } as unknown as VectorIndexProvider - - store.set('x', [1, 0]) - const op = new ReplaceInVectorIndexOperation(legacyProvider, 'x', [1, 0], [0, 1]) - - const rollback = await op.execute() - expect(calls).toEqual(['remove:x', 'add:x']) - expect(store.get('x')).toEqual([0, 1]) - - await rollback() - expect(calls).toEqual(['remove:x', 'add:x', 'remove:x', 'add:x']) - expect(store.get('x')).toEqual([1, 0]) - }) -}) - -describe('brain.update() — the update path stages ONE atomic vector-index leg', () => { - it('a type-only update (unchanged vector — the production flicker shape) never calls removeItem on the vector index', async () => { - const brain = new Brainy(createTestConfig()) - await brain.init() - try { - const id = await brain.add( - createAddParams({ data: 'atomic flicker guard entity', type: 'thing' }) - ) - - const index = (brain as unknown as { index: JsHnswVectorIndex }).index - const removeSpy = vi.spyOn(index, 'removeItem') - const sizeBefore = index.size() - - await brain.update({ id, type: 'document' }) - - expect(removeSpy).not.toHaveBeenCalled() - expect(index.size()).toBe(sizeBefore) - - const updated = await brain.get(id) - expect(updated).not.toBeNull() - expect(updated!.type).toBe('document') - - removeSpy.mockRestore() - } finally { - await brain.close() - } - }) - - it('a genuine vector change on update also never calls removeItem (in-place replace)', async () => { - const brain = new Brainy(createTestConfig()) - await brain.init() - try { - const id = await brain.add( - createAddParams({ data: 'vector change stays visible', type: 'thing' }) - ) - const existing = await brain.get(id, { includeVectors: true }) - // Same dimensionality, guaranteed-different content. - const changed = existing!.vector.map((x: number, i: number) => (i === 0 ? x + 0.25 : x)) - - const index = (brain as unknown as { index: JsHnswVectorIndex }).index - const removeSpy = vi.spyOn(index, 'removeItem') - - await brain.update({ id, vector: changed }) - - expect(removeSpy).not.toHaveBeenCalled() - expect(nounsOf(index).has(id)).toBe(true) - - removeSpy.mockRestore() - } finally { - await brain.close() - } - }) -}) diff --git a/tests/unit/indexes/columnStore/column-store-mixed-kind.test.ts b/tests/unit/indexes/columnStore/column-store-mixed-kind.test.ts deleted file mode 100644 index 1ce21d1f..00000000 --- a/tests/unit/indexes/columnStore/column-store-mixed-kind.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * @module column-store-mixed-kind.test - * @description Typed posting lists: one field, several value KINDS, each - * answerable on its own. - * - * The behaviour these pin replaced a first-writer type freeze. The first value - * a field ever saw fixed that field's type; every later value of another kind - * was coerced to it, and when coercion failed — `Number('electronics')` — the - * value was dropped from the index with no error at all. The row stayed - * readable by id and by vector and vanished from every equality filter on the - * field. These tests therefore care about ORDER: strings-then-numbers and - * numbers-then-strings have to behave identically, because neither writer owns - * the field. - * - * Kinds never coerce into one another at query time either. `5` and `'5'` are - * different values and match different rows. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { ColumnStore } from '../../../../src/indexes/columnStore/ColumnStore.js' -import { MemoryStorage } from '../../../../src/storage/adapters/memoryStorage.js' -import { EntityIdMapper } from '../../../../src/utils/entityIdMapper.js' - -describe('ColumnStore — typed posting lists per (field, kind)', () => { - let storage: MemoryStorage - let idMapper: EntityIdMapper - let store: ColumnStore - - beforeEach(async () => { - storage = new MemoryStorage() - await storage.init() - idMapper = new EntityIdMapper({ storage, storageKey: 'test:idMapper' }) - await idMapper.init() - - store = new ColumnStore({ flushThreshold: 10 }) - await store.init(storage, idMapper) - }) - - afterEach(async () => { - await store.close() - }) - - /** Resolve a filter to the sorted UUIDs it matched. */ - const uuidsOf = async (field: string, value: unknown): Promise => { - const bitmap = await store.filter(field, value) - return Array.from(bitmap) - .map((id) => idMapper.getUuid(Number(id))) - .filter((u): u is string => u !== undefined) - .sort() - } - - describe('equality answers on the query value’s own kind', () => { - it('serves numbers written AFTER strings on the same field', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { category: 'electronics' }) - store.addEntity(BigInt(idMapper.getOrAssign('s2')), { category: 'furniture' }) - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { category: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('n2')), { category: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('n3')), { category: 7 }) - - // The numbers are in the index, though a string got there first. - expect(await uuidsOf('category', 5)).toEqual(['n1', 'n2']) - expect(await uuidsOf('category', 7)).toEqual(['n3']) - // And the strings did not move. - expect(await uuidsOf('category', 'electronics')).toEqual(['s1']) - expect(await uuidsOf('category', 'furniture')).toEqual(['s2']) - }) - - it('serves strings written AFTER numbers on the same field', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { category: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('n2')), { category: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { category: 'electronics' }) - store.addEntity(BigInt(idMapper.getOrAssign('s2')), { category: 'electronics' }) - - // 'electronics' would have become NaN and been dropped under the freeze. - expect(await uuidsOf('category', 'electronics')).toEqual(['s1', 's2']) - expect(await uuidsOf('category', 5)).toEqual(['n1', 'n2']) - }) - - it('does not coerce a number query into the string postings, or back', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('num')), { code: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('str')), { code: '5' }) - - expect(await uuidsOf('code', 5)).toEqual(['num']) - expect(await uuidsOf('code', '5')).toEqual(['str']) - }) - - it('serves booleans mixed into a field that already holds strings and numbers', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { flag: 'yes' }) - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { flag: 1 }) - store.addEntity(BigInt(idMapper.getOrAssign('b1')), { flag: true }) - store.addEntity(BigInt(idMapper.getOrAssign('b2')), { flag: false }) - - expect(await uuidsOf('flag', true)).toEqual(['b1']) - expect(await uuidsOf('flag', false)).toEqual(['b2']) - // `true` stores as 1 internally; that is an encoding, not a value. - expect(await uuidsOf('flag', 1)).toEqual(['n1']) - expect(await uuidsOf('flag', 'yes')).toEqual(['s1']) - }) - - it('answers nothing — not something coerced — for a kind the field never held', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { category: 'electronics' }) - - expect(await uuidsOf('category', 5)).toEqual([]) - expect(await uuidsOf('category', true)).toEqual([]) - }) - - it('holds every kind across a flush, not just the one in the tail buffer', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { category: 'electronics' }) - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { category: 5 }) - await store.flush() - store.addEntity(BigInt(idMapper.getOrAssign('s2')), { category: 'electronics' }) - store.addEntity(BigInt(idMapper.getOrAssign('n2')), { category: 5 }) - - expect(await uuidsOf('category', 'electronics')).toEqual(['s1', 's2']) - expect(await uuidsOf('category', 5)).toEqual(['n1', 'n2']) - }) - }) - - describe('range filters read the numeric postings', () => { - it('ranges over the numeric subset of a mixed field, ignoring its strings', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('cheap')), { price: 100 }) - store.addEntity(BigInt(idMapper.getOrAssign('mid')), { price: 500 }) - store.addEntity(BigInt(idMapper.getOrAssign('dear')), { price: 900 }) - store.addEntity(BigInt(idMapper.getOrAssign('unpriced')), { price: 'on request' }) - await store.flush() - - const inRange = await store.rangeQuery('price', 200, 1000) - const uuids = Array.from(inRange) - .map((id) => idMapper.getUuid(Number(id))) - .sort() - expect(uuids).toEqual(['dear', 'mid']) - }) - - it('an unbounded range still reports every kind — it is the “has a value” probe', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { mixed: 42 }) - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { mixed: 'text' }) - store.addEntity(BigInt(idMapper.getOrAssign('b1')), { mixed: true }) - await store.flush() - - const anyValue = await store.rangeQuery('mixed') - const uuids = Array.from(anyValue) - .map((id) => idMapper.getUuid(Number(id))) - .sort() - expect(uuids).toEqual(['b1', 'n1', 's1']) - }) - }) - - describe('the index reports what a field actually holds', () => { - it('names every kind present, not the one that got there first', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { category: 'electronics' }) - expect(store.getFieldKinds('category')).toEqual(['string']) - - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { category: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('b1')), { category: true }) - expect(store.getFieldKinds('category')).toEqual(['number', 'string', 'boolean']) - - // And the field is still ONE field by name. - expect(store.getIndexedFields()).toEqual(['category']) - expect(store.hasField('category')).toBe(true) - }) - - it('reports an unknown field as holding nothing', () => { - expect(store.getFieldKinds('never-written')).toEqual([]) - }) - }) - - describe('an integer column widens rather than rounding', () => { - it('keeps a non-integer written after integers as itself', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('a')), { score: 4 }) - store.addEntity(BigInt(idMapper.getOrAssign('b')), { score: 4.5 }) - store.addEntity(BigInt(idMapper.getOrAssign('c')), { score: 5 }) - await store.flush() - - // 4.5 used to round to 5 and answer `score === 5` alongside c. - expect(await uuidsOf('score', 4.5)).toEqual(['b']) - expect(await uuidsOf('score', 5)).toEqual(['c']) - expect(await uuidsOf('score', 4)).toEqual(['a']) - }) - }) - - describe('close then reopen', () => { - it('keeps every typed posting, on the same storage', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { category: 'electronics' }) - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { category: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('b1')), { category: true }) - store.addEntity(BigInt(idMapper.getOrAssign('f1')), { score: 1.5 }) - await store.flush() - await store.close() - - store = new ColumnStore({ flushThreshold: 10 }) - await store.init(storage, idMapper) - - expect(store.getFieldKinds('category')).toEqual(['number', 'string', 'boolean']) - expect(await uuidsOf('category', 'electronics')).toEqual(['s1']) - expect(await uuidsOf('category', 5)).toEqual(['n1']) - expect(await uuidsOf('category', true)).toEqual(['b1']) - expect(await uuidsOf('score', 1.5)).toEqual(['f1']) - }) - - it('accepts new values of every kind after the reopen', async () => { - store.addEntity(BigInt(idMapper.getOrAssign('s1')), { category: 'electronics' }) - store.addEntity(BigInt(idMapper.getOrAssign('n1')), { category: 5 }) - await store.flush() - await store.close() - - store = new ColumnStore({ flushThreshold: 10 }) - await store.init(storage, idMapper) - - store.addEntity(BigInt(idMapper.getOrAssign('s2')), { category: 'electronics' }) - store.addEntity(BigInt(idMapper.getOrAssign('n2')), { category: 5 }) - store.addEntity(BigInt(idMapper.getOrAssign('b1')), { category: false }) - await store.flush() - - expect(await uuidsOf('category', 'electronics')).toEqual(['s1', 's2']) - expect(await uuidsOf('category', 5)).toEqual(['n1', 'n2']) - expect(await uuidsOf('category', false)).toEqual(['b1']) - }) - - it('opens an index written by the pre-typed-postings shape and reads it unchanged', async () => { - // A single-kind field is byte-identical to what the old writer produced: - // one manifest at `_column_index//MANIFEST.json`, no kind - // subdirectory anywhere. That IS the old on-disk shape, so proving the - // new reader serves it proves an old index still opens. - store.addEntity(BigInt(idMapper.getOrAssign('a')), { status: 'active' }) - store.addEntity(BigInt(idMapper.getOrAssign('b')), { status: 'archived' }) - await store.flush() - - const keys = await (storage as unknown as { - listObjectsUnderPath: (prefix: string) => Promise - }).listObjectsUnderPath('_column_index/') - expect(keys.some((k) => k.includes('/k/'))).toBe(false) - - await store.close() - store = new ColumnStore({ flushThreshold: 10 }) - await store.init(storage, idMapper) - - expect(store.getFieldKinds('status')).toEqual(['string']) - expect(await uuidsOf('status', 'active')).toEqual(['a']) - }) - }) -}) diff --git a/tests/unit/indexes/columnStore/segment-load-fault.test.ts b/tests/unit/indexes/columnStore/segment-load-fault.test.ts index 9ef4ba13..deb0868f 100644 --- a/tests/unit/indexes/columnStore/segment-load-fault.test.ts +++ b/tests/unit/indexes/columnStore/segment-load-fault.test.ts @@ -5,22 +5,19 @@ * doing so dropped every entity in that segment out of `filter`/`rangeQuery`/ * `sortTopK` with no error, so a corrupt index looked like a merely short result. * - * The three failure classes and their required behaviour (torn-segment - * QUARANTINE contract — a raw throw at query time killed every query on the - * field forever; a silent skip hid the loss; quarantine is the middle): + * The three failure classes and their required behaviour: * - a real storage IO fault (EIO) PROPAGATES verbatim — a present-but-unreadable * segment is not "absent", so it must not read as an empty result; - * - a manifest-listed segment with undecodable bytes is QUARANTINED at - * discovery: the query serves the field's remaining segments degraded and - * `quarantinedSegments()` reports the torn segment (loud once, counted - * always, healable); - * - a manifest-listed segment with NO bytes (gone on disk) quarantines the - * same way. + * - a manifest-listed segment with undecodable bytes throws `ColumnSegmentLoadError`; + * - a manifest-listed segment with NO bytes (gone on disk) throws `ColumnSegmentLoadError`. * Only genuine absence stays benign: querying a field that has no manifest at all * returns empty (nothing was ever written for it) — that is not a fault. */ import { describe, it, expect, beforeEach } from 'vitest' -import { ColumnStore } from '../../../../src/indexes/columnStore/ColumnStore.js' +import { + ColumnStore, + ColumnSegmentLoadError +} from '../../../../src/indexes/columnStore/ColumnStore.js' import { MemoryStorage } from '../../../../src/storage/adapters/memoryStorage.js' import { EntityIdMapper } from '../../../../src/utils/entityIdMapper.js' @@ -83,44 +80,30 @@ describe('ColumnStore segment-load faults surface loudly, absence stays benign ( return s } - it('propagates a storage IO fault verbatim — not [] and not a quarantine (a present-but-unreadable segment is not torn)', async () => { + it('propagates a storage IO fault verbatim — not [] and not a ColumnSegmentLoadError', async () => { storage.faultMode = 'io' const store = await reopen() await expect(store.filter('createdAt', 300)).rejects.toMatchObject({ code: 'EIO' }) - // An IO fault is NOT quarantined — the segment may be fine once the disk - // recovers; only torn/absent bytes enter the ledger. - expect(store.quarantinedSegments('createdAt')).toEqual([]) await store.close() }) - it('QUARANTINES an undecodable manifest-listed segment at discovery — the query serves degraded, the ledger names the tear', async () => { + it('throws ColumnSegmentLoadError when a manifest-listed segment is undecodable', async () => { storage.faultMode = 'corrupt' const store = await reopen() - // Degraded-announced serve: the field's only segment is torn, so the - // result is empty — but the query completes instead of throwing. - const sorted = await store.sortTopK('createdAt', 'desc', 10) - expect(sorted).toEqual([]) - const ledger = store.quarantinedSegments('createdAt') - expect(ledger).toHaveLength(1) - expect(ledger[0].error).toMatch(/decode failed/) - expect(ledger[0].hits).toBeGreaterThanOrEqual(1) - // Subsequent queries keep serving (skip + count), never a throw. - const hitsBefore = ledger[0].hits - await expect(store.filter('createdAt', 300)).resolves.toBeDefined() - expect(store.quarantinedSegments('createdAt')[0].hits).toBeGreaterThan(hitsBefore) + await expect( + store.sortTopK('createdAt', 'desc', 10) + ).rejects.toBeInstanceOf(ColumnSegmentLoadError) await store.close() }) - it('QUARANTINES a manifest-listed segment with no loadable bytes — degraded serve, ledger entry, never a throw', async () => { + it('throws ColumnSegmentLoadError when a manifest-listed segment has no loadable bytes', async () => { storage.faultMode = 'missing' const store = await reopen() - const bitmap = await store.rangeQuery('createdAt', 100, 500) - expect(bitmap.size).toBe(0) - const ledger = store.quarantinedSegments('createdAt') - expect(ledger).toHaveLength(1) - expect(ledger[0].error).toMatch(/no loadable bytes/) + await expect( + store.rangeQuery('createdAt', 100, 500) + ).rejects.toBeInstanceOf(ColumnSegmentLoadError) await store.close() }) diff --git a/tests/unit/metadata-cold-read-guard.test.ts b/tests/unit/metadata-cold-read-guard.test.ts index d079982e..40d37de6 100644 --- a/tests/unit/metadata-cold-read-guard.test.ts +++ b/tests/unit/metadata-cold-read-guard.test.ts @@ -3,19 +3,15 @@ * reported cold `find({ where })` returning a silent `[]` on a freshly-opened * brain (a native metadata index that reports data but has not loaded its field * postings). This guard, the field-index counterpart of verifyGraphAdjacencyLive, - * probes a known persisted value on the first filtered find(). - * - * RE-POINTED to the health-gate law: the guard NEVER rebuilds and NEVER walks - * the store from a read — a read-path rebuild is exactly the dark-rebuild - * failure mode the law retires (open() alone owns building). When the probe - * cannot serve the known value it raises a loud MetadataIndexNotReadyError - * IMMEDIATELY, with no rebuild attempt in between — never a silent empty - * result that misrepresents existing data. + * probes a known persisted value on the first filtered find(): if the index does + * not serve it, brainy rebuilds and re-probes, and raises a loud + * MetadataIndexNotReadyError only if the rebuild still can't serve — never a + * silent empty result that misrepresents existing data. * * The 8.0 JS index cold-loads correctly, so we simulate the cold native failure * mode by intercepting the provider's getIdsForFilter/rebuild. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy, NounType, MetadataIndexNotReadyError } from '../../src/index.js' const V = () => Array.from({ length: 384 }, (_, i) => Math.sin(i * 0.1) + 0.001) @@ -31,10 +27,6 @@ describe('Metadata cold-read guard (#venue silent-[])', () => { await brain.flush() }) - afterEach(async () => { - await brain.close() - }) - it('warm brain: filtered find is correct and the guard does not rebuild', async () => { const mi = brain.metadataIndex let rebuilds = 0 @@ -50,19 +42,37 @@ describe('Metadata cold-read guard (#venue silent-[])', () => { mi.rebuild = origRebuild }) - it('cold index: verifyMetadataLive REFUSES immediately — find({where}) throws MetadataIndexNotReadyError, NEVER a silent [], and NEVER a rebuild attempt', async () => { + it('cold index: verifyMetadataLive self-heals via rebuild — find({where}) is correct, NOT silent []', async () => { const mi = brain.metadataIndex const origGetIds = mi.getIdsForFilter.bind(mi) - let rebuilds = 0 const origRebuild = mi.rebuild.bind(mi) + let cold = true brain._metadataVerified = false // re-arm the one-shot for this scenario - mi.getIdsForFilter = async () => [] // cold: the known value never resolves - mi.rebuild = async () => { rebuilds++; return origRebuild() } + mi.getIdsForFilter = async (...a: any[]) => (cold ? [] : origGetIds(...a)) + mi.rebuild = async () => { + await origRebuild() + cold = false // the rebuild warms the postings + } + try { + const res = await brain.find({ where: { status: 'active' }, limit: 100 }) + expect(res.length).toBe(1) // self-healed — the known entity is returned + } finally { + mi.getIdsForFilter = origGetIds + mi.rebuild = origRebuild + } + }) + + it('unrecoverably cold index: find({where}) throws MetadataIndexNotReadyError — never a silent []', async () => { + const mi = brain.metadataIndex + const origGetIds = mi.getIdsForFilter.bind(mi) + const origRebuild = mi.rebuild.bind(mi) + brain._metadataVerified = false + mi.getIdsForFilter = async () => [] // always cold; rebuild can't fix it + mi.rebuild = async () => {} try { await expect(brain.find({ where: { status: 'active' }, limit: 100 })).rejects.toBeInstanceOf( MetadataIndexNotReadyError ) - expect(rebuilds).toBe(0) // the guard never rebuilds from a read — it refuses loudly instead } finally { mi.getIdsForFilter = origGetIds mi.rebuild = origRebuild diff --git a/tests/unit/migration-lock.test.ts b/tests/unit/migration-lock.test.ts index 63f6953e..f0fbbe4c 100644 --- a/tests/unit/migration-lock.test.ts +++ b/tests/unit/migration-lock.test.ts @@ -18,7 +18,7 @@ * the production feature-detection reads it. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy, NounType, MigrationInProgressError } from '../../src/index.js' import { GraphAdjacencyIndex } from '../../src/graph/graphAdjacencyIndex.js' @@ -39,12 +39,6 @@ describe('Migration LOCK (#18) — coordinated 7.x→8.0 auto-upgrade', () => { await brain.init() }) - afterEach(async () => { - // The "close() is not gated" test already closes `brain` itself as its - // own assertion — closing an already-closed brain is a safe no-op here. - await brain.close().catch(() => {}) - }) - it('does not gate operations when no provider is migrating (fast path)', async () => { const id = await brain.add({ data: 'hello', type: NounType.Concept }) expect(id).toBeTruthy() @@ -136,9 +130,6 @@ describe('Migration LOCK (#18) — coordinated 7.x→8.0 auto-upgrade', () => { expect(e).toBeInstanceOf(MigrationInProgressError) expect(e.retryable).toBe(true) expect(typeof e.elapsedMs).toBe('number') - } finally { - // close() is proven not-gated by the test below — safe even mid-migration. - await shortBrain.close() } }) diff --git a/tests/unit/neural/NaturalLanguageProcessor.test.ts b/tests/unit/neural/NaturalLanguageProcessor.test.ts index 79cf9b6e..0800601e 100644 --- a/tests/unit/neural/NaturalLanguageProcessor.test.ts +++ b/tests/unit/neural/NaturalLanguageProcessor.test.ts @@ -343,11 +343,9 @@ describe('NaturalLanguageProcessor', () => { const duration = Date.now() - startTime expect(result).toBeDefined() - // order-of-magnitude guard: worst honest-iron measurement 4.8s - // (CPU-only inference path, 32-core box); 15s budget covers 3x that - expect(duration).toBeLessThan(15000) + expect(duration).toBeLessThan(200) // Should be fast }) - + it('should handle multiple queries efficiently', async () => { const queries = Array(10).fill('Find AI research') @@ -358,10 +356,8 @@ describe('NaturalLanguageProcessor', () => { const duration = Date.now() - startTime expect(results).toHaveLength(10) - // order-of-magnitude guard: worst honest-iron measurement 48.2s for 10 - // concurrent inference-path queries (CPU-only, 32-core box); ~3x headroom - expect(duration).toBeLessThan(150000) - }, 200000) + expect(duration).toBeLessThan(2000) // Should handle batch in reasonable time + }) it('should cache pattern matching for performance', async () => { const query = 'Find machine learning papers' diff --git a/tests/unit/neural/signals/EmbeddingSignal.test.ts b/tests/unit/neural/signals/EmbeddingSignal.test.ts index ad08e045..f1ff5beb 100644 --- a/tests/unit/neural/signals/EmbeddingSignal.test.ts +++ b/tests/unit/neural/signals/EmbeddingSignal.test.ts @@ -13,11 +13,10 @@ describe('EmbeddingSignal', () => { signal = new EmbeddingSignal(brain) }) - afterEach(async () => { + afterEach(() => { signal.clearCache() signal.clearHistory() signal.resetStats() - await brain.close() }) describe('initialization', () => { @@ -219,10 +218,7 @@ describe('EmbeddingSignal', () => { const finalStats = signal.getStats() expect(finalStats.historySize).toBeLessThanOrEqual(1000) // MAX_HISTORY = 1000 - // Inference-bound correctness test (hundreds of real embeds): measured - // 116-174s on honest CPU-only iron across three machines — the timeout - // covers the slowest observed with headroom; the assertions are exact. - }, 600000) + }) it('should clear history', async () => { const vector = await brain.embed('Test') @@ -581,9 +577,8 @@ describe('EmbeddingSignal', () => { const endTime = Date.now() const totalTime = endTime - startTime - // order-of-magnitude guard: worst honest-iron measurement 22.3s - // (CPU-only inference, 32-core box) for 100 entities, 3x headroom - expect(totalTime).toBeLessThan(70000) + // Should be reasonably fast (< 5 seconds for 100 entities) + expect(totalTime).toBeLessThan(5000) const stats = signal.getStats() expect(stats.calls).toBe(100) diff --git a/tests/unit/plugin-activation-loudness.test.ts b/tests/unit/plugin-activation-loudness.test.ts deleted file mode 100644 index 23e50a50..00000000 --- a/tests/unit/plugin-activation-loudness.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * @module tests/unit/plugin-activation-loudness - * @description The plugin-activation swallow closes. Two laws: - * (1) THE NOT-INSTALLED FREE PASS IS EXACT — a resolution failure earns the - * silent skip ONLY when it names the probed package itself, terminated - * where the name ends. A missing platform-binary SIBLING package - * ("-linux-x64-gnu" — what a deploy replacing node_modules - * mid-restart leaves), an inner file path, or a dependency failure is a - * BROKEN install and must fail loud. A production storm ran 90s of - * throttled WASM behind this exact prefix-match hole. - * (2) A GRACEFUL DECLINE IS NARRATED ON THE ALWAYS-ON CHANNEL — activate() - * returning false warns via prodLog, which `silent: true` cannot patch - * away; a declined accelerator is never an invisible degrade. - */ -import { describe, it, expect, vi, afterEach } from 'vitest' -import { Brainy } from '../../src/brainy.js' -import { prodLog } from '../../src/utils/logger.js' - -const isNotInstalled = (error: unknown, pkg: string): boolean => - (Brainy as unknown as { - isPackageNotInstalledError(e: unknown, p: string): boolean - }).isPackageNotInstalledError(error, pkg) - -const resolutionError = (message: string): Error => { - const e = new Error(message) as Error & { code?: string } - e.code = 'ERR_MODULE_NOT_FOUND' - return e -} - -describe('the not-installed free pass is exact', () => { - const PKG = '@soulcraft/cor' - - it('the package itself, quoted or bare → not-installed (the one free path)', () => { - expect(isNotInstalled(resolutionError(`Cannot find package '${PKG}' imported from /app/x.js`), PKG)).toBe(true) - expect(isNotInstalled(resolutionError(`Cannot find module ${PKG}`), PKG)).toBe(true) - }) - - it('a missing platform-binary SIBLING package is a broken install, never not-installed', () => { - expect(isNotInstalled(resolutionError(`Cannot find package '${PKG}-linux-x64-gnu' imported from /app`), PKG)).toBe(false) - expect(isNotInstalled(resolutionError(`Failed to resolve ${PKG}-darwin-arm64`), PKG)).toBe(false) - }) - - it('an inner file path or a non-resolution error is never not-installed', () => { - expect(isNotInstalled(resolutionError(`Cannot find module '/app/node_modules/${PKG}/native/b.node'`), PKG)).toBe(false) - expect(isNotInstalled(new Error(`dlopen failed: wrong ELF class in ${PKG}`), PKG)).toBe(false) - }) -}) - -describe('a graceful decline is narrated on the always-on channel', () => { - afterEach(() => vi.restoreAllMocks()) - - it('activate() → false warns via prodLog even under silent: true', async () => { - process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true' - const warn = vi.spyOn(prodLog, 'warn') - const brain: any = new Brainy({ - requireSubtype: false, - storage: { type: 'memory' }, - silent: true, - dimensions: 384 - }) - brain.use({ name: 'declining-accelerator', activate: async () => false }) - await brain.init() - try { - expect( - warn.mock.calls.some((c) => String(c[0]).includes('"declining-accelerator" declined activation')) - ).toBe(true) - } finally { - await brain.close().catch(() => {}) - } - }) -}) diff --git a/tests/unit/plugin-autodetect.test.ts b/tests/unit/plugin-autodetect.test.ts index ee830c17..37c181ba 100644 --- a/tests/unit/plugin-autodetect.test.ts +++ b/tests/unit/plugin-autodetect.test.ts @@ -89,14 +89,12 @@ describe('Guarded plugin auto-detection (plugins: undefined)', () => { }) const brain: any = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) await expect(brain.init()).rejects.toThrow(/installed but failed to load/) - await brain.close().catch(() => {}) }) it('installed but not a valid plugin (missing activate) → init() throws', async () => { stubImport(async () => ({ default: { name: '@soulcraft/cor' } })) // no activate() const brain: any = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) await expect(brain.init()).rejects.toThrow(/not a valid Brainy plugin/) - await brain.close().catch(() => {}) }) it('installed but activation fails → init() throws (activateAll posture applies)', async () => { @@ -110,7 +108,6 @@ describe('Guarded plugin auto-detection (plugins: undefined)', () => { })) const brain: any = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) await expect(brain.init()).rejects.toThrow(/failed to activate/) - await brain.close().catch(() => {}) }) it('plugins: [] and plugins: false → no probe at all (explicit opt-out)', async () => { @@ -135,6 +132,5 @@ describe('Guarded plugin auto-detection (plugins: undefined)', () => { silent: true }) await expect(brain.init()).rejects.toThrow(/listed in config\.plugins but could not be loaded/) - await brain.close().catch(() => {}) }) }) diff --git a/tests/unit/plugin-version-coupling.test.ts b/tests/unit/plugin-version-coupling.test.ts index d4685ae2..00b236aa 100644 --- a/tests/unit/plugin-version-coupling.test.ts +++ b/tests/unit/plugin-version-coupling.test.ts @@ -63,9 +63,7 @@ describe('getBrainyVersion() — synchronously correct on first call', () => { expect(v).toBe(PACKAGE_VERSION) expect(v).not.toBe('3.14.0') expect(v).not.toBe('0.0.0') // the unknown-read sentinel must not surface in a real install - // Deliberately major-agnostic: the equality with PACKAGE_VERSION above already - // proves the sync read; this shape pin only guards against sentinel garbage. - expect(v).toMatch(/^\d+\.\d+\.\d+/) + expect(v.startsWith('8.')).toBe(true) }) }) @@ -97,16 +95,13 @@ describe('version coupling at init() — no silent fallback', () => { await brain.close() }) - it('does NOT throw for a realistic version-matched caret range on a COLD init', async () => { + it('does NOT throw for a realistic cor 3.x range (^8.0.0) on a COLD init', async () => { // The actual regression: loadPlugins() is the first init step and makes the - // first getBrainyVersion() call, so a stale sync default ('3.14.0') would - // reject a correctly-matched native provider declaring the real caret range — - // it fails ^ just as it failed ^8, so the regression intent is - // preserved while the range stays major-agnostic. A fresh brain registering a - // `^.0.0` plugin must init cleanly. - const major = PACKAGE_VERSION.split('.')[0] + // first getBrainyVersion() call, so a stale sync default would reject a + // correctly-matched native provider declaring the real 8.x range. A fresh + // brain registering a `^8.0.0` plugin must init cleanly. const brain = memBrain() - brain.use(fakePlugin('@fake/cor-3x', { brainyRange: `^${major}.0.0` })) + brain.use(fakePlugin('@fake/cor-3x', { brainyRange: '^8.0.0' })) await expect(brain.init()).resolves.toBeUndefined() await brain.close() }) @@ -143,6 +138,5 @@ describe('version coupling at init() — no silent fallback', () => { plugins: ['@soulcraft/this-package-does-not-exist-xyz'] }) await expect(brain.init()).rejects.toThrow(/could not be loaded|config\.plugins/) - await brain.close().catch(() => {}) }) }) diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index 82543120..f4064188 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -298,10 +298,9 @@ describe('Brainy plugin integration', () => { // must surface as a failed init(), NOT a silent degrade to the default // engine (the version-coupling guard; see plugin-version-coupling.test.ts). await expect(brain.init()).rejects.toThrow(/failed to activate|native module not found/) - await brain.close().catch(() => {}) }) - it('should use() return this for chaining', async () => { + it('should use() return this for chaining', () => { const plugin: BrainyPlugin = { name: 'chain-test', activate: async () => true @@ -310,8 +309,5 @@ describe('Brainy plugin integration', () => { const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) const result = brain.use(plugin) expect(result).toBe(brain) - // Never init()'d — the constructor still registered it in Brainy's global - // instance registry, so it still needs a close() to deregister. - await brain.close().catch(() => {}) }) }) diff --git a/tests/unit/plugin/provider-generation.test.ts b/tests/unit/plugin/provider-generation.test.ts deleted file mode 100644 index 6295b6f1..00000000 --- a/tests/unit/plugin/provider-generation.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -/** - * Generation threading to the metadata-index and vector-index provider write - * surfaces — the counterpart of the graph pins in - * tests/unit/transaction/graphIndexOperations-generation.test.ts. - * - * The provider contract gained an optional trailing `generation?: bigint` on - * `MetadataIndexProvider.addToIndex`/`removeFromIndex`, - * `VectorIndexProvider.addItem`/`removeItem` (+ the feature-detected - * `updateItem`), and the id-mapper's `getOrAssign`/`remove`. A native provider - * with per-record delta logs stamps its durable records with it — so the value - * arriving MUST be the real commit generation (nonzero, monotonic), never a - * fabricated 0 and never absent on the coordinator's write paths. - * - * Two layers of pins: - * 1. End-to-end: provider doubles registered via the plugin system capture - * the generation argument during brain.add()/update()/remove() and it - * must equal the committed watermark (`brain.now().generation`). - * 2. Operation layer: execute-time (not construction-time) resolution, and - * one shared generation across an op's forward + rollback halves. - */ -import { describe, it, expect, afterEach } from 'vitest' -import { Brainy, NounType } from '../../../src/index.js' -import { MetadataIndexManager } from '../../../src/utils/metadataIndex.js' -import { - AddToVectorIndexOperation, - RemoveFromVectorIndexOperation, - ReplaceInVectorIndexOperation, - AddToMetadataIndexOperation, - RemoveFromMetadataIndexOperation -} from '../../../src/transaction/operations/IndexOperations.js' -import type { VectorIndexProvider } from '../../../src/plugin.js' - -const V = () => Array.from({ length: 384 }, () => Math.random()) - -type Captured = { method: string; id: string; generation: bigint | undefined } - -const brains: Brainy[] = [] -afterEach(async () => { - for (const b of brains.splice(0)) await b.close().catch(() => {}) -}) - -/** Metadata manager subclass that records the generation of every write. */ -function makeCapturingMetadataFactory(calls: Captured[]) { - return (storage: any) => { - class CapturingManager extends MetadataIndexManager { - async addToIndex(id: string, entityOrMetadata: any, skipFlush = false, deferWrites = false, generation?: bigint): Promise { - calls.push({ method: 'addToIndex', id, generation }) - return super.addToIndex(id, entityOrMetadata, skipFlush, deferWrites, generation) - } - async removeFromIndex(id: string, metadata?: any, generation?: bigint): Promise { - calls.push({ method: 'removeFromIndex', id, generation }) - return super.removeFromIndex(id, metadata, generation) - } - } - return new CapturingManager(storage) - } -} - -/** Minimal vector-index double capturing the generation of every write. */ -function makeCapturingVectorFactory(calls: Captured[]) { - return () => { - const items = new Map() - const double: VectorIndexProvider & { updateItem(item: { id: string; vector: number[] }, generation?: bigint): Promise } = { - name: 'capture-double', - async addItem(item, generation) { - calls.push({ method: 'addItem', id: item.id, generation }) - items.set(item.id, item.vector as number[]) - return item.id - }, - async removeItem(id, generation) { - calls.push({ method: 'removeItem', id, generation }) - return items.delete(id) - }, - async updateItem(item, generation) { - calls.push({ method: 'updateItem', id: item.id, generation }) - items.set(item.id, item.vector) - }, - async search() { return [] }, - size: () => items.size, - clear: () => { items.clear() }, - async rebuild() {}, - async flush() { return 0 }, - getPersistMode: () => 'deferred' as const - } - return double - } -} - -async function makeBrain(plugin: any): Promise { - const brain = new Brainy({ - storage: { type: 'memory' }, - requireSubtype: false, - silent: true, - plugins: [] - }) - brain.use(plugin) - await brain.init() - brains.push(brain) - return brain -} - -describe('Metadata-index provider — real commit generation on every write (end-to-end)', () => { - it('add()/update()/remove() pass the nonzero, monotonic commit generation to addToIndex/removeFromIndex', async () => { - const calls: Captured[] = [] - const brain = await makeBrain({ - name: 'capture-metadata', - activate: async (ctx: any) => { - ctx.registerProvider('metadataIndex', makeCapturingMetadataFactory(calls)) - return true - } - }) - - const id = await brain.add({ data: 'one', type: NounType.Concept, metadata: { k: 'a' }, vector: V() }) - const addCall = calls.find((c) => c.method === 'addToIndex' && c.id === id) - expect(addCall).toBeDefined() - expect(typeof addCall!.generation).toBe('bigint') - expect(addCall!.generation!).toBeGreaterThan(0n) - // Committed watermark after a single-op write IS this write's generation. - expect(addCall!.generation!).toBe(BigInt(brain.now().generation)) - - calls.length = 0 - await brain.update({ id, metadata: { k: 'b' } }) - const updRemove = calls.find((c) => c.method === 'removeFromIndex' && c.id === id) - const updAdd = calls.find((c) => c.method === 'addToIndex' && c.id === id) - expect(updRemove?.generation).toBeDefined() - expect(updAdd?.generation).toBeDefined() - // One commit → the remove-old + add-new legs share one watermark. - expect(updAdd!.generation!).toBe(updRemove!.generation!) - expect(updAdd!.generation!).toBe(BigInt(brain.now().generation)) - const updateGen = updAdd!.generation! - expect(updateGen).toBeGreaterThan(0n) - - calls.length = 0 - await brain.remove(id) - const rmCall = calls.find((c) => c.method === 'removeFromIndex' && c.id === id) - expect(rmCall?.generation).toBeDefined() - expect(rmCall!.generation!).toBeGreaterThan(updateGen) // monotonic - expect(rmCall!.generation!).toBe(BigInt(brain.now().generation)) - }) - - it('transact() adds stamp the batch receipt generation', async () => { - const calls: Captured[] = [] - const brain = await makeBrain({ - name: 'capture-metadata-tx', - activate: async (ctx: any) => { - ctx.registerProvider('metadataIndex', makeCapturingMetadataFactory(calls)) - return true - } - }) - - // Bootstrap honesty: init-time infrastructure writes (the VFS root) are - // applied WITHOUT a generation — the provider must receive undefined, - // never a fabricated 0. - for (const c of calls) expect(c.generation).toBeUndefined() - calls.length = 0 - - const db = await brain.transact([ - { op: 'add', data: 'tx-one', type: NounType.Concept, vector: V() }, - { op: 'add', data: 'tx-two', type: NounType.Concept, vector: V() } - ] as any) - - const receiptGen = BigInt(db.receipt!.generation) - const addGens = calls.filter((c) => c.method === 'addToIndex').map((c) => c.generation) - expect(addGens.length).toBeGreaterThanOrEqual(2) - for (const g of addGens) expect(g).toBe(receiptGen) - }) -}) - -describe('Vector-index provider — real commit generation on every write (end-to-end)', () => { - it('add()/update()/remove() pass the nonzero commit generation to addItem/updateItem/removeItem', async () => { - const calls: Captured[] = [] - const brain = await makeBrain({ - name: 'capture-vector', - activate: async (ctx: any) => { - ctx.registerProvider('vector', makeCapturingVectorFactory(calls)) - return true - } - }) - - const id = await brain.add({ data: 'vec', type: NounType.Concept, vector: V() }) - const addCall = calls.find((c) => c.method === 'addItem' && c.id === id) - expect(addCall).toBeDefined() - expect(typeof addCall!.generation).toBe('bigint') - expect(addCall!.generation!).toBeGreaterThan(0n) - expect(addCall!.generation!).toBe(BigInt(brain.now().generation)) - - calls.length = 0 - await brain.update({ id, vector: V() }) - const updCall = calls.find((c) => c.method === 'updateItem' && c.id === id) - expect(updCall?.generation).toBeDefined() - expect(updCall!.generation!).toBeGreaterThan(addCall!.generation!) // monotonic - expect(updCall!.generation!).toBe(BigInt(brain.now().generation)) - - calls.length = 0 - await brain.remove(id) - const rmCall = calls.find((c) => c.method === 'removeItem' && c.id === id) - expect(rmCall?.generation).toBeDefined() - expect(rmCall!.generation!).toBeGreaterThan(updCall!.generation!) - expect(rmCall!.generation!).toBe(BigInt(brain.now().generation)) - }) -}) - -describe('Index operations — generation threading (operation layer)', () => { - function makeVectorSpy() { - const calls: Array<{ method: string; generation: bigint | undefined }> = [] - const index = { - name: 'spy', - async addItem(_item: any, generation?: bigint) { calls.push({ method: 'addItem', generation }); return 'x' }, - async removeItem(_id: string, generation?: bigint) { calls.push({ method: 'removeItem', generation }); return true }, - async updateItem(_item: any, generation?: bigint) { calls.push({ method: 'updateItem', generation }) } - } as unknown as VectorIndexProvider - return { index, calls } - } - - it('vector add/remove/replace resolve the thunk at EXECUTE time and reuse one generation for rollback', async () => { - const { index, calls } = makeVectorSpy() - let current = 1n - const op = new AddToVectorIndexOperation(index, 'id-1', [1, 2], () => current) - current = 42n // assigned after construction, read at execute - const rollback = await op.execute() - expect(calls[0]).toEqual({ method: 'addItem', generation: 42n }) - current = 77n // rollback must NOT re-read — one watermark per round trip - await rollback() - expect(calls[1]).toEqual({ method: 'removeItem', generation: 42n }) - - calls.length = 0 - const rm = new RemoveFromVectorIndexOperation(index, 'id-1', [1, 2], () => 7n) - const rb2 = await rm.execute() - await rb2() - expect(calls).toEqual([ - { method: 'removeItem', generation: 7n }, - { method: 'addItem', generation: 7n } - ]) - - calls.length = 0 - const rep = new ReplaceInVectorIndexOperation(index, 'id-1', [1, 2], [3, 4], () => 9n) - const rb3 = await rep.execute() - await rb3() - expect(calls).toEqual([ - { method: 'updateItem', generation: 9n }, - { method: 'updateItem', generation: 9n } - ]) - }) - - it('metadata add/remove pass the resolved generation through both halves', async () => { - const calls: Array<{ method: string; generation: bigint | undefined }> = [] - const manager = { - async addToIndex(_id: string, _e: any, _s?: boolean, _d?: boolean, generation?: bigint) { - calls.push({ method: 'addToIndex', generation }) - }, - async removeFromIndex(_id: string, _m?: any, generation?: bigint) { - calls.push({ method: 'removeFromIndex', generation }) - } - } as unknown as MetadataIndexManager - - const add = new AddToMetadataIndexOperation(manager, 'id-1', { type: 'x' }, () => 11n) - const rb = await add.execute() - await rb() - const rm = new RemoveFromMetadataIndexOperation(manager, 'id-1', { type: 'x' }, () => 12n) - const rb2 = await rm.execute() - await rb2() - expect(calls).toEqual([ - { method: 'addToIndex', generation: 11n }, - { method: 'removeFromIndex', generation: 11n }, - { method: 'removeFromIndex', generation: 12n }, - { method: 'addToIndex', generation: 12n } - ]) - }) - - it('omitted thunk (legacy caller) → provider receives undefined, never a fabricated 0', async () => { - const { index, calls } = makeVectorSpy() - const op = new AddToVectorIndexOperation(index, 'id-1', [1, 2]) - await op.execute() - expect(calls[0]).toEqual({ method: 'addItem', generation: undefined }) - }) -}) diff --git a/tests/unit/release/wall-entry.test.ts b/tests/unit/release/wall-entry.test.ts deleted file mode 100644 index 8bf9d357..00000000 --- a/tests/unit/release/wall-entry.test.ts +++ /dev/null @@ -1,395 +0,0 @@ -/** - * scripts/wall-entry.mjs — the mechanical releases-wall entry. - * - * The script's only real interface is its CLI (it has no importable - * exports by design — one door, no parallel API to drift from it), so - * these tests spawn it exactly as scripts/release.sh does: as a child - * process, against a fixture CHANGELOG and a throwaway local bare repo - * standing in for git@source.soulcraft.com:soulcraftlabs/releases.git - * (--remote) plus a throwaway cache directory (--cache-dir) standing in - * for ~/.cache/soulcraft-releases — never the real remote, never the - * real developer cache. - */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { execFileSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync, readFileSync, chmodSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -const SCRIPT = join(process.cwd(), 'scripts/wall-entry.mjs') - -/** Run the script and capture the outcome without throwing on a non-zero exit. */ -function run(args: string[], cwd: string): { status: number; stdout: string; stderr: string } { - try { - const stdout = execFileSync('node', [SCRIPT, ...args], { cwd, encoding: 'utf8' }) - return { status: 0, stdout, stderr: '' } - } catch (err: any) { - return { status: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' } - } -} - -function git(args: string[], cwd: string): string { - return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim() -} - -const CHANGELOG_HEADER = '# Changelog\n\nAll notable changes, in this fixture.\n' - -/** Build a CHANGELOG.md with one entry per [version, bullets[]] pair, newest first. */ -function buildChangelog(entries: Array<{ version: string; date: string; bullets: string[] }>): string { - const body = entries - .map( - (e) => - `### [${e.version}](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/vX...v${e.version}) (${e.date})\n\n` + - e.bullets.map((b) => `- ${b} (abc1234)`).join('\n') + - '\n', - ) - .join('\n') - return CHANGELOG_HEADER + '\n' + body -} - -function wallFile(product: string, entries: unknown[]): string { - return JSON.stringify({ product, entries }, null, 2) + '\n' -} - -const BASE_ENTRY = { - version: '10.4.11', - date: '2026-09-02', - headline: 'A faster open', - items: ['A faster open.'], - url: 'https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.11', - thumb: null, -} - -/** A throwaway bare repo standing in for the real soulcraftlabs/releases remote. */ -function initBareRemote(): string { - const remoteDir = mkdtempSync(join(tmpdir(), 'wall-remote-')) - execFileSync('git', ['init', '--bare', '-b', 'main', remoteDir]) - return remoteDir -} - -/** Seed the bare remote with an initial .json, via a throwaway clone. */ -function seedRemote(remoteDir: string, product: string, entries: unknown[]): void { - const seedDir = mkdtempSync(join(tmpdir(), 'wall-seed-')) - execFileSync('git', ['clone', remoteDir, seedDir], { stdio: 'ignore' }) - git(['config', 'user.email', 'seed@example.com'], seedDir) - git(['config', 'user.name', 'Seed'], seedDir) - writeFileSync(join(seedDir, `${product}.json`), wallFile(product, entries)) - git(['add', `${product}.json`], seedDir) - git(['commit', '-m', 'seed'], seedDir) - git(['push', 'origin', 'main'], seedDir) - rmSync(seedDir, { recursive: true, force: true }) -} - -/** Read .json back out of the bare remote's main tip, via a throwaway clone. */ -function readRemote(remoteDir: string, product: string): any { - const readDir = mkdtempSync(join(tmpdir(), 'wall-read-')) - execFileSync('git', ['clone', remoteDir, readDir], { stdio: 'ignore' }) - const data = JSON.parse(readFileSync(join(readDir, `${product}.json`), 'utf8')) - rmSync(readDir, { recursive: true, force: true }) - return data -} - -/** Reject every push — stands in for any push failure (including a genuine - * non-fast-forward raced by a concurrent release rail), which this script - * treats identically: refuse loudly, name the cure, touch nothing further. */ -function makeRemoteRejectPushes(remoteDir: string): void { - const hookPath = join(remoteDir, 'hooks', 'pre-receive') - writeFileSync(hookPath, '#!/bin/sh\necho "remote: simulated push rejection" >&2\nexit 1\n') - chmodSync(hookPath, 0o755) -} - -let dir: string -let remoteDir: string -let cacheDir: string - -beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'wall-entry-test-')) - remoteDir = initBareRemote() - cacheDir = join(mkdtempSync(join(tmpdir(), 'wall-cache-')), 'soulcraft-releases') -}) - -afterEach(() => { - rmSync(dir, { recursive: true, force: true }) - rmSync(remoteDir, { recursive: true, force: true }) - rmSync(cacheDir, { recursive: true, force: true }) -}) - -describe('wall-entry.mjs — generate + publish', () => { - it('derives headline from the first bullet and items from every bullet, hashes stripped, and pushes it to the remote', () => { - seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) - writeFileSync( - join(dir, 'CHANGELOG.md'), - buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix(wall): mechanize the entry', 'test(wall): pin the shape'] }]), - ) - - const result = run( - ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - expect(result.status).toBe(0) - expect(result.stdout).toMatch(/wrote v10\.4\.12.*pushed/i) - - const wall = readRemote(remoteDir, 'open-brainy') - expect(wall.entries).toHaveLength(2) - expect(wall.entries[0]).toEqual({ - version: '10.4.12', - date: '2026-09-03', - headline: 'fix(wall): mechanize the entry', - items: ['fix(wall): mechanize the entry', 'test(wall): pin the shape'], - url: 'https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.12', - thumb: null, - }) - // the older entry stays put, still second - expect(wall.entries[1].version).toBe('10.4.11') - }) - - it('prepends newest-first — the new entry lands at index 0 ahead of every existing one', () => { - seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY, { ...BASE_ENTRY, version: '10.4.10' }]) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.5.0', date: '2026-09-03', bullets: ['feat: ten five'] }])) - - run(['--product', 'open-brainy', '--version', '10.5.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir) - - const wall = readRemote(remoteDir, 'open-brainy') - expect(wall.entries.map((e: any) => e.version)).toEqual(['10.5.0', '10.4.11', '10.4.10']) - }) - - it('replaces an entry with the same version instead of duplicating it — idempotent re-runs', () => { - seedRemote(remoteDir, 'open-brainy', [ - { ...BASE_ENTRY, headline: 'stale headline, pre-fix' }, - { ...BASE_ENTRY, version: '10.4.10' }, - ]) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: the corrected headline'] }])) - - const result = run( - ['--product', 'open-brainy', '--version', '10.4.11', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - expect(result.status).toBe(0) - expect(result.stdout).toMatch(/replaced v10\.4\.11/i) - - const wall = readRemote(remoteDir, 'open-brainy') - expect(wall.entries).toHaveLength(2) // not 3 — replaced, not duplicated - expect(wall.entries[0].version).toBe('10.4.11') - expect(wall.entries[0].headline).toBe('fix: the corrected headline') - expect(wall.entries[1].version).toBe('10.4.10') - }) - - it('a re-run with byte-identical content commits nothing and still succeeds', () => { - // headline always equals items[0] for a derived entry, so this fixture - // (unlike BASE_ENTRY, whose headline/items intentionally diverge for the - // shape-only tests below) has to keep the two in lockstep to ever roundtrip. - const stableEntry = { ...BASE_ENTRY, headline: 'A faster open.', items: ['A faster open.'] } - seedRemote(remoteDir, 'open-brainy', [stableEntry]) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['A faster open.'] }])) - const before = readRemote(remoteDir, 'open-brainy') - - const result = run( - ['--product', 'open-brainy', '--version', '10.4.11', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - expect(result.status).toBe(0) - expect(result.stdout).toMatch(/nothing to commit/i) - expect(readRemote(remoteDir, 'open-brainy')).toEqual(before) - }) - - it('derives the public package-page permalink for the product engine (private repo, never null)', () => { - seedRemote(remoteDir, 'brainy', [{ ...BASE_ENTRY, version: '11.0.5', url: 'https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/11.0.5' }]) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '11.0.6', date: '2026-09-03', bullets: ['fix: a native-only fix'] }])) - - const result = run( - ['--product', 'brainy', '--version', '11.0.6', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - expect(result.status).toBe(0) - - const wall = readRemote(remoteDir, 'brainy') - expect(wall.entries[0].url).toBe('https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/11.0.6') - expect(wall.entries[0].thumb).toBeNull() - }) - - it('refuses a product with no permalink pattern, naming the cure', () => { - seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '1.0.0', date: '2026-09-03', bullets: ['feat: first'] }])) - - const result = run(['--product', 'mystery', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir) - expect(result.status).not.toBe(0) - expect(result.stderr).toMatch(/no permalink pattern for product "mystery"/) - expect(result.stderr).toMatch(/never carry url: null/) - }) - - it('refuses when the CHANGELOG has no entry yet for the target version, and touches no remote', () => { - seedRemote(remoteDir, 'open-brainy', []) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: whatever'] }])) - const beforeSha = git(['rev-parse', 'main'], remoteDir) - - const result = run( - ['--product', 'open-brainy', '--version', '99.0.0', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/no CHANGELOG entry yet/i) - expect(git(['rev-parse', 'main'], remoteDir)).toBe(beforeSha) - }) - - it('refuses by naming the cure when the remote cannot be cloned', () => { - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: whatever'] }])) - const noSuchRemote = join(tmpdir(), 'wall-remote-does-not-exist-' + Date.now()) - - const result = run( - ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', noSuchRemote, '--cache-dir', cacheDir], - dir, - ) - - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/cannot clone/i) - expect(result.stderr).toMatch(/cure:/i) - }) - - it('refuses by naming the cure, and touches no remote, when the fetched wall fails shape validation', () => { - const seedDir = mkdtempSync(join(tmpdir(), 'wall-seed-broken-')) - execFileSync('git', ['clone', remoteDir, seedDir], { stdio: 'ignore' }) - git(['config', 'user.email', 'seed@example.com'], seedDir) - git(['config', 'user.name', 'Seed'], seedDir) - writeFileSync( - join(seedDir, 'open-brainy.json'), - JSON.stringify({ product: 'open-brainy', entries: [{ version: '10.4.11', date: '2026-09-02', items: ['x'], url: null }] }, null, 2), - ) - git(['add', 'open-brainy.json'], seedDir) - git(['commit', '-m', 'seed broken'], seedDir) - git(['push', 'origin', 'main'], seedDir) - rmSync(seedDir, { recursive: true, force: true }) - const beforeSha = git(['rev-parse', 'main'], remoteDir) - - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: whatever'] }])) - - const result = run( - ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/fails shape validation/i) - expect(result.stderr).toMatch(/missing key\(s\) headline/i) - expect(git(['rev-parse', 'main'], remoteDir)).toBe(beforeSha) - }) - - it('refuses by naming the cure when the remote rejects the push (stands in for a raced non-fast-forward)', () => { - seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) - makeRemoteRejectPushes(remoteDir) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: whatever'] }])) - - const result = run( - ['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/push to .* failed/i) - expect(result.stderr).toMatch(/cure:/i) - }) - - it('refuses a cross-product write when the file\'s "product" field does not match --product', () => { - seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) - const seedDir = mkdtempSync(join(tmpdir(), 'wall-seed-mismatch-')) - execFileSync('git', ['clone', remoteDir, seedDir], { stdio: 'ignore' }) - git(['config', 'user.email', 'seed@example.com'], seedDir) - git(['config', 'user.name', 'Seed'], seedDir) - const corrupted = JSON.parse(readFileSync(join(seedDir, 'open-brainy.json'), 'utf8')) - corrupted.product = 'brainy' - writeFileSync(join(seedDir, 'open-brainy.json'), JSON.stringify(corrupted, null, 2) + '\n') - git(['add', 'open-brainy.json'], seedDir) - git(['commit', '-m', 'corrupt product field'], seedDir) - git(['push', 'origin', 'main'], seedDir) - rmSync(seedDir, { recursive: true, force: true }) - - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '1.0.0', date: '2026-09-03', bullets: ['fix: wrong repo'] }])) - - const result = run( - ['--product', 'open-brainy', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/product "brainy".*--product "open-brainy"/i) - }) -}) - -describe('wall-entry.mjs — --dry-run', () => { - it('prints the entry and the target path, and touches neither the cache dir nor the remote', () => { - seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY]) - writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: a dry run'] }])) - const beforeSha = git(['rev-parse', 'main'], remoteDir) - - const result = run( - ['--dry-run', '--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], - dir, - ) - - expect(result.status).toBe(0) - expect(result.stdout).toMatch(/would write to/i) - expect(result.stdout).toMatch(/"version": "10\.4\.12"/) - expect(git(['rev-parse', 'main'], remoteDir)).toBe(beforeSha) - }) -}) - -describe('wall-entry.mjs — --check', () => { - it('passes a well-formed, newest-first file with no duplicates', () => { - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY, { ...BASE_ENTRY, version: '10.4.10' }])) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(0) - expect(result.stdout).toMatch(/OK/) - }) - - it('passes a file where "thumb" is entirely absent (optional per the HQ contract)', () => { - const { thumb, ...noThumb } = BASE_ENTRY as any - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [noThumb])) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(0) - }) - - it('catches a missing entry key', () => { - const broken = { version: '1.0.0', date: '2026-09-03', headline: 'h', items: ['i'] } // no "url" - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [broken])) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/missing key\(s\) url/) - }) - - it('catches an unexpected top-level key (e.g. the retired "history" field)', () => { - const raw = JSON.parse(wallFile('open-brainy', [BASE_ENTRY])) - raw.history = 'retired field' - writeFileSync(join(dir, 'wall.json'), JSON.stringify(raw)) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/unexpected key\(s\) history/) - }) - - it('catches entries that are not newest-first', () => { - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [{ ...BASE_ENTRY, version: '10.4.10' }, BASE_ENTRY])) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/not newest-first/) - }) - - it('catches a duplicate version even with identical entries', () => { - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY, { ...BASE_ENTRY }])) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/duplicate version 10\.4\.11/) - }) - - it('catches an empty items array', () => { - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [{ ...BASE_ENTRY, items: [] }])) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/"items" must be a non-empty array/) - }) - - it('catches a malformed date', () => { - writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [{ ...BASE_ENTRY, date: '09/03/2026' }])) - const result = run(['--check', '--file', 'wall.json'], dir) - expect(result.status).toBe(1) - expect(result.stderr).toMatch(/"date" must be a YYYY-MM-DD string/) - }) -}) diff --git a/tests/unit/reprojection/reprojection-engine.test.ts b/tests/unit/reprojection/reprojection-engine.test.ts deleted file mode 100644 index 58d10b44..00000000 --- a/tests/unit/reprojection/reprojection-engine.test.ts +++ /dev/null @@ -1,590 +0,0 @@ -/** - * @module tests/unit/reprojection/reprojection-engine - * @description Spec-by-example for the pure-TS reprojection engine — the - * frozen contract mirrored from the native twin (a shared conformance suite - * runs against both, so the shapes pinned here are load-bearing): - * - * (a) register + advance folds a scripted source to caught-up with exact - * watermark/applied counts and adapter-owned stamping; - * (b) budget exhaustion answers mid-stream and a second advance RESUMES from - * the watermark — never a refold; - * (c) a door bump mid-advance preempts within one installment — pinned by - * MECHANISM (no further applyBatch after the bumping step), with only a - * generous wall-clock sanity bound; - * (d) advanceAll round-robins families at batch granularity — no starvation; - * (e) swap builds beside (the old adapter serves throughout), flips - * atomically at parity, refuses a concurrent swap with a typed error; - * (f) quarantine: a typed poison fact is skipped + ledgered, narration - * doubles, a NON-typed throw aborts loudly; - * (g) discard() lands on the LOSING adapter after a swap. - */ -import { describe, it, expect, vi, afterEach } from 'vitest' -import { - ReprojectionEngine, - DoorSignal, - ProjectionApplyError, - SwapInFlightError, - MAX_INSTALLMENT_MS, - type ProjectionAdapter, - type FactSource -} from '../../../src/reprojection/reprojectionEngine.js' -import { FactLogSource } from '../../../src/reprojection/factLogSource.js' -import type { CommitFact } from '../../../src/db/factLog.js' -import { prodLog } from '../../../src/utils/logger.js' - -/** Build one committed fact for a generation. */ -function fact(generation: number): CommitFact { - return { - generation, - timestamp: 1_700_000_000_000 + generation, - ops: [ - { - kind: 'noun', - id: `id-${generation}`, - record: { metadata: { n: generation }, vector: null } - } - ] - } -} - -/** A scripted FactSource over a (possibly mutable) list of generations. */ -function scriptedSource(gens: () => number[]): FactSource { - return { - async scan(from: number, limit: number): Promise { - return gens() - .filter((g) => g > from) - .sort((x, y) => x - y) - .slice(0, limit) - .map(fact) - } - } -} - -/** - * A recording in-memory adapter: stamps after data (the watermark advances - * only after a successful apply), applies idempotently (a Map keyed by - * generation), and can be scripted to poison (typed) or hard-fail (untyped) - * specific generations, or to run a hook inside applyBatch. - */ -class RecordingAdapter implements ProjectionAdapter { - readonly family: string - /** Generations per applyBatch call, in call order (empty arrays included). */ - readonly batches: number[][] = [] - /** The upTo passed to each applyBatch call, in call order. */ - readonly upTos: number[] = [] - /** Latest state per generation — idempotent under at-least-once delivery. */ - readonly state = new Map() - /** Generations that throw a typed ProjectionApplyError. */ - readonly poison = new Set() - /** Generations that throw a plain (untyped) Error. */ - readonly hardFail = new Set() - /** Runs inside applyBatch after validation, before the stamp. */ - onApply?: (gens: number[]) => void | Promise - discarded = 0 - private wm: number | null - - constructor(family: string, watermark: number | null = null) { - this.family = family - this.wm = watermark - } - - watermark(): number | null { - return this.wm - } - - async applyBatch(facts: CommitFact[], upTo: number): Promise { - for (const [i, f] of facts.entries()) { - if (this.hardFail.has(f.generation)) { - throw new Error(`disk exploded at generation ${f.generation}`) - } - if (this.poison.has(f.generation)) { - throw new ProjectionApplyError({ - generation: f.generation, - recordIndex: i, - cause: new Error(`unfoldable payload at ${f.generation}`) - }) - } - } - for (const f of facts) this.state.set(f.generation, f.ops) - const gens = facts.map((f) => f.generation) - this.batches.push(gens) - this.upTos.push(upTo) - if (this.onApply) await this.onApply(gens) - this.wm = upTo // stamp-after-data - } - - async discard(): Promise { - this.discarded++ - } -} - -const range = (from: number, to: number): number[] => - Array.from({ length: to - from + 1 }, (_, i) => from + i) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('reprojection engine — (a) register + advance to caught-up', () => { - it('folds a scripted source in order, adapter-stamped, with exact counts', async () => { - const source = scriptedSource(() => range(1, 7)) - const engine = new ReprojectionEngine({ source, batchSize: 3 }) - const adapter = new RecordingAdapter('a') - engine.register(adapter) - - const result = await engine.advance('a', { budgetMs: 10_000 }) - - expect(result.status).toBe('caught-up') - expect(result.watermark).toBe(7) - expect(result.applied).toBe(7) - // Batch shape and the upTo handed to the adapter's own stamp. - expect(adapter.batches).toEqual([[1, 2, 3], [4, 5, 6], [7]]) - expect(adapter.upTos).toEqual([3, 6, 7]) - // The watermark is the ADAPTER's stamp — the engine never wrote one. - expect(adapter.watermark()).toBe(7) - expect(engine.getAdapter('a')).toBe(adapter) - }) - - it('honors upTo as an inclusive cap and answers caught-up at the cap', async () => { - const source = scriptedSource(() => range(1, 9)) - const engine = new ReprojectionEngine({ source, batchSize: 3 }) - const adapter = new RecordingAdapter('a') - engine.register(adapter) - - const result = await engine.advance('a', { budgetMs: 10_000, upTo: 5 }) - - expect(result.status).toBe('caught-up') - expect(result.watermark).toBe(5) - expect(result.applied).toBe(5) - expect(adapter.batches.flat()).toEqual([1, 2, 3, 4, 5]) - }) - - it('a caught-up family answers immediately with zero applied', async () => { - const source = scriptedSource(() => range(1, 4)) - const engine = new ReprojectionEngine({ source, batchSize: 10 }) - const adapter = new RecordingAdapter('a', 4) // already stamped to the head - engine.register(adapter) - - const result = await engine.advance('a', { budgetMs: 10_000 }) - - expect(result).toEqual({ status: 'caught-up', watermark: 4, applied: 0 }) - expect(adapter.batches).toEqual([]) - }) - - it('refuses duplicate registration and unregistered families loudly', async () => { - const engine = new ReprojectionEngine({ source: scriptedSource(() => []) }) - engine.register(new RecordingAdapter('a')) - expect(() => engine.register(new RecordingAdapter('a'))).toThrow(/already registered/) - await expect(engine.advance('ghost', { budgetMs: 0 })).rejects.toThrow(/not registered/) - }) -}) - -describe('reprojection engine — (b) budget exhaustion resumes, never refolds', () => { - it('returns budget-exhausted mid-stream; the next advance resumes from the watermark', async () => { - const source = scriptedSource(() => range(1, 10)) - const engine = new ReprojectionEngine({ source, batchSize: 2 }) - const adapter = new RecordingAdapter('b') - engine.register(adapter) - - // Zero budget: exactly ONE step of guaranteed progress, then the answer. - const first = await engine.advance('b', { budgetMs: 0 }) - expect(first.status).toBe('budget-exhausted') - expect(first.watermark).toBe(2) - expect(first.applied).toBe(2) - expect(adapter.batches).toEqual([[1, 2]]) - - // The second advance RESUMES from the stamp — its first batch starts at 3. - const second = await engine.advance('b', { budgetMs: 10_000 }) - expect(second.status).toBe('caught-up') - expect(second.watermark).toBe(10) - expect(second.applied).toBe(8) - expect(adapter.batches[1]).toEqual([3, 4]) - // No refold: every generation delivered exactly once across both calls. - expect(adapter.batches.flat()).toEqual(range(1, 10)) - }) -}) - -describe('reprojection engine — (c) door bump preempts within one installment', () => { - it('a bump during a step yields preempted at that step boundary — no further applyBatch', async () => { - const source = scriptedSource(() => range(1, 12)) - const engine = new ReprojectionEngine({ source, batchSize: 2 }) - const adapter = new RecordingAdapter('c') - adapter.onApply = (gens) => { - if (gens[0] === 3) engine.doorSignal.bump() // door traffic mid-second-batch - } - engine.register(adapter) - - const started = Date.now() - const result = await engine.advance('c', { budgetMs: 60_000 }) - const elapsed = Date.now() - started - - expect(result.status).toBe('preempted') - expect(result.watermark).toBe(4) - expect(result.applied).toBe(4) - // THE MECHANISM PIN: the batch that observed the bump was the LAST batch — - // preemption landed at the very next boundary, not after more work. - expect(adapter.batches).toEqual([[1, 2], [3, 4]]) - // Generous wall-clock sanity only (the pin above carries the contract): - // two tiny batches plus one installment boundary sit far under 5s. - expect(elapsed).toBeLessThan(5_000) - expect(MAX_INSTALLMENT_MS).toBe(50) - - // Resuming folds the rest — preemption lost nothing. - const resumed = await engine.advance('c', { budgetMs: 60_000 }) - expect(resumed.status).toBe('caught-up') - expect(resumed.watermark).toBe(12) - expect(adapter.batches.flat()).toEqual(range(1, 12)) - }) - - it('bumps are edge-triggered per advance: a stale bump never preempts', async () => { - const source = scriptedSource(() => range(1, 4)) - const doorSignal = new DoorSignal() - const engine = new ReprojectionEngine({ source, doorSignal, batchSize: 2 }) - const adapter = new RecordingAdapter('c2') - engine.register(adapter) - - doorSignal.bump() // BEFORE the advance — belongs to earlier traffic - const result = await engine.advance('c2', { budgetMs: 10_000 }) - expect(result.status).toBe('caught-up') - expect(result.watermark).toBe(4) - }) -}) - -describe('reprojection engine — (d) advanceAll round-robin fairness', () => { - it('a one-batch family is served on the first round despite a huge backlog next to it', async () => { - const source = scriptedSource(() => range(1, 40)) - const engine = new ReprojectionEngine({ source, batchSize: 5 }) - const callOrder: string[] = [] - const big = new RecordingAdapter('big') // 8 batches behind - const small = new RecordingAdapter('small', 35) // 1 batch behind - big.onApply = () => { - callOrder.push('big') - } - small.onApply = () => { - callOrder.push('small') - } - engine.register(big) - engine.register(small) - - const results = await engine.advanceAll({ budgetMs: 10_000 }) - - expect(results.big).toEqual({ status: 'caught-up', watermark: 40, applied: 40 }) - expect(results.small).toEqual({ status: 'caught-up', watermark: 40, applied: 5 }) - // Fairness pin: 'small' folded its single batch on round ONE — it never - // waited behind 'big''s backlog. - expect(callOrder[1]).toBe('small') - expect(callOrder.filter((f) => f === 'small')).toHaveLength(1) - }) - - it('two full-backlog families interleave strictly, one batch each per round', async () => { - const source = scriptedSource(() => range(1, 40)) - const engine = new ReprojectionEngine({ source, batchSize: 5 }) - const callOrder: string[] = [] - const first = new RecordingAdapter('first') - const second = new RecordingAdapter('second') - first.onApply = () => { - callOrder.push('first') - } - second.onApply = () => { - callOrder.push('second') - } - engine.register(first) - engine.register(second) - - const results = await engine.advanceAll({ budgetMs: 10_000 }) - - expect(results.first.status).toBe('caught-up') - expect(results.second.status).toBe('caught-up') - // 8 rounds × (first, second): strict alternation — neither ever ran twice - // while the other waited. - expect(callOrder).toHaveLength(16) - for (let i = 0; i < callOrder.length; i += 2) { - expect(callOrder.slice(i, i + 2)).toEqual(['first', 'second']) - } - }) - - it('budget exhaustion mid-round reports every unfinished family at its own watermark', async () => { - const source = scriptedSource(() => range(1, 40)) - const engine = new ReprojectionEngine({ source, batchSize: 5 }) - const a = new RecordingAdapter('a') - const b = new RecordingAdapter('b') - engine.register(a) - engine.register(b) - - const results = await engine.advanceAll({ budgetMs: 0 }) - - // Zero budget: the leading family gets its one guaranteed step, then the - // budget answer lands for everyone still mid-stream. - expect(results.a.status).toBe('budget-exhausted') - expect(results.b.status).toBe('budget-exhausted') - expect(results.a.applied + results.b.applied).toBeGreaterThanOrEqual(5) - // A later advanceAll resumes both to the head. - const finished = await engine.advanceAll({ budgetMs: 10_000 }) - expect(finished.a.status).toBe('caught-up') - expect(finished.b.status).toBe('caught-up') - expect(a.batches.flat()).toEqual(range(1, 40)) - expect(b.batches.flat()).toEqual(range(1, 40)) - }) -}) - -describe('reprojection engine — (e) swap: build-beside, atomic flip, single-flight', () => { - it('the old adapter serves at its own watermark throughout the build; the flip is atomic at parity', async () => { - const log = range(1, 20) - const source = scriptedSource(() => log) - const engine = new ReprojectionEngine({ source, batchSize: 4 }) - const oldAdapter = new RecordingAdapter('e') - engine.register(oldAdapter) - await engine.advance('e', { budgetMs: 10_000 }) - expect(oldAdapter.watermark()).toBe(20) - - // The log grows after the old adapter stamped — the build must reach the - // HEAD (24), not merely the old watermark (20), before the flip. - log.push(21, 22, 23, 24) - - const servingDuringBuild: Array<{ adapter: ProjectionAdapter | undefined; watermark: number | null }> = [] - let replacement!: RecordingAdapter - const result = await engine.swap('e', async () => { - replacement = new RecordingAdapter('e') - replacement.onApply = () => { - servingDuringBuild.push({ - adapter: engine.getAdapter('e'), - watermark: engine.getAdapter('e')!.watermark() - }) - } - return replacement - }) - - // Build-beside pin: EVERY mid-build observation saw the OLD adapter, - // still serving, still at its own stamp. - expect(servingDuringBuild.length).toBeGreaterThan(0) - for (const seen of servingDuringBuild) { - expect(seen.adapter).toBe(oldAdapter) - expect(seen.watermark).toBe(20) - } - // The flip: the registry now serves the replacement, at parity with head. - expect(engine.getAdapter('e')).toBe(replacement) - expect(result.watermark).toBe(24) - expect(result.applied).toBe(24) - expect(replacement.batches.flat()).toEqual(range(1, 24)) - }) - - it('a second concurrent swap on the same family refuses with the typed single-flight error', async () => { - const source = scriptedSource(() => range(1, 8)) - const engine = new ReprojectionEngine({ source, batchSize: 4 }) - engine.register(new RecordingAdapter('e2')) - - let release!: () => void - const gate = new Promise((resolve) => { - release = resolve - }) - const inFlight = engine.swap('e2', async () => { - const building = new RecordingAdapter('e2') - building.onApply = () => gate // the build parks mid-fold - return building - }) - - // While the first swap builds, a second one is refused — typed. - const refusal = await engine.swap('e2', async () => new RecordingAdapter('e2')).catch((e) => e) - expect(refusal).toBeInstanceOf(SwapInFlightError) - expect((refusal as SwapInFlightError).family).toBe('e2') - - release() - const done = await inFlight - expect(done.watermark).toBe(8) - // Single-flight released: a follow-up swap is admitted again. - const again = await engine.swap('e2', async () => new RecordingAdapter('e2')) - expect(again.watermark).toBe(8) - }) - - it('a failed build discards the partial replacement and leaves the old adapter serving', async () => { - const source = scriptedSource(() => range(1, 8)) - const engine = new ReprojectionEngine({ source, batchSize: 4 }) - const oldAdapter = new RecordingAdapter('e3') - engine.register(oldAdapter) - await engine.advance('e3', { budgetMs: 10_000 }) - - let failed!: RecordingAdapter - await expect( - engine.swap('e3', async () => { - failed = new RecordingAdapter('e3') - failed.hardFail.add(5) // an UNTYPED failure mid-build - return failed - }) - ).rejects.toThrow(/disk exploded/) - - expect(failed.discarded).toBe(1) // the partial build was cleaned up - expect(oldAdapter.discarded).toBe(0) - expect(engine.getAdapter('e3')).toBe(oldAdapter) // still serving, untouched - expect(oldAdapter.watermark()).toBe(8) - }) -}) - -describe('reprojection engine — (f) quarantine: the fourth answer class', () => { - it('a typed poison fact is skipped, ledgered, and the rest folds to quarantined', async () => { - const source = scriptedSource(() => range(1, 10)) - const engine = new ReprojectionEngine({ source, batchSize: 4 }) - const adapter = new RecordingAdapter('f') - adapter.poison.add(6) - engine.register(adapter) - - const result = await engine.advance('f', { budgetMs: 10_000 }) - - expect(result.status).toBe('quarantined') - expect(result.watermark).toBe(10) - expect(result.applied).toBe(9) // every generation but the poison - expect(adapter.batches.flat().sort((x, y) => x - y)).toEqual([1, 2, 3, 4, 5, 7, 8, 9, 10]) - expect(adapter.state.has(6)).toBe(false) - - const ledger = engine.quarantined('f') - expect(ledger).toHaveLength(1) - expect(ledger[0].generation).toBe(6) - expect(ledger[0].error).toBeInstanceOf(ProjectionApplyError) - expect(ledger[0].error.recordIndex).toBe(1) // 6 sat at index 1 of [5..8] - expect(typeof ledger[0].at).toBe('number') - }) - - it('narration doubles: warns on the 1st, 2nd, and 4th quarantine — not the 3rd', async () => { - const warnSpy = vi.spyOn(prodLog, 'warn').mockImplementation(() => {}) - const source = scriptedSource(() => range(1, 10)) - const engine = new ReprojectionEngine({ source, batchSize: 10 }) - const adapter = new RecordingAdapter('f2') - for (const g of [2, 4, 6, 8]) adapter.poison.add(g) - engine.register(adapter) - - const result = await engine.advance('f2', { budgetMs: 10_000 }) - - expect(result.status).toBe('quarantined') - expect(result.watermark).toBe(10) - expect(result.applied).toBe(6) - expect(engine.quarantined('f2').map((q) => q.generation)).toEqual([2, 4, 6, 8]) - const quarantineWarns = warnSpy.mock.calls.filter((c) => String(c[0]).includes('quarantined generation')) - // 4 entries, narrated at counts 1, 2, and 4 — the 3rd stayed quiet. - expect(quarantineWarns).toHaveLength(3) - expect(quarantineWarns.map((c) => String(c[0]))).toEqual([ - expect.stringContaining('(1 quarantined total)'), - expect.stringContaining('(2 quarantined total)'), - expect.stringContaining('(4 quarantined total)') - ]) - }) - - it('an all-poison window still advances the stamp via an empty applyBatch', async () => { - const source = scriptedSource(() => range(1, 3)) - const engine = new ReprojectionEngine({ source, batchSize: 3 }) - const adapter = new RecordingAdapter('f3') - for (const g of [1, 2, 3]) adapter.poison.add(g) - engine.register(adapter) - - const result = await engine.advance('f3', { budgetMs: 10_000 }) - - expect(result.status).toBe('quarantined') - expect(result.watermark).toBe(3) - expect(result.applied).toBe(0) - // The final call carried NO facts but a real upTo — the pure watermark - // advance past poison, stamped by the adapter itself. - expect(adapter.batches).toEqual([[]]) - expect(adapter.upTos).toEqual([3]) - expect(engine.quarantined('f3').map((q) => q.generation)).toEqual([1, 2, 3]) - }) - - it('a NON-typed throw aborts the advance loudly — unknown failure is never poison', async () => { - const source = scriptedSource(() => range(1, 8)) - const engine = new ReprojectionEngine({ source, batchSize: 4 }) - const adapter = new RecordingAdapter('f4') - adapter.hardFail.add(5) - engine.register(adapter) - - await expect(engine.advance('f4', { budgetMs: 10_000 })).rejects.toThrow(/disk exploded at generation 5/) - - expect(adapter.watermark()).toBe(4) // the clean first batch landed; nothing after - expect(engine.quarantined('f4')).toEqual([]) // no ledger entry for an unknown failure - }) - - it('an adapter re-condemning an already-quarantined generation is refused loudly', async () => { - const source = scriptedSource(() => range(1, 4)) - const engine = new ReprojectionEngine({ source, batchSize: 4 }) - // A misbehaving adapter: always blames generation 3, even once it is - // filtered out of its batches. - const adapter: ProjectionAdapter = { - family: 'f5', - watermark: () => null, - applyBatch: async () => { - throw new ProjectionApplyError({ generation: 3, cause: new Error('always 3') }) - }, - discard: async () => {} - } - engine.register(adapter) - - await expect(engine.advance('f5', { budgetMs: 10_000 })).rejects.toThrow(/ALREADY quarantined/) - expect(engine.quarantined('f5').map((q) => q.generation)).toEqual([3]) - }) - - it('an adapter that never stamps is refused loudly instead of spinning', async () => { - const source = scriptedSource(() => range(1, 4)) - const engine = new ReprojectionEngine({ source, batchSize: 2 }) - const adapter: ProjectionAdapter = { - family: 'f6', - watermark: () => null, // never advances - applyBatch: async () => {}, - discard: async () => {} - } - engine.register(adapter) - - await expect(engine.advance('f6', { budgetMs: 10_000 })).rejects.toThrow(/not stamping/) - }) -}) - -describe('reprojection engine — (g) discard lands on the losing adapter after a swap', () => { - it('the OLD adapter is discarded exactly once, after the flip; the winner is never discarded', async () => { - const source = scriptedSource(() => range(1, 6)) - const engine = new ReprojectionEngine({ source, batchSize: 3 }) - const losing = new RecordingAdapter('g') - engine.register(losing) - await engine.advance('g', { budgetMs: 10_000 }) - expect(losing.discarded).toBe(0) // serving adapters are never discarded - - let winner!: RecordingAdapter - await engine.swap('g', async () => { - winner = new RecordingAdapter('g') - winner.onApply = () => { - // Mid-build the loser still serves and is still intact. - expect(losing.discarded).toBe(0) - } - return winner - }) - - expect(losing.discarded).toBe(1) - expect(winner.discarded).toBe(0) - expect(engine.getAdapter('g')).toBe(winner) - }) -}) - -describe('FactLogSource — the production source enforces the window contract', () => { - it('delegates to the injected callback and passes clean windows through', async () => { - const calls: Array<[number, number]> = [] - const source = new FactLogSource(async (from, limit) => { - calls.push([from, limit]) - return range(from + 1, Math.min(from + limit, 5)).map(fact) - }) - const facts = await source.scan(2, 2) - expect(facts.map((f) => f.generation)).toEqual([3, 4]) - expect(calls).toEqual([[2, 2]]) - expect(await source.scan(5, 3)).toEqual([]) - }) - - it('refuses out-of-contract callbacks loudly: oversize, non-ascending, at-or-below from', async () => { - const oversize = new FactLogSource(async () => range(1, 5).map(fact)) - await expect(oversize.scan(0, 2)).rejects.toThrow(/contract violation/) - - const unsorted = new FactLogSource(async () => [fact(3), fact(2)]) - await expect(unsorted.scan(0, 10)).rejects.toThrow(/strictly ascending/) - - const stale = new FactLogSource(async () => [fact(2)]) - await expect(stale.scan(2, 10)).rejects.toThrow(/strictly ascending/) - }) - - it('validates its own window arguments', async () => { - const source = new FactLogSource(async () => []) - await expect(source.scan(-1, 5)).rejects.toThrow(/non-negative integer/) - await expect(source.scan(0, 0)).rejects.toThrow(/positive integer/) - }) -}) diff --git a/tests/unit/storage/pagination-parallel-hydration.test.ts b/tests/unit/storage/pagination-parallel-hydration.test.ts index a98fe8c9..ada324bb 100644 --- a/tests/unit/storage/pagination-parallel-hydration.test.ts +++ b/tests/unit/storage/pagination-parallel-hydration.test.ts @@ -7,7 +7,7 @@ * hydration (zero per-entity reads when unfiltered). Both must preserve the exact * pagination contract: same order, cursor continuation, filters, totalCount. */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' import { Brainy, NounType } from '../../../src/index.js' describe('paginated enumeration — parallel hydration + id-only (cortex heal-cost)', () => { @@ -30,10 +30,6 @@ describe('paginated enumeration — parallel hydration + id-only (cortex heal-co storage = brain.storage }) - afterEach(async () => { - await brain.close() - }) - /** Page the whole dataset through a small limit via cursor and collect ordered ids. */ const pageAll = async (fn: (opts: any) => Promise, key: 'items' | 'ids') => { const out: string[] = [] diff --git a/tests/unit/storage/torn-record-loud.test.ts b/tests/unit/storage/torn-record-loud.test.ts deleted file mode 100644 index d35f8b43..00000000 Binary files a/tests/unit/storage/torn-record-loud.test.ts and /dev/null differ diff --git a/tests/unit/test-suite-coverage-guard.test.ts b/tests/unit/test-suite-coverage-guard.test.ts index f12b0587..93db4421 100644 --- a/tests/unit/test-suite-coverage-guard.test.ts +++ b/tests/unit/test-suite-coverage-guard.test.ts @@ -4,8 +4,7 @@ * config (so it never runs and gives false coverage confidence — the exact drift * that left ~27 test files un-run before 8.0). Every `*.test.ts` must either match * a gate config (`tests/unit/**`, `tests/integration/**`, `*.unit.test.ts`, - * `*.integration.test.ts`, or the perf lane's `tests/configs/vitest.perf.config.ts` - * — see PERF_LANE_FILES below) or be explicitly listed in MANUAL_ONLY below. + * `*.integration.test.ts`) or be explicitly listed in MANUAL_ONLY below. */ import { describe, it, expect } from 'vitest' import { readdirSync } from 'node:fs' @@ -25,67 +24,29 @@ function allTestFiles(dir: string, out: string[] = []): string[] { } /** - * Test files INTENTIONALLY excluded from every automated gate — conformance - * suites invoked directly, and checks that need real resources (network, - * unusual scale) no CI lane provides. Wall-clock/scale benchmarks that DO - * run automatically belong to the perf lane (PERF_LANE_FILES / inGate - * below), not here. Every entry is a conscious decision — a NEW orphan not - * listed here fails the guard below. + * Test files INTENTIONALLY excluded from the unit/integration gate: benchmarks, + * scale/perf measurements, package-size checks, and real-model-load checks. They + * are run manually (slow / need real resources), not in CI. Every entry is a + * conscious decision — a NEW orphan not listed here fails the guard below. */ const MANUAL_ONLY = new Set([ - // Conformance suites run as an explicit gate stage (both engines run them - // by direct invocation), never swept into the unit/integration configs. - 'tests/conformance/collider-fidelity.test.ts', - // Golden-log fold-conformance oracle: the two-implementation contract pin - // (byte + fold digests) — runs in the explicit conformance gate stage, - // same invocation family as the other conformance suites. - 'tests/conformance/golden-log-fold.test.ts', - // The sparse-store cut's shared operator rows (both engines run these): - // explicit conformance-gate invocation, like its siblings. - 'tests/conformance/sparse-store-cut.test.ts', - // NOT the perf lane: no wall-clock/scale assertion, so it does not belong - // in tests/configs/vitest.perf.config.ts's include list — genuinely run - // by hand only. - 'tests/critical-neural-validation.test.ts', - 'tests/package-size-breakdown.test.ts', - // Cross-engine field-addressing conformance suite: pinned bit-for-bit against - // the native accelerator's implementation of the SAME contract, and invoked - // directly (`npx vitest run tests/conformance/namespace-law.test.ts`), never - // swept into the unit/integration gates — a run against a branch where the - // resolver hasn't landed yet must SKIP loudly (see the file's own SELF-SKIP - // doc), not silently pass/fail as a side effect of which gate happened to - // pick it up. - 'tests/conformance/namespace-law.test.ts' -]) - -/** - * The perf lane's own gate: `tests/configs/vitest.perf.config.ts`, run by - * `npm run test:perf`. Mirrors that config's `include` list — kept in sync - * by inspection, the same convention that config uses against the root - * gate's exclude list (see its own header comment). A file that runs here - * is GATED, not manual: it belongs in this set (or the `tests/performance/` - * prefix below), never in MANUAL_ONLY. - */ -const PERF_LANE_FILES = new Set([ - 'tests/critical-performance-benchmark.test.ts', 'tests/api/performance-benchmarks.test.ts', + 'tests/critical-neural-validation.test.ts', + 'tests/critical-performance-benchmark.test.ts', + 'tests/model-loading.test.ts', + 'tests/package-size-breakdown.test.ts', 'tests/package-size-limit.test.ts', - 'tests/model-loading.test.ts' + 'tests/performance/graph-scale-performance.test.ts', + 'tests/performance/triple-intelligence-scale.test.ts', + 'tests/performance/typeAware.bench.test.ts' ]) function inGate(rel: string): boolean { return ( rel.startsWith('tests/unit/') || rel.startsWith('tests/integration/') || - // The lifecycle biography lane — included by the integration config - // ('tests/lifecycle/**/*.test.ts'; see tests/lifecycle/README.md). - rel.startsWith('tests/lifecycle/') || rel.endsWith('.unit.test.ts') || - rel.endsWith('.integration.test.ts') || - // The perf lane (see PERF_LANE_FILES above) — mirrors - // tests/configs/vitest.perf.config.ts's `tests/performance/**` glob. - rel.startsWith('tests/performance/') || - PERF_LANE_FILES.has(rel) + rel.endsWith('.integration.test.ts') ) } diff --git a/tests/unit/transaction/timeout-never-internally-retried.test.ts b/tests/unit/transaction/timeout-never-internally-retried.test.ts deleted file mode 100644 index a43a81a8..00000000 --- a/tests/unit/transaction/timeout-never-internally-retried.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * @module tests/unit/transaction/timeout-never-internally-retried - * @description Regression pin for the no-hot-retry contract (8.10.1). - * - * A production incident: a native-provider op ground 38-40s inside a - * transaction, blew the ~32s budget, was rolled back, and a CONSUMER pipeline - * hot-retried the identical operation into a 6-minute 100%-CPU storm. - * Investigation established brainy itself never auto-retries a - * `TransactionTimeoutError` — the storm was entirely the consumer's hot-retry - * loop, driven by a "retryable" doc-prose claim with no machine-readable - * contract. This file pins the brainy-side half of that story so it can never - * regress silently: - * - * (i) the underlying engine (`TransactionManager.executeTransaction()` → - * `Transaction.execute()`) — the exact machinery every single-record - * write (`add`/`update`/`remove`/...) drives via - * `Brainy.persistSingleOp()` — never internally re-executes a timed-out - * operation, and the error it surfaces carries `retryable === true` and - * `hotRetryUnsafe === true` (see `src/transaction/errors.ts`). - * - * Constructed directly (mirrors the existing - * `tests/unit/transaction/timeout-rollback.test.ts` pattern) rather than - * through a real `brain.add()` call: `transactTimeoutBudget()` floors - * every single-op write's budget at `opCount * 2000`ms with NO override - * seam (`transactionBudgetFloorMs` only RAISES that floor — it cannot - * lower it below the per-op-count term), so getting a real `add()` to - * time out requires a multi-second sleep. The engine-level - * `options.timeout` override used here is the exact same - * `TransactionManager`/`Transaction` code `persistSingleOp` calls — - * pinning it here pins add()'s guarantee without paying that wall-clock - * cost. - * - * (ii) `Brainy.add()`'s upsert-race retry loop (src/brainy.ts, - * `MAX_UPSERT_ATTEMPTS = 10`) — proving the loop's `catch` treats a - * `TransactionTimeoutError` as terminal (immediate rethrow) rather than - * the `InsertPreconditionExistsSignal` it retries on, so a mid-flight - * timeout can never be silently swallowed and re-attempted up to 10 - * times. - */ -import { describe, it, expect } from 'vitest' -import { TransactionManager } from '../../../src/transaction/TransactionManager.js' -import type { Operation, RollbackAction } from '../../../src/transaction/types.js' -import { TransactionTimeoutError } from '../../../src/transaction/errors.js' -import { Brainy } from '../../../src/brainy.js' -import { NounType } from '../../../src/types/graphTypes.js' - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) - -// Brainy's ValidationConfig fixes vectors at exactly 384 dimensions -// (src/utils/paramValidation.ts) — match it so add() doesn't reject test data. -const DIM = 384 -const V = (): number[] => Array(DIM).fill(0.1) - -/** An operation that counts every `execute()` invocation — the re-drive detector. */ -function countingOp(opts: { delayMs?: number; name: string }): Operation & { calls: number } { - const op = { - name: opts.name, - calls: 0, - async execute(): Promise { - op.calls++ - if (opts.delayMs) await sleep(opts.delayMs) - return async () => {} - } - } - return op -} - -describe('transaction timeouts are never internally re-driven (8.10.1 no-hot-retry contract)', () => { - it('(i) the single-op engine (TransactionManager.executeTransaction / Transaction.execute — what add() drives via persistSingleOp) runs the overrun operation EXACTLY once and surfaces ONE retryable+hotRetryUnsafe error', async () => { - const manager = new TransactionManager() - const op0 = countingOp({ name: 'op0-overruns-budget', delayMs: 30 }) - const op1 = countingOp({ name: 'op1-must-never-start' }) - - let caught: unknown - try { - await manager.executeTransaction( - async (tx) => { - tx.addOperation(op0) - tx.addOperation(op1) - }, - // Tiny explicit override — the same override seam `transact()` - // exposes as `options.timeoutMs`; wins outright over the - // opCount*2000 floor that gates every real single-op write - // (transactTimeoutBudget()'s override semantics). - { timeout: 5 } - ) - } catch (err) { - caught = err - } - - expect(caught).toBeInstanceOf(TransactionTimeoutError) - const err = caught as TransactionTimeoutError - // The machine-readable contract callers branch on instead of parsing - // message text (src/transaction/errors.ts). - expect(err.retryable).toBe(true) - expect(err.hotRetryUnsafe).toBe(true) - - // The re-drive assertion: op0 (the one that overran) executed EXACTLY - // once — nothing inside TransactionManager/Transaction looped back and - // re-ran it — and op1 never started at all (the budget gate stopped it - // before it began, per Transaction.execute()'s per-operation loop). - expect(op0.calls).toBe(1) - expect(op1.calls).toBe(0) - }) - - it('(ii) add()\'s upsert-race retry loop (MAX_UPSERT_ATTEMPTS=10) exits on the FIRST TransactionTimeoutError — attempt counter stays at 1, never mistaken for the lost-insert-race signal it retries on', async () => { - const brain = new Brainy({ - requireSubtype: false, - storage: { type: 'memory' }, - silent: true - }) - await brain.init() - - let persistSingleOpCalls = 0 - const timeoutError = new TransactionTimeoutError(5, 1, { - elapsedMs: 6, - totalOperations: 2, - operationName: 'SaveNounMetadata' - }) - // Stub the private commit seam add() drives (persistSingleOp) to throw - // the exact error type the real engine surfaces on a mid-flight timeout. - // This test pins the upsert loop's EXCEPTION-HANDLING contract (does it - // retry a TransactionTimeoutError like it retries - // InsertPreconditionExistsSignal?), not the timing mechanics of a real - // timeout — those are pinned by test (i) and by - // tests/unit/transaction/timeout-rollback.test.ts. - ;(brain as any).persistSingleOp = async (): Promise => { - persistSingleOpCalls++ - throw timeoutError - } - - await expect( - brain.add({ data: 'a', type: NounType.Thing, vector: V() }) - ).rejects.toBe(timeoutError) - - // The loop's attempt counter: exactly one call, never retried up to - // MAX_UPSERT_ATTEMPTS. - expect(persistSingleOpCalls).toBe(1) - await brain.close() - }) -}) diff --git a/tests/unit/type-filtering.unit.test.ts b/tests/unit/type-filtering.unit.test.ts index a1943da9..9e4700b2 100644 --- a/tests/unit/type-filtering.unit.test.ts +++ b/tests/unit/type-filtering.unit.test.ts @@ -4,7 +4,7 @@ * Tests to verify that brain.find({ type: NounType.X }) correctly filters entities */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy, NounType } from '../../src/index.js' describe('Type Filtering (A Consumer Team Issue)', () => { @@ -17,10 +17,6 @@ describe('Type Filtering (A Consumer Team Issue)', () => { await brain.init() }) - afterEach(async () => { - await brain.close() - }) - it('should filter entities by NounType.Person', async () => { // Add 3 people await brain.add({ data: 'John Smith', type: NounType.Person, metadata: { name: 'John' } }) diff --git a/tests/unit/types/nestedBagRecord.test.ts b/tests/unit/types/nestedBagRecord.test.ts deleted file mode 100644 index b8e9be46..00000000 --- a/tests/unit/types/nestedBagRecord.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * @module tests/unit/types/nestedBagRecord - * @description Unit pins for the v2 (nested-bag) stored-record layer — the - * storage half of the field-addressing law. The write door accepts ANY user - * metadata name; what makes that lossless on disk is the record shape: - * engine fields top-level, the user bag NESTED verbatim, discriminated by - * the engine-written format stamp (never by names — names are the user's). - * These pins hold the builders, the discriminator, and the shape-aware - * split that every read path (live, batch, historical) routes through. - */ -import { describe, it, expect } from 'vitest' -import { - buildNounMetadataRecord, - buildVerbMetadataRecord, - splitNounMetadataRecord, - splitVerbMetadataRecord, - isNestedBagRecord, - METADATA_RECORD_FORMAT_KEY, - NESTED_BAG_FORMAT -} from '../../../src/types/reservedFields.js' - -const COLLIDER_BAG = { - confidence: 'user-confidence', - weight: 'user-weight', - subtype: 'user-subtype', - createdAt: 'user-createdAt', - service: 'user-service', - data: 'user-data', - noun: 'user-noun', - _rev: 'user-rev', - level: 7, - plain: 'control' -} - -describe('v2 nested-bag stored records — build / discriminate / split', () => { - it('build → split round-trips a fully colliding user bag VERBATIM', () => { - const record = buildNounMetadataRecord( - { noun: 'document', confidence: 0.25, createdAt: 111, updatedAt: 222, _rev: 1 }, - { ...COLLIDER_BAG } - ) - expect(isNestedBagRecord(record)).toBe(true) - expect(record[METADATA_RECORD_FORMAT_KEY]).toBe(NESTED_BAG_FORMAT) - - const { reserved, custom } = splitNounMetadataRecord(record) - // The engine half is exactly what the engine wrote… - expect(reserved.noun).toBe('document') - expect(reserved.confidence).toBe(0.25) - expect(reserved._rev).toBe(1) - // …and the user bag comes back byte-for-byte, colliders included. - expect(custom).toEqual(COLLIDER_BAG) - }) - - it('the verb mirror round-trips an edge collider bag verbatim', () => { - const record = buildVerbMetadataRecord( - { verb: 'relatedTo', weight: 1.0, confidence: 0.5, createdAt: 333 }, - { verb: 'user-verb', confidence: 'user-c', tag: 't' } - ) - expect(isNestedBagRecord(record)).toBe(true) - const { reserved, custom } = splitVerbMetadataRecord(record) - expect(reserved.verb).toBe('relatedTo') - expect(reserved.confidence).toBe(0.5) - expect(custom).toEqual({ verb: 'user-verb', confidence: 'user-c', tag: 't' }) - }) - - it('a LEGACY flat record (no stamp) splits BY NAME — sound because the pre-law door refused colliders', () => { - const legacy = { - noun: 'document', - confidence: 0.75, - createdAt: 111, - _rev: 2, - legacyField: 'legacy-value' - } - expect(isNestedBagRecord(legacy)).toBe(false) - const { reserved, custom } = splitNounMetadataRecord(legacy) - expect(reserved.confidence).toBe(0.75) - expect(reserved._rev).toBe(2) - expect(custom).toEqual({ legacyField: 'legacy-value' }) - }) - - it('the stamp is the discriminator, never the name: a legacy user OBJECT field named `metadata` does not fake a v2 record', () => { - // Pre-law, 'metadata' was never a reserved name — a flat record could - // legally carry a user object field spelled exactly 'metadata'. Without - // the engine-written stamp it must split as legacy, with that object - // preserved as an ordinary user field. - const legacyWithMetadataField = { - noun: 'document', - confidence: 0.5, - metadata: { nested: 'user-object' } - } - expect(isNestedBagRecord(legacyWithMetadataField)).toBe(false) - const { reserved, custom } = splitNounMetadataRecord(legacyWithMetadataField) - expect(reserved.confidence).toBe(0.5) - expect(custom).toEqual({ metadata: { nested: 'user-object' } }) - }) - - it('a malformed stamp (right key, wrong value / non-object bag) never discriminates as v2', () => { - expect( - isNestedBagRecord({ [METADATA_RECORD_FORMAT_KEY]: 999, metadata: {} }) - ).toBe(false) - expect( - isNestedBagRecord({ [METADATA_RECORD_FORMAT_KEY]: NESTED_BAG_FORMAT, metadata: 'not-a-bag' }) - ).toBe(false) - expect( - isNestedBagRecord({ [METADATA_RECORD_FORMAT_KEY]: NESTED_BAG_FORMAT, metadata: [1, 2] }) - ).toBe(false) - expect(isNestedBagRecord(null)).toBe(false) - expect(isNestedBagRecord(undefined)).toBe(false) - }) - - it('the v2 split never surfaces the stamp or the bag container as fields', () => { - const record = buildNounMetadataRecord({ noun: 'document', _rev: 1 }, { a: 1 }) - const { reserved, custom } = splitNounMetadataRecord(record) - expect(METADATA_RECORD_FORMAT_KEY in reserved).toBe(false) - expect(METADATA_RECORD_FORMAT_KEY in custom).toBe(false) - expect('metadata' in reserved).toBe(false) - expect(custom).toEqual({ a: 1 }) - }) - - it('builders copy the bag (no aliasing): later caller mutation cannot reach the record', () => { - const bag: Record = { a: 1 } - const record = buildNounMetadataRecord({ noun: 'document' }, bag) - bag.a = 999 - bag.b = 'sneaky' - expect((record.metadata as Record).a).toBe(1) - expect('b' in (record.metadata as Record)).toBe(false) - }) -}) diff --git a/tests/unit/types/reserved-metadata-keys.test-d.ts b/tests/unit/types/reserved-metadata-keys.test-d.ts new file mode 100644 index 00000000..37fceefa --- /dev/null +++ b/tests/unit/types/reserved-metadata-keys.test-d.ts @@ -0,0 +1,265 @@ +/** + * @module tests/unit/types/reserved-metadata-keys.test-d + * @description Compile-time tests for the reserved-field contract (layer 1 of + * three — see src/types/reservedFields.ts): a literal reserved key inside any + * `metadata` param is a TypeScript error, while the generic `T` ergonomics + * stay intact (typed bags, untyped brains, index-signature shapes, and the + * documented exemption for consumers who explicitly declare a reserved key in + * their own metadata type). + * + * Runs under vitest typecheck mode (`test.typecheck` in + * tests/configs/vitest.unit.config.ts) — these assertions are validated by + * `tsc`, never executed. The runtime half of the contract (the write-path + * remap for untyped callers) is pinned by + * tests/unit/brainy/update-reserved-metadata-remap.test.ts. + */ + +import { describe, it, assertType } from 'vitest' +import type { + AddParams, + UpdateParams, + RelateParams, + UpdateRelationParams, + TxOperation +} from '../../../src/index.js' +import { NounType, VerbType } from '../../../src/types/graphTypes.js' + +describe('reserved entity keys in metadata are compile errors', () => { + it('AddParams (untyped brain) rejects every reserved key but stays open for custom fields', () => { + // Custom fields of any shape remain legal — exactly the pre-8.0 latitude. + assertType({ + type: NounType.Person, + subtype: 'employee', + data: 'x', + metadata: { dept: 'eng', level: 3, tags: ['a', 'b'], nested: { ok: true } } + }) + + assertType({ + type: NounType.Person, + subtype: 'employee', + data: 'x', + // @ts-expect-error — 'noun' is reserved (the entity type travels via the top-level 'type' param) + metadata: { noun: 'organization' } + }) + assertType({ + type: NounType.Person, + subtype: 'employee', + data: 'x', + // @ts-expect-error — 'subtype' is reserved (use the top-level 'subtype' param) + metadata: { subtype: 'contractor' } + }) + assertType({ + type: NounType.Person, + subtype: 'employee', + data: 'x', + // @ts-expect-error — 'createdAt' is reserved (system-managed) + metadata: { createdAt: Date.now() } + }) + assertType({ + type: NounType.Person, + subtype: 'employee', + data: 'x', + // @ts-expect-error — 'updatedAt' is reserved (system-managed) + metadata: { updatedAt: Date.now() } + }) + assertType({ + type: NounType.Person, + subtype: 'employee', + data: 'x', + // @ts-expect-error — 'confidence' is reserved (use the top-level 'confidence' param) + metadata: { confidence: 0.8 } + }) + assertType({ + type: NounType.Person, + subtype: 'employee', + data: 'x', + // @ts-expect-error — 'weight' is reserved (use the top-level 'weight' param) + metadata: { weight: 0.5 } + }) + assertType({ + type: NounType.Person, + subtype: 'employee', + data: 'x', + // @ts-expect-error — 'service' is reserved (use the top-level 'service' param) + metadata: { service: 'orders' } + }) + assertType({ + type: NounType.Person, + subtype: 'employee', + data: 'x', + // @ts-expect-error — 'data' is reserved (use the top-level 'data' param) + metadata: { data: 'content' } + }) + assertType({ + type: NounType.Person, + subtype: 'employee', + data: 'x', + // @ts-expect-error — 'createdBy' is reserved (use the top-level 'createdBy' param) + metadata: { createdBy: { augmentation: 'importer', version: '1.0' } } + }) + assertType({ + type: NounType.Person, + subtype: 'employee', + data: 'x', + // @ts-expect-error — '_rev' is reserved (system-managed revision counter) + metadata: { _rev: 7 } + }) + }) + + it('AddParams (typed brain) rejects reserved keys alongside the declared shape', () => { + interface EmployeeMeta { + dept: string + level: number + } + + assertType>({ + type: NounType.Person, + subtype: 'employee', + data: 'x', + metadata: { dept: 'eng', level: 3 } + }) + + assertType>({ + type: NounType.Person, + subtype: 'employee', + data: 'x', + // @ts-expect-error — 'confidence' is reserved even when T declares other fields + metadata: { dept: 'eng', level: 3, confidence: 0.8 } + }) + }) + + it('documented exemptions: T-declared reserved keys and index-signature shapes stay assignable', () => { + // A consumer who *explicitly* types a reserved key into their metadata + // shape keeps a working (if unwise) type — the guard exempts keyof T. + interface LegacyMeta { + confidence: number + note: string + } + assertType>({ + type: NounType.Person, + subtype: 'employee', + data: 'x', + metadata: { confidence: 0.8, note: 'declared by the consumer type' } + }) + + // Index-signature metadata types (keyof T = string) remain fully open. + assertType>>({ + type: NounType.Person, + subtype: 'employee', + data: 'x', + metadata: { anything: 'goes', confidence: 0.8 } + }) + }) + + it('UpdateParams patch rejects reserved keys but accepts partial custom patches', () => { + interface EmployeeMeta { + dept: string + level: number + } + + // Partial patch of the declared shape is legal. + assertType>({ id: 'e1', metadata: { dept: 'sales' } }) + // Untyped patch with custom fields is legal. + assertType({ id: 'e1', metadata: { status: 'reviewed', rating: 4.5 } }) + + // @ts-expect-error — 'confidence' is reserved (use the top-level 'confidence' param) + assertType({ id: 'e1', metadata: { confidence: 0.33 } }) + // @ts-expect-error — 'subtype' is reserved (use the top-level 'subtype' param) + assertType({ id: 'e1', metadata: { subtype: 'specialized' } }) + // @ts-expect-error — '_rev' is reserved (pass 'ifRev' for optimistic concurrency) + assertType({ id: 'e1', metadata: { _rev: 3 } }) + // @ts-expect-error — 'confidence' is reserved even when T declares other fields + assertType>({ id: 'e1', metadata: { confidence: 0.1 } }) + }) +}) + +describe('reserved relationship keys in metadata are compile errors', () => { + it('RelateParams rejects reserved keys but stays open for custom edge fields', () => { + assertType({ + from: 'a', + to: 'b', + type: VerbType.ReportsTo, + subtype: 'direct', + metadata: { role: 'peer', since: 2024 } + }) + + assertType({ + from: 'a', + to: 'b', + type: VerbType.ReportsTo, + subtype: 'direct', + // @ts-expect-error — 'verb' is reserved (the relationship type travels via the top-level 'type' param) + metadata: { verb: 'relatedTo' } + }) + assertType({ + from: 'a', + to: 'b', + type: VerbType.ReportsTo, + subtype: 'direct', + // @ts-expect-error — 'confidence' is reserved (use the top-level 'confidence' param) + metadata: { confidence: 0.9 } + }) + assertType({ + from: 'a', + to: 'b', + type: VerbType.ReportsTo, + subtype: 'direct', + // @ts-expect-error — 'weight' is reserved (use the top-level 'weight' param) + metadata: { weight: 0.4 } + }) + assertType({ + from: 'a', + to: 'b', + type: VerbType.ReportsTo, + subtype: 'direct', + // @ts-expect-error — 'service' is reserved (use the top-level 'service' param) + metadata: { service: 'orders' } + }) + }) + + it('UpdateRelationParams patch rejects reserved keys', () => { + assertType({ id: 'r1', metadata: { note: 'fine' } }) + + // @ts-expect-error — 'confidence' is reserved (use the top-level 'confidence' param) + assertType({ id: 'r1', metadata: { confidence: 0.5 } }) + // @ts-expect-error — 'subtype' is reserved (use the top-level 'subtype' param) + assertType({ id: 'r1', metadata: { subtype: 'dotted-line' } }) + // @ts-expect-error — 'createdAt' is reserved (system-managed) + assertType({ id: 'r1', metadata: { createdAt: 1 } }) + }) +}) + +describe('transact() operations inherit the same guard', () => { + it('TxOperation add/update/relate metadata rejects reserved keys', () => { + assertType({ + op: 'add', + type: NounType.Concept, + subtype: 'general', + data: 'tx', + metadata: { custom: 'a' } + }) + assertType({ + op: 'add', + type: NounType.Concept, + subtype: 'general', + data: 'tx', + // @ts-expect-error — 'confidence' is reserved on transact add ops too + metadata: { confidence: 0.7 } + }) + assertType({ + op: 'update', + id: 'e1', + // @ts-expect-error — 'weight' is reserved on transact update ops too + metadata: { weight: 0.2 } + }) + assertType({ + op: 'relate', + from: 'a', + to: 'b', + type: VerbType.RelatedTo, + subtype: 'colleague', + // @ts-expect-error — 'verb' is reserved on transact relate ops too + metadata: { verb: 'contains' } + }) + }) +}) diff --git a/tests/unit/utils/indexReadiness.test.ts b/tests/unit/utils/indexReadiness.test.ts deleted file mode 100644 index 75504c71..00000000 --- a/tests/unit/utils/indexReadiness.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * @module tests/unit/utils/indexReadiness - * @description Pins for the read-gate authority, {@link assessProviderHealth}, and - * its older sibling {@link assessIndexReadiness}. The health-gate law: a provider's - * NAMED, synchronous, O(1) health report — when exposed — REPLACES the `isReady()`/ - * size-heuristic fallback as the read gate's source of truth. A throw from - * `healthReport()` is a CONTRACT VIOLATION (never read as healthy, never swallowed - * into "unknown"); an `unledgered` family is UNKNOWN (never healthy, never broken — - * `serving` is always the provider's own verdict, verbatim). - */ -import { describe, it, expect } from 'vitest' -import { assessIndexReadiness, assessProviderHealth } from '../../../src/utils/indexReadiness.js' -import type { HealthReport, LedgerInvariantResult } from '../../../src/plugin.js' - -function invariant(overrides: Partial = {}): LedgerInvariantResult { - return { - name: 'manifest-residency', - holds: true, - detail: 'ok', - heal: 'none', - source: 'ledger', - ...overrides - } -} - -function report(overrides: Partial = {}): HealthReport { - return { - provider: 'vector', - healthy: true, - serving: true, - invariants: [], - // A FIXED stamp, never Date.now(): the pin at :98 compares two - // independently-built reports, and a live clock made them differ by 1ms - // whenever the millisecond ticked between the two calls — a plant-lane - // red that had nothing to do with the code under test. - checkedAt: 1_700_000_000_000, - durationMs: 1, - generation: 1, - unledgered: [], - ...overrides - } -} - -describe('assessIndexReadiness (legacy isReady() classifier)', () => { - it('unknown when the provider is null/undefined', () => { - expect(assessIndexReadiness(null)).toBe('unknown') - expect(assessIndexReadiness(undefined)).toBe('unknown') - }) - - it('unknown when isReady() is absent', () => { - expect(assessIndexReadiness({})).toBe('unknown') - }) - - it('ready / not-ready mirror isReady()', () => { - expect(assessIndexReadiness({ isReady: () => true })).toBe('ready') - expect(assessIndexReadiness({ isReady: () => false })).toBe('not-ready') - }) -}) - -describe('assessProviderHealth — the read-gate authority', () => { - it('via "none": no provider at all', () => { - const a = assessProviderHealth(null) - expect(a.via).toBe('none') - expect(a.readiness).toBe('unknown') - expect(a.report).toBeNull() - expect(a.reasons.length).toBeGreaterThan(0) - }) - - it('via "size-heuristic": provider exposes neither healthReport() nor isReady()', () => { - const a = assessProviderHealth({}) - expect(a.via).toBe('size-heuristic') - expect(a.readiness).toBe('unknown') - expect(a.report).toBeNull() - }) - - it('via "is-ready": provider exposes isReady() but no healthReport() — ready', () => { - const a = assessProviderHealth({ isReady: () => true }) - expect(a.via).toBe('is-ready') - expect(a.readiness).toBe('ready') - expect(a.reasons).toEqual([]) - }) - - it('via "is-ready": isReady() === false — not-ready with a reason', () => { - const a = assessProviderHealth({ isReady: () => false }) - expect(a.via).toBe('is-ready') - expect(a.readiness).toBe('not-ready') - expect(a.reasons.length).toBeGreaterThan(0) - }) - - it('healthReport() present REPLACES isReady() — serving:true wins even if isReady() lies false', () => { - const p = { isReady: () => false, healthReport: () => report({ serving: true }) } - const a = assessProviderHealth(p) - expect(a.via).toBe('health-report') - expect(a.readiness).toBe('ready') - }) - - it('serving:true, healthy:true, no invariants failing → ready, no reasons', () => { - const p = { healthReport: () => report({ serving: true, healthy: true }) } - const a = assessProviderHealth(p) - expect(a.readiness).toBe('ready') - expect(a.reasons).toEqual([]) - expect(a.report).toEqual(report({ serving: true, healthy: true })) - }) - - it('serving:false with a named heal:"rebuild" failing invariant → not-ready, reason names it', () => { - const failing = invariant({ name: 'posted-count-floor', holds: false, heal: 'rebuild', detail: 'posted 10 < canonical 20' }) - const p = { healthReport: () => report({ serving: false, healthy: false, invariants: [failing] }) } - const a = assessProviderHealth(p) - expect(a.readiness).toBe('not-ready') - expect(a.reasons.some((r) => r.includes('posted-count-floor') && r.includes('heal:rebuild') && r.includes('posted 10 < canonical 20'))).toBe(true) - }) - - it('unledgered-only report (serving:true, no failing invariant) → ready, reason names the unledgered family', () => { - const p = { healthReport: () => report({ serving: true, healthy: true, unledgered: ['canonical-verb-coverage'] }) } - const a = assessProviderHealth(p) - expect(a.readiness).toBe('ready') - expect(a.reasons.some((r) => r.includes('unledgered') && r.includes('canonical-verb-coverage'))).toBe(true) - }) - - it('UNLEDGERED IS UNKNOWN: an unledgered family never flips a NOT-serving provider to ready', () => { - const failing = invariant({ holds: false, heal: 'rebuild', name: 'x' }) - const p = { healthReport: () => report({ serving: false, healthy: false, invariants: [failing], unledgered: ['some-family'] }) } - const a = assessProviderHealth(p) - expect(a.readiness).toBe('not-ready') - }) - - it('serving:true, healthy:false with a heal:"repair" failure → still ready (degraded-but-serving)', () => { - const failing = invariant({ name: 'stale-counter', holds: false, heal: 'repair', detail: 'counter drift' }) - const p = { healthReport: () => report({ serving: true, healthy: false, invariants: [failing] }) } - const a = assessProviderHealth(p) - expect(a.readiness).toBe('ready') - expect(a.reasons.some((r) => r.includes('stale-counter') && r.includes('heal:repair'))).toBe(true) - }) - - it('healthReport() that THROWS is a CONTRACT VIOLATION: not-ready, via health-report, reason names the throw — never "unknown"', () => { - const p = { healthReport: () => { throw new Error('mmap window busy') } } - const a = assessProviderHealth(p) - expect(a.via).toBe('health-report') - expect(a.readiness).toBe('not-ready') - expect(a.report).toBeNull() - expect(a.reasons.some((r) => r.includes('mmap window busy'))).toBe(true) - expect(a.readiness).not.toBe('unknown') - }) - - it('healthReport() that throws a non-Error value still produces a named reason (String(err))', () => { - const p = { healthReport: () => { throw 'boom' } } - const a = assessProviderHealth(p) - expect(a.readiness).toBe('not-ready') - expect(a.reasons.some((r) => r.includes('boom'))).toBe(true) - }) - - it('the returned report carries the generation for narration dedup', () => { - const p = { healthReport: () => report({ generation: 42 }) } - const a = assessProviderHealth(p) - expect(a.report?.generation).toBe(42) - }) -}) diff --git a/tests/unit/utils/metadataIndex-array-bound.test.ts b/tests/unit/utils/metadataIndex-array-bound.test.ts deleted file mode 100644 index 32bf5d8c..00000000 --- a/tests/unit/utils/metadataIndex-array-bound.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * @module tests/unit/utils/metadataIndex-array-bound - * @description THE INDEXABLE-ARRAY BOUND — a law with a name and a refusal, - * not a `continue`. - * - * THE DEFECT. An array-valued metadata field indexes one posting per element, - * so the index has always carried a ceiling. It was 10, and it was applied by a - * bare `continue` deep inside field extraction: - * - * if (Array.isArray(value) && value.length > 10) continue - * - * A row whose `tags` array held ELEVEN entries therefore had that field skipped - * entirely — no posting, no error, no warning. The row then failed to match - * every filtered search on `tags`, including `{ tags: 'a-tag-it-really-has' }`, - * and the caller had no way to tell that from "no row matches". Eleven tags is - * not an exotic shape; the eleventh tag made the row invisible. - * - * THE LAW. Arrays of scalars index up to {@link MAX_INDEXED_ARRAY_LENGTH}, - * hardcoded (the zero-config law: no knob), which clears every legitimate - * multi-value field — tags, authors, keyword lists — and stays below the - * narrowest embedding this engine meets (384 dimensions). Above it the WRITE - * IS REFUSED by name — `MetadataArrayTooLargeError`, carrying the field, the - * length and the bound — at `add`, `update`, `relate` and `updateRelation` - * alike. Nothing is skipped in silence. - * - * THE ONE PLACE THE BOUND STILL SKIPS is a row already on disk, written by an - * older engine under the old rule and read back by a rebuild, a catch-up fold - * or a remove. Refusing there would make an existing store un-rebuildable — so - * the row is admitted and the skipped field is NARRATED. Both sides are pinned. - */ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' -import { Brainy } from '../../../src/brainy' -import { NounType, VerbType } from '../../../src/types/graphTypes' -import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError' -import { resolveEntityId } from '../../../src/utils/idNormalization' -import { prodLog } from '../../../src/utils/logger' - -/** `n` distinct scalar tags. */ -function tags(n: number, prefix = 't'): string[] { - return Array.from({ length: n }, (_, i) => `${prefix}${i}`) -} - -describe('the indexable-array bound', () => { - let brain: Brainy - - beforeEach(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - }) - - afterEach(async () => { - await brain.close() - }) - - describe('BELOW the bound: the array indexes, every element of it', () => { - it('the eleven-element array that used to vanish is searchable', async () => { - // ELEVEN — one over the old silent limit, the whole shape of the defect. - await brain.add({ - id: 'eleven', - data: 'a row with eleven tags', - type: NounType.Document, - metadata: { tags: tags(11) }, - vector: [] - }) - - // Every element is a posting, including the eleventh. - for (const tag of tags(11)) { - const hits = await brain.find({ where: { tags: tag }, limit: 10 } as any) - expect(hits.map((r: any) => r.id)).toContain(resolveEntityId('eleven')) - } - }) - - it('indexes right up to the bound — every element of it', async () => { - await brain.add({ - id: 'at-bound', - data: 'a row at the bound', - type: NounType.Document, - metadata: { tags: tags(MAX_INDEXED_ARRAY_LENGTH) }, - vector: [] - }) - - // The first, the last, and one in the middle — all derived from the - // bound, so the case follows the constant wherever it moves. - for (const tag of ['t0', `t${MAX_INDEXED_ARRAY_LENGTH - 1}`, `t${Math.floor(MAX_INDEXED_ARRAY_LENGTH / 2)}`]) { - const hits = await brain.find({ where: { tags: tag }, limit: 10 } as any) - expect(hits.map((r: any) => r.id)).toContain(resolveEntityId('at-bound')) - } - }) - - it('a nested bag\'s array indexes under its dotted address', async () => { - await brain.add({ - id: 'nested', - data: 'a row with a nested tag list', - type: NounType.Document, - metadata: { facets: { labels: tags(20, 'l') } }, - vector: [] - }) - const hits = await brain.find({ where: { 'facets.labels': 'l19' }, limit: 10 } as any) - expect(hits.map((r: any) => r.id)).toContain(resolveEntityId('nested')) - }) - }) - - describe('ABOVE the bound: the write is refused, by name', () => { - const OVER = MAX_INDEXED_ARRAY_LENGTH + 1 - - it('add() throws a typed error naming the field, the length and the bound', async () => { - const err = await brain - .add({ - id: 'too-many', - data: 'a row with too many tags', - type: NounType.Document, - metadata: { tags: tags(OVER) }, - vector: [] - } as any) - .catch((e: any) => e) - - expect(err).toBeInstanceOf(MetadataArrayTooLargeError) - expect(err.field).toBe('tags') - expect(err.length).toBe(OVER) - expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH) - expect(err.type).toBe('VALIDATION') - // The message carries all three, and names the cures. - expect(err.message).toContain('tags') - expect(err.message).toContain(String(OVER)) - expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH)) - expect(err.message).toContain('vector') - }) - - it('the refused row is not written at all — no half-indexed ghost', async () => { - await expect( - brain.add({ - id: 'refused', - data: 'refused', - type: NounType.Document, - metadata: { tags: tags(OVER) }, - vector: [] - } as any) - ).rejects.toBeInstanceOf(MetadataArrayTooLargeError) - - expect(await brain.get('refused')).toBeNull() - const hits = await brain.find({ where: { tags: 't0' }, limit: 10 } as any) - expect(hits.map((r: any) => r.id)).not.toContain(resolveEntityId('refused')) - }) - - it('a 384-float embedding parked in the metadata bag is refused, not swallowed', async () => { - const err = await brain - .add({ - id: 'bag-vector', - data: 'an embedding in the wrong place', - type: NounType.Document, - metadata: { embedding: Array.from({ length: 384 }, (_, i) => i / 384) }, - vector: [] - } as any) - .catch((e: any) => e) - - expect(err).toBeInstanceOf(MetadataArrayTooLargeError) - expect(err.field).toBe('embedding') - expect(err.length).toBe(384) - }) - - it('update() refuses it too', async () => { - await brain.add({ - id: 'grow', - data: 'starts small', - type: NounType.Document, - metadata: { tags: tags(3) }, - vector: [] - }) - await expect( - brain.update({ id: 'grow', metadata: { tags: tags(OVER) } } as any) - ).rejects.toBeInstanceOf(MetadataArrayTooLargeError) - - // And the row keeps the values it had. - const hits = await brain.find({ where: { tags: 't1' }, limit: 10 } as any) - expect(hits.map((r: any) => r.id)).toContain(resolveEntityId('grow')) - }) - - it('relate() refuses it on a verb\'s metadata', async () => { - await brain.add({ id: 'a', data: 'a', type: NounType.Thing, vector: [] }) - await brain.add({ id: 'b', data: 'b', type: NounType.Thing, vector: [] }) - await expect( - brain.relate({ - from: 'a', - to: 'b', - type: VerbType.RelatedTo, - metadata: { tags: tags(OVER) } - } as any) - ).rejects.toBeInstanceOf(MetadataArrayTooLargeError) - }) - - it('a nested oversize array is refused under its dotted address', async () => { - const err = await brain - .add({ - id: 'nested-over', - data: 'nested and too long', - type: NounType.Document, - metadata: { facets: { labels: tags(OVER, 'l') } }, - vector: [] - } as any) - .catch((e: any) => e) - expect(err).toBeInstanceOf(MetadataArrayTooLargeError) - expect(err.field).toBe('facets.labels') - }) - }) - - describe('a row already on disk is admitted, and the skip is NARRATED', () => { - afterEach(() => { - vi.restoreAllMocks() - }) - - it('extraction over an old oversize row warns by field, length and bound', async () => { - const warn = vi.spyOn(prodLog, 'warn').mockImplementation(() => {}) - const index = (brain as any).metadataIndex - - // The shape an older engine persisted: the write door never saw it, so - // this reaches extraction directly — exactly as a rebuild or a remove - // reading the row back would. - const fields = index.extractIndexableFields({ - metadata: { tags: tags(MAX_INDEXED_ARRAY_LENGTH + 5), keep: 'me' } - }) - - // The oversize field contributes nothing... - expect(fields.filter((f: any) => f.field === 'tags')).toHaveLength(0) - // ...the rest of the row indexes normally — the row is not rejected... - expect(fields.some((f: any) => f.field === 'keep' && f.value === 'me')).toBe(true) - // ...and the skip is said out loud, with everything needed to act on it. - expect(warn).toHaveBeenCalled() - const said = warn.mock.calls.map((c: any[]) => String(c[0])).join('\n') - expect(said).toContain('tags') - expect(said).toContain(String(MAX_INDEXED_ARRAY_LENGTH + 5)) - expect(said).toContain(String(MAX_INDEXED_ARRAY_LENGTH)) - expect(said).toContain('NOT indexed') - }) - - it('an at-bound row on disk is indexed in full and says nothing', async () => { - const warn = vi.spyOn(prodLog, 'warn').mockImplementation(() => {}) - const index = (brain as any).metadataIndex - - const fields = index.extractIndexableFields({ - metadata: { tags: tags(MAX_INDEXED_ARRAY_LENGTH) } - }) - expect(fields.filter((f: any) => f.field === 'tags')).toHaveLength(MAX_INDEXED_ARRAY_LENGTH) - - const said = warn.mock.calls.map((c: any[]) => String(c[0])).join('\n') - expect(said).not.toContain('indexing bound') - }) - }) -}) diff --git a/tests/unit/utils/metadataIndex-nested-orderby.test.ts b/tests/unit/utils/metadataIndex-nested-orderby.test.ts deleted file mode 100644 index 55dab59b..00000000 --- a/tests/unit/utils/metadataIndex-nested-orderby.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * @module tests/unit/utils/metadataIndex-nested-orderby - * @description THE NESTED-FIELD ADDRESSING PIN for ordered reads (the - * field-addressing law, dotted-path clause). The defect this keeps dead: - * `orderBy` on a nested user metadata field (dotted path, e.g. - * `orderBy: 'profile.score'` over `metadata: { profile: { score: 7 } }`) - * silently returned insertion order — a no-op sort — because the sort - * path's value resolution read flat bag keys only. The law: a dotted user - * address is either SERVED CORRECTLY (the batched resolver walks inside - * the bag) or REFUSED with a typed UnresolvableFieldError — never a silent - * pass-through. Both spellings (`profile.score` / `metadata.profile.score`) - * are the same address; the filter side (`where: { 'profile.score': … }`) - * obeys the same law. - */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { Brainy, UnresolvableFieldError } from '../../../src/index.js' -import { NounType } from '../../../src/types/graphTypes.js' - -const ROWS = 30 - -describe('nested (dotted-path) user field orderBy — the field-addressing law', () => { - let brain: Brainy - /** id → nested score, for the rows that carry profile.score */ - const scoreById = new Map() - /** ids of the two rows WITHOUT a profile bag */ - let noProfileIds: string[] = [] - - beforeAll(async () => { - brain = new Brainy({ storage: { type: 'memory' }, requireSubtype: false }) - await brain.init() - for (let i = 0; i < ROWS; i++) { - // (i * 11) % 30 is a permutation of 0..29 (gcd(11,30)=1): every score - // distinct, insertion order maximally different from value order — a - // silent insertion-order pass-through cannot accidentally look sorted. - const score = (i * 11) % ROWS - const id = await brain.add({ - data: `row ${i}`, - type: NounType.Document, - metadata: { profile: { score }, plain: i } - }) - scoreById.set(id, score) - } - const a = await brain.add({ - data: 'no-profile a', - type: NounType.Document, - metadata: { plain: 1000 } - }) - const b = await brain.add({ - data: 'no-profile b', - type: NounType.Document, - metadata: { plain: 1001 } - }) - noProfileIds = [a, b].sort() - }, 120000) - - afterAll(async () => { - await brain.close().catch(() => {}) - }) - - /** Assert one complete ordered read against the sealed ordering contract. */ - function assertOrdered( - rows: Array<{ id: string }>, - order: 'asc' | 'desc', - label: string - ): void { - // Rows are NEVER dropped: all 30 scored + 2 profile-less rows come back. - expect(rows.length, `${label}: complete result`).toBe(ROWS + 2) - - // Missing-value rows sort LAST in BOTH directions, ties by id ascending. - const lastTwo = rows.slice(-2).map((r) => r.id) - expect(lastTwo, `${label}: missing-value rows LAST, id asc`).toEqual(noProfileIds) - - // The scored 30 are ordered by the NESTED value — the exact permutation, - // not insertion order. - const observed = rows.slice(0, ROWS).map((r) => scoreById.get(r.id)) - const wanted = [...scoreById.values()].sort((x, y) => - order === 'asc' ? x - y : y - x - ) - expect(observed, `${label}: nested values in ${order} order`).toEqual(wanted) - } - - it('orderBy: "profile.score" desc — served correctly, missing rows LAST (never a silent insertion-order no-op)', async () => { - const rows = await brain.find({ - type: NounType.Document, - orderBy: 'profile.score', - order: 'desc', - limit: 40 - }) - assertOrdered(rows, 'desc', 'bare dotted, desc') - }) - - it('orderBy: "profile.score" asc — same law in the other direction', async () => { - const rows = await brain.find({ - type: NounType.Document, - orderBy: 'profile.score', - order: 'asc', - limit: 40 - }) - assertOrdered(rows, 'asc', 'bare dotted, asc') - }) - - it('explicit spelling "metadata.profile.score" is the SAME address — identical result', async () => { - const bare = await brain.find({ - type: NounType.Document, - orderBy: 'profile.score', - order: 'desc', - limit: 40 - }) - const explicit = await brain.find({ - type: NounType.Document, - orderBy: 'metadata.profile.score', - order: 'desc', - limit: 40 - }) - assertOrdered(explicit, 'desc', 'metadata.-prefixed, desc') - expect( - explicit.map((r) => r.id), - 'both spellings resolve to the identical ordered id sequence' - ).toEqual(bare.map((r) => r.id)) - }) - - it('a dotted path carried by NO entity REFUSES with UnresolvableFieldError — never a silent insertion-order return', async () => { - await expect( - brain.find({ - type: NounType.Document, - orderBy: 'no.such.path', - order: 'desc', - limit: 40 - }) - ).rejects.toThrow(UnresolvableFieldError) - }) - - it('dotted where: { "profile.score": 7 } finds exactly the right row — the filter side of the same law', async () => { - const wantedId = [...scoreById.entries()].find(([, s]) => s === 7)![0] - const rows = await brain.find({ - type: NounType.Document, - where: { 'profile.score': 7 }, - limit: 40 - }) - expect(rows.map((r) => r.id)).toEqual([wantedId]) - }) -}) diff --git a/tests/unit/utils/metadataIndex-sort-callshape.test.ts b/tests/unit/utils/metadataIndex-sort-callshape.test.ts deleted file mode 100644 index ffe89566..00000000 --- a/tests/unit/utils/metadataIndex-sort-callshape.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * @module tests/unit/utils/metadataIndex-sort-callshape - * @description THE ASYMPTOTIC CALL-SHAPE PIN for ordered reads - * (BRAINY-PROD-LATENCY-TRIAD, David-approved plan Track A1). The defect it - * keeps dead: `getSortedIdsForFilter`'s value resolution did a SERIAL - * `storage.getNoun()` (the heavyweight VECTOR record) per filtered row — - * 62–98ms × 3,224 rows = the measured 199–317 SECOND production sort, with - * `topK` applied only after the full scan. These pins assert the SHAPE of - * the storage traffic, not wall-clock (latency-blind, so they hold on any - * machine): an ordered read performs ZERO per-row vector-record reads and - * resolves sort values through BATCHED metadata-record calls only. - */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' -import { Brainy } from '../../../src/index.js' -import { NounType } from '../../../src/types/graphTypes.js' - -const ROWS = 60 - -describe('ordered reads — the batched call-shape law (no per-row storage loops)', () => { - let brain: Brainy - let storage: { - getNoun: (id: string) => Promise - getNounMetadata: (id: string) => Promise - getNounMetadataBatch: (ids: string[]) => Promise> - } - - beforeAll(async () => { - brain = new Brainy({ storage: { type: 'memory' }, requireSubtype: false }) - await brain.init() - for (let i = 0; i < ROWS; i++) { - await brain.add({ - data: `row ${i}`, - type: NounType.Document, - metadata: { rank: (i * 7) % ROWS, plain: `p${i}` } - }) - } - storage = (brain as unknown as { storage: typeof storage }).storage - }, 120000) - - afterAll(async () => { - await brain.close().catch(() => {}) - }) - - it('user-field orderBy: zero vector-record reads, zero serial metadata reads — batch calls only', async () => { - const getNounSpy = vi.spyOn(storage, 'getNoun') - const singleReadSpy = vi.spyOn(storage, 'getNounMetadata') - const batchSpy = vi.spyOn(storage, 'getNounMetadataBatch') - - const rows = await brain.find({ - type: NounType.Document, - orderBy: 'rank', - order: 'desc', - limit: 10 - }) - expect(rows.length).toBe(10) - expect((rows[0].metadata as Record).rank).toBe(ROWS - 1) - - // THE PIN: the sort's value resolution never opens a vector record and - // never falls into a per-row metadata loop. (Result hydration after - // pagination is allowed to read; the SORT itself must be batch-only — - // hence the ceiling: strictly fewer single reads than sorted rows.) - expect(getNounSpy.mock.calls.length, 'per-row vector-record reads in an ordered read').toBe(0) - expect(batchSpy.mock.calls.length, 'the batch door was used').toBeGreaterThanOrEqual(1) - expect( - singleReadSpy.mock.calls.length, - 'serial per-row metadata reads (the 199s shape)' - ).toBeLessThan(ROWS / 2) - - vi.restoreAllMocks() - }) - - it('system.createdAt orderBy: exact values from batched records — the bucketed index is never a per-row disk excuse', async () => { - const getNounSpy = vi.spyOn(storage, 'getNoun') - const batchSpy = vi.spyOn(storage, 'getNounMetadataBatch') - - const rows = await brain.find({ - type: NounType.Document, - orderBy: 'system.createdAt', - order: 'asc', - limit: 15 - }) - expect(rows.length).toBe(15) - - expect(getNounSpy.mock.calls.length, 'per-row vector-record reads').toBe(0) - expect(batchSpy.mock.calls.length).toBeGreaterThanOrEqual(1) - - // Exactness: ascending createdAt must be non-decreasing with full - // millisecond precision (the old path sorted minute-BUCKETED values or - // paid a per-row disk read for exact ones — both are dead). Find results - // carry the timestamps on the nested full entity. - const stamps = rows.map( - (r) => ((r as unknown as { entity?: { createdAt?: number } }).entity?.createdAt ?? - (r as unknown as { createdAt?: number }).createdAt) as number - ) - for (let i = 1; i < stamps.length; i++) { - expect(stamps[i]).toBeGreaterThanOrEqual(stamps[i - 1]) - } - - vi.restoreAllMocks() - }) - - it('the ordering contract survives the batch path: missing values LAST both directions, ties by id asc, rows never dropped', async () => { - // Three rows lack `rank`? No — all carry it; add two rows WITHOUT it. - const a = await brain.add({ data: 'no-rank a', type: NounType.Document, metadata: { plain: 'x' } }) - const b = await brain.add({ data: 'no-rank b', type: NounType.Document, metadata: { plain: 'y' } }) - - for (const order of ['asc', 'desc'] as const) { - const rows = await brain.find({ - type: NounType.Document, - orderBy: 'rank', - order, - limit: ROWS + 10 - }) - expect(rows.length, `complete result (${order})`).toBe(ROWS + 2) - const lastTwo = rows.slice(-2).map((r) => r.id).sort() - expect(lastTwo, `missing-value rows sort LAST (${order})`).toEqual([a, b].sort()) - } - }) -}) diff --git a/tests/unit/utils/metadataIndex-sparse-range-collation.test.ts b/tests/unit/utils/metadataIndex-sparse-range-collation.test.ts deleted file mode 100644 index 7a2bf0a7..00000000 --- a/tests/unit/utils/metadataIndex-sparse-range-collation.test.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * @module tests/unit/utils/metadataIndex-sparse-range-collation - * @description RANGE QUERIES ON THE LEGACY SPARSE INDEX — order, or a refusal. - * Never a confidently ordered wrong answer. - * - * THE TWO RANGE PATHS. `getIdsForRange` routes a `gte` / `lt` / `between` two - * ways. The column store compares RAW values and is correct. The legacy sparse - * chunk index — the pre-7.20.0 fallback, still read for workspaces that have - * not been rebuilt — compared `normalizeValue()` output, and `normalizeValue` - * carries an escape hatch that destroys order on purpose: a string over 100 - * characters is replaced by a short hash so it can serve as a filesystem-safe - * key. Ordering hashes ranks rows by digest. - * - * THE DEFECT, IN TWO SHAPES. - * - * (a) A LONG BOUND against ordinary values. `where: { title: { gte: } }` collapsed the BOUND to `__HASH_…`, whose - * leading underscores sort below every letter — so a bound that should - * have excluded everything matched the entire field instead. This is the - * shape that reaches a caller who never stored a long value at all. - * - * (b) LONG VALUES in the index. A field whose values ran long was persisted - * hashed, so its order is not recoverable from this index at all. The old - * code compared the digests anyway and returned a subset chosen by hash. - * - * THE LAW. Bounds are normalized WITHOUT the hash escape hatch, so a long - * bound stays comparable — (a) is simply fixed. Where the persisted KEY is a - * hash, the order does not exist to be computed, and the query throws a typed - * `BrainyError('INVALID_QUERY')` naming the field and the cure — (b) is - * refused by name. Loud beats wrong. - * - * THE FIXTURE is a genuine legacy index: it is written through the same - * `ChunkManager` / `SparseIndex` doors a pre-7.20.0 engine wrote through, with - * keys normalized exactly as that engine normalized them, into a field the - * column store does not serve. The chunk WRITE path was removed in 11be039, so - * this is the only way the shape the read path exists for can be built. - * - * NOT CLAIMED HERE. The persisted keys are also lower-cased and trimmed by - * `normalizeValue`, so this path's string ranges are case-INSENSITIVE where - * the column store's are not. The raw values are not in the index to compare — - * that divergence is a property of the bytes on disk and it ends when the - * column store adopts the field. It is named in `getIdsFromChunksForRange`'s - * doc comment rather than papered over. - */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { Brainy } from '../../../src/brainy' -import { NounType } from '../../../src/types/graphTypes' -import { SparseIndex, ChunkManager } from '../../../src/utils/metadataIndexChunking' -import { BrainyError } from '../../../src/errors/brainyError' - -/** The field the legacy index covers — deliberately never given to a row, so - * the column store never learns it and the sparse fallback is the only path. */ -const FIELD = 'legacyTitle' - -/** - * Write a legacy sparse index for `field` exactly as a pre-7.20.0 engine did: - * one chunk, keys normalized through the index's own `normalizeValue`, ids as - * roaring bitmaps, a zone map and a bloom filter over the chunk. - * - * @param brain - The live brain whose metadata index gains the legacy field. - * @param field - Field name to index. - * @param valueToIds - Raw value → the entity ids that carried it. - */ -async function writeLegacySparseIndex( - brain: any, - field: string, - valueToIds: Array<[string, string[]]> -): Promise { - const index = brain.metadataIndex - const chunkManager: ChunkManager = index.chunkManager - const sparseIndex = new SparseIndex(field) - - // The keys a pre-7.20.0 writer persisted: normalizeValue output, hash escape - // hatch and all. This is what makes the fixture the real shape. - const chunk = await chunkManager.createChunk(field) - for (const [value, ids] of valueToIds) { - const key = index.normalizeValue(value, field) - for (const id of ids) await chunkManager.addToChunk(chunk, key, id) - } - await chunkManager.saveChunk(chunk) - - sparseIndex.registerChunk( - { - chunkId: chunk.chunkId, - field, - valueCount: chunk.entries.size, - idCount: Array.from(chunk.entries.values()).reduce((s: number, b: any) => s + b.size, 0), - zoneMap: (chunkManager as any).calculateZoneMap(chunk), - lastUpdated: Date.now(), - splitThreshold: 80, - mergeThreshold: 20 - }, - chunkManager.createBloomFilter(chunk) - ) - - await index.saveSparseIndex(field, sparseIndex) -} - -/** A deterministic string of `n` characters starting with `lead`. */ -function longString(lead: string, n: number): string { - return lead + 'x'.repeat(n - lead.length) -} - -describe('legacy sparse index: range queries order values, or refuse', () => { - let brain: Brainy - let index: any - let ids: string[] - - beforeEach(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) - await brain.init() - index = (brain as any).metadataIndex - - // Rows exist (so the id mapper can resolve them) but carry NO `legacyTitle` - // — the column store must not serve the field the pins query. - ids = [] - for (let i = 0; i < 3; i++) { - const id = `row-${i}` - await brain.add({ id, data: `row ${i}`, type: NounType.Thing, metadata: { lane: 'a' }, vector: [] }) - ids.push(id) - } - expect(index.columnStore.hasField(FIELD)).toBe(false) - }) - - afterEach(async () => { - await brain.close() - }) - - describe('(a) a long BOUND against ordinary short values', () => { - // 'apple' < 'mango' < 'zebra', and every bound below is compared against - // these three raw keys. - beforeEach(async () => { - await writeLegacySparseIndex(brain, FIELD, [ - ['apple', [ids[0]]], - ['mango', [ids[1]]], - ['zebra', [ids[2]]] - ]) - }) - - it('the fixture: the values are stored raw, the long bound is what hashes', () => { - expect(index.normalizeValue('apple', FIELD)).toBe('apple') - // The bound is what the old code collapsed — and a digest sorts below - // every letter, which is exactly why `gte` matched everything. - const bound = longString('zzz', 120) - expect(index.normalizeValue(bound, FIELD)).toMatch(/^__HASH_/) - expect(index.normalizeValue(bound, FIELD) < 'apple').toBe(true) - }) - - it('gte a bound above every value matches NOTHING (it used to match all)', async () => { - const bound = longString('zzz', 120) - const matched = await index.getIdsForRange(FIELD, bound, undefined, true, true) - expect(matched).toEqual([]) - }) - - it('lte a bound above every value matches EVERY value', async () => { - const bound = longString('zzz', 120) - const matched = await index.getIdsForRange(FIELD, undefined, bound, true, true) - expect(matched).toHaveLength(3) - }) - - it('gte a long bound below every value matches every value', async () => { - const bound = longString('aaa', 120) - const matched = await index.getIdsForRange(FIELD, bound, undefined, true, true) - expect(matched).toHaveLength(3) - }) - - it('a long bound orders BETWEEN the values, not below all of them', async () => { - // 'mmm…' sits between 'mango' and 'zebra'. - const bound = longString('mmm', 120) - const matched = await index.getIdsForRange(FIELD, bound, undefined, true, true) - expect(matched).toHaveLength(1) - }) - - it('short bounds are unchanged — the ordinary case still orders correctly', async () => { - expect(await index.getIdsForRange(FIELD, 'b', undefined, true, true)).toHaveLength(2) - expect(await index.getIdsForRange(FIELD, undefined, 'n', true, true)).toHaveLength(2) - expect(await index.getIdsForRange(FIELD, 'b', 'n', true, true)).toHaveLength(1) - // Strict bounds stay strict. - expect(await index.getIdsForRange(FIELD, 'mango', undefined, false, true)).toHaveLength(1) - expect(await index.getIdsForRange(FIELD, 'mango', undefined, true, true)).toHaveLength(2) - }) - }) - - describe('(b) long VALUES — the index holds hashes, so the range is refused', () => { - beforeEach(async () => { - await writeLegacySparseIndex(brain, FIELD, [ - [longString('alpha', 140), [ids[0]]], - [longString('mike', 140), [ids[1]]], - [longString('zulu', 140), [ids[2]]] - ]) - }) - - it('the fixture: the persisted keys really are hashes', async () => { - const chunk = await index.chunkManager.loadChunk(FIELD, 0) - const keys = Array.from(chunk.entries.keys()) as string[] - expect(keys).toHaveLength(3) - for (const k of keys) expect(k).toMatch(/^__HASH_/) - // And their digest order is NOT their value order — the wrong answer the - // old code returned was wrong, not merely arbitrary. - const digestOrder = [...keys].sort() - const valueOrder = [ - index.normalizeValue(longString('alpha', 140), FIELD), - index.normalizeValue(longString('mike', 140), FIELD), - index.normalizeValue(longString('zulu', 140), FIELD) - ] - expect(digestOrder).not.toEqual(valueOrder) - }) - - it('a range over the hashed field throws a typed refusal naming the field', async () => { - await expect( - index.getIdsForRange(FIELD, longString('mike', 140), undefined, true, true) - ).rejects.toThrow(BrainyError) - - const err = await index - .getIdsForRange(FIELD, longString('mike', 140), undefined, true, true) - .catch((e: any) => e) - expect(err).toBeInstanceOf(BrainyError) - expect(err.type).toBe('INVALID_QUERY') - expect(err.message).toContain(FIELD) - expect(err.message).toContain('hash') - // The cure is named, not left to the caller to guess. - expect(err.message).toContain('repairIndex') - }) - - it('every range shape refuses — gte, lte and between alike', async () => { - const lo = longString('alpha', 140) - const hi = longString('zulu', 140) - for (const [min, max] of [ - [lo, undefined], - [undefined, hi], - [lo, hi] - ] as Array<[any, any]>) { - const err = await index.getIdsForRange(FIELD, min, max, true, true).catch((e: any) => e) - expect(err).toBeInstanceOf(BrainyError) - expect(err.type).toBe('INVALID_QUERY') - } - }) - - it('EQUALITY still works on the hashed field — only ordering is refused', async () => { - const matched = await index.getIds(FIELD, longString('mike', 140)) - expect(matched).toHaveLength(1) - }) - }) - - describe('numeric ranges on the legacy path are untouched', () => { - beforeEach(async () => { - await writeLegacySparseIndex(brain, FIELD, [ - ['5', [ids[0]]], - ['50', [ids[1]]], - ['500', [ids[2]]] - ]) - }) - - it('numbers still compare numerically, not lexicographically', async () => { - // The whole point of compareNormalizedValues: "50" < "500" numerically - // even though "500" < "50" would hold as strings by prefix. - expect(await index.getIdsForRange(FIELD, 10, undefined, true, true)).toHaveLength(2) - expect(await index.getIdsForRange(FIELD, undefined, 100, true, true)).toHaveLength(2) - expect(await index.getIdsForRange(FIELD, 10, 100, true, true)).toHaveLength(1) - }) - }) -}) diff --git a/tests/unit/utils/metadataIndex-watermark.test.ts b/tests/unit/utils/metadataIndex-watermark.test.ts deleted file mode 100644 index 6d6f6e28..00000000 --- a/tests/unit/utils/metadataIndex-watermark.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -/** - * @module tests/unit/utils/metadataIndex-watermark - * @description Watermark-stamp pins for the metadata projection. - * - * THE LAW under test: every persisted projection artifact carries a stamp - * asserting "this state reflects every committed generation ≤ W and nothing - * above W, atomically" — written AFTER every byte it certifies is durable — - * and at load the owner computes the three-way verdict: - * stamped==committed → 'adopt' · stampedcommitted OR unstamped → 'rescan', LOUDLY. - * Same rule, same verdict names as the shipped aggregation machinery - * (AggregationIndex.stateAdoptionVerdict). - * - * The verdict is computed at init and consumed via - * {@link MetadataIndexManager.applyWatermarkCatchup} — the coordinator - * (`Brainy.performInit`) calls it right after `init()`, with an open fact - * scan when the verdict is `'catchup'`. This file pins both halves: the - * verdict computation (above) and the fold/no-op/demotion behavior below. - */ -import { describe, it, expect, vi, afterEach } from 'vitest' -import { v4 as uuidv4 } from 'uuid' -import { - MetadataIndexManager, - METADATA_INDEX_STAMP_KEY -} from '../../../src/utils/metadataIndex.js' -import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js' -import { prodLog } from '../../../src/utils/logger.js' -import type { CommitFact, FactScanBatch, FactScanHandle } from '../../../src/db/factLog.js' - -/** A fact scan handle over an in-memory list of facts — batches them one - * fact at a time (batch size is irrelevant to the fold, which reads - * `batch.facts` only). */ -function fakeScan(facts: CommitFact[]): FactScanHandle { - return { - headGeneration: facts.length > 0 ? facts[facts.length - 1].generation : 0, - segmentCount: 1, - approxFactCount: facts.length, - async *batches(): AsyncGenerator { - for (const fact of facts) { - yield { - facts: [fact], - firstGeneration: fact.generation, - lastGeneration: fact.generation, - factCount: 1, - byteSize: 0, - segmentId: 'fake' - } - } - }, - summary: () => ({ factsYielded: facts.length, segmentsRead: 1 }) - } -} - -/** One noun after-image fact — the flat-record shape (no nested `metadata` - * key), matching this file's existing `writeArtifact` convention. */ -function nounAdd(generation: number, id: string, metadata: Record): CommitFact { - return { - generation, - timestamp: Date.now(), - ops: [{ kind: 'noun', id, record: { metadata, vector: null } }] - } -} - -/** One noun tombstone fact. */ -function nounDelete(generation: number, id: string): CommitFact { - return { generation, timestamp: Date.now(), ops: [{ kind: 'noun', id, record: null }] } -} - -/** Fresh storage with a controllable committed generation. */ -async function makeStorage(committed: number | null): Promise { - const storage = new MemoryStorage() - await storage.init() - if (committed !== null) { - vi.spyOn(storage, 'committedGeneration').mockReturnValue(committed) - } - return storage -} - -/** Set (or reset) the mocked committed generation on an existing storage. */ -function setCommitted(storage: MemoryStorage, committed: number): void { - vi.spyOn(storage, 'committedGeneration').mockReturnValue(committed) -} - -/** Session 1: index a field, optionally stamp, flush — the durable artifact. */ -async function writeArtifact( - storage: MemoryStorage, - stamp: number | null -): Promise { - const index = new MetadataIndexManager(storage) - await index.init() - await index.addToIndex(uuidv4(), { status: 'active', role: 'admin' }) - if (stamp !== null) index.stampWatermark(stamp) - await index.flush() -} - -/** Session 2: reopen on the same storage and return the loaded manager. */ -async function reopen(storage: MemoryStorage): Promise { - const index = new MetadataIndexManager(storage) - await index.init() - return index -} - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('metadata index — watermark stamp + three-way load verdict', () => { - it("save-with-stamp then reopen at the same committed generation → 'adopt', zero-work verdict", async () => { - const storage = await makeStorage(5) - await writeArtifact(storage, 5) - - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('adopt') - expect(index.watermark()).toBe(5) - expect(index.watermarkGap()).toBeNull() - }) - - it("stamp BEHIND the committed generation → 'catchup' with the exact gap reported", async () => { - const storage = await makeStorage(5) - await writeArtifact(storage, 5) - - // Later commits landed after the last stamped flush (unclean exit shape). - setCommitted(storage, 8) - - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('catchup') - expect(index.watermark()).toBe(5) - expect(index.watermarkGap()).toEqual({ from: 5, to: 8 }) - }) - - it("stamp ABOVE the committed generation → 'rescan', said out loud", async () => { - const storage = await makeStorage(9) - await writeArtifact(storage, 9) - - // A truncated log on a copied store pulled the watermark back. - setCommitted(storage, 4) - - const warnSpy = vi.spyOn(prodLog, 'warn') - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('rescan') - expect(index.watermarkGap()).toBeNull() - const said = warnSpy.mock.calls.map(c => String(c[0])).join('\n') - expect(said).toContain('RESCAN') - expect(said).toContain('ABOVE') - }) - - it("legacy unstamped artifact on a stamped store → 'rescan', LOUD — never a silent adopt", async () => { - const storage = await makeStorage(3) - await writeArtifact(storage, null) // pre-stamp brain: data flushed, no stamp - - expect(await storage.getMetadata(METADATA_INDEX_STAMP_KEY)).toBeNull() - - const warnSpy = vi.spyOn(prodLog, 'warn') - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('rescan') - expect(index.watermark()).toBeNull() - const said = warnSpy.mock.calls.map(c => String(c[0])).join('\n') - expect(said).toContain('RESCAN') - expect(said).toContain('unstamped') - }) - - it("a store with no committed-generation capability keeps pre-stamp behavior → 'adopt'", async () => { - const storage = await makeStorage(null) // committedGeneration() → null - await writeArtifact(storage, null) - - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('adopt') - expect(index.watermark()).toBeNull() - }) - - it('STAMP-AFTER-DATA: the stamp is the last saveMetadata of the flush, after registry and field indexes', async () => { - const storage = await makeStorage(2) - const index = new MetadataIndexManager(storage) - await index.init() - await index.addToIndex(uuidv4(), { status: 'active' }) - - const keys: string[] = [] - const originalSave = storage.saveMetadata.bind(storage) - vi.spyOn(storage, 'saveMetadata').mockImplementation(async (id, metadata) => { - keys.push(id) - return originalSave(id, metadata) - }) - - index.stampWatermark(2) - await index.flush() - - const stampAt = keys.indexOf(METADATA_INDEX_STAMP_KEY) - expect(stampAt, 'stamp record was written').toBeGreaterThanOrEqual(0) - expect(stampAt, 'stamp is the FINAL metadata write of the flush').toBe(keys.length - 1) - const registryAt = keys.indexOf('__metadata_field_registry__') - expect(registryAt, 'field registry written during this flush').toBeGreaterThanOrEqual(0) - expect(registryAt).toBeLessThan(stampAt) - - // The persisted stamp record carries the required shape. - const record = (await storage.getMetadata(METADATA_INDEX_STAMP_KEY)) as { - watermark: number - formatVersion: number - stampedAt: number - } - expect(record.watermark).toBe(2) - expect(record.formatVersion).toBe(1) - expect(typeof record.stampedAt).toBe('number') - }) - - it('a flush WITHOUT a pending stamp writes no stamp record (no phantom certification)', async () => { - const storage = await makeStorage(2) - const index = new MetadataIndexManager(storage) - await index.init() - await index.addToIndex(uuidv4(), { status: 'active' }) - await index.flush() - - expect(await storage.getMetadata(METADATA_INDEX_STAMP_KEY)).toBeNull() - }) -}) - -describe('metadata index — applyWatermarkCatchup (the coordinator door)', () => { - it("an 'adopt' verdict performs zero index writes", async () => { - const storage = await makeStorage(5) - await writeArtifact(storage, 5) - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('adopt') - - const addSpy = vi.spyOn(index, 'addToIndex') - const removeSpy = vi.spyOn(index, 'removeFromIndex') - - const result = await index.applyWatermarkCatchup(null) - - expect(result).toEqual({ action: 'noop' }) - expect(addSpy).not.toHaveBeenCalled() - expect(removeSpy).not.toHaveBeenCalled() - }) - - it('a catchup window folding an add, an update (same id twice), and a delete → the index serves exactly the final state', async () => { - const storage = await makeStorage(5) - - // Session 1: two pre-existing entities, stamped at generation 5. - const survivorId = uuidv4() - const deletedId = uuidv4() - { - const index = new MetadataIndexManager(storage) - await index.init() - await index.addToIndex(survivorId, { status: 'active' }) - await index.addToIndex(deletedId, { status: 'active' }) - index.stampWatermark(5) - await index.flush() - } - - // The store advanced to generation 8 without another metadata flush — - // the exact shape a crash-then-adopt-reopen leaves behind. - setCommitted(storage, 8) - - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('catchup') - expect(index.watermarkGap()).toEqual({ from: 5, to: 8 }) - - const addedId = uuidv4() - const scan = fakeScan([ - nounAdd(6, addedId, { status: 'new' }), // add - nounAdd(7, addedId, { status: 'updated' }), // update — same id twice - nounDelete(8, deletedId) // delete - ]) - - const result = await index.applyWatermarkCatchup(scan) - - expect(result.action).toBe('caught-up') - expect(result.window).toEqual({ from: 5, to: 8 }) - expect(result.factsApplied).toBe(3) - expect(result.nounsApplied).toBe(3) - expect(result.verbsApplied).toBe(0) - - // Final state: the added/updated id serves ONLY its final value... - expect(await index.getIds('status', 'updated')).toEqual([addedId]) - expect(await index.getIds('status', 'new')).toEqual([]) // stale value gone - // ...the deleted id is gone... - expect(await index.getIds('status', 'active')).toEqual([survivorId]) - // ...and the untouched survivor is unaffected. - expect(await index.getIds('status', 'active')).toContain(survivorId) - - // The window is certified: watermark stamped at `to`, and a fresh - // reopen now verdicts 'adopt'. - expect(index.watermark()).toBe(8) - const reopened = await reopen(storage) - expect(reopened.watermarkVerdict()).toBe('adopt') - }) - - it("a 'rescan' verdict runs the existing rebuild path instead of folding", async () => { - const storage = await makeStorage(9) - await writeArtifact(storage, 9) - setCommitted(storage, 4) // a truncated log pulled the watermark back — stamp ABOVE committed → rescan - - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('rescan') - - const rebuildSpy = vi.spyOn(index, 'rebuild') - const result = await index.applyWatermarkCatchup(null) - - expect(result.action).toBe('rescan') - expect(result.reason).toBeTruthy() - expect(rebuildSpy).toHaveBeenCalledTimes(1) - }) - - it("a 'catchup' verdict with no fact log available demotes to rebuild, narrated", async () => { - const storage = await makeStorage(5) - await writeArtifact(storage, 5) - setCommitted(storage, 8) - - const index = await reopen(storage) - expect(index.watermarkVerdict()).toBe('catchup') - - const rebuildSpy = vi.spyOn(index, 'rebuild') - const result = await index.applyWatermarkCatchup(null) // no scan — no fact log - - expect(result.action).toBe('rescan') - expect(result.reason).toContain('no fact log') - expect(rebuildSpy).toHaveBeenCalledTimes(1) - }) -}) diff --git a/tests/unit/utils/paramValidation.test.ts b/tests/unit/utils/paramValidation.test.ts index 805dd40d..4dc83554 100644 --- a/tests/unit/utils/paramValidation.test.ts +++ b/tests/unit/utils/paramValidation.test.ts @@ -56,15 +56,11 @@ describe('Zero-Config Parameter Validation', () => { })).toThrow('cannot specify both query and vector') }) - it('should refuse cursor outright — even paired with offset — as an unimplemented option', () => { - // cursor is now a typed, unconditional refusal (UnsupportedFindOptionError): - // it used to be accepted-and-ignored, only conflicting when offset was also - // given. Accepted-and-ignored died as a class — cursor refuses on its own, - // so pairing it with offset refuses too, but with the SAME message. + it('should reject both cursor and offset', () => { expect(() => validateFindParams({ cursor: 'abc123', offset: 10 - })).toThrow("find() option 'cursor' is not implemented") + })).toThrow('cannot use both cursor and offset pagination') }) it('should validate vector dimensions', () => { @@ -149,33 +145,7 @@ describe('Zero-Config Parameter Validation', () => { type: NounType.Document } as AddParams)).toThrow('Invalid add() parameters: Missing required field \'data\'') }) - - it('should accept an empty string as real data — only null/undefined is "missing"', () => { - // A legitimate empty file's first write: '' is content, not absence. - expect(() => validateAddParams({ - data: '', - type: NounType.Document - })).not.toThrow() - - // null/undefined (with no vector) is still the genuine missing-field case. - expect(() => validateAddParams({ - data: null as any, - type: NounType.Document - })).toThrow('Invalid add() parameters: Missing required field \'data\'') - expect(() => validateAddParams({ - data: undefined, - type: NounType.Document - })).toThrow('Invalid add() parameters: Missing required field \'data\'') - }) - - it('deferEmbedding accepts empty-string data (real content, not absence)', () => { - expect(() => validateAddParams({ - data: '', - type: NounType.Document, - deferEmbedding: true - } as AddParams)).not.toThrow() - }) - + it('should validate NounType', () => { expect(() => validateAddParams({ data: 'test', @@ -216,22 +186,7 @@ describe('Zero-Config Parameter Validation', () => { id: 'test-id' })).toThrow('must specify at least one field to update') }) - - it('empty-string data counts as a real field to update (truncating content)', () => { - expect(() => validateUpdateParams({ - id: 'test-id', - data: '' - })).not.toThrow() - }) - - it('deferEmbedding accepts empty-string data on update', () => { - expect(() => validateUpdateParams({ - id: 'test-id', - data: '', - deferEmbedding: true - } as UpdateParams)).not.toThrow() - }) - + it('should validate NounType if changing', () => { expect(() => validateUpdateParams({ id: 'test-id', diff --git a/tests/unit/validate-invariants-delegation.test.ts b/tests/unit/validate-invariants-delegation.test.ts index 69133733..45e12ccd 100644 --- a/tests/unit/validate-invariants-delegation.test.ts +++ b/tests/unit/validate-invariants-delegation.test.ts @@ -6,7 +6,7 @@ * validateInvariants(), and repairIndex() maps a failing invariant with heal:'rebuild' * to that provider's rebuild(). "healthy-while-broken must be impossible." */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy, NounType } from '../../src/index.js' import type { ProviderInvariantReport } from '../../src/index.js' @@ -48,10 +48,6 @@ describe('validateIndexConsistency delegates to provider validateInvariants() (P await brain.flush() }) - afterEach(async () => { - await brain.close() - }) - it('a broken provider report makes the store unhealthy and names the failing invariant', async () => { brain.index.validateInvariants = async () => brokenReport('vector') const v = await brain.validateIndexConsistency() @@ -81,31 +77,6 @@ describe('validateIndexConsistency delegates to provider validateInvariants() (P delete brain.index.validateInvariants }) - it('ONE CONTRACT FOR A THROWING PROBE: heal is none (flakiness never buys a rebuild) and serving is not withheld', async () => { - // The probe that fails to RUN must never be read as "the index is broken, - // rebuild it" — that synthesized heal:'rebuild' was the dark-rebuild lever - // one transient exception away, and the native composer already said - // 'none' for the same event. Both engines now agree: named, loud, - // unverified — and never a rebuild, never a withheld serve. - brain.index.validateInvariants = async () => { throw new Error('transient: mmap window busy') } - const v = await brain.validateIndexConsistency() - const thrown = v.providers?.find((p: ProviderInvariantReport) => - p.invariants.some((i) => i.name === 'validate-invariants-threw') - ) - expect(thrown).toBeDefined() - expect(thrown!.healthy).toBe(false) - expect(thrown!.serving).toBe(true) - const inv = thrown!.invariants.find((i) => i.name === 'validate-invariants-threw')! - expect(inv.holds).toBe(false) - expect(inv.heal).toBe('none') - expect(inv.detail).toMatch(/transient: mmap window busy/) - // No provider report in the set recommends a rebuild for this event. - expect( - v.providers!.flatMap((p: ProviderInvariantReport) => p.invariants).some((i) => i.heal === 'rebuild') - ).toBe(false) - delete brain.index.validateInvariants - }) - it('providers without validateInvariants() are omitted (JS baseline unchanged)', async () => { const v = await brain.validateIndexConsistency() expect(v.providers).toBeUndefined() diff --git a/tests/unit/vector-cold-read-guard.test.ts b/tests/unit/vector-cold-read-guard.test.ts index 963009b7..49ca6426 100644 --- a/tests/unit/vector-cold-read-guard.test.ts +++ b/tests/unit/vector-cold-read-guard.test.ts @@ -3,16 +3,11 @@ * @description Pattern-A / Finding 1: a pure semantic find({ query }) has no * filter, so verifyMetadataLive never fires — nothing guarded the vector index. * A cold native vector index that loaded its COUNT but not its serving structure - * returned a silent []. verifyVectorLive() closes that: the health-report/isReady() - * authority first, else a known-vector self-match probe. - * - * RE-POINTED to the health-gate law: the guard NEVER rebuilds and NEVER walks - * the store from a read — a read-path rebuild is exactly the dark-rebuild - * failure mode the law retires (open() alone owns building). A not-serving - * signal (from either strategy) THROWS VectorIndexNotReadyError immediately, - * with no rebuild attempt in between — never a silent empty result. + * returned a silent []. verifyVectorLive() closes that: honest isReady() first, + * else a known-vector self-match probe; self-heal (rebuild) or throw + * VectorIndexNotReadyError — never a silent empty result. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy, NounType, VectorIndexNotReadyError } from '../../src/index.js' const V = (): number[] => Array.from({ length: 384 }, (_, i) => Math.sin(i * 0.1) + 0.001) @@ -28,10 +23,6 @@ describe('Vector cold-read guard (verifyVectorLive) — silent-[] on cold semant await brain.flush() }) - afterEach(async () => { - await brain.close() - }) - it('warm brain: semantic find is correct and the guard does not rebuild', async () => { const vi = brain.index let rebuilds = 0 @@ -43,37 +34,50 @@ describe('Vector cold-read guard (verifyVectorLive) — silent-[] on cold semant vi.rebuild = origRebuild }) - it('cold index (no isReady()): verifyVectorLive REFUSES immediately — throws VectorIndexNotReadyError, NEVER rebuilds', async () => { + it('cold index: verifyVectorLive self-heals via rebuild — semantic find is correct, NOT silent []', async () => { const vi = brain.index const origSearch = vi.search.bind(vi) - let rebuilds = 0 const origRebuild = vi.rebuild.bind(vi) + let cold = true brain._vectorVerified = false - // size()>0 (count present) but search never returns a hit for the known vector. - vi.search = async () => [] - vi.rebuild = async (...a: any[]) => { rebuilds++; return origRebuild(...a) } + // size()>0 (count present) but search returns nothing until a rebuild warms it. + vi.search = async (...a: any[]) => (cold ? [] : origSearch(...a)) + vi.rebuild = async (...a: any[]) => { await origRebuild(...a); cold = false } try { - await expect( - brain.find({ query: 'x', searchMode: 'semantic', limit: 100 }) - ).rejects.toBeInstanceOf(VectorIndexNotReadyError) - expect(rebuilds).toBe(0) // the guard never rebuilds from a read — it refuses loudly instead + const res = await brain.find({ query: 'x', searchMode: 'semantic', limit: 100 }) + expect(res.length).toBeGreaterThan(0) // self-healed } finally { vi.search = origSearch; vi.rebuild = origRebuild } }) - it('native provider reporting isReady()===false THROWS immediately — never rebuilds', async () => { + it('unrecoverably cold index: semantic find throws VectorIndexNotReadyError', async () => { const vi = brain.index - let rebuilds = 0 + const origSearch = vi.search.bind(vi) const origRebuild = vi.rebuild.bind(vi) brain._vectorVerified = false - vi.isReady = () => false - vi.rebuild = async (...a: any[]) => { rebuilds++; return origRebuild(...a) } + vi.search = async () => [] // always cold; rebuild can't fix it + vi.rebuild = async () => {} try { await expect( brain.find({ query: 'x', searchMode: 'semantic', limit: 100 }) ).rejects.toBeInstanceOf(VectorIndexNotReadyError) - expect(rebuilds).toBe(0) // a not-ready report throws immediately — it is never a rebuild trigger + } finally { + vi.search = origSearch; vi.rebuild = origRebuild + } + }) + + it('native provider reporting isReady()===false rebuilds, then serves', async () => { + const vi = brain.index + const origRebuild = vi.rebuild.bind(vi) + let ready = false + brain._vectorVerified = false + vi.isReady = () => ready + vi.rebuild = async (...a: any[]) => { await origRebuild(...a); ready = true } + try { + const res = await brain.find({ query: 'x', searchMode: 'semantic', limit: 100 }) + expect(ready).toBe(true) // rebuild ran because isReady() was false + expect(res).toBeDefined() } finally { delete vi.isReady; vi.rebuild = origRebuild } diff --git a/tests/unit/vfs-multi-instance-diagnostic.test.ts b/tests/unit/vfs-multi-instance-diagnostic.test.ts index 85ff1002..deaa4615 100644 --- a/tests/unit/vfs-multi-instance-diagnostic.test.ts +++ b/tests/unit/vfs-multi-instance-diagnostic.test.ts @@ -4,7 +4,7 @@ * Tests to verify VFS import behavior and identify if VFS creates only wrappers or also graph entities */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy, NounType } from '../../src/index.js' describe('VFS Multi-instance Diagnostic', () => { @@ -17,10 +17,6 @@ describe('VFS Multi-instance Diagnostic', () => { await brain.init() }) - afterEach(async () => { - await brain.close() - }) - it('should verify VFS creates document wrappers AND allows entity filtering', async () => { console.log('\n🔬 VFS Multi-instance Diagnostic Test\n') console.log('='.repeat(70)) diff --git a/tests/unit/vfs-readdir-recursive.test.ts b/tests/unit/vfs-readdir-recursive.test.ts deleted file mode 100644 index 2ee8a775..00000000 --- a/tests/unit/vfs-readdir-recursive.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * vfs.readdir()'s `recursive` option: typed since 7.30 but never read, so it - * silently behaved exactly like `recursive: false`. This pins the real, - * documented contract: a recursive listing returns every descendant (files - * AND directories, all depths) as paths RELATIVE TO THE QUERIED DIRECTORY — - * the same convention Node's `fs.readdir(dir, { recursive: true })` uses — - * for both the plain string-array form and the `withFileTypes` VFSDirent - * form (whose `name` carries that same relative path when recursive). - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { Brainy } from '../../src/brainy.js' -import type { VFSDirent } from '../../src/vfs/types.js' - -describe('vfs.readdir() recursive option', () => { - let brain: Brainy - - beforeEach(async () => { - brain = new Brainy({ - requireSubtype: false, - storage: { type: 'memory' }, - silent: true - }) - await brain.init() - - // Build: - // /a/b.txt - // /a/sub/c.txt - // /a/sub/deeper/d.txt - // /a/sub2/ (empty directory) - await brain.vfs.writeFile('/a/b.txt', 'B') - await brain.vfs.writeFile('/a/sub/c.txt', 'C') - await brain.vfs.writeFile('/a/sub/deeper/d.txt', 'D') - await brain.vfs.mkdir('/a/sub2', { recursive: true }) - }) - - afterEach(async () => { - await brain.close() - }) - - it('non-recursive (default) still returns only direct children, by basename', async () => { - const entries = await brain.vfs.readdir('/a') as string[] - expect([...entries].sort()).toEqual(['b.txt', 'sub', 'sub2']) - }) - - it('recursive: true returns every descendant as a path relative to the queried directory', async () => { - const entries = await brain.vfs.readdir('/a', { recursive: true }) as string[] - expect([...entries].sort()).toEqual([ - 'b.txt', - 'sub', - 'sub/c.txt', - 'sub/deeper', - 'sub/deeper/d.txt', - 'sub2' - ]) - }) - - it('recursive: true at the root has no leading slash on relative entries', async () => { - const entries = await brain.vfs.readdir('/', { recursive: true }) as string[] - expect(entries).toContain('a') - expect(entries).toContain('a/b.txt') - expect(entries).toContain('a/sub/deeper/d.txt') - for (const entry of entries) { - expect(entry.startsWith('/')).toBe(false) - } - }) - - it('recursive + withFileTypes: VFSDirent.name is the relative path, .path stays absolute', async () => { - const entries = await brain.vfs.readdir('/a', { - recursive: true, - withFileTypes: true - }) as VFSDirent[] - - const byName = new Map(entries.map((e) => [e.name, e])) - - const nested = byName.get('sub/deeper/d.txt') - expect(nested).toBeDefined() - expect(nested!.path).toBe('/a/sub/deeper/d.txt') - expect(nested!.type).toBe('file') - - const nestedDir = byName.get('sub/deeper') - expect(nestedDir).toBeDefined() - expect(nestedDir!.path).toBe('/a/sub/deeper') - expect(nestedDir!.type).toBe('directory') - - // Non-recursive VFSDirent behavior is unchanged: name is the basename. - const direct = await brain.vfs.readdir('/a', { withFileTypes: true }) as VFSDirent[] - const directEntry = direct.find((e) => e.path === '/a/b.txt') - expect(directEntry?.name).toBe('b.txt') - }) - - it('recursive + filter composes: only files survive a type filter', async () => { - const entries = await brain.vfs.readdir('/a', { - recursive: true, - filter: { type: 'file' } - }) as string[] - expect([...entries].sort()).toEqual(['b.txt', 'sub/c.txt', 'sub/deeper/d.txt']) - }) -}) diff --git a/tests/vfs/tree-operations.unit.test.ts b/tests/vfs/tree-operations.unit.test.ts index 91743227..8c717115 100644 --- a/tests/vfs/tree-operations.unit.test.ts +++ b/tests/vfs/tree-operations.unit.test.ts @@ -3,7 +3,7 @@ * Ensures tree methods prevent recursion and work correctly */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy } from '../../src/brainy.js' import { VirtualFileSystem } from '../../src/vfs/VirtualFileSystem.js' import { VFSTreeUtils } from '../../src/vfs/TreeUtils.js' @@ -24,10 +24,6 @@ describe('VFS Tree Operations', () => { await vfs.init() }) - afterEach(async () => { - await brain.close() - }) - describe('Critical: No Self-Inclusion Bug', () => { it('should NEVER return a directory as its own child', async () => { // Create test structure diff --git a/tests/vfs/vfs-bug-fixes.unit.test.ts b/tests/vfs/vfs-bug-fixes.unit.test.ts index 12199c8b..f98d6a76 100644 --- a/tests/vfs/vfs-bug-fixes.unit.test.ts +++ b/tests/vfs/vfs-bug-fixes.unit.test.ts @@ -6,7 +6,7 @@ * - Issue #2: File read decompression error */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy } from '../../src/brainy.js' import { VirtualFileSystem } from '../../src/vfs/VirtualFileSystem.js' @@ -25,10 +25,6 @@ describe('VFS Bug Fixes', () => { await vfs.init() }) - afterEach(async () => { - await brain.close() - }) - describe('Issue #1: Duplicate Directory Nodes', () => { it('should not create duplicate directory entries when writing multiple files to same directory', async () => { // Write multiple files to the same directory (reproduce the bug scenario) diff --git a/tests/vfs/vfs-bulkwrite-race.unit.test.ts b/tests/vfs/vfs-bulkwrite-race.unit.test.ts index 09d68568..238ac6b9 100644 --- a/tests/vfs/vfs-bulkwrite-race.unit.test.ts +++ b/tests/vfs/vfs-bulkwrite-race.unit.test.ts @@ -12,7 +12,7 @@ * other operations in parallel batches. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { Brainy } from '../../src/brainy.js' import { VirtualFileSystem } from '../../src/vfs/VirtualFileSystem.js' @@ -30,10 +30,6 @@ describe('VFS bulkWrite Race Condition Fix', () => { await vfs.init() }) - afterEach(async () => { - await brain.close() - }) - describe('operation ordering', () => { it('should create directories before files when mixed in same batch', async () => { // This is the exact scenario that triggered the race condition: diff --git a/tests/vfs/vfs-search-path-scope.unit.test.ts b/tests/vfs/vfs-search-path-scope.unit.test.ts deleted file mode 100644 index 1f3f5333..00000000 --- a/tests/vfs/vfs-search-path-scope.unit.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * @module tests/vfs/vfs-search-path-scope.unit - * @description `vfs.search({ path })` scopes with a SERVED filter. - * - * The scope used to be emitted as `path: { $startsWith }` — an operator that is - * not in the filter vocabulary at all, and whose `$`-less spelling the metadata - * index refuses by the served-operator law (an equality/range posting index - * cannot evaluate a substring without reading every row). Every path-scoped VFS - * search threw; none has ever worked on this engine line. - * - * The scope is now a half-open range over `metadata.path`, which is the VFS's - * truth, is indexed on every VFS entity, and is served by the ordered range - * operators: `[dir + '/', dir + '0')` — '0' being the code point after '/', so - * membership in the range is EXACTLY "carries the prefix `dir/`". The - * non-recursive scope is the directory's own identity, `parent`, an equality. - * - * These pins hold the answer (descendants at every depth, siblings never — the - * `/scope-sibling` trap included), the shape (the operators the search emits - * are answered by the index's own door, never refused), and the law that the - * scope narrows the search BEFORE it runs rather than filtering an over-fetch. - */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' -import { VirtualFileSystem } from '../../src/vfs/VirtualFileSystem.js' -import { Brainy } from '../../src/brainy.js' -import { VFSErrorCode } from '../../src/vfs/types.js' - -/** A word every fixture file carries, so the text leg reaches all of them. */ -const TOKEN = 'quasar' - -describe('vfs.search({ path }) scopes with a served filter', () => { - let brain: Brainy - let vfs: VirtualFileSystem - - /** In scope for '/scope', at three depths. */ - const inScope = ['/scope/a.txt', '/scope/sub/b.txt', '/scope/sub/deep/c.txt'] - /** Out of scope — including the two prefix traps a naive test misses. */ - const outOfScope = ['/scope-sibling/d.txt', '/scope0/e.txt', '/elsewhere/f.txt', '/g.txt'] - - beforeAll(async () => { - brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, silent: true }) - await brain.init() - vfs = brain.vfs - await vfs.init() - - await vfs.mkdir('/scope/sub/deep', { recursive: true }) - await vfs.mkdir('/scope-sibling', { recursive: true }) - await vfs.mkdir('/scope0', { recursive: true }) - await vfs.mkdir('/elsewhere', { recursive: true }) - - for (const path of [...inScope, ...outOfScope]) { - await vfs.writeFile(path, `${TOKEN} content for ${path}`) - } - }) - - afterAll(async () => { - await vfs?.close() - await brain?.close() - }) - - it('includes every descendant depth and excludes every sibling', async () => { - const results = await vfs.search(TOKEN, { path: '/scope', limit: 50 }) - const paths = results.map((r) => r.path).sort() - - expect(paths).toEqual([...inScope].sort()) - for (const path of outOfScope) expect(paths).not.toContain(path) - }) - - it('a trailing slash and a doubled slash name the same scope', async () => { - const plain = await vfs.search(TOKEN, { path: '/scope', limit: 50 }) - const trailing = await vfs.search(TOKEN, { path: '/scope/', limit: 50 }) - const doubled = await vfs.search(TOKEN, { path: '//scope//', limit: 50 }) - - const ids = (rs: Array<{ entityId: string }>) => rs.map((r) => r.entityId).sort() - expect(ids(trailing)).toEqual(ids(plain)) - expect(ids(doubled)).toEqual(ids(plain)) - }) - - it('the root scope is every VFS file — it adds no clause to narrow with', async () => { - const rooted = await vfs.search(TOKEN, { path: '/', limit: 50 }) - const unscoped = await vfs.search(TOKEN, { limit: 50 }) - - const paths = rooted.map((r) => r.path).sort() - expect(paths).toEqual([...inScope, ...outOfScope].sort()) - expect(paths).toEqual(unscoped.map((r) => r.path).sort()) - }) - - it('recursive: false is the immediate children, not the subtree', async () => { - const results = await vfs.search(TOKEN, { path: '/scope', recursive: false, limit: 50 }) - expect(results.map((r) => r.path)).toEqual(['/scope/a.txt']) - }) - - it('recursive: false on a path that does not exist refuses by name', async () => { - await expect( - vfs.search(TOKEN, { path: '/no-such-dir', recursive: false, limit: 50 }) - ).rejects.toMatchObject({ code: VFSErrorCode.ENOENT }) - }) - - it('every operator the search emits is ANSWERED by the index door, never refused', async () => { - const index = (brain as any).metadataIndex - const emitted: any[] = [] - const find = vi.spyOn(brain as any, 'find') - try { - await vfs.search(TOKEN, { path: '/scope', limit: 50 }) - await vfs.search(TOKEN, { path: '/scope/sub', where: { mimeType: 'text/plain' }, limit: 50 }) - await vfs.search(TOKEN, { path: '/scope', recursive: false, limit: 50 }) - await vfs.search(TOKEN, { path: '/', limit: 50 }) - for (const call of find.mock.calls) emitted.push((call[0] as any).where) - } finally { - find.mockRestore() - } - - expect(emitted).toHaveLength(4) - for (const where of emitted) { - // The door itself is the judge: an operator outside the served set is - // REFUSED here (BrainyError INVALID_QUERY), never answered. - await expect(index.getIdsForFilter(where)).resolves.toBeInstanceOf(Array) - } - - // And the scope really is a range on the path — the shape this fix chose. - expect(emitted[0].path).toEqual({ gte: '/scope/', lt: '/scope0' }) - expect(emitted[3].path).toBeUndefined() - }) - - it('the scope narrows the search before it runs — no over-fetch to filter', async () => { - const index = (brain as any).metadataIndex - const filter = vi.spyOn(index, 'getIdsForFilter') - let universe: string[] = [] - try { - await vfs.search(TOKEN, { path: '/scope', limit: 50 }) - // The search's own call — the one carrying the scope. (Path resolution - // asks this same door for the root, before the search is built.) - const scoped = filter.mock.calls.findIndex( - (c) => (c[0] as any)?.path?.gte === '/scope/' - ) - expect(scoped).toBeGreaterThanOrEqual(0) - universe = (await filter.mock.results[scoped].value) as string[] - } finally { - filter.mockRestore() - } - - // The id universe the index resolved for the search is already the scope: - // three files, and not one row from outside it. - const rows = await brain.batchGet(universe) - const paths = [...rows.values()].map((e: any) => e.metadata.path).sort() - expect(paths).toEqual([...inScope].sort()) - }) - - it('the range answers the same ids as walking the tree', async () => { - // The path is the truth and the Contains edges are its projection; a scope - // read from the truth must agree with one walked over the projection. - const walked: string[] = [] - const walk = async (dir: string): Promise => { - for (const name of await vfs.readdir(dir)) { - const child = dir === '/' ? `/${name}` : `${dir}/${name}` - const stat = await vfs.stat(child) - if (stat.isDirectory()) await walk(child) - else walked.push(child) - } - } - await walk('/scope') - - const searched = await vfs.search(TOKEN, { path: '/scope', limit: 50 }) - expect(searched.map((r) => r.path).sort()).toEqual(walked.sort()) - }) -}) diff --git a/tests/vfs/vfs.unit.test.ts b/tests/vfs/vfs.unit.test.ts index b4024155..5ea79377 100644 --- a/tests/vfs/vfs.unit.test.ts +++ b/tests/vfs/vfs.unit.test.ts @@ -53,35 +53,6 @@ describe('VirtualFileSystem - Production Tests', () => { expect(exists).toBe(true) }) - it('should write and read an empty (0-byte) file end-to-end', async () => { - // Pin: validateAddParams() used to treat '' as a missing 'data' field - // (falsy check), so a legitimate empty file's FIRST write threw - // "Missing required field 'data'". '' is real content, not an absent - // field — only null/undefined is absent. - const path = '/empty.txt' - - await vfs.writeFile(path, '') - - const result = await vfs.readFile(path) - expect(result.toString()).toBe('') - - const exists = await vfs.exists(path) - expect(exists).toBe(true) - - const stats = await vfs.stat(path) - expect(stats.size).toBe(0) - expect(stats.isFile()).toBe(true) - - // The file lists like any other. - const entries = await vfs.readdir('/') as string[] - expect(entries).toContain('empty.txt') - - // Overwriting it back to empty (truncate) must also succeed. - await vfs.writeFile(path, 'not empty anymore') - await vfs.writeFile(path, '') - expect((await vfs.readFile(path)).toString()).toBe('') - }) - it('should handle binary files', async () => { const binaryData = Buffer.from([0x00, 0x01, 0x02, 0xFF]) const path = '/binary.dat' @@ -389,14 +360,7 @@ describe('VirtualFileSystem - Production Tests', () => { }) describe('Performance', () => { - it('should handle many files efficiently', async (ctx) => { - // Wall-clock budget assertion — belongs to the perf lane (npm run - // test:perf), not the correctness gate: 121ms alone but 16.5s under - // the gate's sibling-file contention, a flake the code never caused - // (same pattern as storage-batch-operations.test.ts's batch-vs- - // individual timing case). - ctx.skip(!process.env.BRAINY_PERF_LANE, 'wall-clock budget assertion — runs only under the perf lane (npm run test:perf)') - + it('should handle many files efficiently', async () => { const dir = '/performance-test' await vfs.mkdir(dir) diff --git a/vitest.config.ts b/vitest.config.ts index 013c3c9b..116ab234 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,16 +2,9 @@ import { defineConfig } from 'vitest/config' /** * Vitest Configuration - Optimized for Memory-Intensive Tests - * + * * Handles ONNX transformer model testing (4-8GB memory requirement) * Based on 2024-2025 best practices - * - * THE CORRECTNESS GATE: this is the config a bare `vitest run` (no - * `--config` flag) picks up — the delta gate and CI both invoke it that - * way. See CONTRIBUTING.md's "Test gate" section for the full picture. - * Wall-clock/scale benchmarks and tests whose outcome depends on the host - * machine or network rather than the code are excluded below and run on - * demand instead, in their own slot: `npm run test:perf`. */ export default defineConfig({ test: { @@ -45,29 +38,7 @@ export default defineConfig({ 'node_modules/**', 'dist/**', 'scripts/**', - '**/*.browser.test.ts', - - // Wall-clock/scale benchmark family — timing assertions and scale - // sweeps whose pass/fail depends on the host machine's speed, not on - // the code. Whole files only (a file that mixes correctness describes - // with a perf describe stays in the gate). Run on demand via - // `npm run test:perf`, which targets exactly this list. - 'tests/performance/**', - 'tests/critical-performance-benchmark.test.ts', - 'tests/api/performance-benchmarks.test.ts', - - // Environment-dependent by construction, not timing-based: - // package-size-limit shells out to the `npm` CLI (not guaranteed - // present — the functional gate lane is Bun-only host-mode with no - // Node.js runtime) and parses npm-version-specific `npm pack` notice - // text; model-loading's "Real Model Download Integration" case makes - // a genuine, unmocked network call to HuggingFace (its own header - // says "Uses REAL transformer models - NO MOCKING"), and the whole - // file imports `../src/embeddings/model-manager.js`, which no longer - // exists anywhere under src/ — neither belongs in a gate that must be - // deterministic. - 'tests/package-size-limit.test.ts', - 'tests/model-loading.test.ts' + '**/*.browser.test.ts' ], // REPORTERS: Dot for CI, verbose for local