@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.
- package/README.md +89 -28
- package/cordis.patch.yml +17 -0
- package/lib/client.js +155 -14
- package/lib/dsh-build.json +5 -5
- package/lib/engine/mem/config/config_files.d.ts +26 -0
- package/lib/engine/mem/config/domains.d.ts +20 -0
- package/lib/engine/mem/config/loader.d.ts +35 -0
- package/lib/engine/mem/config/paths.d.ts +53 -0
- package/lib/engine/mem/db/chunk.d.ts +26 -0
- package/lib/engine/mem/db/conn.d.ts +13 -0
- package/lib/engine/mem/db/dao/chunks.d.ts +150 -0
- package/lib/engine/mem/db/dao/contradictions.d.ts +98 -0
- package/lib/engine/mem/db/dao/documents.d.ts +51 -0
- package/lib/engine/mem/db/dao/entities.d.ts +67 -0
- package/lib/engine/mem/db/dao/facts.d.ts +472 -0
- package/lib/engine/mem/db/dao/stats.d.ts +14 -0
- package/lib/engine/mem/db/dao/triples.d.ts +80 -0
- package/lib/engine/mem/db/hash.d.ts +2 -0
- package/lib/engine/mem/db/knowledge.d.ts +17 -0
- package/lib/engine/mem/db/migrations.d.ts +71 -0
- package/lib/engine/mem/db/port.d.ts +57 -0
- package/lib/engine/mem/db/schema.d.ts +26 -0
- package/lib/engine/mem/db/sqlite.d.ts +89 -0
- package/lib/engine/mem/db/store.d.ts +35 -0
- package/lib/engine/mem/db/tokenizer.d.ts +61 -0
- package/lib/engine/mem/db/vectors.d.ts +50 -0
- package/lib/engine/mem/dispatch.d.ts +51 -0
- package/lib/engine/mem/entities/extract.d.ts +84 -0
- package/lib/engine/mem/eval/loader.d.ts +19 -0
- package/lib/engine/mem/eval/metrics.d.ts +25 -0
- package/lib/engine/mem/eval/runner.d.ts +10 -0
- package/lib/engine/mem/eval/write_metrics.d.ts +88 -0
- package/lib/engine/mem/git.d.ts +73 -0
- package/lib/engine/mem/hrr/algebra.d.ts +30 -0
- package/lib/engine/mem/hrr/atoms.d.ts +22 -0
- package/lib/engine/mem/hrr/encode.d.ts +32 -0
- package/lib/engine/mem/hrr/index.d.ts +3 -0
- package/lib/engine/mem/index.d.ts +27 -0
- package/lib/engine/mem/lifecycle/contradiction.d.ts +156 -0
- package/lib/engine/mem/lifecycle/index.d.ts +5 -0
- package/lib/engine/mem/lifecycle/maintenance.d.ts +48 -0
- package/lib/engine/mem/lifecycle/presence.d.ts +29 -0
- package/lib/engine/mem/lifecycle/tick.d.ts +43 -0
- package/lib/engine/mem/lifecycle/trust.d.ts +90 -0
- package/lib/engine/mem/modelBootstrap.d.ts +207 -0
- package/lib/engine/mem/router.d.ts +51 -0
- package/lib/engine/mem/runtime.d.ts +232 -0
- package/lib/engine/mem/store/common.d.ts +82 -0
- package/lib/engine/mem/store/doc_files.d.ts +125 -0
- package/lib/engine/mem/store/document_text.d.ts +47 -0
- package/lib/engine/mem/store/floors.d.ts +191 -0
- package/lib/engine/mem/store/hybrid.d.ts +232 -0
- package/lib/engine/mem/store/ingest_guard.d.ts +25 -0
- package/lib/engine/mem/store/knowledge.d.ts +483 -0
- package/lib/engine/mem/store/lexical.d.ts +67 -0
- package/lib/engine/mem/store/memory.d.ts +543 -0
- package/lib/engine/mem/store/source_picker.d.ts +43 -0
- package/lib/engine/mem-contract/config.d.ts +129 -0
- package/lib/engine/mem-contract/env.d.ts +31 -0
- package/lib/engine/mem-contract/family.d.ts +25 -0
- package/lib/engine/mem-contract/index.d.ts +8 -0
- package/lib/engine/mem-contract/log.d.ts +29 -0
- package/lib/engine/mem-contract/remote.d.ts +80 -0
- package/lib/engine/mem-contract/text.d.ts +92 -0
- package/lib/engine/mem-contract/tools.d.ts +284 -0
- package/lib/engine/mem-contract/types.d.ts +615 -0
- package/lib/engine/mem-retrieval/adapters/auto_vstore.d.ts +47 -0
- package/lib/engine/mem-retrieval/adapters/hnswlib.d.ts +174 -0
- package/lib/engine/mem-retrieval/adapters/local_bge.d.ts +121 -0
- package/lib/engine/mem-retrieval/adapters/local_numpy.d.ts +28 -0
- package/lib/engine/mem-retrieval/adapters/local_reranker.d.ts +52 -0
- package/lib/engine/mem-retrieval/adapters/none_reranker.d.ts +10 -0
- package/lib/engine/mem-retrieval/adapters/warm_gate.d.ts +46 -0
- package/lib/engine/mem-retrieval/budget.d.ts +30 -0
- package/lib/engine/mem-retrieval/fusion.d.ts +53 -0
- package/lib/engine/mem-retrieval/index.d.ts +14 -0
- package/lib/engine/mem-retrieval/interfaces.d.ts +82 -0
- package/lib/engine/mem-retrieval/log.d.ts +11 -0
- package/lib/engine/mem-retrieval/registry.d.ts +23 -0
- package/lib/engine/mem-retrieval/rerank.d.ts +13 -0
- package/lib/engine/mem-retrieval/stats.d.ts +64 -0
- package/lib/engine/mem-retrieval/text_budget.d.ts +52 -0
- package/lib/index.js +1773 -473
- package/lib/interface-version.json +2 -2
- package/lib/types/client/domains.d.ts +5 -4
- package/lib/types/client/domains.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/ingest.d.ts +1 -1
- package/lib/types/client/wire.d.ts +36 -0
- package/lib/types/client/wire.d.ts.map +1 -0
- package/lib/types/envinit-bootstrap.d.ts +3 -2
- package/lib/types/envinit.d.ts +2 -19
- package/lib/types/envinit.d.ts.map +1 -1
- package/lib/types/hints.d.ts +1 -1
- package/lib/types/index.d.ts +8 -3
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/open.d.ts +61 -11
- package/lib/types/open.d.ts.map +1 -1
- package/lib/types/prompt.d.ts +19 -1
- package/lib/types/prompt.d.ts.map +1 -1
- package/lib/types/provision.d.ts +1 -1
- package/lib/types/provision.d.ts.map +1 -1
- package/lib/types/reconcile.d.ts +64 -0
- package/lib/types/reconcile.d.ts.map +1 -0
- package/lib/types/remote.d.ts +53 -0
- package/lib/types/remote.d.ts.map +1 -1
- package/lib/types/render.d.ts +35 -0
- package/lib/types/render.d.ts.map +1 -0
- package/lib/types/tool_schema.d.ts +3 -13
- package/lib/types/tool_schema.d.ts.map +1 -1
- package/package.json +27 -18
- package/lib/client.js.map +0 -1
- package/lib/types/client/domains.js +0 -25
- package/lib/types/client/domains.js.map +0 -1
- package/lib/types/client/index.js +0 -1371
- package/lib/types/client/index.js.map +0 -1
- package/lib/types/client/ingest.js +0 -30
- package/lib/types/client/ingest.js.map +0 -1
- package/lib/types/client/paging.js +0 -36
- package/lib/types/client/paging.js.map +0 -1
- package/lib/types/client/summarize.js +0 -79
- package/lib/types/client/summarize.js.map +0 -1
- package/lib/types/data_home.js +0 -26
- package/lib/types/data_home.js.map +0 -1
- package/lib/types/envinit-bootstrap.js +0 -333
- package/lib/types/envinit.js +0 -402
- package/lib/types/envinit.js.map +0 -1
- package/lib/types/hints.js +0 -53
- package/lib/types/hints.js.map +0 -1
- package/lib/types/index.js +0 -755
- package/lib/types/index.js.map +0 -1
- package/lib/types/inject.js +0 -14
- package/lib/types/inject.js.map +0 -1
- package/lib/types/interface_gate.js +0 -84
- package/lib/types/interface_gate.js.map +0 -1
- package/lib/types/open.js +0 -133
- package/lib/types/open.js.map +0 -1
- package/lib/types/prompt.js +0 -148
- package/lib/types/prompt.js.map +0 -1
- package/lib/types/provision.js +0 -177
- package/lib/types/provision.js.map +0 -1
- package/lib/types/remote.js +0 -247
- package/lib/types/remote.js.map +0 -1
- package/lib/types/tool_schema.js +0 -149
- 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;
|