feat: provider access to the fact log + shared stamp verifier via internals

Three additive surfaces for native index providers, which hold only
`storage` and must never construct their own fact-log reader (the log's
open path is writer-side — it reconciles by truncating/rewriting):

- Fact-scan capability on the storage adapter (the getBinaryBlobPath
  pattern): the host brain wires a closure over its LIVE fact log at
  init; providers call storage.scanFacts()/factLogHeadGeneration()/
  factSegmentPaths() and fall back to the enumeration walk on null.
  Restore/reopen swaps the underlying log transparently.
- The family-stamp trio (readFamilyStamp/writeFamilyStamp/
  verifyFamilyStamp + types) exported from @soulcraft/brainy/internals,
  so 'one verifier' is literally one function shared with the native
  side, never two synchronized copies.
- Rollup invariants widened to number|string: content fingerprints
  (e.g. a per-tree SHA-256) are valid invariant values; strict equality
  either way, and a type mismatch reads as incoherence, never a pass.
This commit is contained in:
David Snelling 2026-07-15 12:36:27 -07:00
parent 70886da548
commit 352e356fd5
7 changed files with 153 additions and 3 deletions

View file

@ -999,6 +999,17 @@ export class Brainy<T = any> implements BrainyInterface<T> {
})
}
// Fact-scan capability: wire the storage seam through which index
// providers (which hold only `storage`) reach the fact log. A closure
// over the LIVE log — restore/reopen swaps the instance transparently —
// so a provider's heal can switch from the enumeration walk to one
// sequential fact scan whenever the log exists.
if (typeof (this.storage as BaseStorage).setFactScanSource === 'function') {
;(this.storage as BaseStorage).setFactScanSource(
() => this.generationStore?.getFactLog() ?? null
)
}
// Entity-tree stamp coherence: compare the stamped sourceGeneration +
// rollup invariants against the log head + live counters. Loud on
// genuine incoherence (repairIndex heals), silent on absent/coherent,

View file

@ -1159,6 +1159,36 @@ export interface StorageAdapter {
*/
rawByteSize?(path: string): Promise<number | null>
/**
* @description OPTIONAL fact-scan capability how an index provider that
* holds only `storage` reaches the generation fact log (the host brain
* wires it at init; providers must never construct their own fact-log
* reader the log's open path is writer-side). Present this store hosts
* a fact log AND the host wired the capability. Returns a scan handle over
* committed facts (heal-grade telemetry included), or `null` when no fact
* log exists callers fall back to the canonical enumeration walk.
*/
scanFacts?(options?: {
fromGeneration?: number
toGeneration?: number
kinds?: Array<'noun' | 'verb'>
batchSize?: number
}): import('./db/factLog.js').FactScanHandle | null
/**
* @description OPTIONAL (rides the fact-scan capability): the fact log's
* head generation the replay target for `stamp.sourceGeneration + 1`
* catch-ups. `null` when no fact log exists.
*/
factLogHeadGeneration?(): number | null
/**
* @description OPTIONAL (rides the fact-scan capability): the immutable,
* sealed fact-segment file paths covering `fromGeneration` the zero-copy
* handoff. The mutable tail is excluded (read it via `scanFacts`).
*/
factSegmentPaths?(options?: { fromGeneration?: number }): string[]
/**
* Save statistics data
* @param statistics The statistics data to save

View file

@ -41,10 +41,15 @@ export interface EnumeratedMember {
bytes: number
}
/** The stamp's verified surface, in one of the two member modes. */
/**
* The stamp's verified surface, in one of the two member modes. Rollup
* invariant values may be numbers (counts, byte sizes) or strings (content
* fingerprints, e.g. a per-tree SHA-256) the verifier compares by strict
* equality either way, so a type mismatch reads as incoherence, never a pass.
*/
export type StampMembers =
| { mode: 'enumerated'; files: EnumeratedMember[] }
| { mode: 'rollup'; invariants: Record<string, number> }
| { mode: 'rollup'; invariants: Record<string, number | string> }
/** The generalized family stamp (one shape, one verifier, both engines). */
export interface FamilyStamp {
@ -109,7 +114,7 @@ export async function writeFamilyStamp(
export function verifyFamilyStamp(
stamp: FamilyStamp | null,
head: number,
actual: Record<string, number>
actual: Record<string, number | string>
): StampVerdict {
if (stamp === null) return { state: 'absent' }
if (stamp.sourceGeneration > head) {

View file

@ -19,3 +19,29 @@ export type { MemoryInfo, CacheAllocationStrategy } from './utils/memoryDetectio
// HNSWNounWithMetadata. First-party plugins (Cor) use this to stay in
// lockstep with the entity shape contract.
export { resolveEntityField, STANDARD_ENTITY_FIELDS } from './coreTypes.js'
// The generalized family stamp — ONE verifier, both member modes, shared
// verbatim with native providers so stamp verification is literally one
// function, never two synchronized copies. Providers write the same shape
// (their set-swap rewrites stamps, so adoption is migration-free).
export {
readFamilyStamp,
writeFamilyStamp,
verifyFamilyStamp,
FAMILY_STAMPS_PREFIX,
ENTITY_TREE_STAMP_PATH
} from './db/familyStamp.js'
export type {
FamilyStamp,
StampMembers,
StampVerdict,
EnumeratedMember,
StampStorage
} from './db/familyStamp.js'
// Generation fact-log types — the scan surface a provider reaches through the
// storage capability (`storage.scanFacts` / `storage.factLogHeadGeneration` /
// `storage.factSegmentPaths`, wired by the host brain at init). Providers
// never construct a FactLog themselves: open() is writer-side (it reconciles
// by truncating/rewriting) and there is exactly one writer.
export type { CommitFact, FactOp, FactScanBatch, FactScanHandle } from './db/factLog.js'

View file

@ -842,6 +842,46 @@ export abstract class BaseStorage extends BaseStorageAdapter {
return this.deleteObjectFromPath(path)
}
// ==========================================================================
// Fact-scan capability — the seam through which an index provider holding
// only `storage` reaches the generation fact log. The HOST brain wires the
// source at init (a closure over its live fact log, so restore/reopen stays
// transparent); providers call storage.scanFacts?.(...) and fall back to the
// enumeration walk when it returns null. Providers never construct a
// fact-log reader themselves — the log's open path is writer-side.
// ==========================================================================
/** The host-wired fact-scan source (a closure over the live fact log). */
private _factScanSource: (() => import('../db/factLog.js').FactLog | null) | null = null
/** HOST-ONLY: wire (or clear) the fact-scan capability's source. */
public setFactScanSource(source: (() => import('../db/factLog.js').FactLog | null) | null): void {
this._factScanSource = source
}
/** Open a scan over committed facts, or `null` when no fact log exists. */
public scanFacts(options?: {
fromGeneration?: number
toGeneration?: number
kinds?: Array<'noun' | 'verb'>
batchSize?: number
}): import('../db/factLog.js').FactScanHandle | null {
const log = this._factScanSource?.()
return log ? log.scanFacts(options) : null
}
/** The fact log's head generation, or `null` when no fact log exists. */
public factLogHeadGeneration(): number | null {
const log = this._factScanSource?.()
return log ? log.headGeneration() : null
}
/** Sealed fact-segment paths (zero-copy handoff); empty when none. */
public factSegmentPaths(options?: { fromGeneration?: number }): string[] {
const log = this._factScanSource?.()
return log ? log.segmentPaths(options) : []
}
/**
* @description Remove the container that held a canonical entity's leg files,
* called after both legs are deleted so a delete leaves NOTHING behind no