@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
@@ -7,7 +7,8 @@
7
7
  */
8
8
  import type { Db } from '../port.js';
9
9
  /**
10
- * Every `facts` column EXCEPT the two BLOBs (`hrr_vector` ~8 KB, `semantic_vector` ~2 KB per row).
10
+ * Every `facts` column EXCEPT the two BLOBs (`hrr_vector` ~8 KB, `semantic_vector` ~2 KB per row)
11
+ * and the two DEPRECATED provenance columns (`mirror_source` / `mirror_target`, see below).
11
12
  *
12
13
  * Multi-row reads that only need scalars used to be `SELECT *`, so a page of the admin list and
13
14
  * every recall's trust settlement dragged the whole vector corpus through the driver — tens of
@@ -17,10 +18,15 @@ import type { Db } from '../port.js';
17
18
  * `getById` deliberately still selects everything: `restore` re-indexes the persisted vector it
18
19
  * reads off that one row.
19
20
  *
20
- * `test/db_lifecycle.spec.ts` asserts this list against `PRAGMA table_info(facts)`, so a new column
21
- * fails a test instead of silently vanishing from these two reads.
21
+ * `mirror_source` was written as the constant `'user'` at the single INSERT below and
22
+ * `mirror_target` never had a writer; neither had a reader left once the model-facing views
23
+ * dropped them (零迁移:the COLUMNS stay in `db/schema.ts` for a later migration to clean up,
24
+ * only this projection and the INSERT stop touching them).
25
+ *
26
+ * `test/db_lifecycle.spec.ts` asserts this list against `PRAGMA table_info(facts)` MINUS those
27
+ * two deprecations, so a new column fails a test instead of silently vanishing from these reads.
22
28
  */
23
- export declare const FACT_COLUMNS_NO_BLOB = "fact_id, content, category, tags, trust_score, settle_clock,\n pinned, pinned_at, bonus_count, bonus_window_at, last_reinforced_at, archived_clock,\n retrieval_count, helpful_count, last_retrieved_at, embedding_model, vector_store, status,\n supersedes_id, entities_version, conflict_checked, archived_at, archive_reason, ttl_days,\n mirror_source, mirror_target, created_at, updated_at";
29
+ export declare const FACT_COLUMNS_NO_BLOB = "fact_id, content, category, tags, trust_score, settle_clock,\n pinned, pinned_at, bonus_count, bonus_window_at, last_reinforced_at, archived_clock,\n retrieval_count, helpful_count, last_retrieved_at, embedding_model, vector_store, status,\n supersedes_id, entities_version, conflict_checked, archived_at, archive_reason, ttl_days,\n valid_from, valid_to, assert_count, created_at, updated_at";
24
30
  /**
25
31
  * The FTS leg's exact SQL, exported so the plan guard (`test/fts_plan.spec.ts`) and the bench
26
32
  * control group EXPLAIN the production text itself instead of a copy that can silently drift.
@@ -53,6 +59,21 @@ export declare const FACT_COLUMNS_NO_BLOB = "fact_id, content, category, tags, t
53
59
  * `INDEXED BY`, which pins the plan against `PRAGMA optimize` flipping it back.
54
60
  */
55
61
  export declare const FTS_SEARCH_SQL = "SELECT f.rowid AS id, bm25(facts_fts) AS rank FROM facts_fts f\n JOIN facts fa NOT INDEXED ON fa.fact_id = f.rowid\n WHERE facts_fts MATCH ? AND fa.status = 'active' AND (? IS NULL OR fa.category = ?)\n ORDER BY rank ASC\n LIMIT ?";
62
+ /**
63
+ * {@link FTS_SEARCH_SQL} with the P-08 source predicate, as a SEPARATE statement rather than one
64
+ * more `(? IS NULL OR …)` branch in the shared text.
65
+ *
66
+ * The unfiltered statement's query plan is guarded by `test/fts_plan.spec.ts` (the external-content
67
+ * MATCH must not become the inner loop on a fresh, un-analyzed database), and the guard EXPLAINs
68
+ * `FTS_SEARCH_SQL` itself. Keeping the two texts apart means the default path's plan is provably
69
+ * unchanged, and the filtered path is a NEW statement with its own comment instead of a silent
70
+ * modification of the guarded one.
71
+ *
72
+ * `EXISTS`, never a JOIN on `fact_sources`: a fact with several sources would fan out into several
73
+ * rows, and this leg is `ORDER BY bm25 … LIMIT cap` — the duplicates would eat cap slots and shrink
74
+ * recall behind the caller's back.
75
+ */
76
+ export declare const FTS_SEARCH_SQL_FILTERED = "SELECT f.rowid AS id, bm25(facts_fts) AS rank FROM facts_fts f\n JOIN facts fa NOT INDEXED ON fa.fact_id = f.rowid\n WHERE facts_fts MATCH ? AND fa.status = 'active' AND (? IS NULL OR fa.category = ?)\n AND (? IS NULL OR EXISTS (\n SELECT 1 FROM fact_sources fs WHERE fs.fact_id = fa.fact_id AND fs.ref = ?\n ))\n ORDER BY rank ASC\n LIMIT ?";
56
77
  export declare class FactsDao {
57
78
  private readonly db;
58
79
  constructor(db: Db);
@@ -89,10 +110,12 @@ export declare class FactsDao {
89
110
  rowsByIds(ids: readonly number[]): Record<string, unknown>[];
90
111
  /** `fact_id → content` for a hit set (never N+1). */
91
112
  textsByIds(ids: readonly number[]): Map<number, string>;
92
- /** `fact_id → created_at/updated_at` for a hit set (the recall hit's two timestamps). */
113
+ /** `fact_id → created_at/updated_at/validity` for a hit set (the recall hit's timestamps). */
93
114
  timesByIds(ids: readonly number[]): Map<number, {
94
115
  created_at: string;
95
116
  updated_at: string | null;
117
+ valid_from: string | null;
118
+ valid_to: string | null;
96
119
  }>;
97
120
  /** Active rows that carry a persisted vector (index classification). */
98
121
  activeVectorRows(): {
@@ -149,10 +172,75 @@ export declare class FactsDao {
149
172
  pinnedAt: string | null;
150
173
  windowAt: string;
151
174
  createdAt: string;
175
+ /**
176
+ * P-13: the caller's event time / known end, written straight onto the new row. `null` means
177
+ * "unknown" and is what an omitted argument leaves (the column has no default on purpose).
178
+ * `assert_count` is deliberately NOT named: the column default (1) is the semantics — "this
179
+ * content has been asserted once" — and naming it here would be a second place to keep in step.
180
+ */
181
+ validFrom?: string | null;
182
+ validTo?: string | null;
152
183
  }): {
153
184
  changes: number;
154
185
  lastInsertRowid: number | bigint;
155
186
  };
187
+ /**
188
+ * P-10: land an explicitly given validity on a row that already exists (a duplicate `add` whose
189
+ * caller named `event_date` / `valid_until`). Omitted arguments keep what the row has, exactly
190
+ * like `category`/`ttl_days` (see `add`'s doc).
191
+ */
192
+ applyValidity(factId: number, validFrom: string | undefined, validTo: string | undefined): void;
193
+ /** P-10: the row's assertion counter (`1` when the column is somehow NULL on a legacy row). */
194
+ assertCount(factId: number): number;
195
+ /**
196
+ * P-10: a verbatim re-assertion of an EXISTING active fact. Returns the new count.
197
+ *
198
+ * Only the pure-duplicate branch calls this: reviving an archived row is a resurrection, not a
199
+ * new assertion (`assert_count` answers "how many times was this said", and the archived row's
200
+ * own count already recorded that). `RETURNING` keeps the read and the write one statement, so
201
+ * two processes cannot interleave between them.
202
+ */
203
+ incrementAssertCount(factId: number): number;
204
+ /**
205
+ * P-07 audit: stamp `valid_to` on a row that is leaving the active corpus (supersede / a
206
+ * contradiction verdict). `COALESCE` keeps an end the caller already declared (`valid_until`):
207
+ * overwriting it with the archival instant would replace a real answer with an administrative one.
208
+ */
209
+ markValidTo(factId: number, at: string): void;
210
+ /**
211
+ * P-07: "who superseded this fact", derived from the existing `supersedes_id` link — no
212
+ * redundant column. `MIN` is deterministic when several revisions named the same predecessor
213
+ * (which a merge can produce); the caller renders it as a single direct successor.
214
+ */
215
+ supersededBy(factId: number): number | null;
216
+ /** P-13 coverage: ACTIVE facts that carry an event time (the `admin stats` numerator). */
217
+ countActiveWithValidFrom(): number;
218
+ /**
219
+ * P-11: the ids of ACTIVE facts whose EVENT time (`valid_from`) falls inside an INCLUSIVE local
220
+ * day range, newest first, capped.
221
+ *
222
+ * The comparison is on the DATE PREFIX (`substr(valid_from, 1, 10)`) rather than on the raw text:
223
+ * `valid_from` is whatever ISO string the caller wrote (`2026-09-15` or
224
+ * `2026-09-15T09:30:00+08:00`), and a lexicographic range over the full strings would put a
225
+ * midnight timestamp on the wrong side of a day boundary. Day granularity is the unit the window
226
+ * parser speaks (`store/time_window.ts`), so `substr` is the matching one. No index can serve it;
227
+ * the leg is default-OFF and capped, and a full scan of `facts` for one enabled query is the
228
+ * accepted cost (see the leg's own comment).
229
+ *
230
+ * The `GLOB` is a SHAPE guard, not decoration: the prefix is compared LEXICOGRAPHICALLY, so a
231
+ * non-padded `2026-9-5` (which `Date.parse` accepts, so `normalizeEventTime` stores it) would sort
232
+ * AFTER `2026-09-30` and leak into an October window. Requiring the canonical zero-padded prefix
233
+ * excludes such a value from the leg entirely — the same "no usable event time" treatment, never a
234
+ * mis-binned one.
235
+ *
236
+ * `valid_from IS NOT NULL` is the P-11 discipline written into SQL: a fact with no event time is
237
+ * NOT a candidate here, and there is deliberately no `COALESCE(valid_from, created_at)` fallback —
238
+ * mixing a write clock into an event-time axis is exactly what this leg must not do.
239
+ *
240
+ * `EXISTS`, never a JOIN on `fact_sources` (the {@link FTS_SEARCH_SQL_FILTERED} rule): the cap is
241
+ * `LIMIT`, and a multi-source fact would eat cap slots with duplicate rows.
242
+ */
243
+ windowRows(fromDate: string, toDate: string, category: string | undefined, cap: number, source?: string): number[];
156
244
  findByContent(content: string): Record<string, unknown> | undefined;
157
245
  /**
158
246
  * ACTIVE facts whose entity/triple rows were produced by OLDER extraction rules.
@@ -283,8 +371,8 @@ export declare class FactsDao {
283
371
  * index in order and filter per row (measured 9.8 ms for a category with 17 rows at 33k),
284
372
  * i.e. it would trade a fast path for a slow one to save an index we do not need.
285
373
  */
286
- page(status: string, category: string | undefined, limit: number, offset: number): Record<string, unknown>[];
287
- countInStatus(status: string, category?: string): number;
374
+ page(status: string, category: string | undefined, limit: number, offset: number, source?: string): Record<string, unknown>[];
375
+ countInStatus(status: string, category?: string, source?: string): number;
288
376
  /** Every ACTIVE fact (the report's `active`). */
289
377
  countActive(): number;
290
378
  /** ACTIVE and permanent — pinned facts are exempt from decay (the report's `pinned`). */
@@ -344,7 +432,7 @@ export declare class FactsDao {
344
432
  * facts with a cap of 200 it silently made 32.8k of them unreachable. Recency is the honest
345
433
  * prior for a memory store and `idx_facts_created(status, created_at DESC)` already serves it.
346
434
  */
347
- activeHrrRows(category?: string, limit?: number): {
435
+ activeHrrRows(category?: string, limit?: number, source?: string): {
348
436
  fact_id: number;
349
437
  hrr_vector: Uint8Array;
350
438
  }[];
@@ -356,12 +444,12 @@ export declare class FactsDao {
356
444
  * which is the same information the HRR bundle encodes (its atoms ARE the entity names), so the
357
445
  * narrowing removes the O(N) part rather than a meaningful part of the ranking.
358
446
  */
359
- hrrRowsForFacts(ids: readonly number[], category?: string): {
447
+ hrrRowsForFacts(ids: readonly number[], category?: string, source?: string): {
360
448
  fact_id: number;
361
449
  hrr_vector: Uint8Array;
362
450
  }[];
363
- /** ACTIVE subset of `ids` in one category (`null` = any) — the semantic leg's filter. */
364
- activeIdsIn(ids: readonly number[], category?: string): number[];
451
+ /** ACTIVE subset of `ids` in one category/source (`null` = any) — the semantic leg's filter. */
452
+ activeIdsIn(ids: readonly number[], category?: string, source?: string): number[];
365
453
  /** `retrieval_count` + the "last used" clock (spec §2.5), batched. */
366
454
  touchUsage(ids: readonly number[]): void;
367
455
  /** Recall bonus not granted: only the settled trust moved. */
@@ -376,7 +464,22 @@ export declare class FactsDao {
376
464
  * that JOIN from becoming the drive (see {@link FTS_SEARCH_SQL}, which carries the plans and
377
465
  * measurements).
378
466
  */
379
- ftsSearch(ftsQuery: string, category?: string, limit?: number): {
467
+ ftsSearch(ftsQuery: string, category?: string, limit?: number, source?: string): {
468
+ id: number;
469
+ rank: number;
470
+ }[];
471
+ /**
472
+ * The SHORT-QUERY fallback leg: rows whose `content` contains at least one of the terms as a
473
+ * contiguous substring, scored by how many it contains.
474
+ *
475
+ * Reached only when `buildFtsQuery` produced nothing (a 2-char CJK query — see
476
+ * `db/tokenizer.ts#likeSubstring` and `store/lexical.ts`), so it cannot move an indexed query.
477
+ * `rank` is the number of distinct terms the row contains, which is the same quantity
478
+ * `applyTermFloor` grades with; ties break on ascending id, matching `fuse`'s own tie rule, so a
479
+ * single-term query (all ranks 1) has a deterministic order. `ORDER BY rank` must read the SELECT
480
+ * alias, so the LIKE expressions are built once and bound twice — same fragment, same order.
481
+ */
482
+ ftsSubstringSearch(terms: readonly string[], category?: string, limit?: number, source?: string): {
380
483
  id: number;
381
484
  rank: number;
382
485
  }[];
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The DAO layer's SHARED ALGORITHMS AND CONTRACTS.
3
+ *
4
+ * DAOs are split by AGGREGATE, not by store (`docs/vector-repair-shared-flow.md` §2), and each
5
+ * aggregate keeps its own SQL text: replacing a readable statement with a generic
6
+ * table/column-parameterized builder would cost the comments' index reasoning without converging a
7
+ * business rule — a `count`/`get`/`delete`/`list` one-liner is a different aggregate's statement,
8
+ * not a fork (§4.5 "明确不收敛").
9
+ *
10
+ * What DOES get one implementation here is the part that is literally "one rule, two tables":
11
+ *
12
+ * - the entity-candidate leg's SCORE and ORDER, plus its batch/union loop
13
+ * ({@link entityCandidates} / {@link entityCandidateTail});
14
+ * - the batched `UPDATE … SET entities_version = ?` ({@link setEntitiesVersionBatch});
15
+ * - the short-query LIKE fallback's build/guard/double-bind protocol
16
+ * ({@link likeSubstringLeg});
17
+ * - the document-frequency query shape ({@link queryDocFrequency});
18
+ * - the batched "entity-name bags for a row-id set" read ({@link entityBags}).
19
+ *
20
+ * Each caller still supplies its own statement (or its `FROM`/name/filter pieces), so the SQL stays
21
+ * at the call site while the rule lives here exactly once. No base class: every export is a free
22
+ * function, the style `store/common.ts` established.
23
+ */
24
+ import type { Db } from '../port.js';
25
+ /**
26
+ * The entity-candidate leg's ORDER contract — the tail every candidate statement ends with.
27
+ *
28
+ * The formula is the leg's own score (anchored Jaccard with a saturating union,
29
+ * `store/entity_leg.ts`): `shared / (queryWidth + min(total - shared, widthCap))`. Ordering by it is
30
+ * load-bearing, not cosmetic: `fuse` scales each leg by its maximum, and a survivor's scaled value
31
+ * is unchanged only when the ordering — and therefore the maximum — survives the trim. A narrower
32
+ * row sharing one entity can therefore outrank a wide one sharing two, and the cap keeps it.
33
+ * `total ASC` is only a deterministic tie-break among equal ratios, and the id makes the order total.
34
+ *
35
+ * `idColumn` is the aggregate's own id (`fa.fact_id` / `ce.chunk_id`); that, the `total` subquery's
36
+ * table and the filter predicates are the ONLY differences between the two statements.
37
+ */
38
+ export declare function entityCandidateTail(idColumn: string): string;
39
+ /** One call to the shared entity-candidate runner — see {@link entityCandidates}. */
40
+ export interface EntityCandidateQuery {
41
+ /** The (anchor) names to match: one `IN (…)`, or one per batch when {@link batchNames}. */
42
+ names: readonly string[];
43
+ /**
44
+ * NULL-able filter values bound right AFTER the name list and BEFORE `queryWidth`, in the SQL's
45
+ * own order (both callers spell their predicates as `(? IS NULL OR …)`, so a value the filter
46
+ * does not use is still bound as `null`).
47
+ */
48
+ filterParams: readonly unknown[];
49
+ limit: number;
50
+ /** The query's FULL entity count (`W` in `store/entity_leg.ts`); `widthCap` is its saturating cap. */
51
+ queryWidth: number;
52
+ widthCap: number;
53
+ /**
54
+ * `true` (knowledge): run one statement per name-batch and UNION the ids, because the query can in
55
+ * principle carry more names than a single `IN (…)` may bind. A union of per-batch tops is NOT a
56
+ * top-N, so that caller must enforce its real cap on the union AFTER scoring.
57
+ * `false` (memory): one statement and one exact `LIMIT` — the store's names are request-bounded,
58
+ * so a single `LIMIT` is already the top-N and the cap argument above holds bit-for-bit.
59
+ */
60
+ batchNames: boolean;
61
+ /**
62
+ * The full statement for one placeholder list; it must project `id`, `shared`, `total` and end
63
+ * with {@link entityCandidateTail}.
64
+ */
65
+ sql: (placeholders: string) => string;
66
+ }
67
+ /**
68
+ * Run an entity-candidate query: bind order, batching and union are shared; the SQL is the caller's.
69
+ *
70
+ * Bind order (identical in both aggregates): `<names …>, <filterParams …>, queryWidth, widthCap, limit`.
71
+ */
72
+ export declare function entityCandidates(db: Db, q: EntityCandidateQuery): number[];
73
+ /**
74
+ * The batched `UPDATE … SET entities_version = ?` shared by `FactsDao` and `ChunksDao`.
75
+ *
76
+ * Only the aggregate's `table` / `idColumn` differ. Returns the summed `changes`; the knowledge
77
+ * caller ignores it (its method's signature returns `void`), the memory caller's `number` is
78
+ * unchanged.
79
+ */
80
+ export declare function setEntitiesVersionBatch(db: Db, table: string, idColumn: string, ids: readonly number[], version: number): number;
81
+ /** The per-aggregate pieces of the document-frequency query — see {@link queryDocFrequency}. */
82
+ export interface DocFrequencyQuery {
83
+ /** `FROM …` with its JOINs: everything between the projection and `WHERE`. */
84
+ from: string;
85
+ /** The name expression (`e.name`, `name`), used as the alias, the IN predicate and `GROUP BY`. */
86
+ nameExpr: string;
87
+ /** A constant predicate ANDed after the IN clause; it must add NO bind parameters. */
88
+ where?: string;
89
+ }
90
+ /**
91
+ * Document frequency — "how many rows carry each name".
92
+ *
93
+ * Shared by `EntitiesDao.activeDocFrequency` and `ChunksDao.docFrequency`; the only difference is
94
+ * the population the caller's `where`/`from` describe (memory counts ACTIVE facts, knowledge has no
95
+ * status column and counts chunks). Only the caller's QUERY names are looked up, so this is one
96
+ * indexed COUNT per name rather than a corpus scan. A name absent from the map has frequency 0.
97
+ */
98
+ export declare function queryDocFrequency(db: Db, names: readonly string[], q: DocFrequencyQuery): Map<string, number>;
99
+ /** The two SQL fragments {@link likeSubstringLeg} hands back to the caller's statement builder. */
100
+ export interface LikeSubstringFragments {
101
+ /** The `WHERE` predicate (OR of the term patterns). */
102
+ any: string;
103
+ /** The score expression (count of the terms the row contains), to be aliased as `rank`. */
104
+ count: string;
105
+ }
106
+ /**
107
+ * The SHORT-QUERY LIKE fallback shared by `FactsDao.ftsSubstringSearch` and
108
+ * `ChunksDao.ftsSubstringSearch`.
109
+ *
110
+ * The predicate text itself already comes from `db/tokenizer.ts#likeSubstring`; this owns the
111
+ * protocol AROUND it, which both copies spelled out:
112
+ *
113
+ * 1. build the fragments — empty terms yield no patterns, which means "match nothing" ⇒ `[]`;
114
+ * 2. `count` lands in the SELECT and `any` in the WHERE, so the SAME pattern list is bound TWICE:
115
+ * once with the SELECT's placeholders, then — after the caller's filter params — with the
116
+ * WHERE's;
117
+ * 3. `LIMIT ?` last, defaulting to `-1` (unbounded; the store bounds the leg).
118
+ *
119
+ * `ORDER BY rank DESC, <id> ASC` stays in the caller's SQL: the id column is aggregate-specific and
120
+ * `rank` must read the SELECT alias, so the fragments and the patterns are built once and used twice.
121
+ */
122
+ export declare function likeSubstringLeg(db: Db, column: string, terms: readonly string[], buildSql: (fragments: LikeSubstringFragments) => string, filterParams?: readonly unknown[], limit?: number): {
123
+ id: number;
124
+ rank: number;
125
+ }[];
126
+ /** The per-aggregate SQL pieces of {@link entityBags}. */
127
+ export interface EntityBagsQuery {
128
+ /** `FROM …` with its alias: `fact_entities fe` / `chunk_entities`. */
129
+ table: string;
130
+ /** The id expression, also the `WHERE … IN (…)` subject: `fe.fact_id` / `chunk_id`. */
131
+ keyColumn: string;
132
+ /** The name expression: `e.name` / `name`. */
133
+ nameColumn: string;
134
+ /** The `JOIN` that reaches the name vocabulary, for aggregates that have one (`''`/omitted for `chunk_entities`). */
135
+ joinEntities?: string;
136
+ /**
137
+ * The DEFAULT semantics — the one real difference between the two callers, so it is explicit:
138
+ *
139
+ * - `true` (memory): every requested id is pre-seeded with `[]` in the REQUEST's order, so the
140
+ * caller can assume `bags.get(id)` exists. Names are appended in row order after that;
141
+ * - `false` (knowledge): only rows that actually carry entities appear (the caller uses `?? []`).
142
+ */
143
+ seedEmpty: boolean;
144
+ }
145
+ /**
146
+ * Batched entity-name bags for a row-id set: `Map<rowId, names>`.
147
+ *
148
+ * Shared by `EntitiesDao.bagsForFacts` and `ChunksDao.entityBags` (§4.6.3): one `IN (…)` statement
149
+ * per `batches()` slice, rows folded into the map in first-seen order. The SQL SHAPE is the only
150
+ * aggregate-specific part (`table` / `keyColumn` / `nameColumn` / `joinEntities`); the crucial
151
+ * `seedEmpty` difference above is the caller's to declare and stays observable.
152
+ */
153
+ export declare function entityBags(db: Db, ids: readonly number[], q: EntityBagsQuery): Map<number, string[]>;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * P-07 validity invariants, as SQL an operator (or a test) can run against a database.
3
+ *
4
+ * The batch-1 fields are DISPLAY/AUDIT data — no retrieval leg reads them — so nothing in the read
5
+ * path would notice if a write stopped stamping them. These two statements are the check that
6
+ * cannot be satisfied vacuously: each has a planted-violation test in
7
+ * `test/fact_provenance.spec.ts` proving it reports a real inconsistency.
8
+ *
9
+ * ① The two SEMANTIC retirement paths (`update`'s supersede and a `true_positive` contradiction
10
+ * verdict) archive a fact because it stopped being true. Both must leave an end time, or the
11
+ * audit trail says "retired, cause known, end unknown". Lifecycle archival (`ttl` / `forgot` /
12
+ * `idle` / `manual`) is deliberately NOT in the set: those are retention decisions, not statements
13
+ * about when the fact stopped being true.
14
+ *
15
+ * ② A known end cannot precede a known start.
16
+ */
17
+ import type { Db } from './port.js';
18
+ /** Facts retired by a semantic path (`replaced` / `contradiction`) without a recorded end time. */
19
+ export declare const RETIRED_WITHOUT_VALID_TO_SQL = "SELECT COUNT(*) AS n FROM facts\n WHERE status = 'archived' AND archive_reason IN ('replaced', 'contradiction') AND valid_to IS NULL";
20
+ /** Rows whose known end precedes their known start. */
21
+ export declare const VALID_TO_BEFORE_VALID_FROM_SQL = "SELECT COUNT(*) AS n FROM facts\n WHERE valid_from IS NOT NULL AND valid_to IS NOT NULL AND valid_to < valid_from";
22
+ /** Every P-07 invariant, evaluated. All-zero is the healthy state. */
23
+ export declare function validityInvariantViolations(db: Db): {
24
+ retired_without_valid_to: number;
25
+ valid_to_before_valid_from: number;
26
+ };
@@ -59,3 +59,26 @@ export declare const MAX_FTS_PHRASES = 64;
59
59
  * once when the tokenizer fallback landed).
60
60
  */
61
61
  export declare function buildFtsQuery(query: string, tokenizer?: FtsTokenizer): string | null;
62
+ /**
63
+ * The `LIKE` predicate that matches `column` against every term as a contiguous SUBSTRING.
64
+ *
65
+ * This is the SHORT-QUERY FALLBACK's search primitive: the trigram index holds no term under three
66
+ * characters (measured on the live store: `fts5vocab` over `facts_fts` = 27906 terms, all length 3),
67
+ * and a 2-char `MATCH` returns zero rows — quoted or prefixed. An FTS5 trigram table does still
68
+ * answer a substring `LIKE`, but only by SCANNING when the pattern is shorter than three characters
69
+ * (SQLite documents the index as usable from three), so callers must bound the result themselves.
70
+ * See `store/lexical.ts` for the precision guard and why only 2-char CJK runs reach here.
71
+ *
72
+ * `ESCAPE` is not decoration: the terms are the caller's text, so a `%` or `_` in one would silently
73
+ * turn a literal query into a wildcard. The fragment is built once and shared by BOTH stores' DAOs,
74
+ * the way {@link buildFtsQuery} is — the two copies of that rule had already drifted once.
75
+ *
76
+ * @returns `any` / `count` (SQL expressions over the SAME bind order) and the patterns. `count` is
77
+ * the number of terms the row contains, i.e. the fallback leg's relevance score; `any` is its
78
+ * `WHERE` clause. Empty terms are dropped, so a caller never gets a `%%` that matches everything.
79
+ */
80
+ export declare function likeSubstring(column: string, terms: readonly string[]): {
81
+ any: string;
82
+ count: string;
83
+ params: string[];
84
+ };
@@ -3,18 +3,44 @@ import type { VectorStore } from '../../mem-retrieval/index.js';
3
3
  export declare function float32ToBytes(vec: Float32Array): Buffer;
4
4
  export declare function bytesToFloat32(buf: Uint8Array | null | undefined): Float32Array | null;
5
5
  /**
6
- * Identity of a VECTOR SPACE: which backend, which model, how wide.
6
+ * Identity of a VECTOR SPACE — the REPRESENTATION FINGERPRINT.
7
7
  *
8
8
  * Persisted beside every vector (`facts.embedding_model`, `doc_chunks.embedding_model`) so
9
- * "these vectors came from a different model" is detectable. It used to record only the
10
- * backend name, which made a same-width model swap (bge-small-zh → bge-m3, both 512d)
11
- * indistinguishable from "already encoded": the two spaces then shared one index and were
12
- * ranked against each other.
9
+ * "these vectors came from a different representation" is detectable. It used to record only the
10
+ * backend name, then `backend/model/dim`; both were blind to the rest of what decides which
11
+ * coordinates a vector lives in — pooling, normalization, the truncation window, and the weights
12
+ * behind an unchanged repo name. The space id now embeds:
13
+ *
14
+ * - {@link VECTOR_SPACE_FORMAT}, a format version. Bumping it is a deliberate ONE-TIME full
15
+ * re-encode of every existing library (the old ids simply are not equal to any new id), which
16
+ * is exactly why the stale-vector warning has to explain that case instead of calling it a
17
+ * model swap (see `reportStaleVectors`);
18
+ * - the backend, model and width, as before;
19
+ * - the backend's declared representation (`representationKey`): pooling / normalize /
20
+ * max_input_tokens / model revision. The revision is omitted when the family sidecar could not
21
+ * be read — a documented degradation, never a crash.
13
22
  *
14
23
  * The vector STORE is deliberately not part of the identity: numpy vs hnswlib indexes the
15
24
  * same vectors, so switching it must not trigger re-encoding (that is `reloadIndex`).
16
25
  */
17
- export declare function vectorSpaceId(backend: string, model: string, dim: number): string;
26
+ export declare const VECTOR_SPACE_FORMAT = 2;
27
+ /** `v<format>/` — the prefix every current-format space id starts with (and old ids do not). */
28
+ export declare const VECTOR_SPACE_FORMAT_PREFIX: string;
29
+ /**
30
+ * @param backend - the `SemanticBackend` name.
31
+ * @param model - the configured model id.
32
+ * @param dim - the configured width.
33
+ * @param representation - the normalized representation key (`representationKey`). Omitted by
34
+ * callers with no backend in hand (benchmarks, the pure id tests); such an id is still
35
+ * format-prefixed, so it can never equal a pre-fingerprint id.
36
+ */
37
+ export declare function vectorSpaceId(backend: string, model: string, dim: number, representation?: string): string;
38
+ /**
39
+ * `true` when a recorded space id predates the representation fingerprint (`v2/…`), or comes from a
40
+ * format this build does not know. The distinction is what lets the warning explain a one-time
41
+ * format upgrade separately from "another model's vectors are in this index".
42
+ */
43
+ export declare function isPreFingerprintSpace(recorded: string | null | undefined): boolean;
18
44
  /**
19
45
  * Rebuild a live vstore from persisted vector BLOBs — the restart-recovery path
20
46
  * shared by both stores (the two copies had already drifted: memory warned on
@@ -6,7 +6,7 @@
6
6
  * Note: nodejieba's `tag()` returns `{ word, tag }` objects, and its POS flag set
7
7
  * differs from Python jieba (e.g. proper nouns → `nr`, 名动词 → `vn`).
8
8
  */
9
- interface ExtractedEntity {
9
+ export interface ExtractedEntity {
10
10
  name: string;
11
11
  type: string;
12
12
  method: string;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Deterministic query-derivation engine for the P-02 regression net.
3
+ *
4
+ * The shipped 41-query fixture is frozen to the last bit, so it cannot grow to cover a new
5
+ * pathology without re-freezing seven numbers. This engine derives NEW queries from a corpus
6
+ * instead, with no model and no clock: the same facts + seed always produce byte-identical cases.
7
+ *
8
+ * What it derives, per gold fact:
9
+ * - one query assembled from the gold's SHARED literal fragments plus a bridge fragment the gold
10
+ * does NOT contain (so the query is never answered by simply echoing the gold's own text);
11
+ * - one planted, unrelated, LONG carrier fact that literally contains the bridge (requirement ②);
12
+ * - the collision rate of the query against the non-gold facts;
13
+ * - the counterfactual arm (requirement ③): removing the carrier must put the gold back at rank 1.
14
+ *
15
+ * The two guards:
16
+ * - `no_time_word`: an emitted query may not contain a time expression (a temporal query belongs to
17
+ * a different leg, and would confound "did the ranking change?" with "did the clock move").
18
+ * Offending candidates are DROPPED and counted, not silently kept.
19
+ * - `irrelevant`: a negative-control query that shares no fragment with any fact; a retriever that
20
+ * answers it is returning something for nothing.
21
+ *
22
+ * Requirement ① (a corpus with the real store's length distribution) is served by
23
+ * {@link synthesizeCorpus}, whose output mirrors the measured profile in
24
+ * `docs/bench/BASELINE_REPORT.md` §0.1 (9 / 243 / 313 / 409 / 882 characters); {@link analyzeCorpus}
25
+ * reports how close a corpus actually is so the caller can assert it rather than trust it.
26
+ */
27
+ export interface LengthProfile {
28
+ min: number;
29
+ p25: number;
30
+ median: number;
31
+ p75: number;
32
+ max: number;
33
+ }
34
+ /**
35
+ * The measured ACTIVE-corpus shape on 2026-10-06 (`docs/bench/BASELINE_REPORT.md` §0.1:
36
+ * 86 active, min/p25/median/p75/max = 9/243/313/409/882 characters). Frozen here so a fixture can
37
+ * be checked against the real store's shape instead of against a guess.
38
+ */
39
+ export declare const REAL_LENGTH_PROFILE: LengthProfile;
40
+ export interface CorpusAnalysis {
41
+ n: number;
42
+ length_profile: LengthProfile;
43
+ /** Fraction of facts whose length falls inside the real p25..p75 band. */
44
+ in_band_rate: number;
45
+ }
46
+ export declare function lengthProfileOf(lengths: readonly number[]): LengthProfile;
47
+ export declare function analyzeCorpus(facts: readonly string[]): CorpusAnalysis;
48
+ /**
49
+ * The literal unit of collision: latin/digit words of length ≥ 2, and CJK bigrams.
50
+ *
51
+ * Bigrams are the CJK unit on purpose — it is what a trigram FTS index cannot fully guard, and it
52
+ * is exactly the granularity of the 2026-10-04 self-referential ranking incident (`用户是`, a
53
+ * fragment an unrelated note happened to contain).
54
+ */
55
+ export declare function fragmentsOf(text: string): string[];
56
+ export declare function timeWordsIn(text: string): string[];
57
+ export type GuardKind = 'none' | 'no_time_word' | 'irrelevant';
58
+ export interface DerivedQuery {
59
+ query: string;
60
+ k: number;
61
+ /** Index of the gold fact in `setup_facts`, or null for a negative-control query. */
62
+ gold_index: number | null;
63
+ must_include: number[];
64
+ must_exclude: number[];
65
+ guard: GuardKind;
66
+ /** The literal fragments the query is built from. */
67
+ fragments: string[];
68
+ /** Fraction of the NON-gold facts that literally share at least one query fragment. */
69
+ collision_rate: number;
70
+ /** Index of the planted fragment carrier in `setup_facts`, when this query has one. */
71
+ carrier_index: number | null;
72
+ }
73
+ export interface CollisionSelfCheck {
74
+ query_index: number;
75
+ carrier_index: number;
76
+ gold_index: number;
77
+ /** The fixture is only meaningful if the carrier actually outranks the gold in the base arm. */
78
+ base_carrier_before_gold: boolean;
79
+ /** …and if removing it puts the gold first (the counterfactual arm). */
80
+ counterfactual_gold_top1: boolean;
81
+ }
82
+ export interface DerivedCase {
83
+ id: string;
84
+ setup_facts: string[];
85
+ queries: DerivedQuery[];
86
+ analysis: CorpusAnalysis;
87
+ collision: CollisionSelfCheck | null;
88
+ guards: {
89
+ /** Candidates dropped because they contained a time expression (cumulative across the suite). */
90
+ no_time_word_dropped: number;
91
+ /** Negative-control queries emitted. */
92
+ irrelevant_queries: number;
93
+ };
94
+ }
95
+ export interface DeriveOptions {
96
+ seed?: number;
97
+ /** Maximum number of gold facts to derive a case from. */
98
+ maxGolds?: number;
99
+ /** Shared fragments folded into one query. */
100
+ fragmentsPerQuery?: number;
101
+ /** Target character length of a planted carrier (default: the real store's p75). */
102
+ carrierLength?: number;
103
+ }
104
+ /**
105
+ * Derive one case per shared-fragment gold fact (deterministic order, capped by `maxGolds`).
106
+ *
107
+ * The bridge fragment is a NONCE because the engine may not call a model: a real paraphrase's bridge
108
+ * (`我是谁?` → `用户是谁`, introducing `用户是`) cannot be produced without one. The nonce plays the
109
+ * same mechanical role — a literal the gold does not contain and an unrelated fact does — and the
110
+ * point of the fixture is the collision arithmetic, which is identical.
111
+ */
112
+ export declare function deriveCases(facts: readonly string[], opts?: DeriveOptions): DerivedCase[];
113
+ /**
114
+ * A deterministic corpus whose length distribution mirrors the real store's (requirement ①).
115
+ *
116
+ * `n` short-to-long facts are generated by padding a short seed sentence with the same unrelated
117
+ * filler the carriers use; the caller is expected to assert the result with {@link analyzeCorpus}
118
+ * rather than trust the generator.
119
+ */
120
+ export declare function synthesizeCorpus(n: number, seed?: number): string[];