@avantf/dsh-mem 0.4.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +19 -1
  2. package/lib/client.js +39 -9
  3. package/lib/engine/mem/config/config_files.d.ts +1 -1
  4. package/lib/engine/mem/db/dao/chunks.d.ts +80 -5
  5. package/lib/engine/mem/db/dao/entities.d.ts +35 -11
  6. package/lib/engine/mem/db/dao/fact_sources.d.ts +53 -0
  7. package/lib/engine/mem/db/dao/facts.d.ts +115 -12
  8. package/lib/engine/mem/db/dao/shared.d.ts +153 -0
  9. package/lib/engine/mem/db/invariants.d.ts +26 -0
  10. package/lib/engine/mem/db/tokenizer.d.ts +23 -0
  11. package/lib/engine/mem/db/vectors.d.ts +32 -6
  12. package/lib/engine/mem/entities/extract.d.ts +1 -1
  13. package/lib/engine/mem/eval/derive.d.ts +120 -0
  14. package/lib/engine/mem/eval/derived_runner.d.ts +88 -0
  15. package/lib/engine/mem/eval/index.d.ts +4 -0
  16. package/lib/engine/mem/eval/metrics.d.ts +24 -0
  17. package/lib/engine/mem/eval/runner.d.ts +8 -1
  18. package/lib/engine/mem/index.d.ts +2 -1
  19. package/lib/engine/mem/modelBootstrap.d.ts +2 -2
  20. package/lib/engine/mem/runtime.d.ts +3 -3
  21. package/lib/engine/mem/store/common.d.ts +18 -6
  22. package/lib/engine/mem/store/entity_leg.d.ts +95 -0
  23. package/lib/engine/mem/store/entity_sweep.d.ts +121 -0
  24. package/lib/engine/mem/store/floors.d.ts +51 -31
  25. package/lib/engine/mem/store/hybrid.d.ts +71 -26
  26. package/lib/engine/mem/store/knowledge.d.ts +134 -21
  27. package/lib/engine/mem/store/legs.d.ts +171 -0
  28. package/lib/engine/mem/store/lexical.d.ts +52 -0
  29. package/lib/engine/mem/store/memory.d.ts +177 -92
  30. package/lib/engine/mem/store/secret_guard.d.ts +34 -0
  31. package/lib/engine/mem/store/time_window.d.ts +74 -0
  32. package/lib/engine/mem/store/vector_repair.d.ts +248 -0
  33. package/lib/engine/mem-contract/config.d.ts +8 -16
  34. package/lib/engine/mem-contract/family.d.ts +1 -1
  35. package/lib/engine/mem-contract/tools.d.ts +14 -0
  36. package/lib/engine/mem-contract/types.d.ts +151 -15
  37. package/lib/engine/mem-retrieval/adapters/local_bge.d.ts +37 -7
  38. package/lib/engine/mem-retrieval/fusion.d.ts +14 -1
  39. package/lib/engine/mem-retrieval/index.d.ts +1 -3
  40. package/lib/engine/mem-retrieval/interfaces.d.ts +38 -14
  41. package/lib/engine/mem-retrieval/registry.d.ts +2 -4
  42. package/lib/engine/mem-retrieval/representation.d.ts +50 -0
  43. package/lib/engine/mem-retrieval/stats.d.ts +1 -3
  44. package/lib/index.js +3008 -1008
  45. package/lib/types/client/index.d.ts.map +1 -1
  46. package/lib/types/index.d.ts +1 -1
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/prompt.d.ts +7 -0
  49. package/lib/types/prompt.d.ts.map +1 -1
  50. package/lib/types/remote.d.ts +12 -6
  51. package/lib/types/remote.d.ts.map +1 -1
  52. package/lib/types/vectorMigration.d.ts +22 -18
  53. package/lib/types/vectorMigration.d.ts.map +1 -1
  54. package/package.json +5 -5
@@ -3,18 +3,16 @@
3
3
  *
4
4
  * `MemoryStore.search` and `KnowledgeStore.search` used to carry parallel copies of this flow
5
5
  * (~80 lines each: limit normalization → weights → over-fetch → leg cap → fuse → live filter →
6
- * rerank → slice → output budget → health event). The legs themselves are legitimately
7
- * store-specific — different tables, different filters (`category` vs `domain`/`source`), and only
8
- * memory has the HRR probe — but the ORCHESTRATION is one algorithm, and two copies of it drifted:
6
+ * slice → output budget → health event). The legs themselves are legitimately store-specific —
7
+ * different tables, different filters (`category` vs `domain`/`source`), and only memory has the
8
+ * HRR probe — but the ORCHESTRATION is one algorithm, and two copies of it drifted:
9
9
  *
10
10
  * - the `NaN` limit guard existed only in memory, so a non-finite limit reached knowledge's SQL
11
11
  * `LIMIT` and took the whole cross-store query down with it (`Promise.all` has no per-leg catch);
12
12
  * - `retriever.over_fetch_factor` was read only by memory, while knowledge hardcoded `limit * 3`,
13
13
  * so one config knob silently meant two different things;
14
14
  * - `recordLegCapped()` was called only by memory, so a capped knowledge leg was unobservable —
15
- * exactly the blindness DESIGN §20.17 says the counter exists to remove;
16
- * - the reranker's used/fallback flags were a helper in memory and an inline expression in
17
- * knowledge.
15
+ * exactly the blindness DESIGN §20.17 says the counter exists to remove.
18
16
  *
19
17
  * Fixing those one at a time would leave the next drift a matter of when. The stores now supply
20
18
  * their legs and their hit mapping; everything that can drift lives here once.
@@ -26,7 +24,7 @@
26
24
  * @module store/hybrid
27
25
  */
28
26
  import { type Config, type FloorProfile, type RecallResult, type RetrievalFloorDrops, type RetrievalFloors } from '../../mem-contract/index.js';
29
- import { type BudgetInput, type Hit, type Reranker, type SemanticBackend } from '../../mem-retrieval/index.js';
27
+ import { type BudgetInput, type Hit, type SemanticBackend } from '../../mem-retrieval/index.js';
30
28
  import { type FloorLeg } from './floors.js';
31
29
  /** The `limit` a caller gets when it passes nothing usable. */
32
30
  export declare const DEFAULT_SEARCH_LIMIT = 10;
@@ -63,8 +61,12 @@ export interface HybridLeg {
63
61
  capped?: boolean;
64
62
  /**
65
63
  * Which floor governs this leg, for {@link HybridResult.dropped_by_floor}'s per-leg breakdown.
66
- * The HRR probe shares the Jaccard floor; naming it separately keeps a narrowed candidate set
67
- * attributable without double-counting the Jaccard leg's own drops.
64
+ * The HRR probe shares the entity (`jaccard`) floor; naming it separately keeps a narrowed
65
+ * candidate set attributable without double-counting the entity leg's own drops.
66
+ *
67
+ * The `jaccard` KEY is historical: the entity leg's metric is now anchored Jaccard with a
68
+ * saturating union, whose unit (and the `min_jaccard` floor on it) is defined in
69
+ * `store/entity_leg.ts` — read that before re-calibrating the knob.
68
70
  */
69
71
  leg?: keyof RetrievalFloorDrops;
70
72
  /** Candidates this leg removed because they fell below its floor. */
@@ -113,7 +115,6 @@ export interface HybridDeps<H> {
113
115
  kind: 'memory' | 'knowledge';
114
116
  config: Config;
115
117
  semantic: SemanticBackend;
116
- reranker: Reranker;
117
118
  /**
118
119
  * The legs to fuse, as promises (or values). Awaited concurrently; a rejection costs that leg
119
120
  * only. The async setup a store needs before it can build its legs — memory extracts the query's
@@ -121,7 +122,7 @@ export interface HybridDeps<H> {
121
122
  * body, before the array is returned.
122
123
  */
123
124
  legs(ctx: HybridContext): Promise<readonly (HybridLeg | Promise<HybridLeg>)[]>;
124
- /** Text for the fused ids: the live filter, the reranker's input, and the hit bodies. */
125
+ /** Text for the fused ids: the live filter and the hit bodies. */
125
126
  texts(ids: number[]): Map<number, string>;
126
127
  /** Build the caller-facing hits from the ranked, live, already-sliced ids. */
127
128
  hits(ranked: readonly Hit[], texts: Map<number, string>): H[];
@@ -177,6 +178,12 @@ export interface HybridPlan {
177
178
  * `queries` counts questions instead of legs (DESIGN §20.5).
178
179
  */
179
180
  recordStats?: boolean;
181
+ /**
182
+ * P-01: attach per-leg evidence to the returned hits (`RecallHit.scores` / `final`). Default
183
+ * false, and that default is load-bearing: the evidence is explanation only, so a caller that did
184
+ * not ask must not pay for it in tokens (`fuse` skips building the arrays entirely).
185
+ */
186
+ includeScores?: boolean;
180
187
  }
181
188
  /** A hit after the output budget: `text` may have been shortened, and `truncated` says so. */
182
189
  export type Budgeted<T> = T & {
@@ -207,17 +214,6 @@ export interface HybridResult<H> {
207
214
  * rather than per store, because the health counter compares against whatever this returns.
208
215
  */
209
216
  export declare function legCapFor(config: Config, overFetch: number): number;
210
- /**
211
- * Whether the configured reranker is actually in use — the two flags a retrieval event carries.
212
- *
213
- * "Configured" is not "used": a reranker that is selected but unavailable falls back to the fused
214
- * order, and counting that as a rerank would make the counter lie in exactly the case it exists to
215
- * expose (the reference implementation leaves `rerank_fallback` unfeedable).
216
- */
217
- export declare function rerankState(reranker: Reranker): {
218
- used: boolean;
219
- fallback: boolean;
220
- };
221
217
  /**
222
218
  * Bound a ranked hit list to a token budget, marking what was shortened.
223
219
  *
@@ -233,9 +229,58 @@ export declare function applyOutputBudget<H extends BudgetInput>(config: Config,
233
229
  /**
234
230
  * Run one hybrid search.
235
231
  *
236
- * The order of the last three steps is load-bearing: rerank the over-fetched pool, THEN slice to
237
- * `limit`, THEN apply the output budget, and only then tell the store what was returned. Slicing
238
- * before the rerank would rank an arbitrary subset; budgeting before the slice would spend the
239
- * budget on hits that are about to be dropped.
232
+ * The order of the last steps is load-bearing: slice the fused (over-fetched) pool to `limit`,
233
+ * THEN apply the output budget, and only then tell the store what was returned. Budgeting before
234
+ * the slice would spend the budget on hits that are about to be dropped.
240
235
  */
241
236
  export declare function hybridSearch<H extends BudgetInput>(deps: HybridDeps<H>, plan: HybridPlan): Promise<HybridResult<H>>;
237
+ /**
238
+ * The search-entry fields `MemoryStore.search` and `KnowledgeStore.search` hand to the orchestrator.
239
+ * Both stores' option types (`SearchInput` / `KnowledgeSearchOptions`) satisfy this structurally;
240
+ * everything else on them (`category` / `source` / `domain` / `includeHrr` / `track` / `onResult`) is
241
+ * leg- or domain-specific and stays at the store.
242
+ */
243
+ export interface HybridSearchEntryFields {
244
+ limit?: number;
245
+ /**
246
+ * Knowledge only: overrides the config-derived pool size. Memory's `SearchInput` has no such field
247
+ * and leaves the plan key absent (see {@link runHybridSearch}).
248
+ */
249
+ overFetch?: number;
250
+ maxTokens?: number;
251
+ queryVector?: Float32Array;
252
+ recordStats?: boolean;
253
+ floors?: FloorProfile;
254
+ includeScores?: boolean;
255
+ relaxLegs?: readonly FloorLeg[];
256
+ rewriteQuery?: (query: string) => string | undefined;
257
+ }
258
+ /**
259
+ * Map a store's search arguments onto {@link HybridPlan} and run {@link hybridSearch}. ONE copy.
260
+ *
261
+ * Before this, `MemoryStore.search` and `KnowledgeStore.search` carried the mapping verbatim side by
262
+ * side: the same field list, the same conditional spreads, the same call. That list is part of the
263
+ * orchestrator's contract, so an option added to one entry point and not the other would silently
264
+ * make the two stores query differently — the same shape of drift this module already documents
265
+ * (`NaN` limit, `over_fetch_factor`, unrecorded capped legs).
266
+ *
267
+ * The conditional spreads are deliberate, not cosmetic: a caller that did not pass `overFetch` /
268
+ * `includeScores` / `relaxLegs` / `rewriteQuery` leaves the plan key ABSENT rather than
269
+ * present-with-`undefined`. The orchestrator reads all four through `??` / `=== true` today, so the
270
+ * two are indistinguishable now — but the plan is a contract, and widening it with keys the caller
271
+ * never set is how "absent means default" decays into "present-undefined means set to nothing".
272
+ */
273
+ export declare function runHybridSearch<H extends BudgetInput>(deps: HybridDeps<H>, query: string, fields: HybridSearchEntryFields | undefined): Promise<HybridResult<H>>;
274
+ /**
275
+ * The mapping itself, separated from the call so it can be pinned directly: the contract here is
276
+ * the SHAPE of the plan (which keys exist), which no behavioural assertion inside `hybridSearch`
277
+ * could observe once every optional field is read through `??` / `=== true`.
278
+ */
279
+ export declare function hybridPlanOf(query: string, fields: HybridSearchEntryFields | undefined): HybridPlan;
280
+ /**
281
+ * Build a store's {@link HybridDeps} — the ONE construction site for the contract fields both stores
282
+ * share (`kind` / `config` / `semantic` / `legs` / `texts` / `hits` / `onReturn`). The per-store half
283
+ * is `parts`; the stores used to spell the whole object out twice, so a new required `HybridDeps`
284
+ * field was a change to remember in two places.
285
+ */
286
+ export declare function makeHybridDeps<H>(kind: 'memory' | 'knowledge', config: Config, semantic: SemanticBackend, parts: Pick<HybridDeps<H>, 'legs' | 'texts' | 'hits' | 'onReturn'>): HybridDeps<H>;
@@ -1,8 +1,9 @@
1
- import { type AvantfLogger, type Config, type DocumentDetail, type DocumentSummary, type DomainCatalog, type FloorProfile, type ImportResult, type IngestResult, type KbConflictReport, type KbIngestConflict, type KbSyncReport, type KnowledgeConfig, type RecallHit, type ReindexReport } from '../../mem-contract/index.js';
2
- import type { Reranker, SemanticBackend, VectorStore } from '../../mem-retrieval/index.js';
1
+ import { type AvantfLogger, type Config, type DocumentDetail, type DocumentSummary, type DomainCatalog, type FloorProfile, type ImportResult, type IngestResult, type KbConflictReport, type KbIngestConflict, type KbSyncReport, type KnowledgeConfig, type RecallHit, type ReindexReport, type VectorMigrationOutcome, type VectorMigrationProgress, type VectorSpaceHealth, type VectorsFixStoreReport } from '../../mem-contract/index.js';
2
+ import type { SemanticBackend, VectorStore } from '../../mem-retrieval/index.js';
3
3
  import { type LexicalProbe } from './lexical.js';
4
4
  import { type FloorLeg } from './floors.js';
5
5
  import { type HybridResult } from './hybrid.js';
6
+ import { type VectorRepairTarget } from './vector_repair.js';
6
7
  /** Options for {@link KnowledgeStore.search}. */
7
8
  export interface KnowledgeSearchOptions {
8
9
  domain?: string;
@@ -43,6 +44,8 @@ export interface KnowledgeSearchOptions {
43
44
  * store-level "last result" field would race two concurrent queries.
44
45
  */
45
46
  onResult?: (result: HybridResult<RecallHit>) => void;
47
+ /** P-01: attach per-leg evidence (`scores` / `final`) to every hit. Default false. */
48
+ includeScores?: boolean;
46
49
  }
47
50
  /**
48
51
  * One identity a write will land on: the title it will use, the resolved local path it would read
@@ -87,7 +90,6 @@ export type IngestMode = 'add' | 'replace';
87
90
  export declare class KnowledgeStore {
88
91
  private readonly semantic;
89
92
  private readonly vstore;
90
- private readonly reranker;
91
93
  /** The store config file `addDomain` writes the allowlist back to (`knowledgeConfigPath(home)`). */
92
94
  private readonly domainConfigPath;
93
95
  private readonly logger?;
@@ -109,6 +111,15 @@ export declare class KnowledgeStore {
109
111
  private readonly docFiles;
110
112
  /** > 0 while a batch (directory import) is writing: commits and baseline refreshes wait. */
111
113
  private deferCorpusWrites;
114
+ /**
115
+ * Identity object for the shared entity sweep's in-flight guard (`store/entity_sweep.ts`).
116
+ *
117
+ * The guard itself lives in that module; this is only the per-store identity it keys on. It exists
118
+ * as a field — rather than the adapter object, which is built per call — because the guard must
119
+ * survive between two overlapping calls: `reindex` can be reached from a tool call and the
120
+ * settings-page button at once, and both would select the same stale chunks.
121
+ */
122
+ private readonly entitySweepGuard;
112
123
  /**
113
124
  * Per-file stamps (`path → mtimeMs:size`) and the file-set they were taken from, so a reconcile
114
125
  * can ask "what changed?" for the price of one `stat` per document instead of reading and
@@ -127,17 +138,22 @@ export declare class KnowledgeStore {
127
138
  private readonly allowedDomains;
128
139
  /** Whether the config allowlist is active (non-empty); `false` = `domains: []` = no restriction. */
129
140
  private readonly domainsRestricted;
130
- constructor(dbPath: string, config: Config, kbConfig: KnowledgeConfig, semantic: SemanticBackend, vstore: VectorStore, reranker: Reranker,
141
+ constructor(dbPath: string, config: Config, kbConfig: KnowledgeConfig, semantic: SemanticBackend, vstore: VectorStore,
131
142
  /** The store config file `addDomain` writes the allowlist back to (`knowledgeConfigPath(home)`). */
132
143
  domainConfigPath: string, logger?: AvantfLogger | undefined);
133
144
  /**
134
- * Rebuild the in-memory vstore from persisted chunk vectors.
145
+ * Rebuild the in-memory vstore from persisted chunk vectors, and say loudly what belongs to
146
+ * another vector space.
135
147
  *
136
- * Also detects a VECTOR-SPACE change, which the reload itself cannot see: `reloadVectorIndex`
137
- * checks the width, and a different model at the same width produces dim-valid vectors that are
138
- * then ranked against the current query's — two spaces mixed in one index, with no error anywhere.
139
- * The memory store always said something about this; the knowledge store did not, so a model swap
140
- * degraded every semantic query here until someone happened to run `kb_reindex`.
148
+ * Also detects a VECTOR-SPACE change, which the reload itself cannot see: the reload checks the
149
+ * width, and a different model at the same width produces dim-valid vectors that are then ranked
150
+ * against the current query's — two spaces mixed in one index, with no error anywhere.
151
+ *
152
+ * The reload, the count and the warning are the SHARED open-time step (`store/vector_repair.ts`),
153
+ * which is what turned the knowledge store from "warns about a swapped model but never repairs it"
154
+ * into the same automatic path memory has always had; this store's half is the row read. The
155
+ * manual entry stays `vectors --fix` (the vector-only repair); `kb_reindex` remains the full
156
+ * FTS + entities + vectors re-entry.
141
157
  */
142
158
  private reloadIndex;
143
159
  /**
@@ -242,8 +258,6 @@ export declare class KnowledgeStore {
242
258
  warnings?: string[]): Promise<IngestResult>;
243
259
  /** Materialize one document's editable copy; returns the `file` / `file_error` result fields. */
244
260
  private writeManagedFile;
245
- /** Batched eviction from the live vector index (removeMany when the backend supports it). */
246
- private evictVectors;
247
261
  /** The resolved ingestion boundary (see `ingest_guard.ts`). */
248
262
  private get ingestLimits();
249
263
  /**
@@ -287,7 +301,8 @@ export declare class KnowledgeStore {
287
301
  */
288
302
  private writeReusedVectors;
289
303
  /**
290
- * Encode texts ONE AT A TIME, writing every `WRITE_BATCH` vectors in one transaction.
304
+ * This store's half of the shared encode-and-persist flow (`store/vector_repair.ts`): the backend
305
+ * call, the live index and the `WRITE_BATCH` transaction this store measured its way into.
291
306
  *
292
307
  * The model call is deliberately NOT batched, and that is a MEASURED decision, not an omission:
293
308
  * a batched forward pass pads each item to the longest in its group, and real chunks span 53–550
@@ -298,15 +313,79 @@ export declare class KnowledgeStore {
298
313
  * length-sorted) for callers whose texts suit it.
299
314
  *
300
315
  * What DID pay off is the write side: one commit per `WRITE_BATCH` chunks instead of one per
301
- * chunk (measured 14.2 µs/row vs 2.2 µs/row), and the transaction never spans an `await`.
316
+ * chunk (measured 14.2 µs/row vs 2.2 µs/row), and the transaction never spans an `await` — that
317
+ * is the `writeBatch` below.
318
+ */
319
+ private encodeWriteTarget;
320
+ /**
321
+ * Encode texts one at a time and persist them in `WRITE_BATCH` transactions.
322
+ *
323
+ * A failed chunk is skipped, not thrown: the rows are already committed and their old vectors
324
+ * evicted by the ingest path, so a throw here would leave the caller with a failed ingest and NO
325
+ * COUNT of what was skipped. The shortfall travels back to the tool result
326
+ * (`IngestResult.vectors_failed`) instead of living only in a log line.
302
327
  */
303
328
  private encodeAndStore;
304
329
  /**
305
- * Identity of the vector space this store currently writes into (DESIGN §20): a change of
306
- * backend, model or width makes every stored chunk vector stale, and the whole point of
307
- * recording it is that such a change is DETECTED instead of silently mixing two spaces.
330
+ * Identity of the vector space this store currently writes into (DESIGN §20): the representation
331
+ * fingerprint — backend, model, width AND the backend's declared pooling/normalization/input
332
+ * window/model revision — so any representation change makes stored chunk vectors stale instead
333
+ * of silently mixing two spaces. Public so diagnostics and tests can name the space.
334
+ */
335
+ vectorSpace(): string;
336
+ /**
337
+ * Cheapest possible DETECTION of "the embedding space changed under this store": counts of
338
+ * persisted chunk vectors whose width or recorded space id no longer matches the one this store
339
+ * writes into. Reads blob LENGTHS only — never decodes — so the startup warning and the background
340
+ * drive can afford it at any corpus size.
341
+ */
342
+ vectorSpaceHealth(): VectorSpaceHealth;
343
+ /** {@link vectorSpaceHealth} plus `missing`, the third term of the repair flow's `remaining`. */
344
+ private vectorSpaceCounts;
345
+ /** Async bootstrap the semantic backend (the shared flow's explicit-fix warmup). */
346
+ warmupSemantic(): Promise<boolean>;
347
+ /**
348
+ * This store's half of the shared repair flow (`store/vector_repair.ts`): the primitives the flow
349
+ * steps. The flow itself — batch size, event-loop yield, stop / resume / no-progress rules,
350
+ * `semantic.auto_migrate`, the dry-run split — lives there once, for both stores.
351
+ */
352
+ vectorRepairTarget(): VectorRepairTarget;
353
+ /**
354
+ * The chunks the semantic leg cannot use (no vector / wrong width / another space). One SQL
355
+ * aggregate over blob lengths; `dropIds` is empty because knowledge overwrites in place.
356
+ */
357
+ private classifyRepairVectors;
358
+ /**
359
+ * Encode + persist up to `limit` chunks the semantic leg cannot use, bounded and ordered by id.
360
+ *
361
+ * It does NOT re-extract entities or rebuild FTS: that is `kb_reindex`'s job, and the whole point
362
+ * of the automatic repair is that the vector half can run on its own — which is exactly the
363
+ * objection the "known boundary" in DESIGN §20 used to raise against a background run.
364
+ */
365
+ private reencodeStaleChunks;
366
+ /**
367
+ * The explicit, unbounded repair behind `mem_admin vectors_fix` / `avantf-mem vectors --fix`.
368
+ * `dry_run` previews every count without writing — and without loading the model, which is why
369
+ * the preview also reports `would_warm` (see the contract's `VectorsFixStoreReport`).
370
+ */
371
+ vectorsFix(dryRun?: boolean): Promise<VectorsFixStoreReport>;
372
+ /**
373
+ * ONE bounded slice of the vector-space migration: at most `batchSize` chunks are re-encoded, so
374
+ * the caller keeps control of the event loop between slices. This is the unit the background
375
+ * migration ({@link migrateVectors}) and its tests drive.
376
+ */
377
+ migrateVectorsBatch(batchSize?: number): Promise<VectorMigrationProgress>;
378
+ /**
379
+ * Drive the bounded background migration for this store until it is current, the model is
380
+ * unavailable, or `shouldStop()` turns true (plugin unmount / process shutdown). The loop, the
381
+ * `semantic.auto_migrate` switch, the no-progress rule and the store-named logging all live in the
382
+ * shared flow (`store/vector_repair.ts`); this is the one-store entry.
308
383
  */
309
- private vectorSpace;
384
+ migrateVectors(opts?: {
385
+ batchSize?: number;
386
+ shouldStop?: () => boolean;
387
+ onProgress?: (progress: VectorMigrationOutcome) => void;
388
+ }): Promise<VectorMigrationOutcome>;
310
389
  /**
311
390
  * A stored vector is reusable when it exists, matches the space, and its text is unchanged.
312
391
  *
@@ -317,8 +396,28 @@ export declare class KnowledgeStore {
317
396
  * the store would keep serving a vector for text it no longer holds.
318
397
  */
319
398
  private vectorReusable;
320
- /** One transaction replacing the entity rows of the given chunks (shared by ingest + reindex). */
399
+ /**
400
+ * Extract one chunk's entity names, bounded by `MAX_ENTITIES_PER_CHUNK`.
401
+ *
402
+ * The bound is here, not in either caller: it is a property of what `chunk_entities` may hold for
403
+ * one chunk, and the ingest path and the sweep must not disagree about it.
404
+ */
405
+ private extractRowEntities;
406
+ /** One transaction replacing the entity rows of the given chunks (the ingest path). */
321
407
  private replaceChunkEntities;
408
+ /**
409
+ * The per-row primitive of the shared entity sweep (`store/entity_sweep.ts`).
410
+ *
411
+ * Replaces ONE chunk's entity rows and stamps that same row with `ENTITY_EXTRACTOR_VERSION`, in one
412
+ * transaction. Knowledge's domain difference from memory is exactly this: memory's transaction also
413
+ * moves triples, the HRR bundle and the conflict queue, while a chunk has none of those — its
414
+ * derived state is the entity names alone.
415
+ *
416
+ * Per row, not per batch, because the sweep's contract is that a failure leaves THAT row stale and
417
+ * retryable: inside a batched transaction one bad extraction would roll back the whole batch, and
418
+ * rows it never reached would look stamped.
419
+ */
420
+ private replaceRowEntities;
322
421
  /**
323
422
  * Ingest local files/directories. Every path goes through the SAME boundary as
324
423
  * `ingestUri` — a bare `kb_import` on `~/.ssh` would otherwise be the exact
@@ -434,7 +533,7 @@ export declare class KnowledgeStore {
434
533
  * and the chunk→hit mapping.
435
534
  *
436
535
  * The limit normalization, the over-fetch factor, the leg cap, the capped-leg counter, the
437
- * degraded weights, the rerank and the output budget all live there now. They used to be copied
536
+ * degraded weights and the output budget all live there now. They used to be copied
438
537
  * here, and every fix landed on the memory side only — a `NaN` limit reached this store's SQL and
439
538
  * took the whole cross-store query down with it, `retriever.over_fetch_factor` meant something
440
539
  * different per store, and a capped knowledge leg was invisible to the health counters.
@@ -457,13 +556,27 @@ export declare class KnowledgeStore {
457
556
  * re-encoding (performance review §7.7 / P8).
458
557
  */
459
558
  private semanticPath;
559
+ /**
560
+ * The semantic leg's scope filter, as a `filterTopk` primitive: `undefined` when NO domain/source
561
+ * was asked means "keep the whole pool" — the short-circuit is preserved, so no `chunks.meta`
562
+ * lookup runs at all. Otherwise one batched lookup, and rows whose domain/source do not match (or
563
+ * whose chunk is gone) are dropped.
564
+ */
565
+ private scopeFilter;
460
566
  /**
461
567
  * Sync relevance probe for the plugin's conditional hint (DESIGN §12). Same contract as
462
568
  * `MemoryStore.lexicalProbe` — see `store/lexical.ts`.
463
569
  */
464
570
  lexicalProbe(text: string, stopAt?: number): LexicalProbe;
465
571
  private ftsPath;
466
- /** Entity overlap against entities extracted AT INGEST time (chunk_entities) — no corpus scan. */
572
+ /**
573
+ * Entity overlap against entities extracted AT INGEST time (chunk_entities) — no corpus scan.
574
+ *
575
+ * Same metric as the memory store's sibling (`store/entity_leg.ts` carries the measurement and
576
+ * the reasoning): anchored Jaccard with a saturating union over the query's rare entities, so a
577
+ * wide chunk cannot dilute the denominator and a corpus-wide word cannot drag the whole knowledge
578
+ * base in.
579
+ */
467
580
  private jaccardPath;
468
581
  }
469
582
  export interface Chunk {
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The leg runner: how BOTH stores turn their own raw leg scores into `HybridLeg`s — plus the two
3
+ * leg SOURCES that were the same flow twice ({@link ftsLeg}, {@link semanticLeg}, §4.6 of
4
+ * `docs/vector-repair-shared-flow.md`). Both stores keep only their DAO/vstore primitives and their
5
+ * scope: the `-rank`/`rank` sign convention, the short-query fallback, `max(50, k)`, the dimension
6
+ * check's error and the Map insertion order live here once.
7
+ *
8
+ * `MemoryStore.searchLegs` and `KnowledgeStore.searchLegs` were the same skeleton written twice —
9
+ * a `leg()` wrapper whose `capped` flag is measured on the RAW (pre-floor) score set, the
10
+ * `applyTermFloor` / `applyScoreFloor` application, and the "semantic backend down ⇒ an EQUAL-WEIGHT
11
+ * empty leg with `droppedByFloor: 0`" fallback — and even the comments explaining them were the same
12
+ * paragraph twice. What legitimately differs is only the leg LIST and where each leg's scores come
13
+ * from: memory computes anchors once and shares them between the entity and HRR legs, has the HRR
14
+ * probe and the time-window scan; knowledge's entity leg is a query-driven async Jaccard. Those stay
15
+ * at the store, exactly as §2 of `docs/vector-repair-shared-flow.md` requires.
16
+ *
17
+ * WHY THE CAP IS MEASURED ON THE RAW SET. `size === cap` is the only observable "this leg was cut"
18
+ * signal (a leg that finished under the cap cannot have been trimmed), and it feeds the health
19
+ * counters so the headroom can be sized from data — what makes the cap measurable instead of a silent
20
+ * quality cliff (DESIGN §20.17). The relevance floors remove the tail anyway, so
21
+ * deriving the flag from the FLOORED size would erase the signal exactly when the cap bound. The
22
+ * floor functions only ever REMOVE entries, so the raw set is the honest measurement.
23
+ *
24
+ * WHY THE SEMANTIC LEG CARRIES NO `capped`. It is capped by the POOL size (`overFetch`), not by
25
+ * `legCap`, so comparing it against `legCap` would report a trim that never happened. That is why
26
+ * {@link LegRunner.semantic} is a separate method rather than a flag on {@link LegRunner.scored}:
27
+ * the absence is structural, not something a caller can forget to set.
28
+ *
29
+ * WHY COMPOSITION. No base class (see the spec §2): the two stores share no construction, DB, DAO or
30
+ * table, and a template-method base class would be a structural rewrite of two hub files. This is
31
+ * the same style as `store/common.ts` — free functions plus a small interface — so a THIRD store can
32
+ * be added by supplying its own leg list and sources and driving THIS runner (proved by a fake
33
+ * third store in `test/legs.spec.ts`).
34
+ *
35
+ * CONCURRENCY lives one level up, in `store/hybrid.ts`'s `runLegs`: it awaits the returned
36
+ * (value | promise) array concurrently and isolates a failing leg to itself. This module only BUILDS
37
+ * legs, so both stores get the same awaiting and isolation by construction.
38
+ *
39
+ * @module store/legs
40
+ */
41
+ import { type FloorLeg } from './floors.js';
42
+ import { type HybridContext, type HybridLeg } from './hybrid.js';
43
+ import { type FtsTokenizer } from '../db/tokenizer.js';
44
+ /**
45
+ * The legs {@link LegRunner.scored} may wrap: those whose raw score has an absolute scale a floor
46
+ * can cut. `semantic` is excluded on purpose — it shares `applyScoreFloor` but must not carry the
47
+ * cap signal, and it is reached through {@link LegRunner.semantic} (see the module comment).
48
+ */
49
+ export type ScoredLeg = Exclude<FloorLeg, 'semantic'>;
50
+ /** Build the leg runner for one query. `legCap` is the cap every non-semantic leg is measured against. */
51
+ export interface LegRunner {
52
+ /** The per-leg row cap in force for this query (see `hybrid.ts`'s `legCapFor`). */
53
+ readonly cap: number;
54
+ /**
55
+ * Wrap raw scores that have NO floor of their own (memory's HRR probe and time-window scan): the
56
+ * cap signal is attached and nothing else — no `leg` label, no `droppedByFloor`, exactly as those
57
+ * legs have always shipped (they share the entity leg's weight and floor report).
58
+ */
59
+ plain(scores: Map<number, number>, weight: number): HybridLeg;
60
+ /**
61
+ * A score-floored leg: `applyScoreFloor` on the raw map, then the cap measured on the RAW map. The
62
+ * floored map is what the leg ships; `raw` is only ever read for the cap.
63
+ */
64
+ scored(name: ScoredLeg, raw: Map<number, number>, weight: number, floor: number): HybridLeg;
65
+ /**
66
+ * The FTS leg: the per-row term floor (`applyTermFloor`) needs the candidates' texts, read by the
67
+ * store for the whole raw set (one batched query). The cap is measured on the raw map.
68
+ */
69
+ term(name: 'fts', raw: Map<number, number>, texts: Map<number, string>, query: string, weight: number, floor: number): HybridLeg;
70
+ /**
71
+ * The semantic leg: score-floored like {@link scored}, but deliberately WITHOUT a `capped` key
72
+ * (see the module comment). The async model encode itself happens at the store, which is the only
73
+ * place that knows its backend and filters.
74
+ */
75
+ semantic(raw: Map<number, number>, weight: number, floor: number): HybridLeg;
76
+ /**
77
+ * The fallback for a DOWN semantic backend: an EMPTY leg at the SAME weight, reporting zero floor
78
+ * drops. Equal weight, not zero: the leg is absent, the weight contract (`RecallResult.weights`)
79
+ * must not move to a fourth value, and the orchestrator already rebalanced the weights for
80
+ * `semAvail === false` (`DEGRADED_WEIGHTS`).
81
+ */
82
+ semanticOff(weight: number): HybridLeg;
83
+ }
84
+ /**
85
+ * Build the leg runner for one query.
86
+ *
87
+ * The per-store leg ORDER is NOT here: it is the store's "leg list" (see §4.5 阶段 4) because leg
88
+ * index is part of the contract — `hybrid.ts`'s `unionLegs` merges the original and the
89
+ * self-reference rewrite runs BY INDEX, so a store that reordered its legs would silently re-map
90
+ * evidence. What this runner guarantees is that every leg, whichever order it lands in, gets the
91
+ * same wrapper, the same floor order and the same down-semantic fallback.
92
+ */
93
+ export declare function makeLegRunner(ctx: Pick<HybridContext, 'legCap'>): LegRunner;
94
+ /** The per-store pieces of the FTS leg — see {@link ftsLeg}. `Scope` is opaque to the flow. */
95
+ export interface FtsLegOptions<Scope> {
96
+ query: string;
97
+ /** The per-leg row cap (the store's `legCap`), handed to whichever DAO statement runs. */
98
+ cap: number;
99
+ /** The store's scope shape (`{category, source}` / `{domain, source}`), passed straight through. */
100
+ scope: Scope;
101
+ /** The table's own tokenizer, for the `MATCH` build (`buildFtsQuery`). */
102
+ tokenizer: FtsTokenizer;
103
+ /** The FTS `MATCH` statement; its `rank` is bm25 (negative = better). */
104
+ search: (ftsQuery: string, scope: Scope, limit: number) => readonly {
105
+ id: number;
106
+ rank: number;
107
+ }[];
108
+ /** The short-query LIKE statement; its `rank` counts the terms the row contains (higher = better). */
109
+ substringSearch: (terms: readonly string[], scope: Scope, limit: number) => readonly {
110
+ id: number;
111
+ rank: number;
112
+ }[];
113
+ }
114
+ /**
115
+ * The FTS leg, shared by `MemoryStore.ftsPath` and `KnowledgeStore.ftsPath` (§4.6.1).
116
+ *
117
+ * The two were line-for-line the same flow — `buildFtsQuery` → the `MATCH` statement with NEGATED
118
+ * bm25, else `substringTerms` → the LIKE statement with `rank` — differing only in the DAO and the
119
+ * order its scope parameters bind. Both conventions are load-bearing and preserved verbatim:
120
+ *
121
+ * - **bm25 is negative** (more negative = better match), so the `MATCH` branch negates it and higher
122
+ * stays better everywhere downstream;
123
+ * - **the LIKE `rank`** already counts contained terms, so it is used as-is, keeping the two branches
124
+ * on the same "higher = better" scale;
125
+ * - **an empty term list returns the empty map** (`substringTerms` yields none for a query with no
126
+ * 3-char run) — the leg is empty by construction, not by a query that matched nothing.
127
+ *
128
+ * THE SHORT-QUERY FALLBACK. `buildFtsQuery` returning `null` means the query carries no term the
129
+ * trigram index can express (measured: every term in the FTS table is 3 characters), which used to
130
+ * leave this leg empty by construction — the 2-char CJK shape. `substringTerms` is the one finer
131
+ * predicate the FTS5 trigram TABLE still answers (`LIKE '%…%'`), bounded by `cap`; the accuracy guard
132
+ * is the per-row term floor in `applyTermFloor`, which requires the row to CONTAIN the run.
133
+ */
134
+ export declare function ftsLeg<Scope>(opts: FtsLegOptions<Scope>): Map<number, number>;
135
+ /** The per-store pieces of the semantic leg — see {@link semanticLeg}. */
136
+ export interface SemanticLegOptions {
137
+ query: string;
138
+ /** The requested leg size; the pool is over-fetched to `max(50, k)` below. */
139
+ k: number;
140
+ /** A caller-encoded vector for this exact query; skips {@link encode}. */
141
+ queryVector?: Float32Array;
142
+ /** Publishes the vector actually used, so the relaxed retry reuses it instead of re-encoding. */
143
+ onVector?: (vec: Float32Array) => void;
144
+ encode: (query: string) => Promise<Float32Array>;
145
+ /** The vstore's width, checked against the vector before it is scored. */
146
+ dim: number;
147
+ topk: (vec: Float32Array, k: number) => readonly {
148
+ id: number;
149
+ score: number;
150
+ }[];
151
+ /**
152
+ * The store's eligibility filter over the pool ids.
153
+ *
154
+ * `undefined` = **keep everything** — knowledge's "no domain/source asked" short-circuit stays a
155
+ * short-circuit (no lookup runs at all). A returned set is the allow-list; the flow still maps the
156
+ * pool in ITS order, so ids/scores/insertion order are the vstore's.
157
+ */
158
+ filterTopk: (ids: readonly number[]) => ReadonlySet<number> | undefined;
159
+ }
160
+ /**
161
+ * The semantic leg, shared by `MemoryStore.semanticPath` and `KnowledgeStore.semanticPath` (§4.6.2).
162
+ *
163
+ * The flow: encode (unless the caller supplied the vector) → dimension check → publish → over-fetch
164
+ * `max(50, k)` from the vstore → empty pool early-returns → filter → map id→score. Both stores used
165
+ * to spell all of it out; only the filter differs, and that difference lives in `filterTopk`.
166
+ *
167
+ * Preserved verbatim: the `max(50, k)` over-fetch (the pool must leave room for the filter to remove
168
+ * rows without shrinking the leg below `k`), the `RetrievalInputError` and its exact message, the
169
+ * empty-pool early return, and the returned map's insertion order (the vstore's pool order).
170
+ */
171
+ export declare function semanticLeg(opts: SemanticLegOptions): Promise<Map<number, number>>;
@@ -25,7 +25,22 @@
25
25
  * `matched >= 2`, with the margin between 2 and 1 rather than a tuned threshold inside a
26
26
  * continuum. A single-word query (`cgroup`) scores 1 and stays silent on purpose: one term is not
27
27
  * evidence.
28
+ *
29
+ * THE SHORT-CJK FALLBACK (and why the hint probe does NOT use it). The trigram index has no term
30
+ * shorter than three characters — measured on the live store: `fts5vocab` over `facts_fts` holds
31
+ * 27906 distinct terms and EVERY one is 3 characters (and a 2-char `MATCH` returns nothing, even as
32
+ * a prefix query). So a 2-char CJK query produces no FTS term at all and that leg is empty by
33
+ * construction. The FTS LEG therefore falls back to the one finer predicate the FTS5 trigram TABLE
34
+ * does expose: `content LIKE '%…%'`, which matches a 2-char run by scanning (no index can serve a
35
+ * 2-char pattern). See {@link substringTerms} / {@link gradedTerms}.
36
+ *
37
+ * The conditional-hint probe deliberately keeps the index-expressible terms only: it is
38
+ * SYNCHRONOUS and runs on every step's prompt assembly, so it must stay a bounded number of
39
+ * `LIMIT 1` index lookups — an O(corpus) scan per 2-char term would put a full scan on the prompt
40
+ * path. Behaviour is unchanged there: a 2-char query scored 0 matched terms before and still does
41
+ * (`{terms: 0, matched: 0}`), so the hint stays silent exactly as it did.
28
42
  */
43
+ import { type FtsTokenizer } from '../db/tokenizer.js';
29
44
  /** How many of an input's terms a store holds. `terms === 0` means the input had nothing to test. */
30
45
  export interface LexicalProbe {
31
46
  terms: number;
@@ -46,6 +61,36 @@ export type RelevanceHit = boolean;
46
61
  * caller runs one `LIMIT 1` MATCH per term and counts the hits.
47
62
  */
48
63
  export declare function relevanceTerms(text: string, cap?: number): string[];
64
+ /**
65
+ * The terms the FTS leg falls back to when the trigram index can express NONE of the query.
66
+ *
67
+ * A 2-char CJK run IS the finer granularity the FTS5 trigram TABLE can still answer — not as an
68
+ * indexed token (the vocabulary holds 3-character terms only, measured; a 2-char `MATCH`, quoted or
69
+ * prefixed, returns zero rows) but as the substring predicate FTS5 exposes on a trigram table
70
+ * (`content LIKE '%…%'`, which falls back to a scan for patterns under three characters). One-char
71
+ * runs are deliberately NOT included: a single character is not evidence (the same reason
72
+ * {@link looksRelevant} needs two terms), and it would make the leg match a large share of any
73
+ * corpus. Latin runs are not included either — `buildFtsQuery` already admits latin tokens from
74
+ * three characters, and the module's own floor (5, `MIN_LATIN`) is a separate, measured decision.
75
+ *
76
+ * PRECISION GUARD. The fallback is graded by the SAME per-row floor as the indexed path and by the
77
+ * reachability clamp: for a single 2-char term the effective bar is 1, and the term is a substring,
78
+ * so "the row contains the query's characters CONTIGUOUSLY" is what admits it — not "shares one
79
+ * incidental trigram". A two-run query ('李娜 张伟') produces two substring terms and is judged
80
+ * against `min(configured, 2)`, i.e. both runs must occur in the row.
81
+ */
82
+ export declare function substringTerms(text: string, cap?: number): string[];
83
+ /**
84
+ * The term set the FTS leg is SEARCHED and GRADED with — the one definition of "the query's terms"
85
+ * for that leg (`store/floors.ts` grades with it, `store/hybrid.ts` resolves the reachability clamp
86
+ * from it, and the stores build their MATCH / substring probe from it).
87
+ *
88
+ * The fallback is whole-query, not per run: any text that yields even one index-expressible term is
89
+ * returned UNCHANGED from {@link relevanceTerms}. That is what keeps every long query bit-identical
90
+ * (a mixed text like `缓存失效 李娜` keeps only its trigrams, exactly as before) and confines the
91
+ * new behaviour to the shape that had NO leg at all.
92
+ */
93
+ export declare function gradedTerms(text: string, cap?: number): string[];
49
94
  /**
50
95
  * The gate itself: is this store worth telling the model about?
51
96
  *
@@ -65,3 +110,10 @@ export declare function looksRelevant(probe: LexicalProbe): boolean;
65
110
  * path is synchronous); the default keeps an exact count for the calibration recorded above.
66
111
  */
67
112
  export declare function probeTerms(text: string, holds: (term: string) => boolean, stopAt?: number): LexicalProbe;
113
+ /**
114
+ * The store probe both stores' `lexicalProbe` used to spell out verbatim: build THIS store's MATCH
115
+ * expression for one term (the tokenizer is a property of the store's FTS table) and ask that
116
+ * table whether anything holds it. `probeTerms` owns the term set and the counting; this owns the
117
+ * one part that has to differ, which is now a single `holds` closure per store.
118
+ */
119
+ export declare function probeStoreTerms(text: string, tokenizer: FtsTokenizer, holds: (ftsQuery: string) => boolean, stopAt?: number): LexicalProbe;