724 lines
33 KiB
JavaScript
724 lines
33 KiB
JavaScript
/**
|
|
* Block recall without embeddings: FTS5 lexical match + exact-symbol pinning.
|
|
*
|
|
* ## Why this exists at all
|
|
*
|
|
* HologramAgent's primary recall path is vector cosine over `memory_blocks.vector`
|
|
* (`internal/memory/block_recall.go:78`). This slice deliberately has no embedding
|
|
* provider (docs/feasibility.md §5.1, P0 blocker), so it ports only the *second*
|
|
* half of the fusion: the symbol path.
|
|
*
|
|
* That is not a placeholder. `docs/zh/recall-capability-tiers.md` measures which
|
|
* query shapes actually work, and the strongest one is "带精确串" (queries with an
|
|
* exact token: port, IP, version, path) — which is exactly the symbol path. The
|
|
* weakest is "纯泛化描述", which needs vectors. So this slice covers the tier that
|
|
* empirically works best, and leaves the tier that needs phase B.
|
|
*
|
|
* ## The two signals, and why they are merged the way they are
|
|
*
|
|
* 1. **Exact symbol hit** — a numeric/Latin token from the query appears verbatim
|
|
* in the block text. HologramAgent treats this as a BOOLEAN that pins the
|
|
* candidate to the top, NOT as a weight:
|
|
* `Score = 1.0 + VectorHit*0.01` (internal/memory/fusion.go:427).
|
|
* The reason is measured: if you fold a 1.0 symbol hit into
|
|
* `0.7*vector + 0.3*symbol`, it becomes 0.30 and *loses* to a noise candidate
|
|
* scoring 0.534 (fusion.go:43-49 documents exactly that regression).
|
|
*
|
|
* 2. **FTS5 bm25 lexical rank** — everything else.
|
|
*
|
|
* ## Known limitation, deliberately inherited
|
|
*
|
|
* `unicode61` does not segment Chinese: a CJK run becomes one token, so
|
|
* `MATCH '分机号'` will not find `值班室分机号 4324` (the block's run is
|
|
* `值班室分机号`, a different single token). HologramAgent has the same shape of
|
|
* problem and solves it with jieba (`internal/memory/fusion.go:145`); this slice
|
|
* has no jieba (`docs/feasibility.md` §5.5).
|
|
*
|
|
* The CJK containment fallback below recovers the common case (the query is a
|
|
* substring of the text) without a dictionary — the same *effect* as
|
|
* HologramAgent's sliding-window path (`cjkWindows`, fusion.go:240) at coarser
|
|
* granularity, and it only runs when FTS found nothing. What it cannot do is
|
|
* segmentation- or synonym-aware matching; that is phase B/C's job.
|
|
*
|
|
* @module dsh-memory/recall
|
|
*/
|
|
|
|
/**
|
|
* Extract high-information symbols from a query.
|
|
*
|
|
* Port of `memory.QuerySymbols` (internal/memory/fusion.go:145) restricted to the
|
|
* parts that need no dictionary: numeric runs and Latin words. The jieba half and
|
|
* the CJK sliding window are intentionally absent (see the module note).
|
|
*
|
|
* @param {string} query
|
|
* @returns {string[]} lowercased, de-duplicated symbols
|
|
*/
|
|
export function querySymbols(query) {
|
|
const out = []
|
|
const seen = new Set()
|
|
const add = (value) => {
|
|
const symbol = value.trim()
|
|
// Single-character symbols are not symbols. Ported from AbstainCheck's guard
|
|
// (internal/memory/abstain.go:115-116, "单字不成符号"): a bare `0` would
|
|
// otherwise "exactly" match every block containing a zero and pin noise to
|
|
// the top. The V2 corpus caught precisely this via `billing0`.
|
|
if ([...symbol].length < 2 || seen.has(symbol)) return
|
|
seen.add(symbol)
|
|
out.push(symbol)
|
|
}
|
|
const text = String(query ?? '')
|
|
// Numeric runs. Regexes copied verbatim from HologramAgent
|
|
// (internal/memory/fusion.go:76-79) so this slice extracts the SAME symbols:
|
|
// symbolNumericRe = \d+(?:[./]\d+)*(?:\.\d+)?%?|[vV]\d+(?:\.\d+)+
|
|
// symbolLatinRe = [A-Za-z][A-Za-z0-9_-]{2,}
|
|
// The `{2,}` on the Latin rule is load-bearing: without it `billing0` also
|
|
// yields a bare `0`, which then "exactly" matches every block containing any
|
|
// zero — the regression the V2 corpus caught.
|
|
for (const match of text.matchAll(/\d+(?:[./]\d+)*(?:\.\d+)?%?|[vV]\d+(?:\.\d+)+/g)) add(match[0])
|
|
for (const match of text.matchAll(/[A-Za-z][A-Za-z0-9_-]{2,}/g)) add(match[0].toLowerCase())
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* True when a symbol carries a digit (or is a `v`-prefixed version).
|
|
*
|
|
* Port of `isNumericSymbol` (internal/memory/fusion.go:316). It matters because
|
|
* HologramAgent only trusts "zero hits ⇒ abstain" when the query held an exact
|
|
* symbol (`internal/memory/abstain.go:79-86`): a pure-CJK zero-hit cannot be told
|
|
* apart from a tokenizer failure, so abstaining there would be wrong.
|
|
*
|
|
* @param {string} symbol
|
|
* @returns {boolean}
|
|
*/
|
|
export function isNumericSymbol(symbol) {
|
|
if (!symbol) return false
|
|
if (/\d/.test(symbol)) return true
|
|
// v2.31.5-style: leading v plus at least one digit.
|
|
return /^v\d/i.test(symbol)
|
|
}
|
|
|
|
/**
|
|
* Build an FTS5 MATCH expression from a plain query.
|
|
*
|
|
* Every term is double-quoted so user text cannot inject FTS5 operators
|
|
* (`AND`, `NEAR`, `*`, `:`). Terms are OR-ed: recall should be generous, and the
|
|
* fusion step below does the ranking. Returns null when nothing usable remains —
|
|
* an empty MATCH throws in SQLite.
|
|
*
|
|
* @param {string} query
|
|
* @returns {string | null}
|
|
*/
|
|
export function buildMatchExpression(query) {
|
|
const terms = []
|
|
const seen = new Set()
|
|
const add = (term) => {
|
|
if (term === '' || seen.has(term)) return
|
|
seen.add(term)
|
|
terms.push(`"${term.replace(/"/g, '""')}"`)
|
|
}
|
|
const text = String(query ?? '')
|
|
|
|
// 1. The symbols, verbatim. These are the terms that reliably retrieve,
|
|
// because they are what FTS actually tokenised in the block text
|
|
// (`billing0服务` is ONE unicode61 token; `billing0` alone is not a token,
|
|
// so the *word* is what must be matched, not the raw query chunk).
|
|
for (const symbol of querySymbols(text)) add(symbol.toLowerCase().replace(/[.]/g, ' '))
|
|
|
|
// 2. Digit groups from the query: `8499`, `2379`, `13010`. Length-gated for
|
|
// the same reason querySymbols is — a lone `0` (from `billing0`) is not a
|
|
// symbol, and admitting it turns the MATCH into an everything-query.
|
|
for (const match of text.matchAll(/\d+(?:[./]\d+)*/g)) {
|
|
if (match[0].length >= 2) add(match[0])
|
|
}
|
|
|
|
// 3. CJK runs, emitted whole. unicode61 keeps a run as ONE token, so quoting
|
|
// the run matches only identical runs — narrow but correct. Broader CJK
|
|
// matching is the containment fallback's job (see recallBlocks).
|
|
for (const match of text.matchAll(/[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]+/g)) add(match[0])
|
|
|
|
if (terms.length === 0) {
|
|
// Nothing tokenisable: let the caller fall through to containment rather
|
|
// than issuing an empty MATCH (which SQLite rejects).
|
|
return null
|
|
}
|
|
return terms.join(' OR ')
|
|
}
|
|
|
|
/**
|
|
* Sliding-window needles for a CJK query run.
|
|
*
|
|
* Port of `cjkWindows` (internal/memory/fusion.go:240) at the same 2..4 width.
|
|
* It exists because this slice has no dictionary tokenizer, so "which substring
|
|
* of the question names the subject" has to be approximated by trying every
|
|
* window inside the plausible width range.
|
|
*
|
|
* Non-overlapping prefixes only would be cheaper but loses `分机号` inside
|
|
* `值班室分机号` when the question is `值班室分机号是多少`; taking every window
|
|
* costs a hash probe per block and is still bounded (n·3 needles).
|
|
*
|
|
* @param {string} text
|
|
* @returns {string[]} unique windows, longest first
|
|
*/
|
|
export function cjkNeedles(text) {
|
|
const longestFirst = []
|
|
const seen = new Set()
|
|
for (const run of String(text ?? '').match(/[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]+/g) ?? []) {
|
|
const chars = [...run]
|
|
// 4..2 as well as the whole run: the whole run is the most specific needle.
|
|
for (const width of [chars.length, 4, 3, 2]) {
|
|
if (width < 2 || width > chars.length) continue
|
|
for (let start = 0; start + width <= chars.length; start += 1) {
|
|
const needle = chars.slice(start, start + width).join('')
|
|
if (seen.has(needle)) continue
|
|
seen.add(needle)
|
|
longestFirst.push(needle)
|
|
}
|
|
}
|
|
}
|
|
return longestFirst
|
|
}
|
|
|
|
/**
|
|
* Every start offset of `needle` in `haystack` (non-overlapping scan).
|
|
* @param {string} haystack already lowercased
|
|
* @param {string} needle already lowercased
|
|
* @returns {number[]}
|
|
*/
|
|
function allIndexesOf(haystack, needle) {
|
|
if (needle === '') return []
|
|
const out = []
|
|
let from = 0
|
|
for (;;) {
|
|
const at = haystack.indexOf(needle, from)
|
|
if (at === -1) return out
|
|
out.push(at)
|
|
from = at + 1
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recall blocks for a query.
|
|
*
|
|
* Ordering contract (mirrors `RecallBlocksWithArbitration`,
|
|
* internal/memory/block_recall.go:229 — full scoring first, truncation last):
|
|
*
|
|
* 1. FTS5 candidates over a generous pool
|
|
* 2. exact-symbol boolean pin — pinned candidates first, ordered among
|
|
* themselves by lexical score
|
|
* 3. truncate to `topK`
|
|
*
|
|
* A LIKE scan runs only when FTS5 yields nothing, mirroring the "符号路兜底"
|
|
* shape. It is not the primary path: LIKE `%x%` matches almost everything on
|
|
* short queries and has no notion of relevance, which is exactly what the
|
|
* A5 mutation test asserts by breaking it.
|
|
*
|
|
* @param {import('./store.js').BlockStore} store
|
|
* @param {string} query
|
|
* @param {object} [options]
|
|
* @param {number} [options.topK] max hits returned (default 5)
|
|
* @param {number} [options.poolSize] FTS candidate pool before truncation (default 50)
|
|
* @param {Map<string, number>} [options.vectorScores] optional vector scores for V1 exact-band tie adjustment
|
|
* @returns {Array<{id: string, text: string, source: string, score: number,
|
|
* exact: boolean, why: string}>}
|
|
*/
|
|
export function recallBlocks(store, query, { topK = 5, poolSize = 50, fingerprint, dimension, vectorScores } = {}) {
|
|
const text = String(query ?? '').trim()
|
|
if (text === '') return []
|
|
|
|
const symbols = querySymbols(text)
|
|
const match = buildMatchExpression(text)
|
|
// Computed once: used both for fallback admission and for the CJK coverage
|
|
// signal below, so a longer matching window outranks a shorter one.
|
|
const cjkWindows = cjkNeedles(text)
|
|
|
|
/** @type {Map<string, {id: string, text: string, source: string, lexical: number}>} */
|
|
const pool = new Map()
|
|
if (match !== null) {
|
|
for (const hit of store.searchFts(match, poolSize)) {
|
|
pool.set(hit.id, { ...hit, lexical: hit.score })
|
|
}
|
|
}
|
|
// Containment fallback, only when FTS found nothing.
|
|
//
|
|
// Three distinct jobs, all legitimate:
|
|
// · a query with an exact token must still find its block even when FTS
|
|
// tokenization missed it (a number inside a CJK run, `billing0` glued to
|
|
// `billing0服务`);
|
|
// · a CJK query must find text that literally contains it, which unicode61
|
|
// cannot answer because it keeps a whole run as ONE token;
|
|
// · a CJK *question* (`值班室分机号是多少`) must find the note it is asking
|
|
// about (`值班室分机号 4324`). The runs differ, so whole-run containment
|
|
// misses — this is where HologramAgent uses jieba and we use sliding
|
|
// windows (`cjkWindows`, internal/memory/fusion.go:240) instead: every
|
|
// 2..4-char window of the query run becomes a needle.
|
|
//
|
|
// It is NOT the primary path: containment has no relevance notion and matches
|
|
// almost everything on short queries — the A5 mutation asserts exactly that by
|
|
// substituting it for FTS as the primary path.
|
|
if (pool.size === 0) {
|
|
const needles = symbols.map((symbol) => symbol.toLowerCase())
|
|
for (const run of cjkWindows) needles.push(run)
|
|
if (needles.length > 0) {
|
|
for (const block of store.allBlocks(poolSize)) {
|
|
const haystack = String(block.text_content ?? '').toLowerCase()
|
|
const matched = needles.filter((needle) => haystack.includes(needle))
|
|
if (matched.length === 0) continue
|
|
// Longer needles are more informative; rank containment hits by their
|
|
// best needle length so `分机号` beats a stray shared bigram.
|
|
const best = matched.reduce((longest, needle) => Math.max(longest, [...needle].length), 0)
|
|
pool.set(block.id, {
|
|
id: block.id,
|
|
text: block.text_content,
|
|
source: block.source,
|
|
// Carry the same identity fields the FTS path carries, or the space
|
|
// filter below would silently see `hasVector === undefined` and let
|
|
// every containment hit through — a filter that only covers one of
|
|
// two intake paths is the bug this whole group is about.
|
|
fingerprint: block.fingerprint ?? '',
|
|
hasVector: String(block.vector ?? '') !== '',
|
|
dimension: Number(block.vector_dim ?? 0),
|
|
lexical: Math.min(0.02 + best * 0.02, 0.5), // stays under the 1.0 pin band
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── F2: space filter, applied to the SYMBOL path too ────────────────────
|
|
//
|
|
// Text matching does not depend on the vector space, so a block written under
|
|
// an old space matches the query just as well as a current one. HA shipped
|
|
// without this filter and recorded the result as "一个真实的安全缺陷"
|
|
// (`internal/memory/fusion.go:481-495`): the vector path skipped mismatched
|
|
// blocks while the symbol path returned them, so blocks from a dead space
|
|
// polluted results after any provider change.
|
|
//
|
|
// ★ The same three-condition shape HA uses (`q.Fingerprint != "" &&
|
|
// b.Fingerprint != "" && b.Fingerprint != q.Fingerprint`) is deliberately
|
|
// NOT reproduced. Per the §0.2 ruling this project is fail-CLOSED on an
|
|
// unidentifiable block: a block that carries a vector but cannot name its
|
|
// space is not comparable to anything, so it is excluded rather than passed
|
|
// through. HA's fail-open arrives from the middle condition being false.
|
|
//
|
|
// Blocks with NO vector are unaffected: they never participate in the vector
|
|
// path either, and their text is still legitimately searchable.
|
|
//
|
|
// ★ `skipped` is counted HERE, at the single choke point both intake paths
|
|
// (FTS and containment) funnel through. Counting at the query site instead would
|
|
// cover only whichever path the author happened to be looking at — the same
|
|
// half-coverage that made HA's bug possible.
|
|
let skipped = 0
|
|
if (typeof fingerprint === 'string' && fingerprint !== '') {
|
|
for (const [id, candidate] of pool) {
|
|
// A vector plus a mismatched/absent space identity ⇒ not comparable.
|
|
if (candidate.hasVector === true) {
|
|
if (candidate.fingerprint === '' || candidate.fingerprint !== fingerprint) {
|
|
pool.delete(id)
|
|
skipped += 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── F4b: dimension filter, counted SEPARATELY from the space filter ──────
|
|
//
|
|
// A block can be in the right space and still be unusable: same fingerprint,
|
|
// wrong width. Only this check catches it, and F4 requires the two counts kept
|
|
// apart because the remedies differ (fix provider config vs re-embed).
|
|
//
|
|
// Order matters: the space check runs first, so a block that is BOTH stale and
|
|
// narrow counts once, as a space skip — otherwise the two counters would
|
|
// double-count one row and neither would add up to the number of blocks dropped.
|
|
let skippedDim = 0
|
|
if (Number.isInteger(dimension) && dimension > 0) {
|
|
for (const [id, candidate] of pool) {
|
|
if (candidate.hasVector !== true) continue
|
|
// 0 = no stored width (pre-migration row); treat as unknown, not as wrong.
|
|
if (candidate.dimension === 0) continue
|
|
if (candidate.dimension !== dimension) {
|
|
pool.delete(id)
|
|
skippedDim += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
const scored = []
|
|
for (const candidate of pool.values()) {
|
|
const haystack = candidate.text.toLowerCase()
|
|
// Port of `memory.SymbolScore` (internal/memory/fusion.go:284-308):
|
|
// exactHit counts symbols that are NUMERIC and appear verbatim;
|
|
// wordHit counts the rest.
|
|
// Only a numeric hit earns the boolean pin. A matched Latin word is a
|
|
// coverage signal, not an exact one — without that distinction any shared
|
|
// common word would outrank a real port number match.
|
|
let exactHit = 0
|
|
let wordHit = 0
|
|
const matched = []
|
|
for (const symbol of symbols) {
|
|
if (!haystack.includes(symbol.toLowerCase())) continue
|
|
matched.push(symbol)
|
|
if (isNumericSymbol(symbol)) exactHit += 1
|
|
else wordHit += 1
|
|
}
|
|
const exact = exactHit > 0
|
|
// 1.0 is a BOOLEAN pin, not a weight (fusion.go:427 and the note above it).
|
|
// Without any match, fall back to lexical rank, never above the pin band.
|
|
const coverage = symbols.length === 0 ? 0 : wordHit / symbols.length
|
|
|
|
// Specificity tie-break. When several candidates match the same word, prefer
|
|
// the one where that word is NOT merely a prefix of a longer word:
|
|
// query "billing 端口" must prefer `billing服务` over `billing2服务`.
|
|
// HologramAgent has the same problem and solves it in the splitter with a
|
|
// word-boundary check (`isCrossWordTailMatch`, distill/split.go:383); here
|
|
// the cheap equivalent is "the matched symbol is followed by a non-alnum
|
|
// character or a CJK run boundary".
|
|
let boundaryBonus = 0
|
|
for (const symbol of matched) {
|
|
for (const at of allIndexesOf(haystack, symbol.toLowerCase())) {
|
|
const next = haystack[at + symbol.length]
|
|
// End of text, a non-word char, or a CJK char (word ended cleanly).
|
|
if (next === undefined || !/[a-z0-9]/.test(next)) {
|
|
boundaryBonus += 1
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// CJK coverage: the longest query window present in the block, normalised by
|
|
// the longest possible window. This is what makes `值班室分机号 4324` rank
|
|
// above a block that merely shares `分机号`, without a dictionary.
|
|
let cjkCoverage = 0
|
|
if (cjkWindows.length > 0) {
|
|
const longestPossible = Math.max(...cjkWindows.map((window) => [...window].length))
|
|
for (const window of cjkWindows) {
|
|
if (!haystack.includes(window.toLowerCase())) continue
|
|
const ratio = [...window].length / longestPossible
|
|
if (ratio > cjkCoverage) cjkCoverage = ratio
|
|
}
|
|
}
|
|
|
|
// Banding keeps the signals from swapping places across bands:
|
|
// [1.0+) exact numeric pin — must always outrank everything else
|
|
// [0..1) non-exact match — lexical / word / CJK coverage
|
|
const nonExact = Math.max(candidate.lexical, coverage * 0.5, cjkCoverage * 0.75)
|
|
// Ported from Go: internal/memory/fusion.go:425-428.
|
|
// ExactHit is a boolean pin; an explicitly supplied vector score only makes
|
|
// a same-band tie adjustment. Keep the old no-vector path byte-compatible
|
|
// for A2: it has no VectorHit to apply.
|
|
const exactScore = vectorScores instanceof Map
|
|
? 1.0 + (vectorScores.get(candidate.id) ?? 0) * 0.01
|
|
: 1.0 + candidate.lexical * 0.01 + boundaryBonus * 1e-4
|
|
// Ported from Go: internal/memory/fusion.go:61-66 and :430-434.
|
|
// Non-exact candidates use vector*0.7 + symbol*0.3. A missing vector score
|
|
// is the symbol-only fallback and must not invent a vector contribution.
|
|
const hasVectorScore = vectorScores instanceof Map && vectorScores.has(candidate.id)
|
|
const vectorScore = hasVectorScore ? vectorScores.get(candidate.id) : 0
|
|
const score = exact
|
|
? exactScore
|
|
: vectorScores instanceof Map && hasVectorScore
|
|
? vectorScore * 0.7 + nonExact * 0.3
|
|
: nonExact + boundaryBonus * 1e-3
|
|
// ── V3: attribution ──────────────────────────────────────────────
|
|
// Ported from Go: internal/memory/fusion.go:34-46 (FusedHit's VectorHit /
|
|
// SymbolHit / Why) and :394-398 (the symbol branch that fills them).
|
|
//
|
|
// ★ Every value comes from the branch that already ran — nothing is a
|
|
// constant decided per shape. Go's own note (`:38-39`) is explicit that 0
|
|
// means "did not hit", so the two signals stay independently observable:
|
|
// a block can carry a vector score and no symbol match, or the reverse.
|
|
const vectorHit = vectorScore
|
|
const symbolHit = exact ? 1.0 : coverage
|
|
scored.push({
|
|
id: candidate.id,
|
|
text: candidate.text,
|
|
source: candidate.source,
|
|
score,
|
|
exact,
|
|
vectorHit,
|
|
symbolHit,
|
|
why: vectorScores instanceof Map
|
|
? `vector:${vectorHit};symbol:${symbolHit};${exact ? 'exact' : matched.length > 0 ? `word:${matched.join(',')}` : 'lexical'}`
|
|
: exact ? `exact:${matched.filter(isNumericSymbol).join(',')}` : matched.length > 0 ? `word:${matched.join(',')}` : 'lexical',
|
|
})
|
|
}
|
|
|
|
scored.sort((a, b) => (b.score === a.score ? a.id.localeCompare(b.id) : b.score - a.score))
|
|
return withSkipped(scored.slice(0, topK), skipped, skippedDim)
|
|
}
|
|
|
|
/**
|
|
* Attach a non-enumerable `skipped` count to a recall result.
|
|
*
|
|
* Non-enumerable for the same reason as `degraded`: the phase-A golden fixture
|
|
* compares result arrays, and an enumerable field would change every stored
|
|
* expectation. Callers that want the number read `hits.skipped` explicitly, which
|
|
* also makes it obvious at the call site that they are consuming an out-of-band
|
|
* signal rather than a hit.
|
|
*
|
|
* @param {Array<object>} hits
|
|
* @param {number} skipped blocks excluded by the space filter
|
|
* @returns {Array<object> & {skipped: number}}
|
|
*/
|
|
function withSkipped(hits, skipped, skippedDim = 0) {
|
|
Object.defineProperty(hits, 'skipped', {
|
|
value: skipped,
|
|
enumerable: false,
|
|
configurable: true,
|
|
})
|
|
// Reported separately (F4): a dimension skip and a space skip have different
|
|
// remedies, so one merged number could not be acted on.
|
|
Object.defineProperty(hits, 'skippedDim', {
|
|
value: skippedDim,
|
|
enumerable: false,
|
|
configurable: true,
|
|
})
|
|
return hits
|
|
}
|
|
|
|
/**
|
|
* Attach a non-enumerable diagnosis to a result array.
|
|
*
|
|
* Used instead of widening the return type: `recallBlocks` must stay
|
|
* byte-compatible with phase A (group A), and every existing caller does
|
|
* `hits.length` / `hits[0]` on a plain array. A non-enumerable property rides
|
|
* along without appearing in `JSON.stringify`, `Object.keys`, or deep equality
|
|
* against phase A output.
|
|
*
|
|
* @param {Array<object>} hits
|
|
* @param {string|null} mode one of `no_embedder` / `embed_error` / `aborted` /
|
|
* `vector_ready_unused` / null
|
|
* @param {Error} [error]
|
|
* @returns {Array<object>} the same array
|
|
*/
|
|
function withDiagnosis(hits, mode, error) {
|
|
Object.defineProperty(hits, 'degraded', {
|
|
value: mode,
|
|
enumerable: false,
|
|
configurable: true,
|
|
})
|
|
if (error !== undefined) {
|
|
Object.defineProperty(hits, 'degradedError', {
|
|
value: error instanceof Error ? error.message : String(error),
|
|
enumerable: false,
|
|
configurable: true,
|
|
})
|
|
}
|
|
return hits
|
|
}
|
|
|
|
/**
|
|
* The only vector capability this plugin depends on.
|
|
*
|
|
* Deliberately three members wide and provider-free: no endpoint, no model name,
|
|
* no transport. Phase B decides *whether* vectors participate; it does not
|
|
* decide *which* provider supplies them. Swapping the HTTP adapter for a local
|
|
* ONNX one must not touch a single criterion.
|
|
*
|
|
* @typedef {object} Embedder
|
|
* @property {(text: string, signal?: AbortSignal) => Promise<number[]>} embedText
|
|
* query-side encoding. `signal` fuses the caller's cancellation with the
|
|
* adapter's own timeout; an implementation that ignores it silently makes a
|
|
* cancelled turn block until its own bound (the W9 bug).
|
|
* @property {() => { fingerprint: string, dimension: number }} info
|
|
* synchronous: the fingerprint/dimension must be checkable *before* paying for
|
|
* a network round-trip, so dimension mismatch can fail fast
|
|
* @property {() => Promise<void>} [close]
|
|
*/
|
|
|
|
/**
|
|
* Vectors-assisted recall.
|
|
*
|
|
* Phase B group A ships only the *seam*, not the fusion: with no embedder (or a
|
|
* failing one) this is exactly `recallBlocks`. Group V fills in
|
|
* `options.vectorScores`; until then the vector call is deliberately not wired
|
|
* into scoring, so this wrapper cannot perturb phase A behaviour.
|
|
*
|
|
* Degradation is reported on the returned array (`arr.degraded`), never by
|
|
* throwing — the recall main path must survive a dead embedding service
|
|
* (decision §0.4).
|
|
*
|
|
* @param {import('./store.js').BlockStore} store
|
|
* @param {Embedder | null | undefined} embedder
|
|
* @param {string} query
|
|
* @param {object} [options]
|
|
* @param {number} [options.topK]
|
|
* @param {number} [options.poolSize]
|
|
* @param {AbortSignal} [options.signal]
|
|
* @returns {Promise<Array<object> & {degraded?: string|null}>}
|
|
*/
|
|
export async function recallBlocksHybrid(store, embedder, query, options = {}) {
|
|
// ★ The active space is resolved ONCE and injected into `options` before any
|
|
// branch can run. Every one of the six degradation paths below funnels into
|
|
// `recallBlocks(store, query, options)`, so injecting it here means the space
|
|
// filter applies on ALL of them — the symbol path included. Setting it at each
|
|
// call site would leave exactly one path unfiltered the first time somebody adds
|
|
// a seventh, which is how HA's symbol path ended up unfiltered
|
|
// (`fusion.go:481-495`, recorded there as "一个真实的安全缺陷").
|
|
//
|
|
// When the embedder cannot name its space, `fingerprint` stays undefined and the
|
|
// filter is inert — recall degrades to its pre-F2 behaviour rather than
|
|
// silently excluding every vectorised block.
|
|
const space = embedder && typeof embedder.info === 'function' ? embedder.info() : undefined
|
|
const scoped = {
|
|
...options,
|
|
fingerprint: typeof space?.fingerprint === 'string' && space.fingerprint !== ''
|
|
? space.fingerprint
|
|
: undefined,
|
|
// The declared width travels with the space so the recall path can apply the
|
|
// F4b dimension filter — without it, `skipped_dim` could only ever be 0.
|
|
dimension: typeof space?.dimension === 'number' && space.dimension > 0
|
|
? space.dimension
|
|
: undefined,
|
|
}
|
|
|
|
if (embedder === null || embedder === undefined || typeof embedder.embedText !== 'function') {
|
|
return withDiagnosis(recallBlocks(store, query, scoped), 'no_embedder')
|
|
}
|
|
|
|
const info = space
|
|
if (info === undefined || typeof info.fingerprint !== 'string' || info.fingerprint === '') {
|
|
// An embedder that cannot name its own space cannot produce comparable
|
|
// vectors — treat it as absent rather than silently comparing across spaces.
|
|
return withDiagnosis(recallBlocks(store, query, scoped), 'no_embedder')
|
|
}
|
|
|
|
let queryVector
|
|
try {
|
|
queryVector = await embedder.embedText(String(query ?? ''), options.signal)
|
|
} catch (error) {
|
|
// Decision §0.4: never let the vector side take the whole recall down.
|
|
//
|
|
// ★ Cancellation and failure are different facts and must not share a label.
|
|
// `aborted` means "we stopped on purpose", so the model must not be told the
|
|
// search was incomplete in a way that invites a retry; `embed_error` means
|
|
// "the provider failed", where absence of evidence really is suspicious.
|
|
// Collapsing them made the notice say "search FAILED" for a deliberate
|
|
// cancellation — misleading in exactly the case where the user is watching.
|
|
const aborted =
|
|
options.signal?.aborted === true ||
|
|
(error instanceof Error && (error.name === 'AbortError' || /abort/i.test(error.message)))
|
|
return withDiagnosis(recallBlocks(store, query, scoped), aborted ? 'aborted' : 'embed_error', error)
|
|
}
|
|
|
|
if (!Array.isArray(queryVector) || queryVector.length === 0) {
|
|
return withDiagnosis(recallBlocks(store, query, scoped), 'embed_error',
|
|
new Error(`embedder returned ${Array.isArray(queryVector) ? 'an empty vector' : 'a non-array'}`))
|
|
}
|
|
if (Number.isInteger(info.dimension) && queryVector.length !== info.dimension) {
|
|
return withDiagnosis(recallBlocks(store, query, scoped), 'embed_error',
|
|
new Error(`embedder returned dim ${queryVector.length}, declared ${info.dimension}`))
|
|
}
|
|
|
|
// Group V wires `vectorScores` into scoring. Keeping the call here (and the
|
|
// scoring seam closed) means group A ships a verified no-op on the hot path.
|
|
// Group A only computes the vector; it is not in scoring yet. Reporting
|
|
// 'hybrid' here would be a lie until group V wires it in, and a misleading
|
|
// label is worse than no label — rename it when V actually participates.
|
|
return withDiagnosis(recallBlocks(store, query, scoped), 'vector_ready_unused')
|
|
}
|
|
|
|
/**
|
|
* Render recall hits as the model-facing text injected into the turn.
|
|
*
|
|
* Shape follows HologramAgent's `crossModalMarkdown`
|
|
* (internal/agent/core/crossmodal.go:256): a header that says what the content
|
|
* IS, then one line per hit. The header matters — the measured failure mode in
|
|
* `docs/zh/memory-usability-plan.md` was that the model ignored injected memory
|
|
* because nothing told it these were *historical* fragments rather than the
|
|
* current conversation.
|
|
*
|
|
* ★ A degraded recall renders a notice line. `hits.degraded` is non-enumerable
|
|
* (so it cannot disturb the phase-A golden comparison), which means it is
|
|
* invisible to JSON.stringify, Object.keys, and every serialised log. Asserting
|
|
* it in a test therefore proves nothing about what the MODEL sees — this text is
|
|
* the only channel by which the model learns that recall was partial.
|
|
*
|
|
* @param {Array<{text: string, exact: boolean, score: number, degraded?: string}>} hits
|
|
* @param {string} [sourceLabel]
|
|
* @returns {string} rendered block, or '' when there is nothing to say
|
|
*/
|
|
export function renderRecall(hits, sourceLabel = 'stablemem') {
|
|
if (!Array.isArray(hits)) return ''
|
|
|
|
const lines = hits.map((hit, index) => {
|
|
const tag = hit.exact ? 'exact' : 'text'
|
|
const body = String(hit.text).replace(/\s+/g, ' ').trim().slice(0, 200)
|
|
return `[${index + 1}] (${tag} ${hit.score.toFixed(2)}) ${body}`
|
|
})
|
|
const notice = degradationNotice(hits)
|
|
|
|
// ★ "no entries" and "nothing to say" are different questions.
|
|
//
|
|
// A degraded recall with zero lexical hits still owes the model an explanation:
|
|
// otherwise silence means "memory has nothing on this", when the truth is
|
|
// "memory could not be searched fully". Staying silent there is the false
|
|
// negative the notice exists to prevent.
|
|
//
|
|
// The converse must hold too: a HEALTHY empty recall stays silent. Emitting the
|
|
// header on every empty turn would burn tokens on the commonest case and train
|
|
// the model to ignore the memory section entirely.
|
|
if (lines.length === 0 && notice === '') return ''
|
|
|
|
const header =
|
|
`[Memory · ${sourceLabel}] Recalled from durable block memory — these are PAST notes, not the current conversation.`
|
|
return [header, ...(notice === '' ? [] : [notice]), ...lines].join('\n')
|
|
}
|
|
|
|
/**
|
|
* Human-readable text for a degraded recall, or '' when nothing was degraded.
|
|
*
|
|
* Wording matters: the model must be able to tell "memory has nothing on this"
|
|
* apart from "memory could not be searched fully", because in the second case
|
|
* absence of evidence is not evidence of absence.
|
|
*
|
|
* @param {Array<object> & {degraded?: string|null}} hits
|
|
* @returns {string}
|
|
*/
|
|
export function degradationNotice(hits) {
|
|
const mode = hits?.degraded
|
|
if (mode === undefined || mode === null) return ''
|
|
// One question, asked once: are there entries below? Every branch shares it.
|
|
// Wording contract (pinned by W11f, table-driven): nothing === true => no branch
|
|
// may say "matches below"; nothing === false => the branch must point at them.
|
|
const nothing = !Array.isArray(hits) || hits.length === 0
|
|
switch (mode) {
|
|
case 'no_embedder':
|
|
// Permanent configuration state, not a per-turn failure. The constant index
|
|
// (systemPrompt.context -> renderIndex) already tells the model that block
|
|
// memory exists and is searched lexically. Repeating it on every zero-hit
|
|
// turn costs ~213 chars on the COMMONEST case — measured 10/10 ordinary
|
|
// turns under the shipping default (no embedder) — and trains the model to
|
|
// skip the memory section, which is exactly what W11 exists to prevent for
|
|
// real degradations. W11e pins the silence.
|
|
return ''
|
|
case 'embed_error': {
|
|
// Two wordings, because "matches below are …" is a lie when nothing was
|
|
// found. The distinction matters: with zero entries the model must know the
|
|
// search was incomplete AND empty, not merely incomplete.
|
|
return nothing
|
|
? '[Memory] Semantic search FAILED for this turn and the lexical fallback found nothing. Absence of a memory here does not prove it is unrecorded.'
|
|
: '[Memory] Semantic search FAILED for this turn; matches below are lexical/symbolic only. Absence of a memory here does not prove it is unrecorded.'
|
|
}
|
|
case 'aborted':
|
|
// Deliberate stop, not a fault: say so plainly and do NOT imply the memory
|
|
// is incomplete, which would invite a pointless retry. Same two-wording rule
|
|
// as embed_error — a cancelled turn that ALSO found nothing is the commonest
|
|
// shape of an abort (the user stops, and their query matches nothing), so
|
|
// "matches below are …" is a lie in exactly the case that happens most.
|
|
return nothing
|
|
? '[Memory] Semantic search was cancelled for this turn and the lexical fallback found nothing.'
|
|
: '[Memory] Semantic search was cancelled for this turn; matches below are lexical/symbolic only.'
|
|
case 'vector_ready_unused':
|
|
// Group A state: the vector was computed but does not take part in scoring
|
|
// yet. Not something the model needs to hear about — but keeping the case
|
|
// explicit means a future mode cannot silently fall through to ''.
|
|
return ''
|
|
default:
|
|
// A mode nobody anticipated. "Not found" must not read as "not recorded":
|
|
// this is the ONLY path a future mode can take, so it is the worst possible
|
|
// place to start lying. Carries the same obligation as embed_error.
|
|
return nothing
|
|
? `[Memory] Recall degraded (${mode}); nothing was found, so treat this as "unknown", not "absent".`
|
|
: `[Memory] Recall degraded (${mode}); matches below may be incomplete.`
|
|
}
|
|
}
|