@avantf/dsh-mem 0.3.0 → 0.4.0

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 (145) hide show
  1. package/README.md +89 -28
  2. package/cordis.patch.yml +17 -0
  3. package/lib/client.js +155 -14
  4. package/lib/dsh-build.json +5 -5
  5. package/lib/engine/mem/config/config_files.d.ts +26 -0
  6. package/lib/engine/mem/config/domains.d.ts +20 -0
  7. package/lib/engine/mem/config/loader.d.ts +35 -0
  8. package/lib/engine/mem/config/paths.d.ts +53 -0
  9. package/lib/engine/mem/db/chunk.d.ts +26 -0
  10. package/lib/engine/mem/db/conn.d.ts +13 -0
  11. package/lib/engine/mem/db/dao/chunks.d.ts +150 -0
  12. package/lib/engine/mem/db/dao/contradictions.d.ts +98 -0
  13. package/lib/engine/mem/db/dao/documents.d.ts +51 -0
  14. package/lib/engine/mem/db/dao/entities.d.ts +67 -0
  15. package/lib/engine/mem/db/dao/facts.d.ts +472 -0
  16. package/lib/engine/mem/db/dao/stats.d.ts +14 -0
  17. package/lib/engine/mem/db/dao/triples.d.ts +80 -0
  18. package/lib/engine/mem/db/hash.d.ts +2 -0
  19. package/lib/engine/mem/db/knowledge.d.ts +17 -0
  20. package/lib/engine/mem/db/migrations.d.ts +71 -0
  21. package/lib/engine/mem/db/port.d.ts +57 -0
  22. package/lib/engine/mem/db/schema.d.ts +26 -0
  23. package/lib/engine/mem/db/sqlite.d.ts +89 -0
  24. package/lib/engine/mem/db/store.d.ts +35 -0
  25. package/lib/engine/mem/db/tokenizer.d.ts +61 -0
  26. package/lib/engine/mem/db/vectors.d.ts +50 -0
  27. package/lib/engine/mem/dispatch.d.ts +51 -0
  28. package/lib/engine/mem/entities/extract.d.ts +84 -0
  29. package/lib/engine/mem/eval/loader.d.ts +19 -0
  30. package/lib/engine/mem/eval/metrics.d.ts +25 -0
  31. package/lib/engine/mem/eval/runner.d.ts +10 -0
  32. package/lib/engine/mem/eval/write_metrics.d.ts +88 -0
  33. package/lib/engine/mem/git.d.ts +73 -0
  34. package/lib/engine/mem/hrr/algebra.d.ts +30 -0
  35. package/lib/engine/mem/hrr/atoms.d.ts +22 -0
  36. package/lib/engine/mem/hrr/encode.d.ts +32 -0
  37. package/lib/engine/mem/hrr/index.d.ts +3 -0
  38. package/lib/engine/mem/index.d.ts +27 -0
  39. package/lib/engine/mem/lifecycle/contradiction.d.ts +156 -0
  40. package/lib/engine/mem/lifecycle/index.d.ts +5 -0
  41. package/lib/engine/mem/lifecycle/maintenance.d.ts +48 -0
  42. package/lib/engine/mem/lifecycle/presence.d.ts +29 -0
  43. package/lib/engine/mem/lifecycle/tick.d.ts +43 -0
  44. package/lib/engine/mem/lifecycle/trust.d.ts +90 -0
  45. package/lib/engine/mem/modelBootstrap.d.ts +207 -0
  46. package/lib/engine/mem/router.d.ts +51 -0
  47. package/lib/engine/mem/runtime.d.ts +232 -0
  48. package/lib/engine/mem/store/common.d.ts +82 -0
  49. package/lib/engine/mem/store/doc_files.d.ts +125 -0
  50. package/lib/engine/mem/store/document_text.d.ts +47 -0
  51. package/lib/engine/mem/store/floors.d.ts +191 -0
  52. package/lib/engine/mem/store/hybrid.d.ts +232 -0
  53. package/lib/engine/mem/store/ingest_guard.d.ts +25 -0
  54. package/lib/engine/mem/store/knowledge.d.ts +483 -0
  55. package/lib/engine/mem/store/lexical.d.ts +67 -0
  56. package/lib/engine/mem/store/memory.d.ts +543 -0
  57. package/lib/engine/mem/store/source_picker.d.ts +43 -0
  58. package/lib/engine/mem-contract/config.d.ts +129 -0
  59. package/lib/engine/mem-contract/env.d.ts +31 -0
  60. package/lib/engine/mem-contract/family.d.ts +25 -0
  61. package/lib/engine/mem-contract/index.d.ts +8 -0
  62. package/lib/engine/mem-contract/log.d.ts +29 -0
  63. package/lib/engine/mem-contract/remote.d.ts +80 -0
  64. package/lib/engine/mem-contract/text.d.ts +92 -0
  65. package/lib/engine/mem-contract/tools.d.ts +284 -0
  66. package/lib/engine/mem-contract/types.d.ts +615 -0
  67. package/lib/engine/mem-retrieval/adapters/auto_vstore.d.ts +47 -0
  68. package/lib/engine/mem-retrieval/adapters/hnswlib.d.ts +174 -0
  69. package/lib/engine/mem-retrieval/adapters/local_bge.d.ts +121 -0
  70. package/lib/engine/mem-retrieval/adapters/local_numpy.d.ts +28 -0
  71. package/lib/engine/mem-retrieval/adapters/local_reranker.d.ts +52 -0
  72. package/lib/engine/mem-retrieval/adapters/none_reranker.d.ts +10 -0
  73. package/lib/engine/mem-retrieval/adapters/warm_gate.d.ts +46 -0
  74. package/lib/engine/mem-retrieval/budget.d.ts +30 -0
  75. package/lib/engine/mem-retrieval/fusion.d.ts +53 -0
  76. package/lib/engine/mem-retrieval/index.d.ts +14 -0
  77. package/lib/engine/mem-retrieval/interfaces.d.ts +82 -0
  78. package/lib/engine/mem-retrieval/log.d.ts +11 -0
  79. package/lib/engine/mem-retrieval/registry.d.ts +23 -0
  80. package/lib/engine/mem-retrieval/rerank.d.ts +13 -0
  81. package/lib/engine/mem-retrieval/stats.d.ts +64 -0
  82. package/lib/engine/mem-retrieval/text_budget.d.ts +52 -0
  83. package/lib/index.js +1773 -473
  84. package/lib/interface-version.json +2 -2
  85. package/lib/types/client/domains.d.ts +5 -4
  86. package/lib/types/client/domains.d.ts.map +1 -1
  87. package/lib/types/client/index.d.ts.map +1 -1
  88. package/lib/types/client/ingest.d.ts +1 -1
  89. package/lib/types/client/wire.d.ts +36 -0
  90. package/lib/types/client/wire.d.ts.map +1 -0
  91. package/lib/types/envinit-bootstrap.d.ts +3 -2
  92. package/lib/types/envinit.d.ts +2 -19
  93. package/lib/types/envinit.d.ts.map +1 -1
  94. package/lib/types/hints.d.ts +1 -1
  95. package/lib/types/index.d.ts +8 -3
  96. package/lib/types/index.d.ts.map +1 -1
  97. package/lib/types/open.d.ts +61 -11
  98. package/lib/types/open.d.ts.map +1 -1
  99. package/lib/types/prompt.d.ts +19 -1
  100. package/lib/types/prompt.d.ts.map +1 -1
  101. package/lib/types/provision.d.ts +1 -1
  102. package/lib/types/provision.d.ts.map +1 -1
  103. package/lib/types/reconcile.d.ts +64 -0
  104. package/lib/types/reconcile.d.ts.map +1 -0
  105. package/lib/types/remote.d.ts +53 -0
  106. package/lib/types/remote.d.ts.map +1 -1
  107. package/lib/types/render.d.ts +35 -0
  108. package/lib/types/render.d.ts.map +1 -0
  109. package/lib/types/tool_schema.d.ts +3 -13
  110. package/lib/types/tool_schema.d.ts.map +1 -1
  111. package/package.json +27 -18
  112. package/lib/client.js.map +0 -1
  113. package/lib/types/client/domains.js +0 -25
  114. package/lib/types/client/domains.js.map +0 -1
  115. package/lib/types/client/index.js +0 -1371
  116. package/lib/types/client/index.js.map +0 -1
  117. package/lib/types/client/ingest.js +0 -30
  118. package/lib/types/client/ingest.js.map +0 -1
  119. package/lib/types/client/paging.js +0 -36
  120. package/lib/types/client/paging.js.map +0 -1
  121. package/lib/types/client/summarize.js +0 -79
  122. package/lib/types/client/summarize.js.map +0 -1
  123. package/lib/types/data_home.js +0 -26
  124. package/lib/types/data_home.js.map +0 -1
  125. package/lib/types/envinit-bootstrap.js +0 -333
  126. package/lib/types/envinit.js +0 -402
  127. package/lib/types/envinit.js.map +0 -1
  128. package/lib/types/hints.js +0 -53
  129. package/lib/types/hints.js.map +0 -1
  130. package/lib/types/index.js +0 -755
  131. package/lib/types/index.js.map +0 -1
  132. package/lib/types/inject.js +0 -14
  133. package/lib/types/inject.js.map +0 -1
  134. package/lib/types/interface_gate.js +0 -84
  135. package/lib/types/interface_gate.js.map +0 -1
  136. package/lib/types/open.js +0 -133
  137. package/lib/types/open.js.map +0 -1
  138. package/lib/types/prompt.js +0 -148
  139. package/lib/types/prompt.js.map +0 -1
  140. package/lib/types/provision.js +0 -177
  141. package/lib/types/provision.js.map +0 -1
  142. package/lib/types/remote.js +0 -247
  143. package/lib/types/remote.js.map +0 -1
  144. package/lib/types/tool_schema.js +0 -149
  145. package/lib/types/tool_schema.js.map +0 -1
@@ -0,0 +1,125 @@
1
+ /** The frontmatter a managed file carries; `content_hash` is the body AS INGESTED. */
2
+ interface DocFileMeta {
3
+ doc_id: number;
4
+ domain: string;
5
+ source: string;
6
+ title: string;
7
+ source_uri: string | null;
8
+ ingested_at: string;
9
+ content_hash: string;
10
+ /**
11
+ * The `@avantf/mem-convert` converter that produced the body, when the source was converted
12
+ * (absent for text and PDF, and for every file written before this field existed — readers must
13
+ * treat it as optional, which is why it is not `null`).
14
+ */
15
+ converter?: string;
16
+ }
17
+ /** One managed file as the file system sees it now. */
18
+ interface DocFileState {
19
+ path: string;
20
+ stale: boolean;
21
+ missing: boolean;
22
+ }
23
+ /** A parsed managed file: frontmatter (possibly partial — a hand-written file may omit it) + body. */
24
+ interface ParsedDocFile {
25
+ meta: Partial<DocFileMeta>;
26
+ body: string;
27
+ }
28
+ /** One path segment, made safe and human-readable; `fallback` covers names that sanitize away. */
29
+ export declare function sanitizeSegment(raw: string, fallback: string): string;
30
+ /**
31
+ * A stable title for a body that arrived as TEXT (a paste), where there is no file name to borrow.
32
+ *
33
+ * Pasted text has no `source_uri` to name it after, and `source` defaults to the same value for
34
+ * every paste — so without this, every untitled paste into one domain collapsed onto one identity
35
+ * and the second ingest silently replaced the first. The rule: the first Markdown ATX heading,
36
+ * else the first non-empty line with its block markers stripped.
37
+ *
38
+ * Fenced code is stepped over in BOTH halves: `#include` is not a heading, and a fence opener is
39
+ * not a title. `MAX_SEGMENT` is the same cap the file name uses, so a long line cannot produce a
40
+ * title the managed path has to truncate differently. Empty input falls back to `untitled`, and the
41
+ * result depends only on the text (same body → same title), which is what keeps a re-paste
42
+ * landing on the SAME identity instead of quietly creating a sibling every time.
43
+ */
44
+ export declare function deriveDocTitle(text: string): string;
45
+ /** sha256 of a document body — the staleness key. */
46
+ export declare function bodyHash(body: string): string;
47
+ /**
48
+ * Render one managed file: frontmatter (JSON-encoded values, so any title/URI survives) + body.
49
+ *
50
+ * The body is written VERBATIM — no trailing newline is added. `content_hash` records the ingested
51
+ * text, and `state` compares it against the file's body, so anything this function normalizes
52
+ * would make every document read as edited the moment it was written.
53
+ *
54
+ * An `undefined` value is OMITTED rather than rendered: JSON.stringify(undefined) is the bare token
55
+ * `undefined`, which the tolerant parser would then hand back as the string "undefined". Optional
56
+ * keys (today: `converter`) therefore simply do not appear for the documents that lack them.
57
+ */
58
+ export declare function renderDocFile(meta: DocFileMeta, body: string): string;
59
+ /**
60
+ * Split one managed file into frontmatter and body.
61
+ *
62
+ * Deliberately tolerant: a file without frontmatter (or with a hand-mangled one) is ALL body —
63
+ * the user's content is never thrown away because a header key is missing, and the caller decides
64
+ * what an unknown `doc_id` means.
65
+ */
66
+ export declare function parseDocFile(text: string): ParsedDocFile;
67
+ /**
68
+ * The managed document directory.
69
+ *
70
+ * Paths are `<root>/<domain>/<source>/<title>.md`, sanitized per segment. Collisions resolve by
71
+ * asking the EXISTING file who it belongs to: a file whose frontmatter names another `doc_id` is
72
+ * left alone and this document takes the `<title>~<doc_id>.md` variant. Without that check two
73
+ * documents that sanitize to the same name would take turns clobbering each other.
74
+ */
75
+ export declare class DocFiles {
76
+ private readonly root;
77
+ constructor(root: string);
78
+ /** The directory holding one document's file (created on write, not here). */
79
+ dirFor(domain: string, source: string): string;
80
+ /**
81
+ * The file NAME for a title, minus the directory: a title that is already a Markdown file name
82
+ * (`kb ingest --uri notes.md` names the document after the file) must not become `notes.md.md`.
83
+ */
84
+ fileName(title: string, suffix?: string): string;
85
+ /** The base path for a title (before collision handling). */
86
+ basePath(domain: string, source: string, title: string): string;
87
+ /**
88
+ * The file this document owns: the base path, or its `~<doc_id>` variant when the base path is
89
+ * taken by a DIFFERENT document. Purely derived — no state is written to answer this.
90
+ */
91
+ pathFor(docId: number, domain: string, source: string, title: string): string;
92
+ /** Write the managed file for one document (atomic: temp file + rename). Returns its path. */
93
+ write(meta: DocFileMeta, body: string): string;
94
+ /** Parse one managed file, or `null` when it does not exist. */
95
+ read(path: string): ParsedDocFile | null;
96
+ /**
97
+ * A cheap per-file stamp — `mtimeMs:size`, no reads — or `null` when the file is absent.
98
+ *
99
+ * This is a TRIGGER, never a judgement: same-second edits, coarse filesystem timestamps and
100
+ * `cp -p` can all leave it unchanged, so a matching stamp means "probably unchanged" and only
101
+ * `state()` (which hashes the body) decides. The point is that the common case costs one `stat`
102
+ * per file instead of reading and hashing every file in the corpus.
103
+ */
104
+ stamp(path: string): string | null;
105
+ /** Every `.md` path under the root. A directory walk WITHOUT reads — no frontmatter, no hashing. */
106
+ listPaths(): string[];
107
+ /** Is this document's file there, and does it still match what was ingested? */
108
+ state(docId: number, domain: string, source: string, title: string): DocFileState;
109
+ /** Delete this document's file. `false` when there was nothing to delete. */
110
+ remove(docId: number, domain: string, source: string, title: string): boolean;
111
+ /**
112
+ * Every `.md` under the root, with the `doc_id` its frontmatter names (when it names one).
113
+ * Used to report orphans — files left behind by a document that no longer exists.
114
+ *
115
+ * ASYNC and batched: the walk itself is an async recursive `readdir` (a `readdirSync` gathers
116
+ * every entry in one ~41 ms call at 10k files), and the per-file parse is a `read` — at 10k
117
+ * documents that is a second full read of the corpus on top of `sync`'s staleness pass, and it
118
+ * used to be one uninterrupted synchronous block (performance review §7.5 / P6).
119
+ */
120
+ scan(): Promise<{
121
+ path: string;
122
+ doc_id?: number;
123
+ }[]>;
124
+ }
125
+ export {};
@@ -0,0 +1,47 @@
1
+ /** What the first bytes say a document is. */
2
+ type DocumentFormat = 'pdf' | 'binary' | 'text';
3
+ /** How text bytes were decoded. Reported so a guess is visible instead of silent. */
4
+ type DocumentEncoding = 'utf-8' | 'utf-8-bom' | 'utf-16le' | 'utf-16be' | 'gb18030';
5
+ /** One document's indexable text, and where it came from. */
6
+ export interface DocumentText {
7
+ text: string;
8
+ /**
9
+ * `pdf` = extracted text layer; `converter` = a document format turned into Markdown (see
10
+ * {@link DocumentText.converter}); `text` = decoded bytes (see {@link DocumentText.encoding}).
11
+ */
12
+ via: 'text' | 'pdf' | 'converter';
13
+ encoding?: DocumentEncoding;
14
+ /** Which `@avantf/mem-convert` converter produced the body (only when `via === 'converter'`). */
15
+ converter?: string;
16
+ /** What that conversion could not carry over (dropped media, a truncated sheet, …). */
17
+ warnings?: string[];
18
+ }
19
+ /** Classify one document by its leading bytes. */
20
+ export declare function sniffFormat(bytes: Uint8Array): {
21
+ format: DocumentFormat;
22
+ kind?: string;
23
+ };
24
+ /**
25
+ * Restore the ideographs pdfjs sometimes reports as compatibility codepoints.
26
+ *
27
+ * Some CJK PDFs extract `⽹站` (KANGXI RADICAL NET + 站) instead of `网站` — visually identical,
28
+ * different codepoints, so a search for the real word misses. Only those three compatibility ranges
29
+ * are normalized: NFKC over the whole text would also rewrite full-width punctuation (,。!?),
30
+ * which is real content and must survive verbatim.
31
+ */
32
+ export declare function normalizeCjkCompatibility(text: string): string;
33
+ /**
34
+ * The text of one document, whatever its bytes are.
35
+ *
36
+ * CONVERSION RUNS FIRST, before the magic-number sniff and therefore before the binary rejection.
37
+ * docx/xlsx/html/csv are converted to Markdown here, and the order is not cosmetic: docx and xlsx
38
+ * are ZIPs, so a sniff-first pipeline would file them under "known binary" and refuse them. A
39
+ * document no converter claims falls through to the text/PDF/binary paths unchanged.
40
+ *
41
+ * @param bytes - the whole document (already bounded by the byte cap).
42
+ * @param label - the path or URL, used verbatim in error messages and as the sniff's extension hint.
43
+ * @throws when the document is a binary we cannot read, is not decodable, or is a PDF without a
44
+ * text layer.
45
+ */
46
+ export declare function documentText(bytes: Uint8Array, label: string): Promise<DocumentText>;
47
+ export {};
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The relevance floors both stores apply to their legs' OWN raw scores, before `fuse()`.
3
+ *
4
+ * WHY PRE-FUSION, AND WHY ABSOLUTE. `retrieval-core/src/fusion.ts` scales each leg by that leg's
5
+ * MAXIMUM, so every leg's head is 1.0 by construction and the fused number is only comparable
6
+ * WITHIN one query. A cutoff on the fused score would therefore mean a different thing per query
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.
11
+ *
12
+ * THREE FLOORS, ONE SHAPE. `min_semantic_similarity` / `min_fts_terms` / `min_jaccard`; each `0`
13
+ * means "this leg is not gated", and a candidate EQUAL to its floor is KEPT (only strictly-lower
14
+ * candidates are dropped). The FTS floor is judged PER ROW, not per query: "the store holds this
15
+ * term somewhere" is the conditional-hint probe's question (`lexical.ts`), and it would let a row
16
+ * that shares one incidental trigram ride into the result — exactly the low-relevance candidate the
17
+ * floor exists to remove.
18
+ *
19
+ * DEGRADED RELAXATION. With the semantic leg down (`DEGRADED_WEIGHTS`), FTS + entity are the only
20
+ * evidence a query has, so a two-term bar that the semantic leg used to soften would empty whole
21
+ * short queries. The FTS floor's effective value drops to 1 in that mode; an explicit `0` stays 0.
22
+ *
23
+ * WHY THERE IS A RELAXED PROFILE AT ALL, AND WHY IT IS NOT "NO FLOOR". The floors are calibrated
24
+ * for paraphrase-style relevance, and there is a band where no threshold separates "answers the
25
+ * question" from "unrelated": measured on the live store, the fact answering 「我是谁」 scored cosine
26
+ * 0.444 while four unrelated queries topped out at 0.384 — a 0.06 margin, and the answering fact
27
+ * was the query's own top-1. So the answer is not a lower default (which would trade precision for
28
+ * the whole corpus) but a SECOND pass with an absolute bottom line ({@link LOOSE_FLOORS}), run only
29
+ * when the strict pass returned nothing at all AND the floors are why (`dropped_by_floor > 0`).
30
+ *
31
+ * WHICH LEGS A RELAXED PASS LOWERS, AND WHY THAT IS NOT A NO-OP IN THE REPORT. Only the legs the
32
+ * strict pass actually DROPPED candidates on (see {@link droppedLegs}) are lowered. Lowering a leg
33
+ * that dropped nothing cannot admit anything — a floor only removes entries — so the chosen
34
+ * candidates are identical either way; what changes is the `floors` envelope handed back, which
35
+ * used to claim every leg had been relaxed even when only one was ever the problem. An EXPLICIT
36
+ * `floors: 'loose'` request has no prior pass to read, so it lowers every gated leg — to the SAME
37
+ * bottom lines the automatic pass uses, so the explicit entry can never drop below them (it is not
38
+ * a backdoor; see {@link resolveFloors}).
39
+ *
40
+ * WHY NOT EXEMPT `pinned` FACTS FROM THE FLOORS. It was the obvious shortcut and it is wrong: the
41
+ * exemption only lets a fact into the fusion pool, it does not add score, and `fuse` ranks by each
42
+ * leg's normalized raw score before slicing to `limit`. So on a query with plenty of strong
43
+ * candidates the archive sits at the tail and is invisible — but on a weak (or should-be-empty)
44
+ * query it fills the tail, i.e. every search starts showing the same archive. The effect grows with
45
+ * the number of pinned facts, and on a small corpus (`overFetch = limit × 5` covers the whole store)
46
+ * the exemption degenerates into "every pinned fact joins every fusion". A relaxed PASS with a floor
47
+ * of its own is bounded instead: it costs one extra query, only when the strict answer was empty,
48
+ * and it can still report "nothing relevant" for a genuinely unrelated question.
49
+ *
50
+ * FTS REACHABILITY. `min_fts_terms` counts distinct query terms a row hits, but a query can only
51
+ * produce so many terms (`relevanceTerms`: latin words ≥5 chars + CJK 3-grams) — a 3-character CJK
52
+ * query yields exactly ONE trigram, so a configured bar of 2 is unreachable and that leg is empty
53
+ * for it by construction. The effective bar is therefore `min(configured, termCount)`.
54
+ *
55
+ * @module store/floors
56
+ */
57
+ import type { FloorProfile, RetrievalFloorDrops, RetrievalFloors } from '../../mem-contract/index.js';
58
+ /** The retriever knobs the floors read (a structural type so tests can pass a literal). */
59
+ export interface FloorConfig {
60
+ min_semantic_similarity: number;
61
+ min_fts_terms: number;
62
+ min_jaccard: number;
63
+ }
64
+ /**
65
+ * The legs that own a floor. The HRR probe has no floor of its own — it shares the Jaccard one — so
66
+ * it never appears here; a drop on `hrr` relaxes `jaccard` (see {@link droppedLegs}).
67
+ */
68
+ export type FloorLeg = 'semantic' | 'fts' | 'jaccard';
69
+ /**
70
+ * What the RELAXED pass lowers each floor TO, as absolute values.
71
+ *
72
+ * - semantic **0.40**: strictly BETWEEN the measured unrelated-query ceiling (0.384) and the
73
+ * pronoun-query top-1 (0.444). Both bounds are load-bearing. Above 0.384, so a genuinely
74
+ * unrelated question still comes back EMPTY from the relaxed pass — the four measured samples
75
+ * topped out at 0.300/0.384/0.315/0.351, and a 0.35 bar would already admit two of them. Below
76
+ * 0.444, so 「我是谁」 finds its fact. 0.40 keeps the larger margin on the noise side, which is
77
+ * where a false positive costs the caller a wrong answer rather than a missing one.
78
+ * - fts: NOT a constant — {@link looseTermFloor}, half the query's distinct terms with a floor of
79
+ * 1. `1` here is the "no terms to scale by" baseline (the previously constant value); a query
80
+ * that cannot produce terms is not graded at all, and one that produces a single term (every
81
+ * 3-char CJK query) is already graded against that one term by the reachability clamp.
82
+ * - jaccard **0.15** (positive — the previous `0` was the ungated hole this closes). Measured, not
83
+ * guessed, on two corpora: (a) the live store, 64 active facts, the common-entity questions a
84
+ * real user asks about this repo ("插件怎么安装" / "任务怎么拆分" / "知识库在哪里" …) put their
85
+ * TOP non-answering entity candidate at Jaccard 0.0909 and most at 0.02–0.07, so 0.15 removes that
86
+ * whole tail; (b) the gold-labelled 35-query eval set, where the LOWEST Jaccard any answering pair
87
+ * reached is 0.25 (「老王喜欢什么风格」) — a bottom line above 0.25 starts dropping gold pairs,
88
+ * while 0.15 filters none. The entity leg cannot fully separate the two classes on short queries
89
+ * (a one-entity query against a one-entity fact is 1.0 by construction, and non-gold pairs reach
90
+ * 0.667), so this is deliberately a "no incidental small overlap" line, not a relevance classifier.
91
+ */
92
+ export declare const LOOSE_FLOORS: RetrievalFloors;
93
+ /**
94
+ * The FTS bar the RELAXED pass applies to a query carrying `termCount` distinct terms.
95
+ *
96
+ * Positive on purpose: "Relaxed" must never mean "ungated", and the old constant of `1` let a single
97
+ * incidental trigram become a leg's head (per-leg max normalization makes it 1.0) on any query long
98
+ * enough to produce several terms. Half the query's terms — `ceil(termCount / 2)`, at least 1 — keeps
99
+ * a real fraction of what the query asked for while still relaxing the configured bar of 2 to 1 for
100
+ * a two-term query. `termCount` 0/omitted keeps the baseline of 1 (such a query is not graded by
101
+ * {@link applyTermFloor} at all). The `min(configured, …)` in {@link resolveFloors} means this can
102
+ * only relax, never raise.
103
+ */
104
+ export declare function looseTermFloor(termCount?: number): number;
105
+ /** The floor every leg's drops start from. */
106
+ export declare function emptyFloorDrops(): RetrievalFloorDrops;
107
+ /**
108
+ * The FTS bar actually applied to a query carrying `termCount` distinct terms.
109
+ *
110
+ * A query cannot be graded against more terms than it has (see the module comment: a 3-char CJK
111
+ * query has one trigram), so the configured bar is CLAMPED to the reachable maximum. `termCount`
112
+ * `0`/omitted leaves the value alone — such a query is not graded at all by {@link applyTermFloor},
113
+ * and changing the REPORTED value there would misdescribe what the caller is reading. `0` stays `0`
114
+ * (the operator's off switch).
115
+ *
116
+ * This is the ONE place the clamp lives; `resolveFloors` (what the result reports) and
117
+ * `applyTermFloor` (what the leg does) both call it, so the number the caller reads is the number
118
+ * the leg used.
119
+ */
120
+ export declare function effectiveTermFloor(floor: number, termCount?: number): number;
121
+ /** The per-query floor resolution inputs beyond the configuration. */
122
+ export interface FloorResolution {
123
+ /** `loose` lowers each configured floor (see {@link LOOSE_FLOORS}); absent means `strict`. */
124
+ profile?: FloorProfile;
125
+ /** This query's distinct term count (`relevanceTerms(query).length`), for the FTS clamp. */
126
+ termCount?: number;
127
+ /**
128
+ * Which legs a `'loose'` profile may lower, from {@link droppedLegs} of the pass that made the
129
+ * relaxation necessary. Omitted = every gated leg, which is what an EXPLICIT `floors: 'loose'`
130
+ * request means (the caller asked for the profile outright and there is no prior pass to read).
131
+ * Either way the target values are the same {@link LOOSE_FLOORS} / {@link looseTermFloor} bottom
132
+ * lines — naming fewer legs can only make the pass stricter, never looser.
133
+ */
134
+ relaxLegs?: readonly FloorLeg[];
135
+ }
136
+ /**
137
+ * The legs a pass actually dropped candidates on — the only ones a relaxed pass can help.
138
+ *
139
+ * A leg that dropped nothing has no candidate below its strict floor, so lowering that floor admits
140
+ * nothing new; including it in the relaxed `floors` would just misreport which leg was the problem.
141
+ * `hrr` maps onto `jaccard` because the probe shares the entity leg's candidate set and floor (see
142
+ * `HybridLeg.leg`).
143
+ */
144
+ export declare function droppedLegs(drops: RetrievalFloorDrops): FloorLeg[];
145
+ /**
146
+ * Resolve the effective floors for one query (see the module comment for the rules).
147
+ *
148
+ * Exported so both stores and the tests read the SAME rule rather than re-deriving it — the whole
149
+ * point of hosting the orchestration in one file was that a per-store copy of a rule drifts.
150
+ *
151
+ * The relaxed profile takes `min(configured, relaxed)` per leg: it can only RELAX. A leg the
152
+ * operator disabled (`0` = off) stays disabled, a floor the operator already set below the relaxed
153
+ * value is left alone, and {@link FloorResolution.relaxLegs} can only narrow WHICH legs move.
154
+ * "Relaxed" never means "ungated" — otherwise the pass could not answer "nothing relevant".
155
+ */
156
+ export declare function resolveFloors(retriever: FloorConfig, semAvail: boolean, resolution?: FloorResolution): RetrievalFloors;
157
+ /** `0` = off; a score EQUAL to the floor passes (the floor drops strictly-lower candidates only). */
158
+ export declare function passesFloor(score: number, floor: number): boolean;
159
+ /** Drop the entries of one leg's raw score map that fall below `floor`, counting them. */
160
+ export declare function applyScoreFloor(scores: Map<number, number>, floor: number): {
161
+ scores: Map<number, number>;
162
+ dropped: number;
163
+ };
164
+ /**
165
+ * How many distinct query terms `text` carries.
166
+ *
167
+ * Substring containment, case-insensitively: FTS5's trigram tokenizer matches a quoted phrase by
168
+ * consecutive trigrams and is ASCII-case-insensitive, which is the same predicate as "the term
169
+ * occurs in the text". `relevanceTerms` lowercases its latin words, so the comparison has to.
170
+ */
171
+ export declare function countMatchedTerms(text: string, terms: readonly string[]): number;
172
+ /**
173
+ * Apply the per-row FTS floor to one FTS leg's scores.
174
+ *
175
+ * The terms come from `relevanceTerms(query)` (latin words ≥ 5 chars + every CJK 3-gram) — the same
176
+ * tokenisation the conditional-hint probe uses, so "relevant enough" means one thing in this repo.
177
+ * A query with NO such term is not graded at all: there is nothing to count, and dropping every row
178
+ * on a technicality would turn a 3–4 char latin query (`buildFtsQuery` still emits it) into silence.
179
+ *
180
+ * The bar itself is {@link effectiveTermFloor}: a query that can only produce one term (a 3-char CJK
181
+ * query is one trigram) is measured against that one term, not against a configured 2 it could never
182
+ * reach.
183
+ */
184
+ export declare function applyTermFloor(scores: Map<number, number>, texts: Map<number, string>, query: string, floor: number): {
185
+ scores: Map<number, number>;
186
+ dropped: number;
187
+ };
188
+ /** Sum per-leg drop counts (the cross-store result merges both stores' reports). */
189
+ export declare function mergeFloorDrops(...parts: readonly (RetrievalFloorDrops | undefined)[]): RetrievalFloorDrops;
190
+ /** Sum every leg of one drop report (the retry decision and the health counter both read it). */
191
+ export declare function totalFloorDrops(drops: RetrievalFloorDrops): number;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The retrieval orchestration BOTH stores run.
3
+ *
4
+ * `MemoryStore.search` and `KnowledgeStore.search` used to carry parallel copies of this flow
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:
9
+ *
10
+ * - the `NaN` limit guard existed only in memory, so a non-finite limit reached knowledge's SQL
11
+ * `LIMIT` and took the whole cross-store query down with it (`Promise.all` has no per-leg catch);
12
+ * - `retriever.over_fetch_factor` was read only by memory, while knowledge hardcoded `limit * 3`,
13
+ * so one config knob silently meant two different things;
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.
18
+ *
19
+ * Fixing those one at a time would leave the next drift a matter of when. The stores now supply
20
+ * their legs and their hit mapping; everything that can drift lives here once.
21
+ *
22
+ * A leg that THROWS is isolated: it contributes nothing, the query still answers from the others,
23
+ * and the failure is logged. The read path used to have no per-leg catch at all (the write path
24
+ * did), so an ONNX error at inference cost the caller the FTS and entity legs too.
25
+ *
26
+ * @module store/hybrid
27
+ */
28
+ 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';
30
+ import { type FloorLeg } from './floors.js';
31
+ /** The `limit` a caller gets when it passes nothing usable. */
32
+ export declare const DEFAULT_SEARCH_LIMIT = 10;
33
+ /**
34
+ * A retrieval input the CALLER got wrong, as opposed to a backend that failed at runtime.
35
+ *
36
+ * Leg failures are isolated (see {@link runLegs}) so one dead leg cannot cost the caller the whole
37
+ * query — but that must not extend to a caller contract violation. A wrong-width `queryVector` means
38
+ * the caller encoded with a different backend than this store, so EVERY query in that session would
39
+ * score as garbage; answering from the other legs would hide exactly the misconfiguration the check
40
+ * exists to surface.
41
+ */
42
+ export declare class RetrievalInputError extends Error {
43
+ }
44
+ /** …and the over-fetch factor when the config does not set one (matches the contract default). */
45
+ export declare const DEFAULT_OVER_FETCH_FACTOR = 5;
46
+ /** The three weights a `RecallResult` reports. */
47
+ export type RetrievalWeights = RecallResult['weights'];
48
+ /** One retrieval leg's contribution to the fusion. */
49
+ export interface HybridLeg {
50
+ /** Fusion weight; the caller resolves it against `semAvail` (see {@link HybridContext}). */
51
+ weight: number;
52
+ /** candidate id → raw score, in the leg's own scale (`fuse` normalizes per leg). */
53
+ scores: Map<number, number>;
54
+ /**
55
+ * True when the leg was cut at {@link HybridContext.legCap}.
56
+ *
57
+ * `size === cap` is the only observable "this leg was trimmed" signal — a leg that finished under
58
+ * the cap cannot have been cut — and it feeds the health counters, so the headroom can be sized
59
+ * from data instead of from a constant nobody re-derives. Measured on the leg's RAW score set:
60
+ * the relevance floors may shrink a capped leg afterwards, and re-deriving this from the floored
61
+ * size would silently erase the cap signal.
62
+ */
63
+ capped?: boolean;
64
+ /**
65
+ * 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.
68
+ */
69
+ leg?: keyof RetrievalFloorDrops;
70
+ /** Candidates this leg removed because they fell below its floor. */
71
+ droppedByFloor?: number;
72
+ }
73
+ /** What the orchestrator resolved before handing control to the store's legs. */
74
+ export interface HybridContext {
75
+ /** The query, trimmed. */
76
+ query: string;
77
+ /** How many hits the caller receives (already guarded against a non-finite input). */
78
+ limit: number;
79
+ /** Size of the fused pool the legs should aim to fill. */
80
+ overFetch: number;
81
+ /** Per-leg row cap. */
82
+ legCap: number;
83
+ /** Whether the semantic backend is live; false means the degraded weights are in force. */
84
+ semAvail: boolean;
85
+ /**
86
+ * The EFFECTIVE relevance floors for this query, already resolved for `semAvail` (see
87
+ * `store/floors.ts`). Handed to the store for the same reason as {@link weights}: a leg applies
88
+ * its own floor, and deriving the relaxed value twice is how two stores end up cutting different
89
+ * candidates.
90
+ */
91
+ floors: RetrievalFloors;
92
+ /**
93
+ * The fusion weights in force, already rebalanced for `semAvail`. Handed to the store because a
94
+ * leg's weight is part of the leg, and the degraded rebalance must be the SAME one the result
95
+ * reports — deriving it twice is how two stores end up reporting different numbers.
96
+ */
97
+ weights: RetrievalWeights;
98
+ /** A query vector the caller already encoded (the cross-store router encodes once for both stores). */
99
+ queryVector?: Float32Array;
100
+ /**
101
+ * Called by a store when it ENCODED this query itself (rather than reusing {@link queryVector}),
102
+ * so a retry pass can hand the same vector back instead of paying the model again.
103
+ *
104
+ * The single-store `search` path is the one that needs this: its caller supplies no vector, and the
105
+ * relaxed retry below re-ran the whole pass — including `semantic.encode(query)`. The cross-store
106
+ * router already passes a vector, so it is unaffected (performance review §7.7 / P8).
107
+ */
108
+ onQueryVector?(vec: Float32Array): void;
109
+ }
110
+ /** What a store supplies. Generic in `H`, the caller-facing hit shape. */
111
+ export interface HybridDeps<H> {
112
+ /** Which health-event kind this search records. */
113
+ kind: 'memory' | 'knowledge';
114
+ config: Config;
115
+ semantic: SemanticBackend;
116
+ reranker: Reranker;
117
+ /**
118
+ * The legs to fuse, as promises (or values). Awaited concurrently; a rejection costs that leg
119
+ * only. The async setup a store needs before it can build its legs — memory extracts the query's
120
+ * entities ONCE and shares them between the Jaccard and HRR legs — happens in this method's own
121
+ * body, before the array is returned.
122
+ */
123
+ 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
+ texts(ids: number[]): Map<number, string>;
126
+ /** Build the caller-facing hits from the ranked, live, already-sliced ids. */
127
+ hits(ranked: readonly Hit[], texts: Map<number, string>): H[];
128
+ /**
129
+ * Called with the hits the caller ACTUALLY receives — after the output budget, not before it.
130
+ *
131
+ * This is where a store reinforces what it returned. Reinforcing the pre-budget list used to
132
+ * refresh the dormancy clock (and grant the trust bonus) for hits the token budget had already
133
+ * dropped, i.e. rewarding facts the caller never saw; the cross-store path had already learned
134
+ * that lesson (R5/R21) and the single-store path had not.
135
+ */
136
+ onReturn?(kept: readonly Budgeted<H>[]): void;
137
+ }
138
+ /** One search request, before resolution. */
139
+ export interface HybridPlan {
140
+ query: string;
141
+ limit?: number;
142
+ /** Overrides the config-derived pool size; never smaller than `limit`. */
143
+ overFetch?: number;
144
+ /** Per-call output token budget; `0` = unlimited, omitted = `retriever.max_output_tokens`. */
145
+ maxTokens?: number;
146
+ queryVector?: Float32Array;
147
+ /**
148
+ * Which relevance-floor policy governs this query (see the contract's `FLOOR_PROFILES`):
149
+ * omitted = the configured floors plus ONE relaxed pass when they empty the result, `'strict'` =
150
+ * no fallback, `'loose'` = the relaxed floors outright.
151
+ *
152
+ * The cross-store router pins a profile on every store call (`'strict'` for its first pass,
153
+ * `'loose'` for its second): only the MERGED result may decide whether relaxing is warranted, or a
154
+ * store that happens to be empty would inject its relaxed tail into another store's strict answer.
155
+ */
156
+ floors?: FloorProfile;
157
+ /**
158
+ * Which legs a `'loose'` pass may lower (see `store/floors.ts`'s `FloorResolution.relaxLegs`).
159
+ * Omitted on the default policy: the single-store retry reads the STRICT pass's own drop report and
160
+ * lowers exactly those legs. The cross-store router has no single strict pass — it pins the legs
161
+ * the MERGED strict pass dropped on its second call, so "only what actually dropped" holds there
162
+ * too (relaxing a leg that dropped nothing admits no candidate; it only misreports `floors`).
163
+ */
164
+ relaxLegs?: readonly FloorLeg[];
165
+ /**
166
+ * Emit a health event for this search (default true). The cross-store router sets it false: it
167
+ * fuses both stores into ONE user-facing query and records a single `kind: 'cross'` event, so
168
+ * `queries` counts questions instead of legs (DESIGN §20.5).
169
+ */
170
+ recordStats?: boolean;
171
+ }
172
+ /** A hit after the output budget: `text` may have been shortened, and `truncated` says so. */
173
+ export type Budgeted<T> = T & {
174
+ truncated?: boolean;
175
+ };
176
+ export interface HybridResult<H> {
177
+ hits: Budgeted<H>[];
178
+ /** The semantic backend was down, so the degraded weights were used. */
179
+ degraded: boolean;
180
+ weights: RetrievalWeights;
181
+ /** Tokens the returned hits carry; `0` means "not computed" (an unlimited budget skips the pass). */
182
+ used_tokens: number;
183
+ /** The floors actually applied (post degraded-relaxation AND profile), for the result envelope. */
184
+ floors: RetrievalFloors;
185
+ /** How many candidates each leg dropped below its floor. */
186
+ dropped_by_floor: RetrievalFloorDrops;
187
+ /**
188
+ * The strict pass returned nothing while having dropped candidates, and ONE relaxed pass supplied
189
+ * these hits — `floors` are that pass's values. An explicit `floors: 'loose'` request does NOT set
190
+ * this: the caller asked for the relaxed profile, so nothing was "relaxed behind its back".
191
+ */
192
+ relaxed?: boolean;
193
+ }
194
+ /**
195
+ * The per-leg row cap: `retriever.leg_cap` when set, otherwise derived from the pool size.
196
+ *
197
+ * Derived default: 4× the pool, so each leg can fill the fused result on its own. Stated once here
198
+ * rather than per store, because the health counter compares against whatever this returns.
199
+ */
200
+ export declare function legCapFor(config: Config, overFetch: number): number;
201
+ /**
202
+ * Whether the configured reranker is actually in use — the two flags a retrieval event carries.
203
+ *
204
+ * "Configured" is not "used": a reranker that is selected but unavailable falls back to the fused
205
+ * order, and counting that as a rerank would make the counter lie in exactly the case it exists to
206
+ * expose (the reference implementation leaves `rerank_fallback` unfeedable).
207
+ */
208
+ export declare function rerankState(reranker: Reranker): {
209
+ used: boolean;
210
+ fallback: boolean;
211
+ };
212
+ /**
213
+ * Bound a ranked hit list to a token budget, marking what was shortened.
214
+ *
215
+ * An unlimited budget returns the hits untouched: `fitToTokenBudget` would still walk every entry
216
+ * to sum `used_tokens`, and every caller discards that number (the cross-store path passes 0
217
+ * deliberately — it bounds the MERGED result instead). `used_tokens: 0` means "not computed", and
218
+ * the budget module keeps its own honest accounting for callers that ask.
219
+ */
220
+ export declare function applyOutputBudget<H extends BudgetInput>(config: Config, hits: H[], perCall?: number): {
221
+ kept: Budgeted<H>[];
222
+ used_tokens: number;
223
+ };
224
+ /**
225
+ * Run one hybrid search.
226
+ *
227
+ * The order of the last three steps is load-bearing: rerank the over-fetched pool, THEN slice to
228
+ * `limit`, THEN apply the output budget, and only then tell the store what was returned. Slicing
229
+ * before the rerank would rank an arbitrary subset; budgeting before the slice would spend the
230
+ * budget on hits that are about to be dropped.
231
+ */
232
+ export declare function hybridSearch<H extends BudgetInput>(deps: HybridDeps<H>, plan: HybridPlan): Promise<HybridResult<H>>;
@@ -0,0 +1,25 @@
1
+ /** The boundary knobs, as resolved from `knowledge.ingest`. */
2
+ export interface IngestLimits {
3
+ /** Roots a local `source_uri` may be read from; empty ⇒ the process workspace. */
4
+ local_roots: readonly string[];
5
+ allow_outside_workspace: boolean;
6
+ allow_private_network: boolean;
7
+ }
8
+ /**
9
+ * Whether a URL hostname addresses loopback / private / link-local / other
10
+ * non-public space. Names are matched by suffix too (`*.local` mDNS, `*.internal`,
11
+ * `*.home.arpa`), since those resolve inside the LAN by construction.
12
+ */
13
+ export declare function isPrivateHostname(hostname: string): boolean;
14
+ /** Validate one `http(s)` source URL, refusing non-public targets unless opted in. */
15
+ export declare function assertFetchableUrl(uri: string, limits: Pick<IngestLimits, 'allow_private_network'>): URL;
16
+ /** The roots a local `source_uri` may be read from (realpath'd), defaulting to the workspace. */
17
+ export declare function allowedLocalRoots(limits: Pick<IngestLimits, 'local_roots'>): string[];
18
+ /**
19
+ * Resolve a local `source_uri` to the real absolute path it names, refusing one
20
+ * that lands outside the allowed roots.
21
+ *
22
+ * @returns the `realpath` — which is what the caller should BOTH read and store, so
23
+ * the recorded `source_uri` is exactly the file that was read.
24
+ */
25
+ export declare function resolveLocalSource(raw: string, limits: IngestLimits): string;