fix: getNouns().totalCount reports true total, not page size; quiet benign mmap-vector log

getNounsWithPagination returned collectedNouns.length as totalCount, but the
type-first shard scan early-terminates at offset+limit — so
getNouns({ pagination: { limit: 1 } }).totalCount was 1 for any non-empty brain.
The index-rebuild gate calls exactly that, so cold starts logged
"Small dataset (1 items) - rebuilding all indexes" and rebuilt from scratch
regardless of corpus size (a production deployment saw this for an ~8,800-entity
brain). Now reports the authoritative O(1) noun counter (maintained on add/delete,
rehydrated from counts.json on init) as the unfiltered total and derives hasMore
from it. Filtered scans unchanged. Layout-independent (branch/COW included).

Also downgrade the "mmap-vector backend not wired" console.log to prodLog.debug:
it is benign in the native-vector-index model (the native provider owns its own
vector storage and has no setVectorBackend hook), but it fired on every init and
was repeatedly mistaken for the cold-start cause.

Regression: tests/unit/storage/getNouns-totalCount.test.ts. Full unit suite green (1505).
This commit is contained in:
David Snelling 2026-06-17 14:01:33 -07:00
parent adec0ba3c3
commit edff637bfa
4 changed files with 156 additions and 8 deletions

View file

@ -10,6 +10,44 @@ Full auto-generated changelog: `CHANGELOG.md` · Releases: https://github.com/so
--- ---
## v7.32.1 — 2026-06-17
**Affected products:** consumers on `filesystem` / `mmap-filesystem` storage whose logs showed
`🔄 Small dataset (1 items) - rebuilding all indexes…` on cold start, or noise from a
`mmap-vector backend not wired` line on every init. Two fixes. Drop-in; no API or data changes.
### Fix — `getNouns().totalCount` reports the true total, not the page size
`storage.getNouns({ pagination: { limit } })` returned `totalCount` equal to the **page size**, not
the dataset total: the type-first shard scan early-terminates at `offset + limit` for memory
efficiency, and the page-collected length was returned as the total. So
`getNouns({ pagination: { limit: 1 } })` reported `totalCount: 1` for **any** non-empty brain.
The index-rebuild gate uses exactly this call to size the corpus, so a cold start that needs a
rebuild logged `Small dataset (1 items) - rebuilding all indexes…` regardless of the real entity
count (a production deployment saw this for an ~8,800-entity brain — the rebuild then ran from
scratch instead of loading the persisted vector snapshot).
`getNounsWithPagination` now reports the authoritative O(1) noun counter (maintained on every
add/delete and rehydrated from `counts.json` on init) as the unfiltered `totalCount`, and computes
`hasMore` from it. Filtered scans are unchanged (collected length, a lower bound). Layout-independent
(applies equally to branch/COW layouts). Regression: `tests/unit/storage/getNouns-totalCount.test.ts`.
### Log — benign "mmap-vector backend not wired" downgraded to debug
When a native vector provider replaces the JS HNSW index (it owns its own vector storage and exposes
no `setVectorBackend` hook), brainy logged `mmap-vector backend not wired … per-entity reads in use`
on **every** init. This is expected and benign in the native-index model — not a fault, and not by
itself an indication of per-entity reads — but it appeared on every warm and was repeatedly mistaken
for a cold-start cause. It is now a debug-level line (surface it with `BRAINY_LOG_LEVEL=debug`).
> Note: this release fixes the misleading *count/log*. The remaining cold-start symptom on the
> native line (rebuilding instead of loading the persisted vector snapshot) is resolved when the
> native provider loads its snapshot at construction so the index reports a non-zero size before
> brainy's rebuild gate — already the model in the next major (8.0 + native 3.0).
---
## v7.32.0 — 2026-06-16 ## v7.32.0 — 2026-06-16
**Affected products:** anyone who needs a **portable graph backup**, a partial export, or a **Affected products:** anyone who needs a **portable graph backup**, a partial export, or a

View file

@ -9473,12 +9473,17 @@ export class Brainy<T = any> implements BrainyInterface<T> {
setVectorBackend?: (backend: MmapVectorBackend) => void setVectorBackend?: (backend: MmapVectorBackend) => void
} }
if (typeof indexWithBackend.setVectorBackend !== 'function') { if (typeof indexWithBackend.setVectorBackend !== 'function') {
if (!this.config.silent) { // Expected in the native-vector-index model: a native provider (e.g.
console.log( // @soulcraft/cortex) replaces the JS HNSW index and owns its own vector
'[brainy] mmap-vector backend not wired (vector index manages its own ' + // storage + persisted snapshot, so there is no setVectorBackend hook to
'vector storage; no setVectorBackend hook) — per-entity reads in use' // wire here. This is benign, not a fault, and does NOT by itself imply
// per-entity reads — keep it at debug level so it never reads as a problem
// in normal operation. (The old console.log fired on every init and was
// repeatedly mistaken for the cold-start cause; see BRAINY-MMAP-VECTOR-HOOK.)
prodLog.debug(
'[brainy] mmap-vector backend not wired (native vector index manages ' +
'its own vector storage; no setVectorBackend hook)'
) )
}
return return
} }

View file

@ -1515,11 +1515,28 @@ export abstract class BaseStorage extends BaseStorageAdapter {
// Apply pagination // Apply pagination
const paginatedNouns = collectedNouns.slice(offset, offset + limit) const paginatedNouns = collectedNouns.slice(offset, offset + limit)
const hasMore = collectedNouns.length > targetCount
// totalCount must be the TRUE dataset total, not the size of this page.
// The shard scan above early-terminates at `targetCount = offset + limit`
// for memory efficiency, so `collectedNouns.length` only ever reaches the
// page size — returning it as `totalCount` made every non-empty brain look
// like it held exactly `limit` items. In particular
// `getNouns({ pagination: { limit: 1 } })` reported `totalCount: 1`, which
// tripped the index-rebuild gate into logging "Small dataset (1 items)" and
// rebuilding from scratch regardless of the real corpus size. For the
// unfiltered case the authoritative total is the O(1) counter maintained on
// every add/delete and rehydrated from `counts.json` on init; `Math.max`
// guards against a stale counter ever under-reporting below what we
// actually collected. A filtered scan has no cheap exact total, so it keeps
// the collected length (a lower bound — unchanged behaviour).
const totalCount = filter
? collectedNouns.length
: Math.max(this.totalNounCount, collectedNouns.length)
const hasMore = offset + paginatedNouns.length < totalCount
return { return {
items: paginatedNouns, items: paginatedNouns,
totalCount: collectedNouns.length, totalCount,
hasMore, hasMore,
nextCursor: hasMore && paginatedNouns.length > 0 nextCursor: hasMore && paginatedNouns.length > 0
? paginatedNouns[paginatedNouns.length - 1].id ? paginatedNouns[paginatedNouns.length - 1].id

View file

@ -0,0 +1,88 @@
/**
* @module tests/unit/storage/getNouns-totalCount
* @description Regression for BRAINY-MMAP-VECTOR-HOOK (Section H). The
* index-rebuild gate (`rebuildIndexesIfNeeded`) reads
* `getNouns({ pagination: { limit: 1 } }).totalCount` to decide whether a brain
* is "small" enough to rebuild inline. The shard-scan pagination
* (`BaseStorage.getNounsWithPagination`) early-terminates at `offset + limit`
* for memory efficiency, then returned `collectedNouns.length` as `totalCount`
* i.e. the PAGE size, never the dataset total. So every non-empty filesystem
* brain reported `totalCount: 1` and logged "Small dataset (1 items)" on cold
* start, regardless of the real corpus size (a consumer saw this for an
* ~8.8k-entity brain). `totalCount` must be the authoritative O(1) noun counter
* (maintained on every add/delete, rehydrated from `counts.json` on init).
*/
import { describe, it, expect } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { FileSystemStorage } from '../../../src/storage/adapters/fileSystemStorage.js'
import type { NounMetadata } from '../../../src/coreTypes.js'
const DIM = 8
/** A deterministic non-zero vector so the noun record is well-formed. */
function vec(seed: number): number[] {
return Array.from({ length: DIM }, (_, i) => ((seed + i) % 7) / 7 - 0.5)
}
/** Storage shards by UUID, so ids must be 32 hex chars. */
function uuid(i: number): string {
return i.toString(16).padStart(32, '0')
}
/**
* Seed `n` unique nouns. Metadata is saved BEFORE the noun record: it is the
* recommended order (avoids stat drift) and it is also where the O(1) noun
* counter is incremented, so this mirrors the real add() path.
*/
async function seed(storage: FileSystemStorage, n: number): Promise<void> {
for (let i = 0; i < n; i++) {
const id = uuid(i)
await storage.saveNounMetadata(id, {
noun: 'thing',
createdAt: Date.now(),
updatedAt: Date.now()
} as NounMetadata)
await storage.saveNoun({ id, vector: vec(i), connections: new Map(), level: 0 })
}
}
describe('FileSystemStorage.getNouns totalCount (rebuild-gate count)', () => {
it('reports the TRUE total, not the page size, for a 1-item page', async () => {
const dir = mkdtempSync(join(tmpdir(), 'brainy-totalcount-'))
try {
const storage = new FileSystemStorage(dir)
await storage.init()
await seed(storage, 25)
// The exact call the index-rebuild gate makes.
const onePage = await storage.getNouns({ pagination: { limit: 1 } })
expect(onePage.items.length).toBe(1)
// Before the fix this was 1 (collectedNouns.length === limit).
expect(onePage.totalCount).toBe(25)
expect(onePage.hasMore).toBe(true)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('still reports the true total after a cold reopen (counts rehydrate)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'brainy-totalcount-reopen-'))
try {
const first = new FileSystemStorage(dir)
await first.init()
await seed(first, 25)
// Flush the count to counts.json so a fresh instance rehydrates it.
await (first as unknown as { persistCounts(): Promise<void> }).persistCounts()
// Cold start: a brand-new instance over the same directory.
const reopened = new FileSystemStorage(dir)
await reopened.init()
const page = await reopened.getNouns({ pagination: { limit: 1 } })
expect(page.totalCount).toBe(25)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})