@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
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The two arms the P-02 regression net runs, over the derived cases from `derive.ts`.
3
+ *
4
+ * - `stubRank` / `runDerivedCaseStub`: a DETERMINISTIC, model-free retriever that scores a fact by
5
+ * how many of the query's literal fragments it contains. It exists to prove the FIXTURE, not the
6
+ * product: a planted carrier that literally contains the bridge must outrank the gold in the base
7
+ * arm, and removing it must put the gold back at rank 1. That is the "counterfactual self-check"
8
+ * the collision sensitivity requirement asks for, and it needs no model to be meaningful.
9
+ * - `runDerivedCaseReal`: the same cases driven through a real runtime (`remember` + `recall`), for
10
+ * the nightly job. It is duck-typed so this module does not depend on the runtime.
11
+ *
12
+ * The two arms share ONE outcome shape, so a report can say which arm produced which numbers.
13
+ */
14
+ import { type DerivedCase, type GuardKind } from './derive.js';
15
+ export interface RankedFacts {
16
+ /** Fact indices, best first. */
17
+ ranked: number[];
18
+ /** The stub score of each ranked fact (parallel to `ranked`). */
19
+ scores: number[];
20
+ }
21
+ /**
22
+ * Deterministic lexical-overlap stub: score = number of the query's distinct fragments the fact
23
+ * contains literally. Raw count, deliberately NOT length-normalized — the pathology under test is
24
+ * "a long unrelated note that happens to contain the bridge out-scores the short gold", which a
25
+ * per-character normalization would erase.
26
+ */
27
+ export declare function stubRank(query: string, facts: readonly string[], k?: number): RankedFacts;
28
+ export interface DerivedQueryOutcome {
29
+ query: string;
30
+ guard: GuardKind;
31
+ ranked: number[];
32
+ scores: number[];
33
+ gold_rank: number | null;
34
+ carrier_rank: number | null;
35
+ must_include_ok: boolean;
36
+ must_exclude_ok: boolean;
37
+ /** The fixture's own recorded collision rate (fraction of non-gold facts sharing a fragment). */
38
+ collision_rate: number;
39
+ }
40
+ export interface CollisionCheckOutcome {
41
+ /** `true` when the planted carrier really does outrank the gold in the base arm. */
42
+ base_carrier_before_gold: boolean;
43
+ /** `true` when removing the carrier puts the gold first (the counterfactual arm). */
44
+ counterfactual_gold_top1: boolean;
45
+ }
46
+ export interface DerivedCaseOutcome {
47
+ id: string;
48
+ /** Arm label so a merged report cannot confuse stub numbers with real ones. */
49
+ arm: 'stub' | 'real';
50
+ queries: DerivedQueryOutcome[];
51
+ collision_self_check: CollisionCheckOutcome | null;
52
+ }
53
+ export declare function runDerivedCaseStub(c: DerivedCase): DerivedCaseOutcome;
54
+ export interface DerivedSuiteOutcome {
55
+ arm: 'stub';
56
+ cases: DerivedCaseOutcome[];
57
+ all_collision_self_checks_pass: boolean;
58
+ /** A retriever that emits any query containing a time expression has broken the guard. */
59
+ any_time_word_query: boolean;
60
+ /** Every `irrelevant` negative-control query must come back empty. */
61
+ all_irrelevant_empty: boolean;
62
+ }
63
+ export declare function runDerivedSuiteStub(cases: readonly DerivedCase[]): DerivedSuiteOutcome;
64
+ /** The slice of the runtime the real arm needs. Duck-typed on purpose (no runtime import). */
65
+ export interface DerivedRuntimePort {
66
+ remember(req: {
67
+ action: 'add';
68
+ content: string;
69
+ }): Promise<{
70
+ fact_id: number;
71
+ }>;
72
+ recall(req: {
73
+ action: 'search';
74
+ query: string;
75
+ limit?: number;
76
+ }): Promise<{
77
+ hits: {
78
+ ref_id: number;
79
+ }[];
80
+ }>;
81
+ }
82
+ /** Seed a case's facts and return the fact ids in `setup_facts` order. */
83
+ export declare function seedDerivedFacts(rt: DerivedRuntimePort, facts: readonly string[]): Promise<number[]>;
84
+ /**
85
+ * Drive one derived case through a real runtime. The `k` of each query is honored with `limit`
86
+ * (the derived queries use k=3), and the returned order is the store's own.
87
+ */
88
+ export declare function runDerivedCaseReal(rt: DerivedRuntimePort, c: DerivedCase): Promise<DerivedCaseOutcome>;
@@ -0,0 +1,4 @@
1
+ export * from './metrics.js';
2
+ export * from './loader.js';
3
+ export * from './runner.js';
4
+ export * from './write_metrics.js';
@@ -23,3 +23,27 @@ export interface AggregateMetrics {
23
23
  must_exclude_pass_rate: number;
24
24
  }
25
25
  export declare function aggregate(perQuery: PerQueryMetrics[]): AggregateMetrics;
26
+ /** Discounted cumulative gain at k (binary relevance). */
27
+ export declare function dcgAtK(actual: number[], expected: number[], k: number): number;
28
+ /**
29
+ * nDCG@k (binary relevance).
30
+ *
31
+ * 0 when there is nothing relevant to find (and when the ideal ranking is empty), never NaN — the
32
+ * per-query value is averaged and a NaN would poison the whole aggregate.
33
+ */
34
+ export declare function ndcgAtK(actual: number[], expected: number[], k: number): number;
35
+ /** How many relevant results appear in the first `n` slots. */
36
+ export declare function relevantInTop(actual: number[], expected: number[], n: number): number;
37
+ /** The window "top-3 relevant count" is measured over. */
38
+ export declare const TOP_N = 3;
39
+ export interface RankingMetrics {
40
+ n_queries: number;
41
+ mean_ndcg_at_k: number;
42
+ /** Relevant results inside the top {@link TOP_N}, summed over queries. */
43
+ top3_relevant_total: number;
44
+ /** Mean relevant results inside the top {@link TOP_N}. */
45
+ mean_top3_relevant: number;
46
+ /** Fraction of queries with at least one relevant result in the top {@link TOP_N}. */
47
+ top3_hit_rate: number;
48
+ }
49
+ export declare function aggregateRanking(perQuery: PerQueryMetrics[]): RankingMetrics;
@@ -1,10 +1,17 @@
1
- import { type PerQueryMetrics, type AggregateMetrics } from './metrics.js';
1
+ import { type PerQueryMetrics, type AggregateMetrics, type RankingMetrics } from './metrics.js';
2
2
  import type { EvalCase } from './loader.js';
3
3
  /** A retrieve function over the case's facts, returning indices in relevance order. */
4
4
  type RetrieveFn = (query: string, k: number, facts: string[]) => Promise<number[]>;
5
5
  interface EvalReport {
6
6
  perQuery: PerQueryMetrics[];
7
7
  summary: AggregateMetrics;
8
+ /**
9
+ * P-02 ranking-quality metrics. A SIBLING of `summary`, never a key inside it: `eval_zh.spec.ts`
10
+ * asserts `summary` with `toEqual` over exactly seven keys, so a new key there would turn a
11
+ * frozen-number regression into a key-set failure. `perQuery` is unchanged, so the bench
12
+ * harness's `(ids, scores)` fingerprint over it cannot move.
13
+ */
14
+ ranking: RankingMetrics;
8
15
  }
9
16
  export declare function evaluateCases(cases: EvalCase[], retrieve: RetrieveFn): Promise<EvalReport>;
10
17
  export {};
@@ -20,8 +20,9 @@ export * from './dispatch.js';
20
20
  export * from './runtime.js';
21
21
  export * from './store/memory.js';
22
22
  export * from './store/knowledge.js';
23
+ export * from './store/vector_repair.js';
23
24
  export * from './store/hybrid.js';
24
25
  export * from './store/source_picker.js';
25
26
  export * from './store/lexical.js';
26
27
  export * from './store/floors.js';
27
- export { registerSemanticBackend, registerReranker, registerVectorStore, type Reranker, type SemanticBackend, type VectorStore, } from '../mem-retrieval/index.js';
28
+ export { registerSemanticBackend, registerVectorStore, type SemanticBackend, type VectorStore, } from '../mem-retrieval/index.js';
@@ -40,8 +40,8 @@ export interface WarmOptions {
40
40
  deferTokenizerUntilIdle?: boolean;
41
41
  }
42
42
  /**
43
- * Fire-and-forget model bootstrap for DSH startup. Configures the semantic /
44
- * rerank model env (mirror + cache) and triggers the model download/load
43
+ * Fire-and-forget model bootstrap for DSH startup. Configures the semantic
44
+ * model env (mirror + cache) and triggers the model download/load
45
45
  * asynchronously, without blocking the plugin's `apply`. Returns the availability
46
46
  * of the semantic path once the download/load completes.
47
47
  *
@@ -1,4 +1,4 @@
1
- import type { AdminRequest, KbAddRequest, KbConflictReport, KbRequest, QueryRequest, RecallRequest, RecallResult, RememberRequest } from '../mem-contract/index.js';
1
+ import type { AdminRequest, KbAddRequest, KbConflictReport, KbRequest, QueryRequest, RecallRequest, RecallResult, RememberRequest, VectorsFixReport } from '../mem-contract/index.js';
2
2
  import { type AvantfLogger, type StatsSummary } from '../mem-contract/index.js';
3
3
  import { type SemanticBackend } from '../mem-retrieval/index.js';
4
4
  import { type LoadedConfig } from './config/loader.js';
@@ -20,7 +20,7 @@ export interface RuntimeOptions {
20
20
  semantic?: SemanticBackend;
21
21
  /**
22
22
  * The family framework's managed roots, as the built-in defaults for `tools.dir` and
23
- * `semantic.cache_dir` / `rerank.cache_dir` (see `LoadConfigOptions.managedRoots`).
23
+ * `semantic.cache_dir` (see `LoadConfigOptions.managedRoots`).
24
24
  *
25
25
  * The DSH plugin passes `<family home>/tools` and `<family home>/models` while
26
26
  * `@avantf/dsh-plugin-base` owns the fetching; the CLI and the MCP server pass nothing, because they
@@ -153,7 +153,7 @@ export type AdminDispatch = {
153
153
  }>): StoreResult<MemoryStore, 'vectorsDiagnose'>;
154
154
  (req: Extract<AdminRequest, {
155
155
  action: 'vectors_fix';
156
- }>): StoreResult<MemoryStore, 'vectorsFix'>;
156
+ }>): Promise<VectorsFixReport>;
157
157
  (req: Extract<AdminRequest, {
158
158
  action: 'contradict_check';
159
159
  }>): StoreResult<MemoryStore, 'checkContradictions'>;
@@ -28,10 +28,25 @@ export declare function normalizeWrites(values: readonly string[]): string[];
28
28
  /**
29
29
  * Identity of the vector space a store currently writes into.
30
30
  *
31
- * A change of backend, model or width makes every stored vector stale, and the point of recording
32
- * the identity is that such a change is DETECTED instead of silently mixing two spaces in one index.
31
+ * A change of backend, model, width — or of anything else that decides which coordinates a vector
32
+ * lives in (pooling, normalization, the input window, the model revision behind an unchanged repo
33
+ * name) — makes every stored vector stale. The point of recording the identity is that such a
34
+ * change is DETECTED instead of silently mixing two spaces in one index. The backend declares its
35
+ * representation (`SemanticBackend.representation`); one that does not is fingerprinted as
36
+ * `rep=undeclared`, which is honest rather than a claim nobody verified.
33
37
  */
34
38
  export declare function vectorSpaceOf(semantic: SemanticBackend, model: string): string;
39
+ /** The two contract counts plus the internal "predates the representation fingerprint" split. */
40
+ export interface StaleVectorCounts {
41
+ stale: number;
42
+ space_stale: number;
43
+ /**
44
+ * Of {@link space_stale}, how many were written BEFORE the representation fingerprint (`v2/…`).
45
+ * The count is what makes the warning able to say "this is a one-time format upgrade" instead of
46
+ * blaming a model swap the operator never made.
47
+ */
48
+ legacy?: number;
49
+ }
35
50
  /**
36
51
  * Drop ids from the vector index, in one batch when the backend supports it.
37
52
  *
@@ -59,10 +74,7 @@ export declare function evictVectors(vstore: VectorStore, ids: readonly number[]
59
74
  * @param counts how many persisted vectors are unusable in the current space, by kind
60
75
  * @param fix the manual entry that re-encodes them, as the operator would type it
61
76
  */
62
- export declare function reportStaleVectors(kind: string, space: string, counts: {
63
- stale: number;
64
- space_stale: number;
65
- }, fix: string): void;
77
+ export declare function reportStaleVectors(kind: string, space: string, counts: StaleVectorCounts, fix: string): void;
66
78
  /**
67
79
  * Hand the event loop back between two synchronous batches.
68
80
  *
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The entity leg's metric: ANCHORED JACCARD WITH A SATURATING UNION.
3
+ *
4
+ * WHY THE OLD METRIC FAILED SHORT QUERIES. The leg scored the plain Jaccard ratio
5
+ * `|Q ∩ F| / |Q ∪ F|`. Both stores are written by the SAME entity extractor, which keeps every
6
+ * noun-ish token, so a real memory fact carries ~31 entity names (measured on the live store:
7
+ * min 3 / median 31 / max 66; `scripts/ENTITY_TOKENIZER_VALUE.md`). A query extracts one or two.
8
+ * Even a PERFECT 1-entity match is then `1 / (1 + 31 - 1) ≈ 0.03`, so with `min_jaccard` at any
9
+ * useful value the leg is unreachable for a short query BY CONSTRUCTION — measured: 1-entity
10
+ * queries could clear 0.2 against 2 of 80 facts. Extracting MORE entities only grows the
11
+ * denominator (the tokenizer direction was measured and rejected), so the fix has to be the
12
+ * metric, not the extractor.
13
+ *
14
+ * THE METRIC. `A` is the query's ANCHOR entities (below). The score keeps the Jaccard form — so a
15
+ * fact that is "about" the query outranks one that merely contains it — but the fact's own width
16
+ * enters through a SATURATING term:
17
+ *
18
+ * score(A, W, F) = |A ∩ F| / ( W + min(|F \ A|, ENTITY_UNION_CAP) )
19
+ *
20
+ * The query side is `W`, the query's FULL entity count, not `|A|`: the Jaccard denominator is the
21
+ * union, and the query's own width belongs in it whether or not each entity turned out to be
22
+ * discriminative (a question that names five things asks for more than one that names one). When
23
+ * every query entity is an anchor (the frozen eval set) and the fact is no wider than
24
+ * `W + ENTITY_UNION_CAP`, this IS the plain Jaccard ratio `|Q ∩ F| / |Q ∪ F|` — those facts keep
25
+ * their EXACT old score and ordering (verified bit-for-bit on all four eval arms). Past the cap the
26
+ * denominator stops growing, so a wide fact that genuinely mentions a query's rare entity is
27
+ * REACHABLE instead of being divided into oblivion. The cap is not a free parameter: the floor is
28
+ * 0.2 and a two-entity query sharing ONE entity must still clear it (`1 / (2 + C) >= 0.2 ⇒ C <= 3`),
29
+ * so `C = 3` is the largest fact penalty that keeps that shape.
30
+ *
31
+ * WHY ANCHORS (IDF FILTERING). Removing the width blow-up alone would let a corpus-wide word
32
+ * (`用户` / `插件` / `任务` …) admit EVERY fact that mentions it — "drag the whole store in". The
33
+ * extracted bags are dominated by such words, so the precision lever is rarity: an entity is an
34
+ * ANCHOR when its document frequency over the active corpus is at most {@link anchorCeiling}. Only
35
+ * anchors can produce candidates or contribute to the numerator; a query whose entities are ALL
36
+ * generic therefore yields no entity evidence and the other legs answer it. That is deliberate
37
+ * (silent beats noisy), and the FTS leg's 2-char substring fallback (§20.20⑦) serves the 2-char
38
+ * shape.
39
+ *
40
+ * WHAT `W` COUNTS, AND WHY IT IS NOT `|A|`. `W` is the query's FULL entity count — every entity the
41
+ * extractor returned, generic or absent included. A Jaccard denominator is the union of what the
42
+ * query asked for, and the old metric counted all of it; keeping `W` there is what makes a narrow
43
+ * fact's score IDENTICAL to the old ratio, so the frozen eval and every narrow real fact keep their
44
+ * exact numbers and ordering. Dropping the absent entities from `W` was measured and moved the live
45
+ * eval (`CI 用哪个 Python 版本` lost rank 1): a query that names things the corpus does not carry
46
+ * really is asking for more, and the floor is calibrated against that.
47
+ *
48
+ * THE CEILING SCALES WITH THE CORPUS, WITH A FLOOR. A fixed count would mean different things on a
49
+ * 3-fact eval case and a 300k-fact store, and a purely relative one would call every entity
50
+ * "generic" on a tiny corpus (`ceil(0.2 * 3) = 1` filters any name that appears twice — which is
51
+ * every name in a 3-fact relation case). {@link ANCHOR_MIN} forgives small corpora; the relative
52
+ * term is what bites on a real store. Both are calibrated, not guessed: DESIGN §20.19.
53
+ *
54
+ * @module store/entity_leg
55
+ */
56
+ /** An entity is an anchor at or below `max(ANCHOR_MIN, ceil(ANCHOR_RATE × activeFacts))`. */
57
+ export declare const ANCHOR_RATE = 0.2;
58
+ /**
59
+ * Small-corpus forgiveness, and its bound.
60
+ *
61
+ * Without it, `ceil(0.2 × 3) = 1` would call every name appearing twice "generic" — which is every
62
+ * name in a 3-fact relation case — and the eval set would lose its entity leg entirely. This is
63
+ * measured, not guessed: at `2` the eval corpus calls `Python` (carried by all three facts of its
64
+ * case) generic, and `CI 用哪个 Python 版本` loses its answer's entity evidence to the FTS leg —
65
+ * the live arm regressed `must_exclude`, the degraded arm lost rank 1 on that query. `4` keeps a
66
+ * name a small corpus repeats; the relative term is what bites on a real store (a 3-fact case
67
+ * filters nothing, by design).
68
+ */
69
+ export declare const ANCHOR_MIN = 4;
70
+ /**
71
+ * How many entities a fact may carry beyond the query's anchors before the width penalty stops
72
+ * growing (see the module doc: derived from the 0.2 floor and the 2-anchor reachability
73
+ * requirement, `1 / (2 + C) >= 0.2`).
74
+ */
75
+ export declare const ENTITY_UNION_CAP = 3;
76
+ /** The document-frequency ceiling for a corpus of `corpus` active facts/chunks. */
77
+ export declare function anchorCeiling(corpus: number): number;
78
+ /**
79
+ * The query entities that may serve as evidence.
80
+ *
81
+ * `df` is the DOCUMENT FREQUENCY of each query entity over the active corpus (how many facts /
82
+ * chunks carry it). An entity is kept when it occurs at least once and at most
83
+ * {@link anchorCeiling} times; the returned order is the input order (the caller only needs the
84
+ * set, and a stable order keeps the SQL/scoring deterministic).
85
+ */
86
+ export declare function selectAnchors(queryEntities: Iterable<string>, df: ReadonlyMap<string, number>, corpus: number): string[];
87
+ /**
88
+ * The leg's raw score, in [0, 1] — see the module doc for the formula and the constants.
89
+ *
90
+ * `0` for an empty anchor set (no discriminative query entity) and for "shares none". `queryWidth`
91
+ * is the query's full entity count (`W`), which keeps the narrow-fact case identical to the old
92
+ * Jaccard ratio; the fact's width only matters up to {@link ENTITY_UNION_CAP} extra entities, so a
93
+ * wide bag cannot divide a short query into nothing while a narrow one still ranks above a wide one.
94
+ */
95
+ export declare function anchoredOverlap(anchors: readonly string[], queryWidth: number, factEntities: ReadonlySet<string>): number;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The ONE entity-version sweep both stores run.
3
+ *
4
+ * `entities_version` is the stamp that says "these entity rows were produced by the CURRENT
5
+ * extraction rules". A rule change makes every row carrying an older stamp stale, and the only way
6
+ * to close that gap is to re-extract those rows' text — for memory the entity links, the triples and
7
+ * the HRR bundle derived from them; for knowledge the `chunk_entities` rows. That SKELETON was
8
+ * written twice and drifted apart in exactly the ways a copy drifts: memory had the in-flight guard,
9
+ * the budget clamp and the `{rebuilt, deferred, skipped}` report, while knowledge had the same
10
+ * selection inlined in `reindex` with no guard at all. This module is the one copy; the stores
11
+ * contribute a per-aggregate adapter (which rows, how to extract, what to write) and nothing else.
12
+ *
13
+ * Layering, deliberate: this is an ORCHESTRATION rule, not SQL, so it lives beside `common.ts` and
14
+ * `legs.ts` in `store/` — the DAO layer stays split by aggregate and keeps its own statements
15
+ * (`docs/vector-repair-shared-flow.md` §2 / §7).
16
+ *
17
+ * What must NOT move here: the extractor, the rows a store writes, and that store's side effects.
18
+ * Memory's transaction also replaces triples, moves the HRR bundle and re-queues the conflict check
19
+ * (both conflict legs read what the sweep rebuilt); knowledge replaces `chunk_entities` under
20
+ * `MAX_ENTITIES_PER_CHUNK`. Those are domain semantics, and folding them in would trade one
21
+ * duplicated loop for two conditional branches.
22
+ *
23
+ * @module store/entity_sweep
24
+ */
25
+ /**
26
+ * What one bounded sweep pass did.
27
+ *
28
+ * `rebuilt` counts rows this pass re-extracted AND wrote — its batch size when it filled it. The
29
+ * stamp is what makes a corpus current, so a row re-extracted to the same names still counts (and is
30
+ * not selected again); a row whose write FAILED does not, because its old stamp survives. It is
31
+ * deliberately not "rows whose extraction output changed", which no caller could use: they drain a
32
+ * stale corpus by looping until `deferred === 0`.
33
+ */
34
+ export interface EntitySweepReport {
35
+ /** Rows this pass re-extracted and wrote (its batch size, when it filled it and nothing failed). */
36
+ rebuilt: number;
37
+ /** Rows still carrying an older stamp after this pass. */
38
+ deferred: number;
39
+ /** Another pass was already running, so this one did nothing (`deferred` is still current). */
40
+ skipped: boolean;
41
+ }
42
+ /**
43
+ * Rows one sweep pass may rebuild when the caller names no budget.
44
+ *
45
+ * `MemoryStore.reindexEntities` has always defaulted to this, and the CLI passes it explicitly; it
46
+ * lives here so the shared loop and that public re-export cannot drift. Bounds MEMORY, not only
47
+ * mission: a pass selects every chosen row's text before rebuilding the first one.
48
+ */
49
+ export declare const ENTITY_SWEEP_BATCH = 2000;
50
+ /**
51
+ * A row the sweep may rebuild, as far as the SHARED loop is concerned.
52
+ *
53
+ * Deliberately unconstrained: the loop only routes a row from the selection to the adapter's own
54
+ * `extractAndWriteEntities`, so it must not name any column. Memory's row carries
55
+ * `{ fact_id, content }`, knowledge's `{ chunk_id, text }` — parameterizing the loop over the
56
+ * store's row type is what keeps those names out of the orchestration layer.
57
+ */
58
+ export type EntitySweepRow = object;
59
+ /**
60
+ * Per-store primitives. One adapter per aggregate, deliberately narrow.
61
+ *
62
+ * `staleEntityBatch` returns BOTH the scope's full stale count and a bounded slice in one read: the
63
+ * loop needs the remaining count for its report and the rows for its mission, and two queries would
64
+ * scan the same table twice. The two numbers must describe the same population: `total` counts every
65
+ * row the store's scope considers stale (memory: every ACTIVE fact; knowledge: every chunk of the
66
+ * sweep's domain scope), and `rows` is that population ordered by id, truncated to `limit`.
67
+ */
68
+ export interface EntitySweepTarget<T extends EntitySweepRow> {
69
+ /**
70
+ * The scope's full stale count plus at most `limit` of those rows, ordered by id.
71
+ *
72
+ * `limit` is `maxPerPass`; the loop calls this once per pass. Both numbers must describe the SAME
73
+ * population — `total` is every stale row of the store's scope (memory: every ACTIVE fact;
74
+ * knowledge: every chunk of the sweep's domain), `rows` is that population truncated.
75
+ */
76
+ staleEntityBatch(limit: number): {
77
+ total: number;
78
+ rows: readonly T[];
79
+ };
80
+ /**
81
+ * Optional count-only read of the SAME population as {@link staleEntityBatch}'s `total`.
82
+ *
83
+ * It exists for the guard branch, which needs the current deferred count and no rows; absent, that
84
+ * branch falls back to `staleEntityBatch(0).total`. The loop holds the guard for the whole pass and
85
+ * each store is single-threaded, so the count cannot move between the two calls it makes.
86
+ */
87
+ staleEntityCount?(): number;
88
+ /** Re-extract one row's text and write that store's derived rows. May throw; the loop isolates. */
89
+ extractAndWriteEntities(row: T): Promise<void>;
90
+ /**
91
+ * Log a row this pass could not rebuild. Required, not defaulted: only the store knows which
92
+ * column names the row in its own logs (`fact_id` / `chunk_id`), and a shared warning cannot.
93
+ */
94
+ onRowError(row: T, error: unknown): void;
95
+ }
96
+ /** Everything {@link sweepVersionedEntities} needs beyond the adapter. */
97
+ export interface EntitySweepOptions {
98
+ /** Rows this pass may rebuild; defaults to {@link ENTITY_SWEEP_BATCH}, clamped at 0. */
99
+ budget?: number;
100
+ /** Return `true` to stop before the next row (plugin unloading, runtime closed). */
101
+ shouldStop?: () => boolean;
102
+ }
103
+ /**
104
+ * Re-extract the entity rows written by older extraction rules, ONE bounded pass.
105
+ *
106
+ * The contract, in order: take the guard (an overlapping pass reports `skipped` and does nothing);
107
+ * read the scope's stale count and at most `maxPerPass` rows; re-extract each row through the
108
+ * adapter, isolating failures; report `{ rebuilt, deferred, skipped }`.
109
+ *
110
+ * Not an unbounded drain, on purpose: this is reachable from a settings-page button and a periodic
111
+ * heartbeat, so it must not block on a whole stale corpus. Callers that want the corpus current loop
112
+ * until `deferred === 0` (the CLI does).
113
+ *
114
+ * A row whose extraction or write throws is skipped, not fatal: the transaction that stamps the
115
+ * version never ran, so the row is still stale and the NEXT pass retries it — the same retry contract
116
+ * the knowledge store used per chunk. Letting the throw escape would instead abandon the rest of the
117
+ * batch with no report of what was done.
118
+ *
119
+ * @param guard the store's stable in-flight identity (see {@link SWEEPING})
120
+ */
121
+ export declare function sweepVersionedEntities<T extends EntitySweepRow>(target: EntitySweepTarget<T>, guard: object, opts?: EntitySweepOptions): Promise<EntitySweepReport>;
@@ -5,9 +5,9 @@
5
5
  * MAXIMUM, so every leg's head is 1.0 by construction and the fused number is only comparable
6
6
  * WITHIN one query. A cutoff on the fused score would therefore mean a different thing per query
7
7
  * (and would move whenever a leg's head changed). The legs' raw numbers do have an absolute scale —
8
- * cosine for the semantic leg, the Jaccard ratio for the entity leg, "distinct query terms hit" for
9
- * FTS — so that is where the floors live. The side benefit is that flooring a leg's tail cannot
10
- * move its maximum, so the cap-invariance §20.17 relies on is untouched.
8
+ * cosine for the semantic leg, anchored Jaccard for the entity leg (`store/entity_leg.ts`),
9
+ * "distinct query terms hit" for FTS — so that is where the floors live. The side benefit is that
10
+ * flooring a leg's tail cannot move its maximum, so the cap-invariance §20.17 relies on is untouched.
11
11
  *
12
12
  * THREE FLOORS, ONE SHAPE. `min_semantic_similarity` / `min_fts_terms` / `min_jaccard`; each `0`
13
13
  * means "this leg is not gated", and a candidate EQUAL to its floor is KEPT (only strictly-lower
@@ -49,14 +49,25 @@
49
49
  * of its own is bounded instead: it costs one extra query, only when the strict answer was empty,
50
50
  * and it can still report "nothing relevant" for a genuinely unrelated question.
51
51
  *
52
- * FTS REACHABILITY. `min_fts_terms` counts distinct query terms a row hits, but a query can only
53
- * produce so many terms (`relevanceTerms`: latin words ≥5 chars + CJK 3-grams) — a 3-character CJK
54
- * query yields exactly ONE trigram, so a configured bar of 2 is unreachable and that leg is empty
55
- * for it by construction. The effective bar is therefore `min(configured, termCount)`, and
56
- * `termCount` is that of the text being GRADED — with 方案 A's augmentation the original and the
57
- * rewrite are different texts with different counts, so each resolves its own bar
58
- * (`store/hybrid.ts`). Resolving the rewrite from the original's count is the defect that let a
59
- * single incidental trigram pass a bar it could not reach (2026-10-04).
52
+ * FTS REACHABILITY, AND THE SHORT-QUERY FALLBACK. `min_fts_terms` counts distinct query terms a row
53
+ * hits, but a query can only produce so many terms — a 3-character CJK query yields exactly ONE
54
+ * trigram, so a configured bar of 2 is unreachable and that leg is empty for it by construction. The
55
+ * effective bar is therefore `min(configured, termCount)`, and `termCount` is that of the text being
56
+ * GRADED — with 方案 A's augmentation the original and the rewrite are different texts with
57
+ * different counts, so each resolves its own bar (`store/hybrid.ts`). Resolving the rewrite from the
58
+ * original's count is the defect that let a single incidental trigram pass a bar it could not reach
59
+ * (2026-10-04).
60
+ *
61
+ * That clamp still leaves the shape with NO term at all: a 2-char CJK query (`buildFtsQuery` returns
62
+ * `null` — the trigram index holds no term under three characters, measured on the live store). The
63
+ * term set is therefore {@link gradedTerms}: when the query yields no index-expressible term, the
64
+ * 2-char CJK runs themselves become the terms, and the legs search/grade them as SUBSTRINGS
65
+ * (`content LIKE '%…%'`, the finer predicate an FTS5 trigram table still exposes; see
66
+ * `store/lexical.ts`). The clamp stays the same rule and stays honest — one substring term is
67
+ * `min(configured, 1)`, and {@link applyTermFloor} re-derives it from the query it is grading, so
68
+ * the reported number is the applied number. The guard against the noise a 2-char term invites is
69
+ * the term itself: the row must CONTAIN the run contiguously (not merely share a trigram), and a
70
+ * multi-run query must contain `min(configured, runs)` of them.
60
71
  *
61
72
  * @module store/floors
62
73
  */
@@ -86,16 +97,20 @@ export type FloorLeg = 'semantic' | 'fts' | 'jaccard';
86
97
  * - fts: NOT a constant — {@link looseTermFloor}, half the query's distinct terms with a floor of
87
98
  * 1. `1` here is the "no terms to scale by" baseline (the previously constant value); a query
88
99
  * that cannot produce terms is not graded at all, and one that produces a single term (every
89
- * 3-char CJK query) is already graded against that one term by the reachability clamp.
90
- * - jaccard **0.15** (positive — the previous `0` was the ungated hole this closes). Measured, not
91
- * guessed, on two corpora: (a) the live store, 64 active facts, the common-entity questions a
92
- * real user asks about this repo ("插件怎么安装" / "任务怎么拆分" / "知识库在哪里" …) put their
93
- * TOP non-answering entity candidate at Jaccard 0.0909 and most at 0.02–0.07, so 0.15 removes that
94
- * whole tail; (b) the gold-labelled 35-query eval set, where the LOWEST Jaccard any answering pair
95
- * reached is 0.25 (「老王喜欢什么风格」) — a bottom line above 0.25 starts dropping gold pairs,
96
- * while 0.15 filters none. The entity leg cannot fully separate the two classes on short queries
97
- * (a one-entity query against a one-entity fact is 1.0 by construction, and non-gold pairs reach
98
- * 0.667), so this is deliberately a "no incidental small overlap" line, not a relevance classifier.
100
+ * 3-char CJK query, and every 2-char one through the substring fallback) is already graded
101
+ * against that one term by the reachability clamp.
102
+ * - jaccard **0.15** (positive — the previous `0` was the ungated hole this closes). The metric was
103
+ * recalibrated on 2026-10-04 (`store/entity_leg.ts`): the leg still scores a Jaccard ratio, but
104
+ * over the query's ANCHOR entities and with the fact's extra-entity term SATURATED at
105
+ * `ENTITY_UNION_CAP`. 0.15 on that unit means "shares at least one discriminative entity" for any
106
+ * query with up to six anchors (`1/6 = 0.167 > 0.15`), i.e. the bottom line that cannot be lowered
107
+ * to "any incidental overlap" without admitting a length-dependent fraction. The old Jaccard
108
+ * calibration, kept for the record: (a) on the live store the top non-answering candidate for
109
+ * common-entity questions sat at 0.0909 and most at 0.02–0.07; (b) the lowest Jaccard any
110
+ * answering pair on the gold 35-query set reached was 0.25. The entity leg cannot separate the
111
+ * classes on short queries by itself, so this is deliberately a "no incidental small overlap"
112
+ * line, not a relevance classifier — see `store/entity_leg.ts` for why the fact's own width was
113
+ * saturated rather than used as the separator.
99
114
  */
100
115
  export declare const LOOSE_FLOORS: RetrievalFloors;
101
116
  /**
@@ -116,10 +131,10 @@ export declare function emptyFloorDrops(): RetrievalFloorDrops;
116
131
  * The FTS bar actually applied to a query carrying `termCount` distinct terms.
117
132
  *
118
133
  * A query cannot be graded against more terms than it has (see the module comment: a 3-char CJK
119
- * query has one trigram), so the configured bar is CLAMPED to the reachable maximum. `termCount`
120
- * `0`/omitted leaves the value alone — such a query is not graded at all by {@link applyTermFloor},
121
- * and changing the REPORTED value there would misdescribe what the caller is reading. `0` stays `0`
122
- * (the operator's off switch).
134
+ * query has one trigram, and a 2-char one has one substring term), so the configured bar is CLAMPED
135
+ * to the reachable maximum. `termCount` `0`/omitted leaves the value alone — such a query is not
136
+ * graded at all by {@link applyTermFloor}, and changing the REPORTED value there would misdescribe
137
+ * what the caller is reading. `0` stays `0` (the operator's off switch).
123
138
  *
124
139
  * This is the ONE place the clamp lives; `resolveFloors` (what the result reports / a run is handed)
125
140
  * and `applyTermFloor` (what the leg does) both call it, so the number a run reads is the number its
@@ -133,7 +148,7 @@ export interface FloorResolution {
133
148
  /** `loose` lowers each configured floor (see {@link LOOSE_FLOORS}); absent means `strict`. */
134
149
  profile?: FloorProfile;
135
150
  /**
136
- * The distinct term count of the text being GRADED (`relevanceTerms(text).length`), for the FTS
151
+ * The distinct term count of the text being GRADED (`gradedTerms(text).length`), for the FTS
137
152
  * clamp. It must describe the query the legs will actually run on: under 方案 A's augmentation the
138
153
  * rewrite is a DIFFERENT text with its own term count, and resolving both runs from the original's
139
154
  * count is the defect that let one incidental trigram through (see `store/hybrid.ts`). Callers with
@@ -182,16 +197,21 @@ export declare function applyScoreFloor(scores: Map<number, number>, floor: numb
182
197
  *
183
198
  * Substring containment, case-insensitively: FTS5's trigram tokenizer matches a quoted phrase by
184
199
  * consecutive trigrams and is ASCII-case-insensitive, which is the same predicate as "the term
185
- * occurs in the text". `relevanceTerms` lowercases its latin words, so the comparison has to.
200
+ * occurs in the text" — and the 2-char fallback terms ARE substrings by construction
201
+ * (`store/lexical.ts`), so one predicate covers both. `relevanceTerms` lowercases its latin words,
202
+ * so the comparison has to.
186
203
  */
187
204
  export declare function countMatchedTerms(text: string, terms: readonly string[]): number;
188
205
  /**
189
206
  * Apply the per-row FTS floor to one FTS leg's scores.
190
207
  *
191
- * The terms come from `relevanceTerms(query)` (latin words ≥ 5 chars + every CJK 3-gram) — the same
192
- * tokenisation the conditional-hint probe uses, so "relevant enough" means one thing in this repo.
193
- * A query with NO such term is not graded at all: there is nothing to count, and dropping every row
194
- * on a technicality would turn a 3–4 char latin query (`buildFtsQuery` still emits it) into silence.
208
+ * The terms come from `gradedTerms(query)` — latin words ≥ 5 chars + every CJK 3-gram, and (only
209
+ * when the query produces no such term) the 2-char CJK runs as substrings. The indexed terms are
210
+ * the same tokenisation the conditional-hint probe uses, so "relevant enough" means one thing in
211
+ * this repo; the fallback exists because the hint path may stay silent while the LEG must not
212
+ * (`store/lexical.ts`). A query with NO such term is not graded at all: there is nothing to count,
213
+ * and dropping every row on a technicality would turn a 3–4 char latin query (`buildFtsQuery` still
214
+ * emits it) into silence.
195
215
  *
196
216
  * The bar itself is {@link effectiveTermFloor}: a query that can only produce one term (a 3-char CJK
197
217
  * query is one trigram) is measured against that one term, not against a configured 2 it could never