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 index da5887f6..5e93cd96 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -5,10 +5,6 @@ name: CI # 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: ['**'] 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 index 6bd42b2a..a08875ae 100644 --- a/.forgejo/workflows/publish-source.yml +++ b/.forgejo/workflows/publish-source.yml @@ -12,11 +12,6 @@ on: push: tags: - 'v*' - workflow_dispatch: - inputs: - ref_reason: - description: 'why this manual run (e.g. tag event dropped)' - required: false jobs: publish: @@ -37,7 +32,7 @@ jobs: run: | set -eo pipefail - SOURCE_NPM_REG="https://source.soulcraft.com/api/packages/soulcraftlabs/npm/" + SOURCE_NPM_REG="https://source.soulcraft.com/api/packages/soulcraft/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' — @@ -48,13 +43,13 @@ jobs: case "$VERSION" in *-*) NPM_TAG="rc" ;; esac - echo "Publishing @soulcraftlabs/brainy@${VERSION} to The Source registry (dist-tag: ${NPM_TAG})..." + echo "Publishing @soulcraft/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}" + echo "@soulcraft:registry=${SOURCE_NPM_REG}" + echo "//source.soulcraft.com/api/packages/soulcraft/npm/:_authToken=${FORGE_NPM_TOKEN}" } > "$TMPRC" # The release script bumps package.json's version before it tags, so @@ -69,7 +64,7 @@ jobs: # exit code: a benign duplicate publish (a prior run, or a mirror, already # landed this exact version) reports failure even though the registry # already holds the right content. - LANDED_VERSION="$(npm view "@soulcraftlabs/brainy@${VERSION}" version --userconfig "$TMPRC" 2>/dev/null || echo "")" + LANDED_VERSION="$(npm view "@soulcraft/brainy@${VERSION}" version --userconfig "$TMPRC" 2>/dev/null || echo "")" rm -f "$TMPRC" if [ "$LANDED_VERSION" != "$VERSION" ]; then @@ -78,7 +73,7 @@ jobs: fi if [ "$PUBLISH_OK" = true ]; then - echo "Published and verified @soulcraftlabs/brainy@${VERSION} on The Source registry." + echo "Published and verified @soulcraft/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." + echo "::warning::npm publish reported failure, but readback confirms @soulcraft/brainy@${VERSION} is already live on The Source (a prior run or mirror landed it) — treating this run as successful, since the registry content is correct. Any OTHER failure mode would have failed the readback check above instead." fi diff --git a/CHANGELOG.md b/CHANGELOG.md index c4f89332..ec925642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,155 +2,23 @@ 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.13](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.12...v10.4.13) (2026-09-03) - -- A shutdown that holds its listener until the exit decision, and a test suite that closes every brain it opens -- fix(shutdown): the engine's signal handler keeps its listener registered until the exit decision is made — closing the last live instance no longer deregisters the handler mid-run, so a second signal delivery during a clean shutdown can never kill the process after the work is done (a2ea21b3) -- fix(release): the release wall entry commits under an explicit git identity read from the developer's checkout; a host with no identity refuses by name instead of failing inside git (aac853d3) -- test(hygiene): every brain a test file creates is closed by that file — 40 files fixed, the leaks that let a stray cadence narrate into later files are gone; brains whose init() was expected to fail are closed too (6eb5e448) - -### [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) +### [10.4.1](https://source.soulcraft.com/soulcraft/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) +### [10.4.0](https://source.soulcraft.com/soulcraft/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) +### [10.4.0-rc.4](https://source.soulcraft.com/soulcraft/brainy/compare/v10.4.0-rc.3...v10.4.0-rc.4) (2026-08-25) - 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) +### [10.4.0-rc.3](https://source.soulcraft.com/soulcraft/brainy/compare/v10.4.0-rc.2...v10.4.0-rc.3) (2026-08-25) - fix(update-seam): the metadata crossing never carries BigInt endpoint ints (f4780c8e) - Merge branch 'worktree-agent-ad3aff0dffd17a6eb' (f14da34b) @@ -159,7 +27,7 @@ All notable changes to this project will be documented in this file. See [standa - feat(open-path): init never gates on the embedding model; open goes concurrent; slow opens narrate (96624f40) -### [10.4.0-rc.2](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.4.0-rc.1...v10.4.0-rc.2) (2026-08-25) +### [10.4.0-rc.2](https://source.soulcraft.com/soulcraft/brainy/compare/v10.4.0-rc.1...v10.4.0-rc.2) (2026-08-25) - 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) @@ -170,7 +38,7 @@ All notable changes to this project will be documented in this file. See [standa - feat(health): the gate reads the named report — reads refuse loudly, never rebuild; open serves before it returns; the ceremony door (f8f64780) -### [10.4.0-rc.1](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.3.1...v10.4.0-rc.1) (2026-08-24) +### [10.4.0-rc.1](https://source.soulcraft.com/soulcraft/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) @@ -183,13 +51,13 @@ All notable changes to this project will be documented in this file. See [standa - ci(gate): the machine-health preflight and the truncation verdict guard (1e046aa1) -### [10.3.1](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.3.0...v10.3.1) (2026-08-18) +### [10.3.1](https://source.soulcraft.com/soulcraft/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) +### [10.3.0](https://source.soulcraft.com/soulcraft/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) @@ -198,14 +66,14 @@ All notable changes to this project will be documented in this file. See [standa - feat(log): system commits carry their origin; the attested per-id reconcile door (9ac9e706) -### [10.2.0](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v10.1.0...v10.2.0) (2026-08-17) +### [10.2.0](https://source.soulcraft.com/soulcraft/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) +### [10.1.0](https://source.soulcraft.com/soulcraft/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) @@ -214,7 +82,7 @@ All notable changes to this project will be documented in this file. See [standa - feat(query): the sparse-store cut — where on a never-carried field serves operator truth, never a refusal (7b67db4d) -### [10.0.0](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v9.0.0...v10.0.0) (2026-08-12) +### [10.0.0](https://source.soulcraft.com/soulcraft/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) @@ -246,7 +114,7 @@ All notable changes to this project will be documented in this file. See [standa - test: version-coupling pins go major-agnostic — the 8.x literals broke at the 9.0.0 bump while the coupling law itself behaved correctly (8a6807e8) -### [9.0.0](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v8.11.0...v9.0.0) (2026-08-04) +### [9.0.0](https://source.soulcraft.com/soulcraft/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) @@ -281,7 +149,7 @@ All notable changes to this project will be documented in this file. See [standa - feat: scanFacts liveness contract — first batch or loud failure within a documented bound (f8e6da2b) -### [8.11.0](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v8.10.1...v8.11.0) (2026-07-27) +### [8.11.0](https://source.soulcraft.com/soulcraft/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) @@ -290,19 +158,19 @@ All notable changes to this project will be documented in this file. See [standa - ci: run the pipeline on the forge (999d0ebb) -### [8.10.3](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v8.10.2...v8.10.3) (2026-08-03) +### [8.10.3](https://source.soulcraft.com/soulcraft/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) +### [8.10.2](https://source.soulcraft.com/soulcraft/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) +### [8.10.1](https://source.soulcraft.com/soulcraft/brainy/compare/v8.10.0...v8.10.1) (2026-07-24) - refactor: remove the orphaned transaction-result type left behind by the dead-path removal (edf123a5) - fix: warm() metadata surface routes through the active provider (warm hook added to the metadata contract); add maintenanceDebt() observability surface (5b2cbf74) diff --git a/CLAUDE.md b/CLAUDE.md index 56df0b72..6acd0e0d 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c58520b7..d277091d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ may find elsewhere in the repo's history. ## Where the project lives -The source of truth is a self-hosted forge: **source.soulcraft.com/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. @@ -31,7 +31,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 +41,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 +57,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 fbf129ac..ca558340 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,9 @@

- Brainy + Brainy

Brainy

-> **Frozen at 10.4.13 (2026-09-03).** This repository is the reference implementation of the Brainy store format and API, -> published under the MIT license. Version 10.4.13 is its last release; the repository is read-only from here. The engine -> continues as `@soulcraft/brainy`, which bundles this layer as owned code; every published version of this package stays -> available on The Source. Use this repository to read a Brainy store independently or to verify the conformance contract. -

Three database paradigms. One API. Zero configuration.
The in-process knowledge database for TypeScript — vector search, graph traversal,
@@ -16,9 +11,9 @@

- Package on The Source - Repository - CI + npm version + npm downloads + CI Documentation MIT License TypeScript @@ -35,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 | @@ -52,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 64e64873..bc400412 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,17 +1,7 @@ # @soulcraft/brainy — Release Notes for Consumers -> **Frozen at 10.4.13 (2026-09-03).** 10.4.13 is the last release of `@soulcraftlabs/brainy`; this repository is read-only from here. -> Release notes for the product engine continue on its own wall. - -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://source.soulcraft.com/soulcraft/brainy/releases **How to use:** Brainy is the underlying data engine for downstream applications. Read this when: - Upgrading `@soulcraft/brainy` in your application @@ -41,227 +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 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/PLUGINS.md b/docs/PLUGINS.md index 238d2252..3078c239 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -46,7 +46,7 @@ If no plugin provides a given key, brainy uses its built-in JavaScript implement ### 1. Implement the `BrainyPlugin` interface ```typescript -import type { BrainyPlugin, BrainyPluginContext } from '@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() @@ -272,10 +272,10 @@ When provided by an optional native acceleration plugin (such as `@soulcraft/cor #### `cache` **Type:** `UnifiedCache` -Replaces the global `UnifiedCache` singleton used for VFS path resolution, semantic caching, and vector index caching. Must implement the `UnifiedCache` interface (available from `@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 +325,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 +360,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 +440,7 @@ A minimal but useful plugin that provides SIMD-accelerated distance calculations ```typescript // simd-distance-plugin/src/plugin.ts -import type { BrainyPlugin, BrainyPluginContext } from '@soulcraftlabs/brainy/plugin' +import type { BrainyPlugin, BrainyPluginContext } from '@soulcraft/brainy/plugin' // Hypothetical native module import { simdCosineDistance } from './native.js' @@ -470,7 +470,7 @@ export default simdDistancePlugin "main": "./dist/plugin.js", "types": "./dist/plugin.d.ts", "peerDependencies": { - "@soulcraftlabs/brainy": ">=7.0.0" + "@soulcraft/brainy": ">=7.0.0" } } ``` @@ -478,7 +478,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..82f48c91 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 | |---|---|---| @@ -1918,11 +1918,11 @@ isn't serving throws instead of rebuilding mid-query: | `MetadataIndexNotReadyError` | `find({ where })` | Metadata/field index isn't serving | | `VectorIndexNotReadyError` | `find({ query })`, `similar()` | Vector index isn't serving | -All three are exported from `@soulcraftlabs/brainy`. Catch them to distinguish +All three are exported from `@soulcraft/brainy`. Catch them to distinguish "index not ready" from a genuine empty result: ```typescript -import { MetadataIndexNotReadyError } from '@soulcraftlabs/brainy' +import { MetadataIndexNotReadyError } from '@soulcraft/brainy' try { const rows = await brain.find({ where: { status: 'active' } }) @@ -2208,7 +2208,7 @@ For the full taxonomy with all 169 types and their descriptions, see: - **📖 Documentation:** [Full Documentation](../) - **🐛 Issues:** [GitHub Issues](https://github.com/soulcraftlabs/brainy/issues) - **💬 Discussions:** [GitHub Discussions](https://github.com/soulcraftlabs/brainy/discussions) -- **📦 NPM:** [@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/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 index d24dd66b..c459021b 100644 --- a/docs/concepts/field-addressing.md +++ b/docs/concepts/field-addressing.md @@ -167,7 +167,7 @@ await brain.find({ orderBy: 'createdAt' }) `UnresolvableFieldError` is exported from the package root: ```typescript -import { UnresolvableFieldError } from '@soulcraftlabs/brainy' +import { UnresolvableFieldError } from '@soulcraft/brainy' try { await brain.find({ orderBy: 'createdAt' }) diff --git a/docs/concepts/index-health.md b/docs/concepts/index-health.md index 923267df..18bf7010 100644 --- a/docs/concepts/index-health.md +++ b/docs/concepts/index-health.md @@ -68,19 +68,6 @@ 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 @@ -95,7 +82,7 @@ catchable error naming the reason: | `MetadataIndexNotReadyError` | `find({ where })` | The metadata/field index isn't serving — a filtered read would otherwise return `[]` indistinguishable from "no matches" | | `VectorIndexNotReadyError` | `find({ query })`, `similar()` | The vector index isn't serving — a semantic search would otherwise return `[]` indistinguishable from "nothing similar" | -All three are exported from `@soulcraftlabs/brainy`. Catch them where your application +All three are exported from `@soulcraft/brainy`. Catch them where your application needs to distinguish "this index isn't ready yet" from "there's genuinely nothing here" — a health dashboard, a retry policy, an operator alert. The fix is always the same: reconcile the index, either by reopening the brain (which brings every diff --git a/docs/concepts/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 index f7d2c7f7..fad3c766 100644 --- a/docs/guides/namespace-migration.md +++ b/docs/guides/namespace-migration.md @@ -80,7 +80,7 @@ If you read raw stored records (fact-log scanners, export tooling), use the exported shape-aware splitters — they handle both record eras: ```typescript -import { splitNounMetadataRecord } from '@soulcraftlabs/brainy' +import { splitNounMetadataRecord } from '@soulcraft/brainy' const { reserved, custom } = splitNounMetadataRecord(rawRecord) // reserved = engine fields · custom = the user's bag, ANY names ``` @@ -88,7 +88,7 @@ const { reserved, custom } = splitNounMetadataRecord(rawRecord) Feature detection (never version-sniff): ```typescript -import * as brainy from '@soulcraftlabs/brainy' +import * as brainy from '@soulcraft/brainy' const lawActive = 'FIELD_ADDRESSING_CAPABILITY' in brainy // 'field-addressing/v1' ``` diff --git a/docs/guides/nextjs-integration.md b/docs/guides/nextjs-integration.md index 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/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 bd12e46c..70635835 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@soulcraftlabs/brainy", - "version": "10.4.13", + "name": "@soulcraft/brainy", + "version": "10.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@soulcraftlabs/brainy", - "version": "10.4.13", + "name": "@soulcraft/brainy", + "version": "10.4.1", "license": "MIT", "dependencies": { "@msgpack/msgpack": "^3.1.2", diff --git a/package.json b/package.json index a3bd0483..4125b073 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { - "name": "@soulcraftlabs/brainy", - "version": "10.4.13", - "brainyContract": 1, + "name": "@soulcraft/brainy", + "version": "10.4.1", "description": "Universal Knowledge Protocol™ - World's first Triple Intelligence database unifying vector, graph, and document search in one API. Stage 3 CANONICAL: 42 nouns × 127 verbs covering 96-97% of all human knowledge.", "main": "dist/index.js", "module": "dist/index.js", @@ -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://source.soulcraft.com/soulcraft/brainy", "bugs": { - "url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/issues" + "url": "https://source.soulcraft.com/soulcraft/brainy/issues" }, "repository": { "type": "git", - "url": "git+https://source.soulcraft.com/soulcraftlabs/open-brainy.git" + "url": "git+https://source.soulcraft.com/soulcraft/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/lib/deterministicStamp.ts b/scripts/lib/deterministicStamp.ts deleted file mode 100644 index c2a66604..00000000 --- a/scripts/lib/deterministicStamp.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Deterministic generation-stamp resolution for Brainy's build-time code - * generators. - * - * Two builds of the same source tree must produce byte-identical output. - * A wall-clock stamp (`new Date()`) breaks that guarantee, so every - * generator that writes a "Generated:" header or a `generatedAt` field - * into its output must resolve the stamp through this module instead. - * - * Resolution order: - * 1. The newest git commit timestamp among the generator's input files - * (the generator script itself always counts as an input). - * 2. If git metadata is unavailable (for example, building from a - * published npm tarball with no `.git` directory), the stamp already - * recorded in the previously generated output file. - * 3. If neither is available, the fixed epoch string - * `1970-01-01T00:00:00.000Z`. - * - * Every fallback logs a line to stderr — deterministic degradation is - * loud, never a silent divergence. - */ - -import { execFileSync } from 'child_process' -import * as fs from 'fs' - -const EPOCH_STAMP = '1970-01-01T00:00:00.000Z' -const STAMP_PATTERN = /\*\s*Generated:\s*(\S+)/ - -/** - * Resolve the deterministic stamp for a generator run. - * - * @param inputPaths Absolute paths to every file whose content determines - * the generator's output, including the generator script itself. - * @param previousOutputPath Absolute path to the previously generated - * file, used for the existing-stamp fallback when git is unavailable. - * @returns An ISO-8601 timestamp string that is deterministic for a given - * source tree. - */ -export function resolveDeterministicStamp( - inputPaths: string[], - previousOutputPath: string -): string { - const gitStamp = newestGitCommitTimestamp(inputPaths) - if (gitStamp) { - return gitStamp - } - - const existingStamp = readExistingStamp(previousOutputPath) - if (existingStamp) { - process.stderr.write( - `[deterministic-stamp] no git commit history found for generator inputs; ` + - `reusing existing stamp from ${previousOutputPath}: ${existingStamp}\n` - ) - return existingStamp - } - - process.stderr.write( - `[deterministic-stamp] no git commit history and no previous output at ` + - `${previousOutputPath}; falling back to fixed epoch stamp ${EPOCH_STAMP}\n` - ) - return EPOCH_STAMP -} - -/** - * Find the newest git commit timestamp among the given input paths. - * Returns null if git is unavailable, the tree is not a git repository, - * or none of the inputs have any commit history yet. - */ -function newestGitCommitTimestamp(inputPaths: string[]): string | null { - let newest: string | null = null - - for (const inputPath of inputPaths) { - if (!fs.existsSync(inputPath)) { - continue - } - - let out: string - try { - out = execFileSync( - 'git', - ['log', '-1', '--format=%cI', '--', inputPath], - { stdio: ['ignore', 'pipe', 'ignore'] } - ) - .toString() - .trim() - } catch { - // git missing, not a repository, or no permissions — handled by the - // caller's fallback chain. - continue - } - - if (!out) { - // Path exists but has no commit history yet (e.g. newly created, - // uncommitted file). - continue - } - - if (!newest || new Date(out).getTime() > new Date(newest).getTime()) { - newest = out - } - } - - return newest -} - -/** - * Parse the `* Generated: ` header out of a previously - * generated file, if one exists. - */ -function readExistingStamp(outputPath: string): string | null { - if (!fs.existsSync(outputPath)) { - return null - } - - const content = fs.readFileSync(outputPath, 'utf-8') - const match = content.match(STAMP_PATTERN) - return match ? match[1] : null -} diff --git a/scripts/release.sh b/scripts/release.sh index a9a1f6e9..5d03e66b 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -15,11 +15,11 @@ NC='\033[0m' # No Color RELEASE_TYPE="${1:-patch}" # patch, minor, or major SKIP_TESTS=false DRY_RUN=false -# --source-only is now a no-op: The Source is the one registry, so every -# release already ships Source-only — tag, CI's publish to The Source, the -# release page, and the docs push, with no separate storefront leg to skip. -# The flag is still accepted (for backward-compatible invocations) and just -# prints a notice; it no longer changes behavior. +# --source-only: the HOME leg only — tag, CI's publish to The Source, and the +# release page; NO storefront (npmjs) publish, NO pair verification, NO docs +# push. The pair-gate shape: a prerelease the fleet's other engine devDeps +# from our own registry while the pair is proven, never a public artifact. +# Refused for a non-prerelease version — a public floor is always a pair. SOURCE_ONLY=false for arg in "$@"; do @@ -109,7 +109,7 @@ else ;; *) echo -e "${RED}❌ Invalid release type: ${RELEASE_TYPE}${NC}" - echo "Usage: ./scripts/release.sh [patch|minor|major|] [--dry-run] [--source-only (no-op; The Source is the one registry)]" + echo "Usage: ./scripts/release.sh [patch|minor|major|] [--dry-run] [--source-only (prereleases only)]" exit 1 ;; esac @@ -129,7 +129,11 @@ if [ "$PRERELEASE" = true ]; then echo -e "${YELLOW}⚠️ Prerelease → npm dist-tag '${NPM_TAG}', GitHub prerelease${NC}" fi if [ "$SOURCE_ONLY" = true ]; then - echo -e "${YELLOW}⚠️ The Source is the one registry; --source-only is implied${NC}" + if [ "$PRERELEASE" != true ]; then + echo -e "${RED}❌ --source-only is for prereleases only: a non-prerelease version is a public floor and always ships as the byte-identical pair.${NC}" + exit 1 + fi + echo -e "${YELLOW}⚠️ --source-only → The Source (home) ONLY: no npmjs publish, no pair verification, no docs push${NC}" fi echo "" @@ -154,26 +158,13 @@ else fi # Create new changelog entry -RELEASE_DATE=$(date +%Y-%m-%d) -CHANGELOG_ENTRY="### [${NEW_VERSION}](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) (${RELEASE_DATE}) +CHANGELOG_ENTRY="### [${NEW_VERSION}](https://source.soulcraft.com/soulcraft/brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) ($(date +%Y-%m-%d)) ${COMMITS} " -# A CURATED entry wins over the generated one. When a release is cut from a -# lineage that diverged from the previous tag (a candidate branch carrying -# main's history), `git log ..HEAD` lists every commit the tag never -# saw — old notes, already-shipped fixes under new hashes, merge commits — and a -# wall entry derived from it would misreport the release. If CHANGELOG.md -# already carries a `### [NEW_VERSION]` heading, it was written on purpose: -# keep it, and skip the generated prepend entirely. -CURATED_ENTRY=false -if grep -qE "^### \[${NEW_VERSION}\]" CHANGELOG.md 2>/dev/null; then - CURATED_ENTRY=true - echo -e "${YELLOW}CHANGELOG already carries a curated ### [${NEW_VERSION}] entry — keeping it, not generating one from commits${NC}" -fi # Prepend to CHANGELOG.md after header -if [ "$CURATED_ENTRY" = false ] && [ -f "CHANGELOG.md" ]; then +if [ -f "CHANGELOG.md" ]; then # Read header (first 4 lines) HEADER=$(head -n 4 CHANGELOG.md) # Read rest of file @@ -187,19 +178,6 @@ if [ "$CURATED_ENTRY" = false ] && [ -f "CHANGELOG.md" ]; then fi echo -e "${GREEN}✅ CHANGELOG updated${NC}\n" -# Step 6b: Update the releases wall entry — mechanical, derived from the -# CHANGELOG entry just composed. The fleet's HQ page reads open-brainy.json -# from the one shared releases repo, soulcraftlabs/releases on The Source — -# this used to be hand-written after every release (David: never again — -# make it a step of the rail, landed in the one shared home; this repo no -# longer hosts its own copy). This step clones/fetches that repo into a -# local cache, prepends the entry, and pushes it directly — a real -# cross-repo push, refusing loudly (never skipping) on any -# clone/validation/commit/push failure. -echo -e "${BLUE}5️⃣▸ Updating the releases wall...${NC}" -node scripts/wall-entry.mjs --product open-brainy --version "${NEW_VERSION}" --date "${RELEASE_DATE}" --from-changelog CHANGELOG.md -echo -e "${GREEN}✅ Releases wall updated${NC}\n" - # Step 7: Create release commit echo -e "${BLUE}6️⃣ Creating release commit...${NC}" git add package.json package-lock.json CHANGELOG.md @@ -231,9 +209,9 @@ echo -e "${GREEN}✅ Pushed to origin${NC}\n" # .forgejo/workflows/publish-source.yml, which builds and publishes on The # Source's own runner (datacenter-side: seconds, not the laptop's WAN timing # out on an 87MB tarball PUT). The laptop holds no home-registry publish -# credential anymore; it only waits for CI's result before continuing on to -# the release page and the docs push. -SOURCE_NPM_REG="https://source.soulcraft.com/api/packages/soulcraftlabs/npm/" +# credential anymore; it only waits for CI's result before trusting the +# home/npmjs pair enough to publish the storefront leg. +SOURCE_NPM_REG="https://source.soulcraft.com/api/packages/soulcraft/npm/" SOURCE_POLL_INTERVAL_S=15 SOURCE_POLL_MAX_ATTEMPTS=200 # 200 × 15s = 50 minutes — the runner is sequential and a busy day's ci.yml # backlog has twice exceeded the old 20-minute window (8.10.3, 9.0.0); @@ -241,7 +219,7 @@ SOURCE_POLL_MAX_ATTEMPTS=200 # 200 × 15s = 50 minutes — the runner is sequen echo -e "${BLUE}9️⃣ Waiting for CI to publish v${NEW_VERSION} to The Source registry (home)...${NC}" SOURCE_LANDED=false for ((attempt = 1; attempt <= SOURCE_POLL_MAX_ATTEMPTS; attempt++)); do - LANDED_VERSION=$(npm view "@soulcraftlabs/brainy@${NEW_VERSION}" version "--@soulcraftlabs:registry=${SOURCE_NPM_REG}" 2>/dev/null || echo "") + LANDED_VERSION=$(npm view "@soulcraft/brainy@${NEW_VERSION}" version "--@soulcraft:registry=${SOURCE_NPM_REG}" 2>/dev/null || echo "") if [ "$LANDED_VERSION" = "$NEW_VERSION" ]; then SOURCE_LANDED=true break @@ -254,16 +232,62 @@ if [ "$SOURCE_LANDED" = true ]; then echo -e "${GREEN}✅ CI published v${NEW_VERSION} to The Source${NC}\n" else echo -e "${RED}❌ CI's home publish did not land — check the workflow run on The Source; the pair must not diverge.${NC}" - echo -e "${RED} v${NEW_VERSION} was tagged and pushed, but @soulcraftlabs/brainy@${NEW_VERSION} never became visible on the${NC}" - echo -e "${RED} Source registry after ${SOURCE_POLL_MAX_ATTEMPTS} attempts, ${SOURCE_POLL_INTERVAL_S}s apart. Aborting.${NC}" + echo -e "${RED} v${NEW_VERSION} was tagged and pushed, but @soulcraft/brainy@${NEW_VERSION} never became visible on the${NC}" + echo -e "${RED} Source registry after ${SOURCE_POLL_MAX_ATTEMPTS} attempts, ${SOURCE_POLL_INTERVAL_S}s apart. Aborting before npmjs.${NC}" exit 1 fi +if [ "$SOURCE_ONLY" = true ]; then + echo -e "${YELLOW}9️⃣½ Storefront (npmjs) leg SKIPPED — --source-only: v${NEW_VERSION} lives on The Source under dist-tag '${NPM_TAG}' only${NC}\n" +else + echo -e "${BLUE}9️⃣½ Publishing to npmjs (storefront, dist-tag: ${NPM_TAG})...${NC}" + # BYTE-IDENTITY LAW: the storefront republishes CI's EXACT artifact — download + # the tarball The Source serves and publish that file, never a fresh local pack + # (a local rebuild can differ byte-wise, and the fleet verifies the pair by + # shasum across registries). + STOREFRONT_TMP="$(mktemp -d)" + (cd "$STOREFRONT_TMP" && npm pack "@soulcraft/brainy@${NEW_VERSION}" "--@soulcraft:registry=${SOURCE_NPM_REG}" >/dev/null) + SOURCE_TARBALL="$(ls "$STOREFRONT_TMP"/soulcraft-brainy-*.tgz)" + echo -e "${BLUE} home artifact: $(sha256sum "$SOURCE_TARBALL" | cut -d' ' -f1)${NC}" + npm publish "$SOURCE_TARBALL" --tag "$NPM_TAG" "--@soulcraft:registry=https://registry.npmjs.org/" + rm -rf "$STOREFRONT_TMP" + # Brainy is the only PUBLIC @soulcraft package — verify visibility after every publish. + npm access get status @soulcraft/brainy "--@soulcraft:registry=https://registry.npmjs.org/" || true + # Verify the pair is byte-identical by registry-reported shasum — divergence + # here means the storefront leg must be treated as failed, loudly. RETRIED + # with raw curl: npmjs metadata propagates with a lag measured in minutes, + # and a one-shot npm-view probe fired a false DIVERGENCE on 10.0.0 while a + # raw curl of the registry document already confirmed byte-identity. The + # probe now reads the registry JSON directly (no npm cache in the path) and + # gives propagation up to 5 minutes before calling the pair divergent. + NPMJS_VERIFY_ATTEMPTS=20 + NPMJS_VERIFY_INTERVAL_S=15 # 20 × 15s = 5 minutes of propagation grace + SOURCE_SHA=$(npm view "@soulcraft/brainy@${NEW_VERSION}" dist.shasum "--@soulcraft:registry=${SOURCE_NPM_REG}" 2>/dev/null || echo "source-unavailable") + PAIR_IDENTICAL=false + for ((attempt = 1; attempt <= NPMJS_VERIFY_ATTEMPTS; attempt++)); do + NPMJS_SHA=$(curl -fsSL "https://registry.npmjs.org/@soulcraft%2Fbrainy" 2>/dev/null \ + | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const v=JSON.parse(d).versions[process.argv[1]];console.log(v?v.dist.shasum:'')}catch{console.log('')}})" "${NEW_VERSION}" \ + || echo "") + if [ -n "$NPMJS_SHA" ] && [ "$SOURCE_SHA" = "$NPMJS_SHA" ]; then + PAIR_IDENTICAL=true + break + fi + echo -e "${YELLOW} … npmjs metadata not settled (attempt ${attempt}/${NPMJS_VERIFY_ATTEMPTS}: '${NPMJS_SHA:-absent}' vs '${SOURCE_SHA}'); retrying in ${NPMJS_VERIFY_INTERVAL_S}s${NC}" + sleep "$NPMJS_VERIFY_INTERVAL_S" + done + if [ "$PAIR_IDENTICAL" = true ]; then + echo -e "${GREEN}✅ Published to npmjs — byte-identical pair (shasum ${NPMJS_SHA})${NC}\n" + else + echo -e "${RED}❌ REGISTRY DIVERGENCE: The Source shasum ${SOURCE_SHA} != npmjs shasum ${NPMJS_SHA} after ${NPMJS_VERIFY_ATTEMPTS} attempts — investigate before announcing${NC}\n" + exit 1 + fi +fi + # Step 11: Release object on The Source (presentational — the tag, CHANGELOG, # and RELEASES.md are the record; this just gives The Source's UI a release page). echo -e "${BLUE}🔟 Creating release page on The Source...${NC}" if [ -n "${FORGEJO_RELEASE_TOKEN:-}" ]; then - if curl -sf -X POST "https://source.soulcraft.com/api/v1/repos/soulcraftlabs/open-brainy/releases" \ + if curl -sf -X POST "https://source.soulcraft.com/api/v1/repos/soulcraft/brainy/releases" \ -H "Authorization: token ${FORGEJO_RELEASE_TOKEN}" -H "Content-Type: application/json" \ -d "{\"tag_name\":\"v${NEW_VERSION}\",\"name\":\"v${NEW_VERSION}\",\"prerelease\":${PRERELEASE}}" >/dev/null; then echo -e "${GREEN}✅ Release page created on The Source${NC}\n" @@ -274,15 +298,29 @@ else echo -e "${RED}⚠️ FORGEJO_RELEASE_TOKEN unset — no release page created; tag + CHANGELOG remain the record${NC}\n" fi -# Step 12 RETIRED (2026-08-31, CORTEX-SITE-BRAINY-RENAME round 12, David-ruled): -# soulcraft.com/docs carries the paid product's documentation only. This -# engine's documentation home is THIS repository — README and docs/ — and the -# site serves 301s for the slugs this rail used to push. The push script stays -# in the tree for history; the rail no longer calls it. -echo -e "${BLUE}Docs step: this engine documents itself in its own repo (site push retired 2026-08-31)${NC}" +# Step 12: Push public docs to the soulcraft.com docs ingest door +# (VENUE-DOCS-RELEASE-PUSH). Skips with a loud warning when +# DOCS_INGEST_SECRET is unset; fails loudly (without undoing the publish — +# that already happened) when a push errors, so the docs site never +# silently trails npm. +if [ "$SOURCE_ONLY" = true ]; then + echo -e "${YELLOW}1️⃣2️⃣ Docs push SKIPPED — --source-only (a home-only prerelease publishes no public docs)${NC}\n" +else + echo -e "${BLUE}1️⃣2️⃣ Pushing public docs to soulcraft.com/docs...${NC}" + if node scripts/push-docs.js; then + echo -e "${GREEN}✅ Docs push step done${NC}\n" + else + echo -e "${RED}❌ Docs push FAILED — soulcraft.com/docs trails npm until re-run or interim sync${NC}\n" + fi +fi echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e "${GREEN}🎉 Release ${NEW_VERSION} complete!${NC}" echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo "" -echo -e "🏠 The Source: ${BLUE}https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v${NEW_VERSION}${NC}" +if [ "$SOURCE_ONLY" = true ]; then + echo -e "📦 npmjs: ${YELLOW}not published (--source-only)${NC}" +else + echo -e "📦 npm: ${BLUE}https://www.npmjs.com/package/@soulcraft/brainy/v/${NEW_VERSION}${NC}" +fi +echo -e "🏠 The Source: ${BLUE}https://source.soulcraft.com/soulcraft/brainy/releases/tag/v${NEW_VERSION}${NC}" diff --git a/scripts/wall-entry.mjs b/scripts/wall-entry.mjs deleted file mode 100644 index 5079cf86..00000000 --- a/scripts/wall-entry.mjs +++ /dev/null @@ -1,539 +0,0 @@ -#!/usr/bin/env node -/** - * @module scripts/wall-entry - * @description The releases-wall entry, made mechanical. The fleet's HQ page - * reads one public JSON per product from the ONE releases repo on The Source - * (soulcraftlabs/releases, files .json at its root — shape - * {product, entries:[{version, date, headline, items, url, thumb?}]}), at - * https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/.json. - * Those entries were hand-written after every release, then briefly written - * into this repo's own releases/.json; this script is the one door - * that composes an entry and lands it in the shared repo, so it is never - * hand-written and never forked across repos again. - * - * Two modes: - * - * 1. Generate + publish (default): - * node wall-entry.mjs --product

--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) - } -} - -/** - * Resolve the git identity for the wall commit from the repository the rail - * is actually running in — the developer's own checkout (`process.cwd()`; - * `release.sh` invokes this script from the repo root with no `cd`), via - * git's normal config precedence (repo-local, then global, then system). - * Never guessed and never left to git's own "who are you?" prompt: a host - * with no configured identity anywhere (a bare CI box, say) must refuse - * loudly rather than have git manufacture a placeholder identity or hang. - * @returns {{name: string, email: string}} - */ -function resolveWallCommitIdentity() { - const repo = process.cwd() - let name = '' - let email = '' - try { - name = git(['config', 'user.name'], repo) - } catch { - name = '' - } - try { - email = git(['config', 'user.email'], repo) - } catch { - email = '' - } - if (!name || !email) { - fail('no git identity for the wall commit — set user.name/user.email') - } - return { name, email } -} - -/** - * 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 - } - - const identity = resolveWallCommitIdentity() - - try { - git(['add', `${product}.json`], cacheDir) - git( - ['-c', `user.name=${identity.name}`, '-c', `user.email=${identity.email}`, '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/brainy.ts b/src/brainy.ts index fc08f291..6289e8f2 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,7 +25,6 @@ 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 { defaultEmbeddingFunction, @@ -199,12 +197,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 { assessIndexReadiness, assessProviderHealth } from './utils/indexReadiness.js' import { reconstructNounWrapper } from './db/factLog.js' import { asBrainyFieldRefusal } from './db/fieldAddressing.js' import { @@ -402,15 +395,6 @@ interface PlannedTransact { * marker outlives its write. */ markerRecords: FactMarkerRecord[] - /** - * Ids the batch's `{ op: 'update' }` unvector door (`vector: []`) needs to - * decrement on the vectored-noun ledger — consumed by `transact()` with a - * proper `await this.storage.noteVectorUnlanded?.(id)` per id, AFTER - * `commitTransaction` resolves (never for a rejected batch). Kept separate - * from `postCommit` (`Array<() => void>`, called synchronously, fire-and- - * forget) because the ledger hook is async and must be awaited. - */ - vectorUnlands: string[] } /** @@ -531,36 +515,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 - - /** True for the entire duration of ONE `closeOnShutdown()` run (the - * signal-path handler in {@link registerShutdownHooks}) — from before it - * starts closing instances until after it has decided whether to exit. - * THE RACE THIS CLOSES: closing the LAST live instance calls - * `close()` → `deregisterShutdownHooksIfIdle()` synchronously, which - * removes `Brainy.sigtermListener` from `process` — while `closeOnShutdown` - * (that very listener's OWN still-running invocation) hasn't yet reached - * `exitIfSoleShutdownOwner()`'s `process.exit(0)`. In that window Node has - * NO registered SIGTERM listener, so a second/concurrent delivery of the - * same signal (a raced re-send, common on a loaded host) falls through to - * Node's default disposition and kills the process outright — the - * clean-shutdown work already finished, but the process never reports the - * 0 it earned. `deregisterShutdownHooksIfIdle()` checks this flag and - * defers; `closeOnShutdown()`'s `finally` re-runs the deregistration check - * once it is done, so the listener never actually leaks past its use. */ - private static shutdownSignalHandlerActive = 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 @@ -776,71 +730,9 @@ export class Brainy implements BrainyInterface { // Write acks NEVER await it; a failed background flush is LOUD and re-armed. private _persistDirtyWrites = 0 private _persistLastFlushAt = Date.now() - /** - * Whether a write has been committed since the last flush that ran. THE - * ENGINE DOES NO PERIODIC WORK WITHOUT A CAUSE: a brain nobody has written - * to has nothing to make durable, and a flush over it must cost nothing and - * say nothing. Before this, a flush called every provider, stamped the - * watermarks, persisted the generation counter and re-stamped the entity - * tree whether or not anything had changed — roughly 28 writes for a store - * that had not moved. - * - * WHAT THIS DOES NOT EXPLAIN, stated so nobody reads it as solved: a - * production process holding 21 brains printed "All indexes flushed to disk - * in 216-601ms" per brain every ~35s and idled at 1.26 cores with no writes - * for ten minutes. This engine's cadence is WRITE-DRIVEN — every trigger - * runs through noteWriteForPersistence, which only a committed write calls — - * so something was calling flush() on those brains, and this gate makes such - * a call free rather than accounting for it. The caller is still unidentified. - */ - private _dirtySinceLastFlush = false private _persistIdleTimer: ReturnType | null = null private _persistBackgroundFlight: Promise | null = null - /** - * 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 @@ -850,50 +742,6 @@ export class Brainy implements BrainyInterface { 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 @@ -963,24 +811,6 @@ 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 @@ -994,18 +824,7 @@ export class Brainy implements BrainyInterface { // Read-gate narration dedup: a degraded-but-serving or not-ready health // report narrates via prodLog.warn ONCE per (provider, report.generation) — // never once per read. Keyed on the provider instance itself. - /** - * 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 _lastNarratedHealthGeneration = new Map() constructor(config?: BrainyConfig) { // The reserved-field write policy died with the field-addressing law: @@ -1123,14 +942,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. @@ -1176,17 +995,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. * @@ -1280,84 +1088,21 @@ 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' } - ] + // OPEN-PATH NARRATION: lightweight phase timing across the five + // named stretches of init — storage init / generation-store open+fold / + // index init+gate / VFS bootstrap / embedding-warm-started. Each + // `markPhase()` call records elapsed ms SINCE THE PREVIOUS checkpoint, + // so the buckets always sum to the pre-integration/warmOnOpen total. + // Silent under 2s; one `prodLog.warn` line naming every phase's ms + // above it, so the operator's next restart storm names its own slow + // phase instead of re-deriving it from a stack of raw timestamps. 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 + phaseTimingsMs[name] = now - lastPhaseCheckpoint 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 { @@ -1420,7 +1165,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( @@ -1444,13 +1189,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 @@ -1488,11 +1230,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 — @@ -1504,11 +1242,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 @@ -1519,11 +1253,7 @@ 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 @@ -1686,27 +1416,10 @@ 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() ]) } @@ -1790,24 +1503,11 @@ 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() - ) + await this.rebuildIndexesIfNeeded() this.lazyRebuildCompleted = true // Check for pending data migrations @@ -1817,9 +1517,8 @@ export class Brainy implements BrainyInterface { // cache, roaring, msgpack, sort:topK, distance), HNSW/metadata/graph // index construction, the eager cold-load, id-resolver + connections- // codec wiring, crash-recovery index rebuild, the replay-gap check, - // legacy VFS blob adoption, blob-history backfill, the legacy - // zero-norm VFS root migration, and the rebuildIndexesIfNeeded() gate - // + migration check. + // legacy VFS blob adoption, blob-history backfill, and the + // rebuildIndexesIfNeeded() gate + migration check. markPhase('index-init-gate') // Register shutdown hooks for graceful count flushing (once globally) @@ -1880,11 +1579,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 @@ -1908,11 +1603,7 @@ export class Brainy implements BrainyInterface { 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) - ) + const authority = await readLogAuthority(this.storage) this._logAuthority = authority if (authority.authority === 'log') { this.generationStore.setLogDurability('at-ack') @@ -1923,12 +1614,7 @@ export class Brainy implements BrainyInterface { 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() - ) + await this.adoptLogAuthority() prodLog.info( '[Brainy] storage authority adopted at open: generation log ' + '(fleet default; oracle green; durable-at-ack enabled)' @@ -1967,22 +1653,9 @@ export class Brainy implements BrainyInterface { // 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() - ) + await this.bridgeLegacyPendingEmbedSidecars() + await this.recoverPendingEmbedsFromLog() if (this._pendingEmbedIds.size > 0) { prodLog.info( `[Brainy] ${this._pendingEmbedIds.size} deferred embed(s) pending from a previous ` + @@ -2084,7 +1757,7 @@ export class Brainy implements BrainyInterface { const phaseList = Object.entries(phaseTimingsMs) .map(([name, ms]) => `${name}=${ms}ms`) .join(', ') - prodLog.narrate( + prodLog.warn( `[Brainy] slow open: ${totalOpenMs}ms total (${phaseList}) — see the ` + `phase breakdown above to find which one to investigate first` ) @@ -2141,15 +1814,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}`) } } @@ -2160,227 +1825,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...') - // HOLD THE LISTENER FOR THE WHOLE RUN. Closing the LAST live instance - // below calls close() → deregisterShutdownHooksIfIdle(), which removes - // Brainy's own SIGTERM/SIGINT listeners from `process` — synchronously, - // before THIS invocation has reached exitIfSoleShutdownOwner()'s - // process.exit(0). Left alone, that opens a window with no registered - // listener for the signal at all, so a second/concurrent delivery of - // the same signal (a raced re-send — not rare on a loaded host) falls - // through to Node's default disposition and kills the process outright - // AFTER the clean-shutdown work already finished, reporting a signal - // kill instead of the 0 the shutdown earned. Setting this flag makes - // deregisterShutdownHooksIfIdle() defer; the `finally` below re-checks - // it once this run is fully done — closeOnShutdown, not a nested - // close(), owns exactly when the listener actually comes off. - Brainy.shutdownSignalHandlerActive = true try { - // 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) + 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++ } } - if (closedCount > 0) { - console.log(`Flushed successfully (${closedCount} instance${closedCount > 1 ? 's' : ''})`) + if (flushedCount > 0) { + console.log(`Flushed successfully (${flushedCount} instance${flushedCount > 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.` - ) - } - } finally { - // Release the hold and run the deferred check ourselves — the last - // close() above may have found the flag set and skipped its own - // deregistration, so nobody else will do this if we don't. - Brainy.shutdownSignalHandlerActive = false - Brainy.deregisterShutdownHooksIfIdle() - } - } - - /** - * 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 - ) - } - } - } finally { - Brainy.beforeExitFlushInFlight = false + } catch (error) { + console.error('Failed to flush on shutdown:', error) } } @@ -2388,52 +1909,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) @@ -2444,17 +1939,9 @@ export class Brainy implements BrainyInterface { * script that closed every brain exits on its own — a library must never * keep its host process alive. Re-initializing later re-registers them * (the `shutdownHooksRegisteredGlobally` flag resets here). - * - * Deferred (not skipped — {@link closeOnShutdown}'s `finally` always - * re-checks) while a signal-path shutdown is actively running: that - * handler's OWN still-in-flight invocation is `Brainy.sigtermListener`, and - * removing it out from under itself — which closing the LAST instance here - * would otherwise do, synchronously, mid-run — would leave `process` with - * no listener for the signal for the remainder of that run. See - * {@link shutdownSignalHandlerActive}'s doc for the exact race this closes. */ private static deregisterShutdownHooksIfIdle(): void { - if (Brainy.instances.length > 0 || !Brainy.shutdownHooksRegisteredGlobally || Brainy.shutdownSignalHandlerActive) { + if (Brainy.instances.length > 0 || !Brainy.shutdownHooksRegisteredGlobally) { return } if (Brainy.sigtermListener) process.off('SIGTERM', Brainy.sigtermListener) @@ -2463,11 +1950,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 } @@ -2518,33 +2000,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 * @@ -2715,58 +2170,6 @@ export class Brainy implements BrainyInterface { */ 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 @@ -2780,9 +2183,6 @@ export class Brainy implements BrainyInterface { */ 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() } } @@ -2793,277 +2193,10 @@ export class Brainy implements BrainyInterface { * 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. + * is re-reaped there — a bounded no-op, never a lost vector. */ - private clearPendingEmbed( - id: string, - durability: 'durable' | 'in-memory-only' = 'durable' - ): void { + private clearPendingEmbed(id: string): 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[] } } /** @@ -3074,22 +2207,9 @@ export class Brainy implements BrainyInterface { * 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}. + * BOUND (honest): no durable low-water mark exists for the earliest + * unconsumed pending, so the fold scans the log's committed facts from + * generation 1 — a sequential read of the log at open, O(log bytes). * 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 @@ -3102,13 +2222,9 @@ export class Brainy implements BrainyInterface { 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 }) + const scan = log.scanFacts({ fromGeneration: 1 }) 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) @@ -3123,21 +2239,6 @@ export class Brainy implements BrainyInterface { } } } - 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` - ) } /** @@ -3229,23 +2330,11 @@ export class Brainy implements BrainyInterface { 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') + if (!entity || entity.data === undefined || entity.data === null) { + // Orphan reap: a deleted row's tombstone fact durably disarms the + // marker at the next recovery fold; a data-less-but-present row + // (edge case) re-folds and re-reaps — bounded, never a lost vector. + this.clearPendingEmbed(id) continue } // Hang guard: a wedged embedder must not block every later pending @@ -3450,12 +2539,6 @@ export class Brainy implements BrainyInterface { * engine's own cadence (callers never call flush() in hot paths). */ private noteWriteForPersistence(): void { - // THE DIRTY WITNESS. Set on every committed write — both commit paths - // (single-op and transaction) end here, and the deferred-embed worker - // lands its vectors through the single-op path — BEFORE the policy check, - // so a `'manual'` consumer's explicit flush() is never skipped either. - // Cleared by a flush that actually runs; see flush(). - this._dirtySinceLastFlush = true const cfg = this.config.persistence if (this.isReadOnly || cfg?.policy === 'manual') return this._persistDirtyWrites++ @@ -3518,18 +2601,9 @@ export class Brainy implements BrainyInterface { * 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 { + if (this._persistBackgroundFlight) return const counted = this._persistDirtyWrites this._persistDirtyWrites = 0 this._persistLastFlushAt = Date.now() @@ -3833,43 +2907,13 @@ export class Brainy implements BrainyInterface { // vector shape, is structurally impossible). The background worker // embeds + inserts. const deferringEmbed = params.deferEmbedding === true && !params.vector - let vector = deferringEmbed + const vector = deferringEmbed ? [] : params.vector || (await this.embed(params.data)) - // THE ZERO-NORM LAW (canonical write side): a zero-norm vector is not a - // vector — it never crosses an engine boundary (the engine pair's seam - // law). This engine's own cosine distance treats an all-zero vector - // safely (a zero-norm operand always scores MAXIMUM distance — see - // isZeroNormVector's JSDoc), but a downstream engine serving squared- - // euclidean distance cannot tell it apart from a legitimate origin - // point — a false attractor that silently darkened 150+ rows in a - // production deployment. The index belt (AddToVectorIndexOperation) - // already refuses to INDEX a zero-norm vector, but until now the - // CANONICAL write still persisted it and the vectored-noun ledger - // counted it — so a near-empty store whose only vectored row was - // zero-norm read "canonical vectored > 0, index size 0" and threw a - // not-ready error at open. Normalize HERE, before the dimension pin, - // the vectored-ledger flag (`SaveNounMetadataOperation`'s `hasVector`), - // and the index ops below ever see it, so it persists as the sanctioned - // "unvectored" `[]` shape instead — the canonical write still succeeds. - if (!deferringEmbed && vector.length > 0 && isZeroNormVector(vector)) { - prodLog.warn( - `[Brainy] add(): entity ${id} was given an explicit all-zero vector — ` + - `a zero-norm vector is not a vector; persisted unvectored ([]) instead.` - ) - vector = [] - } - // Ensure dimensions are set (a deferred-embed stub carries no dimension // information — the worker's real vector goes through the same guard). - // Gated on `vector.length > 0`, not `!deferringEmbed`: ANY insert whose - // 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 (!deferringEmbed) { if (!this.dimensions) { this.dimensions = vector.length } else if (vector.length !== this.dimensions) { @@ -3985,15 +3029,10 @@ 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) { + // Operation 3: Add to HNSW index (after entity saved). A deferred + // embed has nothing to index yet — the worker's atomic update + // inserts the real vector. + if (!deferringEmbed) { tx.addOperation( new AddToVectorIndexOperation(this.index, id, vector, this.indexWriteGeneration) ) @@ -4241,16 +3280,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) { @@ -4297,170 +3326,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: [] }) @@ -4742,53 +3607,25 @@ export class Brainy implements BrainyInterface { // often the host writes. const dataUnchanged = rawHasNewData && Brainy.sameEntityData(params.data, existing.data) const hasNewData = rawHasNewData && !dataUnchanged - - // THE ZERO-NORM LAW (canonical write side) — see add()'s matching - // comment: an explicit REAL all-zero vector is not a vector. Normalize - // to the sanctioned "unvectored" `[]` shape BEFORE the dimension - // check, the unvector-door decision below, and the index ops ever see - // it — a local copy; `params.vector` itself is never mutated. - let explicitVector = params.vector - if (explicitVector && explicitVector.length > 0 && isZeroNormVector(explicitVector)) { - prodLog.warn( - `[Brainy] update(): entity ${params.id} was given an explicit all-zero vector — ` + - `a zero-norm vector is not a vector; persisted unvectored ([]) instead.` - ) - explicitVector = [] - } - - // THE SANCTIONED UNVECTOR DOOR: `explicitVector` at length 0 (an - // explicit `vector: []`, or a real all-zero vector just normalized - // above) is an instruction to remove the vector NOW — never "please - // embed". `validateUpdateParams` already refuses combining it with - // `deferEmbedding: true` (an empty array is truthy, so that guard - // fires unconditionally on any explicit `vector`). Idempotent on an - // already-unvectored row: the ledger decrement near the end of this - // method is gated on the PRIOR vector actually having been real. - const isExplicitUnvector = explicitVector !== undefined && explicitVector.length === 0 - // MT5 deferred re-embedding: the OLD vector keeps serving semantic // search — stale-but-present, never absent (the flicker law) — until // the background worker embeds the new data and swaps it atomically. const deferringEmbed = - params.deferEmbedding === true && hasNewData && !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) { + params.deferEmbedding === true && hasNewData && !params.vector + 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 + vector = params.vector } else if (hasNewData && !deferringEmbed) { vector = await this.embed(params.data) } // A deferred data change does NOT reindex now (the vector is unchanged; // the worker's atomic swap carries the real reindex later). const needsReindexing = Boolean( - (hasNewData && !deferringEmbed) || params.type || explicitVector + (hasNewData && !deferringEmbed) || params.type || params.vector ) // Always update the noun with new metadata @@ -4882,22 +3719,6 @@ export class Brainy implements BrainyInterface { ? [this.enqueuePendingEmbed(params.id)] : undefined - // Leg D — the unvector door clears a PENDING deferred-embed marker: - // without this, the worker would later embed this row's current data - // and silently re-vector it, defeating the caller's explicit "remove - // the vector now" instruction. The clear rides THIS SAME commit fact - // (an `embed.landed` record with an empty vector — the recovery fold - // disarms a pending marker on ANY `embed.landed` for the id, - // regardless of the vector it carries), so a crash between the write - // and the in-memory clear below still recovers disarmed. Mutually - // exclusive with `embedMarkers` above: `deferringEmbed` requires an - // ABSENT `explicitVector`, so the two branches never both apply. - const clearsPendingEmbed = isExplicitUnvector && this._pendingEmbedIds.has(params.id) - const commitRecords: FactMarkerRecord[] | undefined = - embedMarkers ?? (clearsPendingEmbed - ? [{ type: 'embed.landed', id: params.id, vector: [] }] - : undefined) - // Execute atomically with transaction system, generation-stamped as one // immutable Model-B generation (before-image = the entity's prior state). await this.persistSingleOp({ nouns: [params.id] }, async (tx) => { @@ -4986,33 +3807,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, embedMarkers) // Aggregation hook (outside transaction — derived data). `existing` is // the full get() view — every reserved field top-level — and must be @@ -5060,19 +3855,32 @@ export class Brainy implements BrainyInterface { */ /** * @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). + * crossing. The seam's metadata is JSON-safe BY CONTRACT (a native provider + * serializes it; u64 ints as Number corrupt above 2^53) — but + * {@link resolveVerbEndpointInts} MIRRORS the resolved endpoint ints onto + * the verb object itself as BigInt (`verb.sourceInt`/`targetInt`), so a + * verb object reused as index metadata carried BigInts into + * JSON.stringify, which throws, aborting the whole transaction (found by + * the first joint pair gate). 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. * @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) + 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 } private metadataIndexRetractionOp( @@ -5480,19 +4288,6 @@ export class Brainy implements BrainyInterface { this._graphAdjacencyVerified = true return 'live' } - // A provider that is REBUILDING ITSELF gets a refusal that says so, - // with its own progress: open deliberately did not wait for it (see - // rebuildIndexesIfNeeded), so this door is temporarily closed and will - // open on its own. Anything else is a broken index needing a repair. - const rebuilding = assessProviderRebuild(this.graphIndex) - if (rebuilding) { - throw new GraphIndexNotReadyError( - `Graph adjacency index is ${describeRebuildProgress(rebuilding)} and is not serving ` + - `yet. find({ connected }), neighbors() and related() refuse rather than serve an ` + - `empty result. The brain is open and every other family is serving; this door opens ` + - `by itself when the provider reports serving — no action is needed.` - ) - } throw new GraphIndexNotReadyError( `Graph adjacency index is not serving (via ${assessment.via}): ` + `${assessment.reasons.join('; ') || 'not ready'}. find({ connected }), neighbors() and ` + @@ -5596,15 +4391,6 @@ export class Brainy implements BrainyInterface { this._metadataVerified = true return 'live' } - const rebuilding = assessProviderRebuild(this.metadataIndex) - if (rebuilding) { - throw new MetadataIndexNotReadyError( - `Metadata field index is ${describeRebuildProgress(rebuilding)} and is not serving ` + - `yet. find({ where }) and other filtered reads refuse rather than serve an empty ` + - `result. The brain is open and every other family is serving; this door opens by ` + - `itself when the provider reports serving — no action is needed.` - ) - } throw new MetadataIndexNotReadyError( `Metadata field index is not serving (via ${assessment.via}): ` + `${assessment.reasons.join('; ') || 'not ready'}. find({ where }) and other filtered ` + @@ -5734,15 +4520,6 @@ export class Brainy implements BrainyInterface { this._vectorVerified = true return 'live' } - const rebuilding = assessProviderRebuild(this.index) - if (rebuilding) { - throw new VectorIndexNotReadyError( - `Vector index is ${describeRebuildProgress(rebuilding)} and is not serving yet. ` + - `Semantic find({ query }) and proximity search refuse rather than serve an empty ` + - `result. The brain is open and every other family is serving; this door opens by ` + - `itself when the provider reports serving — no action is needed.` - ) - } throw new VectorIndexNotReadyError( `Vector index is not serving (via ${assessment.via}): ` + `${assessment.reasons.join('; ') || 'not ready'}. Semantic find({ query }) and ` + @@ -8136,47 +6913,6 @@ 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 @@ -8259,7 +6995,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) { @@ -8296,7 +7032,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) { @@ -8324,7 +7060,7 @@ export class Brainy implements BrainyInterface { 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) { @@ -8367,37 +7103,7 @@ 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) @@ -8430,18 +7136,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) @@ -8452,32 +7146,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) { @@ -8534,33 +7216,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) { @@ -8575,7 +7248,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) { @@ -8603,7 +7276,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) { @@ -8619,11 +7292,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 @@ -8666,20 +7337,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, @@ -8708,28 +7367,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 @@ -9541,11 +8178,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() @@ -10531,15 +9163,6 @@ 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) @@ -11622,18 +10245,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 }) } } @@ -11783,8 +10394,7 @@ export class Brainy implements BrainyInterface { casUpdates: [], createdNouns: new Set(), changeEvents: [], - markerRecords: [], - vectorUnlands: [] + markerRecords: [] } for (const op of ops) { @@ -11906,26 +10516,10 @@ export class Brainy implements BrainyInterface { // marker-less committed row would be a silently missing vector, which is // the disallowed direction). The background worker embeds + inserts. const deferringEmbed = params.deferEmbedding === true && !params.vector - let vector = deferringEmbed + const vector = deferringEmbed ? [] : params.vector || (await this.embed(params.data)) - - // THE ZERO-NORM LAW — see the single-add() insert path's matching - // comment (a zero-norm vector is not a vector; never crosses an engine - // boundary). Normalized here BEFORE the dimension pin and the - // vectored-ledger `hasVector` flag below ever see it. - if (!deferringEmbed && vector.length > 0 && isZeroNormVector(vector)) { - prodLog.warn( - `[Brainy] transact add: entity ${id} was given an explicit all-zero vector — ` + - `a zero-norm vector is not a vector; persisted unvectored ([]) instead.` - ) - vector = [] - } - - // Gated on `vector.length > 0` — see the single-add() insert path's - // matching comment: an explicit `vector: []` carries no dimension - // information either, deferred or not. - if (!deferringEmbed && vector.length > 0) { + if (!deferringEmbed) { if (!this.dimensions) { this.dimensions = vector.length } else if (vector.length !== this.dimensions) { @@ -12007,12 +10601,9 @@ export class Brainy implements BrainyInterface { // for a deferred embed (stub vector `[]`; counted later at landing). new SaveNounMetadataOperation(this.storage, id, storageMetadata, isNew, vector.length > 0), 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)] - : []), + ...(deferringEmbed + ? [] + : [new AddToVectorIndexOperation(this.index, id, vector, this.indexWriteGeneration)]), new AddToMetadataIndexOperation(this.metadataIndex, id, entityForIndexing, this.indexWriteGeneration) ) plan.touchedNouns.push(id) @@ -12092,62 +10683,17 @@ export class Brainy implements BrainyInterface { 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 + vector = params.vector } else if (hasNewData) { 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(hasNewData || params.type || params.vector) const newMetadata = params.merge !== false @@ -13371,100 +11917,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 @@ -13473,27 +11926,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() @@ -13574,18 +12006,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 @@ -13596,7 +12016,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) { @@ -13609,24 +12029,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 @@ -13643,16 +12055,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 ` + @@ -13666,92 +12073,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. @@ -13865,54 +12186,6 @@ export class Brainy implements BrainyInterface { } } - /** - * 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 @@ -14124,49 +12397,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 } }) } @@ -16805,44 +15050,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 @@ -16868,10 +15075,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 } /** @@ -16894,18 +15112,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) @@ -16933,16 +15141,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 @@ -16996,8 +15204,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 ) @@ -17082,44 +15290,22 @@ export class Brainy implements BrainyInterface { await this.verifyGraphAdjacencyLive() } - 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 } @@ -17171,64 +15357,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 * @@ -17255,56 +15407,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 @@ -17312,93 +15463,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 * @@ -17923,141 +16032,6 @@ export class Brainy implements BrainyInterface { return !this.pluginRegistry.hasProvider('embeddings') } - /** - * @description LEG C of the zero-norm/unvector-door law — migrate a - * legacy zero-norm VFS root BEFORE the vector-leg open gate - * ({@link rebuildIndexesIfNeeded}'s `vectorCoverageGap` check) ever - * compares the canonical vectored-noun count against the vector index's - * size. A pre-fix store may have persisted the VFS root (the fixed - * all-zeros UUID) with a REAL all-zero placeholder vector — lawful inside - * brainy (`cosineDistance` treats a zero-norm operand as MAXIMUM distance, - * see {@link isZeroNormVector}'s JSDoc) but never indexed (the index belt - * refuses to insert a zero-norm vector) and never meant to cross an - * engine boundary. Left unmigrated, the canonical ledger still counts it - * as vectored while the vector index correctly holds nothing for it — a - * near-empty store whose ONLY vectored row is this zero-norm root reads - * "canonical vectored 1, index size 0" and throws - * `VectorIndexNotReadyError` at open, going DARK instead of serving. - * - * THE LIFECYCLE LAW: nothing at open may scale with brain size. This step - * is safe under that law BECAUSE the VFS root lives at a FIXED, - * well-known id (`00000000-0000-0000-0000-000000000000` — mirrors - * `VirtualFileSystem.VFS_ROOT_ID`; kept as a literal here, the same - * convention as the other reserved-root literals in this file and in - * `db/factLog.ts`/`db/portableGraph.ts` — `brainy.ts` cannot import - * `VirtualFileSystem.ts`, which itself imports `Brainy`) — this is ONE - * direct canonical read by id (`storage.getNoun`, the same O(1) - * fixed-path lookup {@link unvectorNounForRootMigration} itself uses - * internally), NEVER a listing or a walk over `entities/nouns/**`. An - * absent root (a store that has never used the VFS) is a no-op, no error. - * - * Runs UNCONDITIONALLY at every open, independent of whether a - * `VirtualFileSystem` is ever constructed this session — the vector-leg - * gate this fixes runs during Brainy's OWN init, before any - * `VirtualFileSystem` instance exists to run its own lazy migration at - * `doInitializeRoot()` (kept in place as the second line of defense for a - * VFS actually opened this session — belt AND suspenders, never either - * alone). - */ - private async migrateLegacyZeroNormVfsRootIfNeeded(): Promise { - const VFS_ROOT_ID = '00000000-0000-0000-0000-000000000000' - // TORN-TOLERANT: a torn root record is a recovery-walk healer's job - // (see tests/integration/recovery-walk-tolerance.test.ts — an init-time - // walk that meets a torn record narrates+counts, via the adapter's own - // loud floor at the read site, and heals PAST it; the open itself must - // still succeed), not this O(1) migration check's. Skip this open's - // migration attempt rather than aborting init(): this leg is a - // defensive EXTRA (the index belt + VirtualFileSystem's own - // doInitializeRoot() migration still stand as the other lines of - // defense), and it retries harmlessly at a later open once the root - // heals. - let root: HNSWNounWithMetadata | null - try { - root = await this.storage.getNoun(VFS_ROOT_ID) - } catch (err) { - if ((err as { code?: string }).code !== 'TORN_RECORD') throw err - prodLog.warn( - `[Brainy] open(): the VFS root's record is TORN — skipping the zero-norm root ` + - `migration check this open (the recovery walk is the healer; this migration ` + - `retries harmlessly once the root heals).` - ) - return - } - if (!root || !Array.isArray(root.vector) || root.vector.length === 0) return - if (!isZeroNormVector(root.vector)) return - const migrated = await this.unvectorNounForRootMigration(VFS_ROOT_ID) - if (migrated) { - prodLog.warn( - `[Brainy] open(): migrated the VFS root's legacy all-zero placeholder vector to ` + - `the unvectored shape (zero-norm vectors never cross an engine boundary) — run ` + - `before the vector-leg open gate compares canonical-vectored-count against the ` + - `vector index, so a near-empty store never reads a false coverage gap.` - ) - } - } - - /** - * SANCTIONED, ONE-TIME MIGRATION HOOK — rewrite a canonical noun's - * persisted vector from a real (non-empty) vector to the "unvectored" - * 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 */ @@ -18138,19 +16112,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, @@ -18649,34 +16612,16 @@ export class Brainy implements BrainyInterface { 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 (this._lastNarratedHealthGeneration.get(provider) !== generation) { + this._lastNarratedHealthGeneration.set(provider, generation) + prodLog.warn( + `[Brainy] ${assessment.report.provider} health (generation ${generation}): ` + + assessment.reasons.join('; ') + ) } } 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 ` + @@ -18999,37 +16944,9 @@ 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 @@ -19241,15 +17158,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. ` + @@ -19808,93 +17716,17 @@ export class Brainy implements BrainyInterface { * invariant-driven pass (it was already rebuilt unconditionally — a second, * report-driven pass over the same family would be redundant at best). * - * NARRATION IS PART OF THE CONTRACT. A repair on a production store ran for - * more than thirty minutes at a full core with NOT ONE log line between its - * start and its end while the doors kept serving; the operator could tell it - * was alive only from `top`. Every phase now announces itself before it - * works, a heartbeat names the phase still running every five seconds, and - * each phase reports its own wall — carried in the receipt as - * `durationMs` per family, so nobody has to infer progress from CPU. - * * @param options.rebuild - Family name(s) to unconditionally rebuild, or `'all'` for all three (`'metadata' | 'graph' | 'vector'`). - * @returns The full per-family receipt (see {@link RepairReport}); also narrated as it goes. + * @returns The full per-family receipt (see {@link RepairReport}); also narrated via `prodLog.warn`. */ async repairIndex(options?: { rebuild?: Array<'metadata' | 'graph' | 'vector'> | 'all' }): Promise { await this.ensureInitialized() - // A repair recounts, prunes and rebuilds outside the commit paths; the - // dirty witness is set so a caller's flush after a repair does its normal - // work rather than finding the brain "clean". - this._dirtySinceLastFlush = true const startedAt = Date.now() const families: RepairFamilyReport[] = [] - - // 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}`) + const record = (family: string, entry: Omit): void => { + families.push({ family, ...entry }) } - /** - * Close the current phase: stamp its wall into the receipt row and say - * what it did. Every family row carries its own `durationMs`. - */ - const record = (family: string, entry: Omit): void => { - const durationMs = Date.now() - phaseStartedAt - families.push({ family, ...entry, durationMs }) - prodLog.narrate( - `[Brainy] repairIndex: "${family}" finished in ${durationMs}ms — ` + - (entry.checked - ? `${entry.healed} heal(s)${entry.rebuilt ? ', rebuilt' : ''}` + - (entry.detail ? ` (${entry.detail})` : '') - : `skipped (${entry.skipped ?? entry.reason ?? 'no reason given'})`) - ) - phaseStartedAt = Date.now() - } - - try { - return await this.runRepairIndexPhases(options, families, record, beginPhase, startedAt) - } finally { - clearInterval(heartbeat) - } - } - - /** - * @description The phases of {@link repairIndex}, separated so its heartbeat - * can live in a `finally` around them. Not a public door — see `repairIndex` - * for the contract. - * @param options - As `repairIndex`. - * @param families - The receipt rows being accumulated. - * @param record - Closes a phase: stamps its wall and narrates its outcome. - * @param beginPhase - Announces a phase before it works. - * @param startedAt - When the repair began, for the closing line. - * @returns The full receipt. - */ - private async runRepairIndexPhases( - options: { rebuild?: Array<'metadata' | 'graph' | 'vector'> | 'all' } | undefined, - families: RepairFamilyReport[], - record: (family: string, entry: Omit) => void, - beginPhase: (name: string, cause: string) => void, - startedAt: number - ): Promise { - // Prune orphaned canonical containers left by the pre-8.3.1 partial-delete // defect: a delete that removed the metadata (content) leg but left the // vector leg + the entity directory (a "ghost"), or left an empty directory @@ -19908,10 +17740,6 @@ 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', { @@ -19922,7 +17750,7 @@ export class Brainy implements BrainyInterface { : {}) }) if (pruned > 0) { - prodLog.narrate( + 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.` @@ -19938,10 +17766,6 @@ 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', { @@ -19964,10 +17788,6 @@ 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, @@ -19977,7 +17797,7 @@ export class Brainy implements BrainyInterface { : {}) }) 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).` ) @@ -19988,25 +17808,17 @@ export class Brainy implements BrainyInterface { record('vfs-containment', { checked: false, healed: 0, skipped: 'VFS not initialized' }) } - beginPhase( - 'metadata-corruption', - 'detect-and-repair pass over the metadata index' - ) await this.metadataIndex.detectAndRepairCorruption() record('metadata-corruption', { checked: true, healed: 0, detail: 'detect-and-repair pass ran (see its own narration for repairs)' }) // Lift a failed-rollback write-quarantine: force a full rebuild so the // derived indexes are provably reconciled with canonical, then clear the // flag so writes resume. if (this.storeInconsistency) { - beginPhase( - 'write-quarantine', - 'full derived-index rebuild to lift the quarantine set by a failed transaction rollback' - ) await this.rebuildIndexesIfNeeded(true) const cleared = this.storeInconsistency record('write-quarantine', { checked: true, healed: 1, detail: `lifted (${cleared.records.length} record(s) reconciled)` }) this.storeInconsistency = null - prodLog.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.` @@ -20037,9 +17849,9 @@ export class Brainy implements BrainyInterface { record(`provider:${familyName}`, { checked: false, healed: 0, skipped: 'no rebuild() contract' }) continue } - beginPhase( - `provider:${familyName}`, - `explicit rebuild requested — rebuilding '${familyName}' unconditionally, no invariant consulted` + prodLog.warn( + `[Brainy] repairIndex(): explicit rebuild requested for '${familyName}' — ` + + `rebuilding unconditionally (no invariant consulted).` ) // The metadata family routes through the online build-beside // orchestrator (B3 D3) instead of the provider's own rebuild() — @@ -20056,7 +17868,7 @@ export class Brainy implements BrainyInterface { rebuilt: true, reason: 'explicit rebuild requested' }) - prodLog.narrate(`[Brainy] repairIndex(): '${familyName}' rebuild complete.`) + prodLog.warn(`[Brainy] repairIndex(): '${familyName}' rebuild complete.`) continue } @@ -20065,16 +17877,11 @@ export class Brainy implements BrainyInterface { rebuild?: () => Promise } | null if (!p || typeof p.validateInvariants !== 'function' || typeof p.rebuild !== 'function') { - beginPhase(`provider:${familyName}`, 'checking the provider contract') record(`provider:${familyName}`, { checked: false, healed: 0, skipped: 'no validateInvariants/rebuild contract' }) continue } - beginPhase( - `provider:${familyName}`, - `reading the '${familyName}' provider's own invariant report, then healing only what it asks for` - ) let report: ProviderInvariantReport try { report = await p.validateInvariants() @@ -20091,7 +17898,7 @@ export class Brainy implements BrainyInterface { checked: true, healed: 1, detail: `rebuilt from canonical (failing: ${report.invariants.filter((i) => !i.holds).map((i) => i.name).join(', ')})` }) - prodLog.narrate( + prodLog.warn( `[Brainy] repairIndex(): provider '${report.provider}' has a failing invariant ` + `requiring a rebuild — reconciling its derived state from canonical.` ) @@ -20115,7 +17922,7 @@ export class Brainy implements BrainyInterface { const failingRepairs = report.invariants .filter((i) => !i.holds && i.heal === 'repair') .map((i) => i.name) - prodLog.narrate( + prodLog.warn( `[Brainy] repairIndex(): provider '${report.provider}' asks for an incremental ` + `repair (${failingRepairs.join(', ')}) — running its own repair().` ) @@ -20154,7 +17961,6 @@ 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 @@ -20163,13 +17969,11 @@ export class Brainy implements BrainyInterface { const healedTotal = families.reduce((n, f) => n + f.healed, 0) const report: RepairReport = { families, healedTotal, durationMs: Date.now() - startedAt } - prodLog.narrate( + prodLog.warn( `[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(', ') + families.map((f) => `${f.family}=${f.checked ? f.healed : 'skipped'}`).join(', ') ) return report } @@ -20695,137 +18499,12 @@ export class Brainy implements BrainyInterface { } /** - * @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 { + async close(): Promise { // Persistence cadence teardown: no background flush may fire after close // begins (close() runs its own final flush). if (this._persistIdleTimer) { @@ -20835,21 +18514,6 @@ export class Brainy implements BrainyInterface { 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(() => {}) - } // Cancel any pending post-import background deduplication FIRST — it is a // writer (merge-deletes), and no delete pass may start mid- or post-close. @@ -20892,64 +18556,40 @@ export class Brainy implements BrainyInterface { // 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() } })() @@ -20962,54 +18602,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() } })(), @@ -21046,6 +18655,38 @@ export class Brainy implements BrainyInterface { } } + // Stop the cross-process flush-request watcher (no-op if never started). + if (this.storage && typeof this.storage.stopFlushRequestWatcher === 'function') { + this.storage.stopFlushRequestWatcher() + } + + // Release the writer lock (no-op for readers and for backends that don't + // hold a lock). Must run after the metadata buffer drain — otherwise a + // pending write could land after a successor writer claimed the lock. + if (this.storage && typeof this.storage.releaseWriterLock === 'function') { + await this.storage.releaseWriterLock() + } + + // Shut down the VFS: stops its background maintenance interval and the + // PathResolver's — both are ref'd timers that would keep the process + // alive after the last brain closes (consumer-reported hang). + if (this._vfs) { + await this._vfs.close() + } + + this.initialized = false + // close() is terminal: block lazy re-initialization on any subsequent + // operation (ensureInitialized() throws once this is set). + this.closed = true + + // Drop this instance from the global registry, and when it was the last + // one, deregister the global shutdown hooks — their ref'd signal handles + // would otherwise keep the process alive after every brain is closed. + const instanceIndex = Brainy.instances.indexOf(this) + if (instanceIndex !== -1) { + Brainy.instances.splice(instanceIndex, 1) + } + Brainy.deregisterShutdownHooksIfIdle() } } diff --git a/src/coreTypes.ts b/src/coreTypes.ts index 4b018e94..8112afd2 100644 --- a/src/coreTypes.ts +++ b/src/coreTypes.ts @@ -872,27 +872,6 @@ export interface StorageAdapter { */ 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 - /** * Get noun with metadata combined * @returns Combined HNSWNounWithMetadata or null diff --git a/src/db/errors.ts b/src/db/errors.ts index da62eb0b..e20488f8 100644 --- a/src/db/errors.ts +++ b/src/db/errors.ts @@ -28,7 +28,7 @@ * speculative `with()` overlay; the canonical storage walk only ever answers * "what is live right now." * - * All are exported from the package root (`@soulcraftlabs/brainy`). + * All are exported from the package root (`@soulcraft/brainy`). */ /** @@ -351,63 +351,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..ca130454 100644 --- a/src/db/factLog.ts +++ b/src/db/factLog.ts @@ -40,10 +40,7 @@ * 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`). + * tail's first byte exists, so no segment file is ever unaccounted for. * * ## Mixed-version logs (the v2 live-write cutover) * @@ -692,74 +689,6 @@ function parseSegment( 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)` - ) -} - /** * The generation fact log. One instance per open store; every method assumes * the single-writer discipline the generation store already enforces (calls @@ -825,6 +754,22 @@ export class FactLog { return this.manifest.brainId !== undefined || this.tailVersion === FACT_LOG_FORMAT_V2 } + /** + * Open the log and reconcile it to committed truth: read the manifest, + * establish the tail's intact content (torn-tail scan), then TRUNCATE any + * fact with `generation > committedGeneration` — those never committed (a + * crash between fact-append and the commit point). After open, the log is + * exactly the committed prefix. + */ + /** + * 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). + */ /** * STREAMING twin of {@link FactLog.peekFactsAbove} for the recovery fold: * yields facts above the bound one SEGMENT at a time, ascending, without @@ -834,18 +779,13 @@ export class FactLog { * 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) + const files = [...stored.segments.map((s) => s.file)] + if (stored.tailSegment) files.push(stored.tailSegment) let lastGen = committedGeneration for (const file of files) { const bytes = await this.storage.readRawBytes(`${FACTS_PREFIX}/${file}`) @@ -867,27 +807,13 @@ export class FactLog { } } - /** - * 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) + const files = [...stored.segments.map((s) => s.file)] + if (stored.tailSegment) files.push(stored.tailSegment) for (const file of files) { const bytes = await this.storage.readRawBytes(`${FACTS_PREFIX}/${file}`) if (bytes === null) continue @@ -900,13 +826,6 @@ export class FactLog { 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 - * fact with `generation > committedGeneration` — those never committed (a - * crash between fact-append and the commit point). After open, the log is - * exactly the committed prefix. - */ async open(committedGeneration: number): Promise { const stored = (await this.storage.readRawObject(FACTS_MANIFEST_PATH)) as FactsManifest | null if (stored && typeof stored === 'object' && Array.isArray(stored.segments)) { 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/generationSegments.ts b/src/db/generationSegments.ts index 91451281..0c14b60c 100644 --- a/src/db/generationSegments.ts +++ b/src/db/generationSegments.ts @@ -147,60 +147,6 @@ export class GenerationSegmentStore { return this.coveringSegment(gen) !== null } - /** - * @description True when `meta` declares more generations than it holds - * frames — a segment sealed by a writer that folded across a hole. The - * manifest records `frames` at fold time, so this is an O(1) comparison - * against the declared span and needs no I/O. - */ - private isSparse(meta: SegmentMeta): boolean { - return meta.lastGeneration - meta.firstGeneration + 1 !== meta.frames - } - - /** - * @description The generations this tier ACTUALLY holds, as coalesced - * ascending intervals — not what the segments declare. - * - * Dense segments (every one a current writer produces) contribute their - * declared range with no I/O. A SPARSE segment — one sealed before the - * density law was enforced, whose declared range spans generations it has - * no frame for — has its real generation list read from its sidecar and - * contributed instead, with the discrepancy narrated once. - * - * This is what keeps a store that already carries the damage from wedging. - * `open()` seeds `committedRanges` from these intervals, so a hole is never - * re-admitted as a committed generation, and the auto-compaction pass that - * used to fail on every run with "packed history is damaged" simply never - * asks for the missing frame. - * - * @returns Ascending, non-overlapping `[first, last]` intervals. - */ - async actualRanges(): Promise> { - const out: Array<[number, number]> = [] - for (const meta of this.manifest.segments) { - if (!this.isSparse(meta)) { - out.push([meta.firstGeneration, meta.lastGeneration]) - continue - } - const missing = meta.lastGeneration - meta.firstGeneration + 1 - meta.frames - prodLog.warn( - `[GenerationSegments] sealed segment ${meta.file} declares generations ` + - `${meta.firstGeneration}..${meta.lastGeneration} but holds only ${meta.frames} ` + - `frame(s) — ${missing} generation(s) in that span were never folded into it. ` + - `Serving the frames it actually holds; the declared span is not treated as ` + - `committed history. (Written by a pre-density-law writer that folded across a ` + - `gap; the segment itself is intact and no record is lost.)` - ) - const idx = await this.sidecarFor(meta) - for (const [gen] of idx.generations) { - const last = out[out.length - 1] - if (last !== undefined && gen === last[1] + 1) last[1] = gen - else out.push([gen, gen]) - } - } - return out - } - /** * Fold consecutive generations into ONE new sealed segment + sidecar and * append it to the manifest atomically. Caller guarantees: `gens` is @@ -218,38 +164,6 @@ export class GenerationSegmentStore { throw new Error('[GenerationSegments] fold() input must be strictly ascending') } } - // THE DENSITY LAW, MADE MECHANICAL. - // - // A sealed segment declares a CONTIGUOUS range [firstGeneration, - // lastGeneration] and every reader treats that range as containment: - // `coveringSegment` is an interval test, `hasGeneration` returns true for - // anything inside it, and `open()` seeds committedRanges from it. So a - // segment folded from a SPARSE input silently claims generations it does - // not hold, and the first read of one of those holes throws - // "inside sealed segment ... but has no frame — packed history is damaged". - // - // That is exactly how the damage was produced. `repackHistory` skipped - // generations mid-batch — ones absent from committedRanges, ones still in - // the pending buffer, ones whose tx.json would not read — and handed the - // survivors here, where the range was computed from the first and last of - // them. Worse, the mis-declared range was then merged back into - // committedRanges at the next open, which is what turned a quiet hole into - // a repeating auto-compaction failure on every subsequent run. - // - // Callers now split at discontinuities; this refusal is what keeps any - // future caller from reintroducing the class. A refusal here loses - // nothing — the generations stay in the live tier, readable, and the next - // pass folds them correctly. - for (let i = 1; i < gens.length; i++) { - if (gens[i].generation !== gens[i - 1].generation + 1) { - throw new Error( - `[GenerationSegments] fold() input is not contiguous: ${gens[i - 1].generation} → ` + - `${gens[i].generation} skips ${gens[i].generation - gens[i - 1].generation - 1} ` + - `generation(s). A sealed segment declares a dense range, so folding a sparse ` + - `batch would claim generations it does not hold. Split the batch at the gap.` - ) - } - } const last = this.manifest.segments[this.manifest.segments.length - 1] if (last && gens[0].generation <= last.lastGeneration) { throw new Error( @@ -450,37 +364,12 @@ export class GenerationSegmentStore { 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 - } + // In the covering range but not present: the packed tier is dense by + // construction (fold packs every generation it is handed, including + // record-less ones) — absence inside a sealed range is damage. 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` + `range but has no frame — packed history is damaged` ) } diff --git a/src/db/generationStore.ts b/src/db/generationStore.ts index 89f83a8f..bfb68959 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 { @@ -102,35 +96,6 @@ 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. @@ -572,29 +537,12 @@ export class GenerationStore { this.horizonGen = finiteGen(manifest?.horizon, 'manifest horizon') this.counter = Math.max(finiteGen(counterFile?.generation, 'generation counter'), this.committed) - // Discover existing generation record directories — 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 @@ -704,56 +652,21 @@ export class GenerationStore { : 'WHOLE-LOG fold' : 'above-manifest replay' let replayed = 0 - const foldStartedAt = Date.now() const replayFact = async (fact: CommitFact): Promise => { for (const op of fact.ops) { - let image: { metadata: unknown | null; vector: unknown | null } - 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 } - } + const image = + op.record === null + ? { metadata: null, vector: null } + : { 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( + prodLog.warn( `[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` + `(at generation ${fact.generation}); do not restart, the fold is finite` ) } if (fact.generation > this.committed) { @@ -768,7 +681,7 @@ export class GenerationStore { } } if (uncleanOpen) { - prodLog.narrate( + prodLog.warn( `[GenerationStore] log-authority recovery: ${foldKind} beginning ` + `(unclean shutdown detected) — streaming replay, bounded memory, ` + `progress every 1000 facts. Do not restart the process; a restart ` + @@ -791,10 +704,9 @@ export class GenerationStore { } await this.storage.writeRawObject(MANIFEST_PATH, manifest) await this.storage.syncRawObjects([MANIFEST_PATH]) - prodLog.narrate( + prodLog.warn( `[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` + `canonical (${foldKind}; committed at ${this.committed}) — an acked write is never lost` ) } // A recovery fold re-applied (and the barrier below re-syncs) every @@ -805,16 +717,7 @@ export class GenerationStore { 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.clearCleanShutdownMarker() } await this.factLog.open(this.committed) } else { @@ -828,15 +731,9 @@ export class GenerationStore { 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)]) + const packedRanges = this.segments + .segments() + .map((s): [number, number] => [s.firstGeneration, Math.min(s.lastGeneration, this.committed)]) .filter(([lo, hi]) => lo <= hi) if (packedRanges.length > 0) { // Merge packed (older) + live (newer) interval sets — both ascending; @@ -904,11 +801,7 @@ export class GenerationStore { } } - /** - * 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}. - */ + /** Consume the clean-shutdown marker (every open; a clean close re-writes it). */ private async clearCleanShutdownMarker(): Promise { try { await this.storage.deleteRawObject(CLEAN_SHUTDOWN_PATH) @@ -1370,9 +1263,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. @@ -1447,13 +1337,6 @@ export class GenerationStore { 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, // un-durable single-op generations would be inconsistent). Same loud @@ -2323,37 +2206,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 +2289,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() @@ -3223,26 +3068,13 @@ export class GenerationStore { 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 + await segments.fold(foldInput) + segmentsCreated++ + // Segment + manifest durable → the live copies retire. + for (const g of foldInput) { + await this.storage.removeRawPrefix(`${GENERATIONS_PREFIX}/${g.generation}`) } + folded += foldInput.length } if (folded > 0) { prodLog.info( diff --git a/src/db/types.ts b/src/db/types.ts index 56bdef11..363de086 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -450,21 +450,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). */ 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..d002164e 100644 --- a/src/graph/graphAdjacencyIndex.ts +++ b/src/graph/graphAdjacencyIndex.ts @@ -1052,17 +1052,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 — @@ -1105,31 +1094,13 @@ export class GraphAdjacencyIndex implements GraphIndexProvider { } /** - * 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) { 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..77e4f84d 100644 --- a/src/hnsw/hnswIndex.ts +++ b/src/hnsw/hnswIndex.ts @@ -10,7 +10,7 @@ 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' @@ -64,34 +64,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 @@ -608,15 +580,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 @@ -991,13 +954,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider { return } - // Same refusal as addItem (see EmptyVectorIndexError's JSDoc) — an - // in-place relink must never rewrite an already-indexed node down to the - // unvectored shape or poison the pinned dimension. - if (vector.length === 0) { - throw new EmptyVectorIndexError(id, 'updateItem') - } - if (this.dimension === null) { this.dimension = vector.length } else if (vector.length !== this.dimension) { @@ -1599,15 +1555,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}`) } @@ -1817,56 +1765,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 +1815,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/index.ts b/src/index.ts index 673e1e6f..973136a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -184,7 +184,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 +202,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 ============= @@ -231,8 +230,7 @@ export { GenerationCompactedError, StoreInconsistentError, PendingFlushDurabilityError, - CanonicalEnumerationUnavailableError, - PendingSingleOpsUnflushedError + CanonicalEnumerationUnavailableError } from './db/errors.js' export type { UnreconciledRecord } from './db/errors.js' export type { diff --git a/src/indexes/columnStore/ColumnStore.ts b/src/indexes/columnStore/ColumnStore.ts index 6bff86d4..4fe45bff 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' @@ -55,89 +52,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 +121,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 +140,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 +157,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 +264,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 +292,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 +311,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 +376,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 +398,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 +410,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 +430,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 +463,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 +471,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) } /** @@ -980,15 +625,8 @@ export class ColumnStore implements ColumnStoreProvider { /** 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 }) - } - } + for (const [key, q] of this.segmentQuarantine) { + if (key.startsWith(`${field}:`)) out.push({ segment: key.slice(field.length + 1), error: q.error, hits: q.hits }) } return out } @@ -1160,22 +798,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 +822,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 +863,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 +870,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 +913,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/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/plugin.ts b/src/plugin.ts index 23a8c883..bfdc403a 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -22,7 +22,7 @@ import type { GraphIndexStats } from './graph/graphAdjacencyIndex.js' // Re-export the provider contracts that already live closer to their // implementations so a plugin author (Cor) can import the *entire* -// provider surface from one stable entrypoint: `@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 +41,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 @@ -411,129 +411,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 diff --git a/src/storage/adapters/baseStorageAdapter.ts b/src/storage/adapters/baseStorageAdapter.ts index a90adb93..22bf1366 100644 --- a/src/storage/adapters/baseStorageAdapter.ts +++ b/src/storage/adapters/baseStorageAdapter.ts @@ -1066,18 +1066,6 @@ export abstract class BaseStorageAdapter implements StorageAdapter { 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 +1077,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 @@ -1168,24 +1152,6 @@ export abstract class BaseStorageAdapter implements StorageAdapter { }) } - /** - * 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 +1311,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..fd9dbb4c 100644 --- a/src/storage/adapters/fileSystemStorage.ts +++ b/src/storage/adapters/fileSystemStorage.ts @@ -14,13 +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, @@ -100,30 +97,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 +110,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 +118,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 @@ -642,20 +602,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 +641,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 +655,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 @@ -1943,41 +1865,18 @@ export class FileSystemStorage extends BaseStorage { } } - // THE CLEAN-CLOSE RECORD IS READ BEFORE ANY VERDICT (see - // WriterCloseRecord). A lock file whose release was RECORDED is - // bookkeeping left by an orderly shutdown, not evidence of anything — - // and that is true whether the previous holder was another process or - // an earlier instance in THIS one. A production restart reported - // "Re-acquiring writer lock ... this is a bug" immediately after a clean - // close, sending an operator hunting for a leak that did not exist. - const closeRecord = existing ? await this.readWriterCloseRecord() : null - const releasedCleanly = - existing !== null && - closeRecord !== null && - this.closeRecordVouchesFor(closeRecord, existing) - if (existing) { // Same-process re-open: a second Brainy instance in this Node process // (e.g. test "simulate server restart" patterns, or a consumer that // explicitly re-instantiates without closing first). This isn't the // dangerous cross-process case the lock exists to prevent — the two // instances share a memory space and can't silently diverge from each - // other beyond what their callers already see. Warn and take over — - // 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 +1886,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 +1901,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 @@ -2072,12 +1956,6 @@ export class FileSystemStorage extends BaseStorage { await fs.promises.unlink(claimTmp).catch(() => {}) } - // CONSUME the previous writer's clean-close record. It described the - // lock generation that just ended; leaving it in place would let it - // vouch for OUR lock if this process later dies without closing — - // turning a real crash into a "closed cleanly" verdict. One unlink. - await this.clearWriterCloseRecord() - this.installWriterLock(info) return info } @@ -2201,27 +2079,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 +2095,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) @@ -2400,130 +2173,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 @@ -2903,46 +2590,19 @@ export class FileSystemStorage extends BaseStorage { ) { 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') - } + this.allCountsSuspect = counts.allCountsSuspect === true } 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') + const nouns = await this.scanCanonicalEntities('nouns') + const verbs = await this.scanCanonicalEntities('verbs') + this.totalNounCountAll = nouns.count + this.totalVerbCountAll = verbs.count + this.allCountsSuspect = false + console.warn( + `[FileSystemStorage] counts.json predates the ALL-visibility count ledger — ` + + `derived once from the canonical id tree (${nouns.count} nouns, ${verbs.count} verbs, ` + + `every tier) and persisted; no further scan.` + ) + needsPersist = true } // The vectored-noun scalar (shipped after the ALL scalars above — a @@ -2955,12 +2615,14 @@ export class FileSystemStorage extends BaseStorage { 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') + const vectored = await this.scanVectoredNounCount() + this.totalVectoredNounCount = vectored + console.warn( + `[FileSystemStorage] counts.json predates the vectored-noun count ledger — ` + + `derived once by reading every noun's vectors.json (${vectored} vectored) and ` + + `persisted; no further scan.` + ) + needsPersist = true } if (needsPersist) { await this.persistCounts() @@ -2989,22 +2651,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 @@ -3021,7 +2667,6 @@ export class FileSystemStorage extends BaseStorage { 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 @@ -3052,11 +2697,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 +2704,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 +2724,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)) } } } @@ -3274,20 +2781,14 @@ export class FileSystemStorage extends BaseStorage { } /** - * 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. + * Count canonical nouns holding a REAL (non-empty) vector — the vectored- + * noun ledger scalar. UNLIKE {@link scanCanonicalEntities}, presence + * cannot be decided from the id-directory listing alone: a deferred-embed + * noun's `vectors.json` EXISTS (written at `add()` time with `vector: []`) + * until its embed LANDS, so this walk reads every noun's `vectors.json` + * CONTENT — O(nouns) reads, not O(ids) listing. Used ONLY for a one-time + * legacy-counts.json derivation or a lost/corrupted counts.json recovery; + * the result is persisted so this scan never repeats. */ private async scanVectoredNounCount(): Promise { const base = path.join(this.rootDir, 'entities', 'nouns') @@ -3301,12 +2802,7 @@ export class FileSystemStorage extends BaseStorage { for (const entry of ids) { if (!entry.isDirectory()) continue const record = await this.readEntityVectorRaw(path.join(shardPath, entry.name)) - if ( - record && - Array.isArray(record.vector) && - record.vector.length > 0 && - !isZeroNormVector(record.vector) - ) { + if (record && Array.isArray(record.vector) && record.vector.length > 0) { vectored++ } } @@ -3338,25 +2834,13 @@ export class FileSystemStorage extends BaseStorage { // 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/baseStorage.ts b/src/storage/baseStorage.ts index a1cc2e35..d6ccc5fa 100644 --- a/src/storage/baseStorage.ts +++ b/src/storage/baseStorage.ts @@ -125,36 +125,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 +203,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 @@ -1437,29 +1373,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 @@ -1540,18 +1453,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 +1489,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}. @@ -2284,18 +2183,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,24 +2210,13 @@ 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 @@ -2347,9 +2226,7 @@ export abstract class BaseStorage extends BaseStorageAdapter { // 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 } }) @@ -2470,14 +2347,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 @@ -2688,79 +2560,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 +2593,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 @@ -2942,33 +2761,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 +2804,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 +2842,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 +2880,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) @@ -4908,10 +4692,6 @@ export abstract class BaseStorage extends BaseStorageAdapter { 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() diff --git a/src/transaction/operations/IndexOperations.ts b/src/transaction/operations/IndexOperations.ts index 0142dc54..139c67fe 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. @@ -91,30 +88,6 @@ export class AddToVectorIndexOperation implements Operation { } 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) @@ -290,52 +263,14 @@ export class ReplaceInVectorIndexOperation implements Operation { // 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) - } + // the item-did-not-exist posture). + await index.updateItem!({ id: this.id, vector: this.oldVector }, generation) } } @@ -346,14 +281,9 @@ export class ReplaceInVectorIndexOperation implements Operation { 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. + // declared before-state. 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.oldVector }, generation) } } } @@ -391,21 +321,13 @@ export class AddToMetadataIndexOperation implements Operation { // 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, false, generation) // 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, generation) } } } @@ -441,21 +363,13 @@ export class RemoveFromMetadataIndexOperation implements Operation { // 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, generation) // 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, false, generation) } } } diff --git a/src/types/brainy.types.ts b/src/types/brainy.types.ts index b99f0261..63356828 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 } @@ -561,33 +561,6 @@ export interface UpdateRelationParams { * 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 @@ -816,12 +789,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 * @@ -1250,13 +1217,6 @@ export interface RepairFamilyReport { skipped?: string /** Why the outcome is what it is when neither `detail` nor `skipped` says it. */ reason?: string - /** - * The phase's own wall, in milliseconds. A repair on a production store ran - * for over thirty minutes without a single line of output; an operator had - * to read `top` to know it was alive. A receipt that cannot say WHERE the - * time went is not a receipt — every row carries its own. - */ - durationMs?: number } /** The full receipt returned by repairIndex(). */ @@ -1447,33 +1407,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 * diff --git a/src/types/reservedFields.ts b/src/types/reservedFields.ts index ce2108f8..15b585c5 100644 --- a/src/types/reservedFields.ts +++ b/src/types/reservedFields.ts @@ -65,7 +65,7 @@ * | `_rev` | system-managed revision counter — pass `ifRev` to `update()` for CAS | * * @example - * import { RESERVED_ENTITY_FIELDS } from '@soulcraftlabs/brainy' + * import { RESERVED_ENTITY_FIELDS } from '@soulcraft/brainy' * const isReserved = (key: string) => * (RESERVED_ENTITY_FIELDS as readonly string[]).includes(key) */ diff --git a/src/utils/brainyTypes.ts b/src/utils/brainyTypes.ts index 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/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..498f2003 100644 --- a/src/utils/indexReadiness.ts +++ b/src/utils/indexReadiness.ts @@ -153,83 +153,3 @@ export function assessProviderHealth(provider: unknown): ProviderHealthAssessmen reasons: readiness === 'not-ready' ? ['isReady() returned false'] : [] } } - -/** - * @description A provider's self-report that it is REBUILDING ITS OWN index - * right now. Returned by the optional `rebuildInProgress()` hook. - * - * The distinction this exists to make: a provider reporting `serving: false` - * because it is BROKEN and a provider reporting `serving: false` because it is - * BUSY BUILDING ITSELF look identical through `healthReport()` alone, and - * brainy treated both the same way — it called `rebuild()` and waited for it, - * on the foreground of `init()`. A production store whose metadata provider - * had to rebuild paid 641 SECONDS of that wait before `init()` returned, with - * every other family idle behind it. - * - * A provider that reports progress here owns its own rebuild: brainy neither - * starts one nor waits for it, `init()` returns, the other families serve, and - * THAT family's doors refuse by name — carrying this progress — until the - * provider reports itself serving. - * - * Every field but `phase` is optional and every field is a MEASUREMENT: a - * provider reports only what it actually tracks, never an estimate dressed as - * a fact. - */ -export interface ProviderRebuildProgress { - /** The provider's own name for what it is doing. Quoted verbatim in refusals. */ - phase: string - /** Units completed so far, if the provider counts them. */ - done?: number - /** Units expected in total, if the provider knows it. */ - total?: number - /** Epoch millis when this rebuild started, if the provider tracks it. */ - startedAt?: number -} - -/** A provider that can report a rebuild it is running itself. */ -interface MaybeRebuildingProvider { - rebuildInProgress?: () => ProviderRebuildProgress | null -} - -/** - * @description Ask a provider whether it is rebuilding itself right now. - * Synchronous, O(1), feature-detected: a provider without the hook reports - * nothing and is treated exactly as before. - * @param provider - Any index provider, or `null`/`undefined`. - * @returns The provider's progress, or `null` when it is not rebuilding (or - * does not implement the hook). - */ -export function assessProviderRebuild(provider: unknown): ProviderRebuildProgress | null { - const p = provider as MaybeRebuildingProvider | null | undefined - if (p == null || typeof p.rebuildInProgress !== 'function') return null - try { - const progress = p.rebuildInProgress() - if (!progress || typeof progress.phase !== 'string' || progress.phase.length === 0) { - return null - } - return progress - } catch { - // A throwing hook says nothing trustworthy about a rebuild; fall through to - // the ordinary health verdict rather than inventing one. - return null - } -} - -/** - * @description Render a rebuild progress report as one operator-facing clause, - * for a refusal message. Includes only what the provider actually measured. - * @param progress - The provider's report. - * @returns A clause such as `rebuilding ("metadata shadow build", 4,096/14,056, 12s elapsed)`. - */ -export function describeRebuildProgress(progress: ProviderRebuildProgress): string { - const parts: string[] = [`"${progress.phase}"`] - if (typeof progress.done === 'number' && typeof progress.total === 'number') { - parts.push(`${progress.done.toLocaleString()}/${progress.total.toLocaleString()}`) - } else if (typeof progress.done === 'number') { - parts.push(`${progress.done.toLocaleString()} done`) - } - if (typeof progress.startedAt === 'number') { - parts.push(`${Math.round((Date.now() - progress.startedAt) / 1000)}s elapsed`) - } - return `rebuilding (${parts.join(', ')})` -} diff --git a/src/utils/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..3cc56b2e 100644 --- a/src/utils/metadataIndex.ts +++ b/src/utils/metadataIndex.ts @@ -40,7 +40,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 @@ -289,10 +289,8 @@ export class MetadataIndexManager implements MetadataIndexProvider { // 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. + // SHAPE rules in extractIndexableFields (arrays >10 never become + // posting scalars; >100-char values index hashed), never by name. } // Initialize metadata cache with similar config to search cache @@ -963,41 +961,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 +979,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 +996,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 +1024,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 +1191,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 +1250,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 */ @@ -1389,10 +1289,9 @@ export class MetadataIndexManager implements MetadataIndexProvider { * '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) + * - Value-SHAPE rules apply uniformly to all names: arrays >10 never + * become posting scalars; purely numeric key names (array indices) + * skip; >100-char values index hashed (normalizeValue) */ private extractIndexableFields(data: any): Array<{ field: string, value: any }> { const fields: Array<{ field: string, value: any }> = [] @@ -1454,37 +1353,13 @@ export class MetadataIndexManager implements MetadataIndexProvider { // 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.` - ) - 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)) { + } 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) { @@ -1634,56 +1509,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 +1529,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) } } @@ -2404,74 +2241,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 +2257,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). @@ -2738,19 +2486,6 @@ export class MetadataIndexManager implements MetadataIndexProvider { /** 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, @@ -2930,67 +2665,6 @@ export class MetadataIndexManager implements MetadataIndexProvider { 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 diff --git a/src/utils/paramValidation.ts b/src/utils/paramValidation.ts index f1addb5b..d43559dc 100644 --- a/src/utils/paramValidation.ts +++ b/src/utils/paramValidation.ts @@ -18,7 +18,6 @@ import { findCallerLocation } from './callerLocation.js' 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) { @@ -539,53 +538,8 @@ function rejectForgedSystemKeys(metadata: Record | undefined, s } } -/** - * 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; @@ -634,14 +588,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`) @@ -654,23 +602,11 @@ export function validateAddParams(params: AddParams): void { */ 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' — ` + @@ -707,16 +643,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`) @@ -729,7 +657,6 @@ export function validateUpdateParams(params: UpdateParams): void { */ 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). @@ -779,7 +706,6 @@ export function validateRelateParams(params: RelateParams): void { */ 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/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/VirtualFileSystem.ts b/src/vfs/VirtualFileSystem.ts index bccd6fea..90018863 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')) @@ -105,6 +89,16 @@ export class VirtualFileSystem implements IVirtualFileSystem { // Uses deterministic UUID format for storage compatibility private static readonly VFS_ROOT_ID = '00000000-0000-0000-0000-000000000000' + // OPEN-PATH FIX: the dimension of the placeholder vector given to the VFS + // root when it is first created (see `doInitializeRoot`). Mirrors the + // built-in WASM embedding engine's fixed, hardcoded output size + // (all-MiniLM-L6-v2 — see src/embeddings/candle-wasm/src/lib.rs + // HIDDEN_SIZE and src/embeddings/EmbeddingManager.ts). Deliberately NOT + // derived from `brain.dimensions` — this constant only applies to the + // default-embedder branch, where the true output dimension is this fixed + // value by construction, never a moving target. + private static readonly VFS_ROOT_VECTOR_DIMENSIONS = 384 + /** * Construct a VFS bound to a Brainy instance. * @@ -158,17 +152,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 +241,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 +260,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) { @@ -322,42 +277,31 @@ export class VirtualFileSystem implements IVirtualFileSystem { // 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. + // runs once per store (the root already exists — with a real vector — + // in every previously-opened production store; 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". + // Chosen fix: an explicit all-zero vector, not `deferEmbedding: true`. + // `deferEmbedding` looked attractive (ack now, embed later) but its + // 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 — worse than the + // explicit-vector path, which never touches the engine for this row. + // An all-zero vector is safe: `cosineDistance` (src/utils/distance.ts) + // explicitly returns the MAXIMUM distance whenever either operand's + // norm is zero, so the root can never rank ahead of real content in a + // similarity search, and HNSW indexes it like any other vector. // - // 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. + // Only the default WASM engine gets this treatment — its output + // dimension (384) is fixed and hardcoded, so the placeholder can never + // mis-pin `brain.dimensions` for it. 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 risk pinning the wrong dimension + // ahead of the caller's own first real embed. const rootVector = this.brain.usesDefaultWasmEmbedder() - ? ([] as number[]) + ? new Array(VirtualFileSystem.VFS_ROOT_VECTOR_DIMENSIONS).fill(0) : undefined await this.brain.add({ @@ -418,100 +362,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 +384,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 +396,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 } /** @@ -1572,19 +1421,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 +1437,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 +1603,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 +2144,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 +2156,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/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/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/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/canonical-count-ledger.test.ts b/tests/integration/canonical-count-ledger.test.ts index 3d292e97..f3c8ce20 100644 --- a/tests/integration/canonical-count-ledger.test.ts +++ b/tests/integration/canonical-count-ledger.test.ts @@ -225,12 +225,9 @@ describe('canonical count ledger — the vectored-noun scalar (the vector leg\'s } /** 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. */ + * hidden system VFS-root noun that itself carries a real vector, so a + * brand-new store's `vectors.all` is 1, not 0. Tests assert DELTAS off + * this baseline rather than hardcoding it away. */ let baseline: number beforeEach(async () => { @@ -238,7 +235,6 @@ describe('canonical count ledger — the vectored-noun scalar (the vector leg\'s 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() @@ -352,7 +348,7 @@ describe('canonical count ledger — the vectored-noun scalar (the vector leg\'s 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(baseline + 1) // the root + the one non-deferred noun 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/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/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/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-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..94053d55 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 }) 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/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 index f2952116..1c9a642d 100644 --- a/tests/integration/health-gate.test.ts +++ b/tests/integration/health-gate.test.ts @@ -188,7 +188,7 @@ describe('health gate (b) — unledgered is unknown: never blocks a serving prov describe('health gate (c) — degraded-but-serving narrates once per generation', () => { // PER-FAMILY LAW (10.4.1): a metadata find() consults the METADATA leg only — the // degraded report lives on the family the read actually consults. - it('a heal:"repair" failure serves; narrates once per DISTINCT VERDICT, not once per generation bump', async () => { + it('a heal:"repair" failure serves; narrates once per generation, twice across a generation bump', async () => { const brain = new Brainy(createTestConfig({ silent: true })) await brain.init() brains.push(brain) @@ -197,13 +197,12 @@ describe('health gate (c) — degraded-but-serving narrates once per generation' const internals = internalsOf(brain) let generation = 1 - let detail = 'counter drift' internals.metadataIndex.healthReport = () => healthReport({ provider: 'vector', serving: true, healthy: false, - invariants: [invariant({ name: 'stale-vector-counter', holds: false, heal: 'repair', detail })], + invariants: [invariant({ name: 'stale-vector-counter', holds: false, heal: 'repair', detail: 'counter drift' })], generation }) @@ -213,22 +212,11 @@ describe('health gate (c) — degraded-but-serving narrates once per generation' await expect(brain.find({ where: { team: 'atlas' } })).resolves.toHaveLength(1) await expect(brain.find({ where: { team: 'atlas' } })).resolves.toHaveLength(1) - expect(countNarrations()).toBe(1) // same verdict both times — one narration + expect(countNarrations()).toBe(1) // same generation 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 + expect(countNarrations()).toBe(2) // generation bumped — a second narration delete internals.metadataIndex.healthReport }) diff --git a/tests/integration/history-repacking.test.ts b/tests/integration/history-repacking.test.ts index bb07268d..2bcee038 100644 --- a/tests/integration/history-repacking.test.ts +++ b/tests/integration/history-repacking.test.ts @@ -16,7 +16,6 @@ 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' @@ -58,107 +57,6 @@ describe('history repacking — the two-tier lifecycle', () => { } }) - /** - * THE HOLE, END TO END — the shape a real store carries. - * - * A forensic fixture was measured with generation directories 1..2503 - * present except for exactly one: 1416. Its fact-log segment already showed - * the tell — `seg-...1410.bfl` declaring firstGeneration 1410, lastGeneration - * 1940 (531 generations) while recording only 530 facts. - * - * Before the fix, repacking such a store folded ACROSS that hole: the batch - * skipped 1416 (no readable delta) and the sealed segment declared a range - * spanning it anyway. The next open merged that declared range back into - * committedRanges, re-admitting 1416 as committed history, and every - * subsequent auto-compaction pass then asked the packed tier for a frame - * that was never written — producing, on EVERY run, the non-fatal narration - * - * Auto-compaction of generational history failed (non-fatal): generation - * N is inside sealed segment seg-....bgs's declared range but has no frame - * — packed history is damaged - * - * This pin removes a generation directory to make the same hole, then - * requires repack + reopen + compaction to complete cleanly. - */ - it('a missing generation directory does not poison the packed tier', async () => { - const dir = tempDir() - // `retention: 'all'` throughout: close() otherwise auto-compacts the - // history away, and this pin needs the cold generations still on disk so - // there is something to punch a hole in. The live window stays at its - // production default for the build phase, so nothing folds yet. - const archival = async (): Promise => { - const b = new Brainy({ - requireSubtype: false, - storage: { type: 'filesystem', path: dir }, - embeddingFunction: stub, - retention: 'all' - }) - await b.init() - return b - } - const brain = await archival() - - const id = await brain.add({ - data: 'holed-entity', - type: NounType.Document, - metadata: { v: 0 } - }) - // One flush per update: single-op writes coalesce inside a flush window, - // so a history deep enough to have a middle needs the windows separated. - for (let v = 1; v <= 12; v++) { - await brain.update({ id, metadata: { v } }) - await brain.flush() - } - await brain.close() - - // Punch the hole: delete ONE generation directory in the middle of the - // cold range, exactly as the real store presents it. - const genRoot = path.join(dir, '_generations') - const numeric = fs - .readdirSync(genRoot, { withFileTypes: true }) - .filter((e) => e.isDirectory() && /^\d+$/.test(e.name)) - .map((e) => Number(e.name)) - .sort((a, b) => a - b) - expect(numeric.length).toBeGreaterThan(6) - const victim = numeric[Math.floor(numeric.length / 2)] - fs.rmSync(path.join(genRoot, String(victim)), { recursive: true, force: true }) - - // Now shrink the live window and reopen. close() repacks automatically - // (brainy.ts phase 0b), so this is the production sequence exactly: a - // store with a hole in its history gets folded by ordinary housekeeping, - // with nobody asking for it. - ;(GenerationStore as any).REPACK_LIVE_WINDOW = 3 - const reopened = await archival() - const result = await reopened.repackHistory() - expect(result.foldedGenerations).toBeGreaterThan(0) - - const segDir = path.join(dir, SEGMENTS_PREFIX) - const manifestPath = ['manifest.json', 'manifest.json.gz'] - .map((f) => path.join(segDir, f)) - .find((p) => fs.existsSync(p))! - const raw = manifestPath.endsWith('.gz') - ? zlib.gunzipSync(fs.readFileSync(manifestPath)).toString('utf8') - : fs.readFileSync(manifestPath, 'utf8') - const manifest = JSON.parse(raw) as { - segments: Array<{ firstGeneration: number; lastGeneration: number; frames: number }> - } - - // THE LAW: every sealed segment declares exactly as many generations as it - // holds frames, and none of them spans the victim. - for (const s of manifest.segments) { - expect(s.lastGeneration - s.firstGeneration + 1).toBe(s.frames) - expect(victim >= s.firstGeneration && victim <= s.lastGeneration).toBe(false) - } - - await reopened.close() - - // And the pass that used to fail on every run now completes: reopen (which - // re-seeds committedRanges from the packed tier) then compact history. - const third = await openBrain(dir) - await expect(third.compactHistory({ maxGenerations: 2 })).resolves.toBeDefined() - await third.close() - }) - it('repack preserves every historical read across cold reopen; folded dirs are gone', async () => { ;(GenerationStore as any).REPACK_LIVE_WINDOW = 3 const dir = tempDir() diff --git a/tests/integration/hybrid-search-vfs.test.ts b/tests/integration/hybrid-search-vfs.test.ts index 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/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/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/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/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/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/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/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/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/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/vector-leg-open-build.test.ts b/tests/integration/vector-leg-open-build.test.ts index ea841545..a1ccaefa 100644 --- a/tests/integration/vector-leg-open-build.test.ts +++ b/tests/integration/vector-leg-open-build.test.ts @@ -176,22 +176,21 @@ describe('vector-leg open-build (two-engine gate, last red)', () => { }) 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). + // ARCHITECTURAL NOTE (found while building this pin): every brainy store + // carries ONE permanent, always-vectored noun beyond user data — the VFS + // root (`entities/nouns/.../00000000-0000-0000-0000-000000000000`, + // src/vfs/VirtualFileSystem.ts). It is inserted with an explicit all-zero + // (but non-empty, length-384) vector on EVERY store's first open — never + // deferred (a deliberate WASM-cold-compile-avoidance fix, see that + // file's comment) — and VFS init unconditionally re-creates it if + // missing, before the rebuild gate ever runs. A literal "0 vectored + // nouns" store is therefore unreachable through the public API; a + // brand-new store's `vectors.all` floor is 1, not 0. 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 + // — the coverage-gap comparison sees exactly the root (1), never + // root+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({ @@ -203,8 +202,6 @@ describe('vector-leg open-build (two-engine gate, last red)', () => { }) 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" 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/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 index d5b82c30..e9f98dac 100644 --- a/tests/integration/writer-lock-fencing.test.ts +++ b/tests/integration/writer-lock-fencing.test.ts @@ -61,7 +61,6 @@ describe('writer-lock fencing', () => { // 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) 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/biography.test.ts b/tests/lifecycle/biography.test.ts index 274f0ef0..8b274fce 100644 --- a/tests/lifecycle/biography.test.ts +++ b/tests/lifecycle/biography.test.ts @@ -382,17 +382,9 @@ describe.sequential('lifecycle — the working store', () => { }, // Every noun this biography ever adds carries an explicit/computed // vector (the harness never defers an embed), so the vectored-noun - // scalar tracks nouns.all exactly 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`. + // scalar tracks nouns.all exactly. vectors: { - all: aliveEntities.length + model.vfsFileNouns + all: aliveEntities.length + model.vfsFileNouns + model.vfsBaselineNouns }, suspect: 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/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/batch-operations.test.ts b/tests/unit/brainy/batch-operations.test.ts index 16f0f93d..889127ee 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 @@ -555,18 +545,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 +560,12 @@ describe('Brainy Batch Operations', () => { // Might throw if there's a limit expect(error).toBeDefined() } - }) + // order-of-magnitude guard: this test batches 20x the item count of the + // sibling "perform better" test above (worst measured 11.9s for 50 + // items on CPU-only honest iron); the prior 60s timeout was itself + // observed being hit, so this is 3x that floor rather than a scaled + // extrapolation, to leave real headroom for run-to-run variance + }, 180000) 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.test.ts b/tests/unit/brainy/find.test.ts index 59601456..5bead272 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 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/metadata-provider-contract.test.ts b/tests/unit/brainy/metadata-provider-contract.test.ts index 466fc654..945c0670 100644 --- a/tests/unit/brainy/metadata-provider-contract.test.ts +++ b/tests/unit/brainy/metadata-provider-contract.test.ts @@ -18,7 +18,7 @@ * 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' @@ -34,10 +34,6 @@ describe('metadata-provider contract wiring (getIdsForFilter opts)', () => { mi = (brain as any).metadataIndex }) - 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 () => { let probes = 0 let repairs = 0 diff --git a/tests/unit/brainy/migration-deference.test.ts b/tests/unit/brainy/migration-deference.test.ts index b5817c3d..31b9b216 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). * @@ -242,7 +242,7 @@ describe('rc.8 no-freeze migration deference (isMigrating / stampBrainFormat / b // --- Hook 3: marker module export ---------------------------------------- it('the brain-format marker module exports the compiled epoch + data-format constants', () => { - // cor imports these from '@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 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/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/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/db/generation-segments.test.ts b/tests/unit/db/generation-segments.test.ts index f16e67b3..27ab85cb 100644 --- a/tests/unit/db/generation-segments.test.ts +++ b/tests/unit/db/generation-segments.test.ts @@ -147,119 +147,4 @@ describe('db/GenerationSegmentStore — the D1+D3 packed tier', () => { await expect(store.fold([gen(4), gen(4)])).rejects.toThrow(/strictly ascending/) await expect(store.fold([])).rejects.toThrow(/at least one generation/) }) - - // ========================================================================== - // THE DENSITY LAW - // ========================================================================== - // - // A sealed segment declares a CONTIGUOUS range and every reader treats that - // range as containment. Folding a sparse batch therefore makes the segment - // claim generations it does not hold — and because `open()` merges declared - // ranges back into committedRanges, the hole is re-admitted as committed - // history and every later maintenance pass fails asking for a frame that was - // never written. That is the "generation N is inside sealed segment - // seg-....bgs's declared range but has no frame — packed history is damaged" - // narration seen on every run of the affected stores. - - it('fold REFUSES a batch with a hole — a dense range may not be declared over sparse input', async () => { - await expect(store.fold([gen(1), gen(2), gen(4)])).rejects.toThrow( - /not contiguous: 2 → 4 skips 1 generation/ - ) - // The refusal loses nothing: no segment was sealed, so the generations - // stay in the live tier and the next pass folds them correctly. - expect(store.segments()).toHaveLength(0) - expect(store.hasGeneration(1)).toBe(false) - }) - - it('a wider gap names how many generations it would have swallowed', async () => { - await expect(store.fold([gen(10), gen(20)])).rejects.toThrow( - /not contiguous: 10 → 20 skips 9 generation\(s\)/ - ) - }) - - it('two contiguous runs folded separately declare honest ranges', async () => { - // What the caller now does instead of folding across the gap. - const a = await store.fold([gen(1), gen(2), gen(3)]) - const b = await store.fold([gen(7), gen(8)]) - expect(a).toMatchObject({ firstGeneration: 1, lastGeneration: 3, frames: 3 }) - expect(b).toMatchObject({ firstGeneration: 7, lastGeneration: 8, frames: 2 }) - // The gap is honestly outside the packed tier. - for (const g of [4, 5, 6]) expect(store.hasGeneration(g)).toBe(false) - for (const g of [1, 2, 3, 7, 8]) expect(store.hasGeneration(g)).toBe(true) - expect(await store.actualRanges()).toEqual([ - [1, 3], - [7, 8] - ]) - }) - - it('actualRanges() is exact and I/O-free for dense segments', async () => { - await store.fold([gen(1), gen(2)]) - await store.fold([gen(3), gen(4)]) - // Adjacent dense segments each contribute their declared range. - expect(await store.actualRanges()).toEqual([ - [1, 2], - [3, 4] - ]) - }) - - // ---- pre-existing damage: a store sealed by the old writer ---------------- - - /** - * Seal a SPARSE segment the way the pre-fix writer did: write the bytes and - * sidecar for a contiguous run, then rewrite the manifest so the segment - * declares a wider range than the frames it holds. This reproduces on disk - * exactly what the affected stores carry, without needing the old code. - */ - const sealSparseSegment = async (): Promise => { - await store.fold([gen(1), gen(2), gen(3)]) - const manifest = (await storage.readRawObject(`${SEGMENTS_PREFIX}/manifest.json`)) as any - // Declare 1..5 while holding frames for 1..3 — generations 4 and 5 become - // holes inside a sealed range. - manifest.segments[0].lastGeneration = 5 - await storage.writeRawObject(`${SEGMENTS_PREFIX}/manifest.json`, manifest) - } - - it('a pre-existing sparse segment reports its holes as UNPACKED, not as damage', async () => { - await sealSparseSegment() - const reopened = new GenerationSegmentStore(storage as any) - await reopened.open() - - // The frames it really holds still serve, byte-faithfully. - expect((await reopened.readDelta(2))?.timestamp).toBe(1_700_000_000_002) - expect(await reopened.readRecords(3)).toHaveLength(2) - - // The holes answer "not packed" instead of throwing. This is the fix for - // the wedge: the old reader threw here on EVERY maintenance pass. - expect(await reopened.readDelta(4)).toBeNull() - expect(await reopened.readRecords(5)).toBeNull() - }) - - it('actualRanges() excludes the holes so they are never re-admitted as committed', async () => { - await sealSparseSegment() - const reopened = new GenerationSegmentStore(storage as any) - await reopened.open() - // Declared 1..5; actually holds 1..3. The store seeds committedRanges from - // THIS, so generations 4 and 5 never become committed history again. - expect(await reopened.actualRanges()).toEqual([[1, 3]]) - }) - - it('a DENSE segment missing a frame is still loud damage', async () => { - // The other side of the branch: when the manifest claims a complete span, - // a missing frame means the manifest and sidecar disagree — real damage, - // and it must not be quietly downgraded to "unpacked". - await store.fold([gen(1), gen(2), gen(3)]) - const idxPath = `${SEGMENTS_PREFIX}/seg-${String(1).padStart(20, '0')}.idx` - const raw = (await storage.readRawBytes(idxPath))! - const { decode, encode } = await import('@msgpack/msgpack') - const idx = decode(raw) as any - // Drop generation 2's entry while the manifest still declares 3 frames. - idx.generations = idx.generations.filter(([g]: [number]) => g !== 2) - await storage.writeRawBytes(idxPath, encode(idx)) - - const reopened = new GenerationSegmentStore(storage as any) - await reopened.open() - await expect(reopened.readDelta(2)).rejects.toThrow( - /manifest and the sidecar disagree; packed history is damaged/ - ) - }) }) 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/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-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/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/metadata-cold-read-guard.test.ts b/tests/unit/metadata-cold-read-guard.test.ts index d079982e..b4f82f15 100644 --- a/tests/unit/metadata-cold-read-guard.test.ts +++ b/tests/unit/metadata-cold-read-guard.test.ts @@ -15,7 +15,7 @@ * 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 +31,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 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/signals/EmbeddingSignal.test.ts b/tests/unit/neural/signals/EmbeddingSignal.test.ts index ad08e045..54d34b64 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', () => { 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..ffcc2a88 100644 --- a/tests/unit/plugin-version-coupling.test.ts +++ b/tests/unit/plugin-version-coupling.test.ts @@ -143,6 +143,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/release/wall-entry.test.ts b/tests/unit/release/wall-entry.test.ts deleted file mode 100644 index b29ae326..00000000 --- a/tests/unit/release/wall-entry.test.ts +++ /dev/null @@ -1,403 +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-')) - // wall-entry.mjs is run with this dir as its cwd, standing in for the real - // developer checkout it reads its commit identity from (process.cwd()) — - // give it a repo-local identity the same way seedRemote gives one to the - // seed clone, so the suite is deterministic on a host with no global git - // config (a bare CI box) as much as one with a developer's own. - execFileSync('git', ['init', '-q', dir]) - git(['config', 'user.name', 'Wall Entry Test'], dir) - git(['config', 'user.email', 'wall-entry-test@example.com'], dir) - 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/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/test-suite-coverage-guard.test.ts b/tests/unit/test-suite-coverage-guard.test.ts index f12b0587..d4d268ac 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,12 +24,10 @@ 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 @@ -43,11 +40,15 @@ const MANUAL_ONLY = new Set([ // 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/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/performance/graph-scale-performance.test.ts', + 'tests/performance/triple-intelligence-scale.test.ts', + 'tests/performance/typeAware.bench.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 @@ -58,21 +59,6 @@ const MANUAL_ONLY = new Set([ '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/package-size-limit.test.ts', - 'tests/model-loading.test.ts' -]) - function inGate(rel: string): boolean { return ( rel.startsWith('tests/unit/') || @@ -81,11 +67,7 @@ function inGate(rel: string): boolean { // ('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/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/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-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/validate-invariants-delegation.test.ts b/tests/unit/validate-invariants-delegation.test.ts index 69133733..a5def81f 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() diff --git a/tests/unit/vector-cold-read-guard.test.ts b/tests/unit/vector-cold-read-guard.test.ts index 963009b7..0905f298 100644 --- a/tests/unit/vector-cold-read-guard.test.ts +++ b/tests/unit/vector-cold-read-guard.test.ts @@ -12,7 +12,7 @@ * signal (from either strategy) THROWS VectorIndexNotReadyError immediately, * with no rebuild attempt in between — 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 +28,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 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/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..4b4ba8d2 100644 --- a/tests/vfs/vfs.unit.test.ts +++ b/tests/vfs/vfs.unit.test.ts @@ -389,14 +389,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