G2† 补成自动化(你坚持的那条,我认)
你指出 G 组只有三条变异是自动化断言,G2† 只活在我的 commit message 里 ——
手工记录的变异不会随时间复现。已编成 test,并扩到覆盖**每一条**降级路径:
DEGRADATION_PATHS 数据表(6 条,新增第五条时未覆盖则测试即红)
embedder is null / undefined / 无 embedText
★ 无 info() ← 你点名的那条
★ info() 的 fingerprint 为空串
★ info() 的 fingerprint 非字符串
+ 每条都断言 degraded=no_embedder、仍能召回、且零 fetch
+ 额外一条「指纹检查在 embedText 之前」(钉顺序,不只钉结果)
变异实测打红
把 embedText 调用挪到指纹检查之前
not ok 8 - G2†: the fingerprint check runs BEFORE embedText is called
让无指纹分支发一次 fetch
not ok 5 - G2†: "embedder with no info()" ... zero network calls
H 组实现(lib/embed/http.js,单文件,G5 守卫)
- 协议与 HA 逐字段一致:model / modality / side / text;响应两种形状都收
- 指纹 http:<model>:<dim>:<sha256(规范化 endpoint)[:8]>
规范化(hash 之前):去空白 / scheme+host 小写 / 去尾斜杠 / 去默认端口
不做 query 重排与 .. 解析(那些差异通常是真实的)
★ 构造时算一次并复用 —— 因为同一个串必须同时进两张表
- 失败在 adapter 层是硬错误(非 2xx / 无 embedding / 维度不符 / 超限 / 超时),
在召回层被 catch 成 degraded='embed_error',退回 FTS5
- 维度不符绝不截断不填充(block_recall.go:135-138)
票② 两表指纹一致 —— 落在 H 组 schema,未拖到 F
- memory_blocks 加 vector / fingerprint 两列(phase A 的表已发布,
这里用 CREATE TABLE IF NOT EXISTS 的新列,旧库需 ALTER;已记入待办)
- graph_centroid 列名对齐 HA(vector_count / vector / stats / built_at),
并把 is_anomalous 提成独立列(票③口径),不塞进 stats JSON
- putBlock 拒绝「有向量但无指纹」——无标签的向量永远不可安全比较
- putBlock 重提交不带向量时不清空已有向量(与 PutMemoryBlocks 的空值不覆盖同构)
- 新增 saveCentroid / loadCentroid / blockFingerprints
票② 判据
ok - ticket②: block fingerprint and centroid fingerprint are byte-identical
ok - ticket② mutation: a re-derived centroid fingerprint misses the lookup
(加一个尾随空格即查不到,且不抛错 —— 正是必须用断言钉住的原因)
ok - ticket②: a vector without a fingerprint is refused at write time
ok - ticket②: re-committing without a vector must not erase the stored one
H 组变异(8 条,全部实跑打红)
HM1 side 写死 document → not ok 1 - H1
HM2 指纹去掉 endpoint hash → not ok 10/12/13 - F-fp1/fp3/fp4(三条一起红)
HM3 维度不符改为截断 → not ok 5 - H3b
HM4 去掉响应体积上限 → not ok 9 - H6
HM5 保留尾斜杠 → not ok 11 - F-fp2
HM6 非 2xx 当成功 → not ok 4 - H3
HM7 忽略 timeout signal → not ok 8 - H4
HM8 指纹每次 info() 重算 → not ok 15 - F-fp6
一处设计确认:lib/index.js 现在 import './embed/http.js',
G5 仍绿 —— 因为该文件没有 fetch/Authorization 等 transport 标识符。
「可以 import adapter,不可以知道 transport」正是 G5 的设计意图。
验证
- npm test 76/76(56 + H 组 20)
- npm run check 9/9
- 提交前连跑 3 次 test + 3 次 check,6 次全绿
待办(不催,记着)
- 票③ D7 fail-closed(中心表空但有向量行 ⇒ dim 报 null 不报 0)跟 F 组
- phase A 旧库升级需 ALTER TABLE 加两列,随 D 组的 stats 工具一起做
212 lines
7.4 KiB
JavaScript
212 lines
7.4 KiB
JavaScript
/**
|
|
* HTTP embedding adapter — the ONLY file allowed to know a transport.
|
|
*
|
|
* Group G's `G5` guard enforces that: no other file under `lib/` may contain
|
|
* `fetch(`, `Authorization`, `Bearer`, `http(s)://`, `model_dir`, or `api_key`.
|
|
* Keeping the transport confined here is what makes "swap HTTP for a local ONNX
|
|
* runner" a one-file change instead of an audit of the whole plugin.
|
|
*
|
|
* ## Protocol
|
|
*
|
|
* Deliberately identical to HologramAgent's `http` provider
|
|
* (`internal/memory/vector/http_embedder.go:72-87`) so an existing HA sidecar can
|
|
* be pointed at without a shim:
|
|
*
|
|
* ```jsonc
|
|
* // request
|
|
* { "model": "...", "modality": "text", "side": "query",
|
|
* "text": "...", "data": "<base64>", "mime": "image/png" }
|
|
* // response — either shape is accepted
|
|
* { "embedding": [ ... ] }
|
|
* { "data": [ { "embedding": [ ... ] } ] }
|
|
* ```
|
|
*
|
|
* ## Fingerprint
|
|
*
|
|
* `http:<model>:<dim>:<sha256(normalised endpoint)[:8]>`
|
|
*
|
|
* HA derives `http:<model>:<dim>` (`http_embedder.go:99`) — model and dimension
|
|
* only. That is unsafe once an endpoint is configurable: two deployments of the
|
|
* same model name at the same dimension produce **the same fingerprint but
|
|
* different vectors** (different weights, different hardware, a different
|
|
* region), so blocks from both spaces compare silently and wrongly. Adding the
|
|
* endpoint hash closes that hole. The digest is truncated to 8 hex chars: it
|
|
* only needs to distinguish a handful of configured endpoints, not to resist an
|
|
* attacker.
|
|
*
|
|
* Normalisation happens **before** hashing so that cosmetic differences in the
|
|
* configuration string do not each trigger a full re-embed:
|
|
*
|
|
* - strip surrounding whitespace
|
|
* - lowercase the scheme and the host
|
|
* - drop a trailing `/` from the path
|
|
* - drop an explicit default port (`:80` for http, `:443` for https)
|
|
*
|
|
* It deliberately does NOT reorder query strings or resolve `..` in paths:
|
|
* those differences are usually real, and hiding them would mask a genuine
|
|
* misconfiguration.
|
|
*
|
|
* @module dsh-memory/embed/http
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto'
|
|
|
|
/** Request timeout; HA's default is 30s (`http_embedder.go:95-97`). */
|
|
const DEFAULT_TIMEOUT_MS = 30_000
|
|
|
|
/** Response body cap. HA uses 4 MiB (`http_embedder.go:136`). */
|
|
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
|
|
|
|
/**
|
|
* Normalise an endpoint string for fingerprinting.
|
|
*
|
|
* @param {string} endpoint
|
|
* @returns {string} the normalised form (empty string when input is unusable)
|
|
*/
|
|
export function normalizeEndpoint(endpoint) {
|
|
const raw = String(endpoint ?? '').trim()
|
|
if (raw === '') return ''
|
|
|
|
let url
|
|
try {
|
|
url = new URL(raw)
|
|
} catch {
|
|
// Not a parseable URL: fall back to the trimmed string so the fingerprint is
|
|
// still stable, and let the request itself fail loudly later.
|
|
return raw
|
|
}
|
|
|
|
// URL already lowercases protocol and host; be explicit about it so the
|
|
// behaviour survives a switch away from WHATWG URL.
|
|
const scheme = url.protocol.toLowerCase()
|
|
const host = url.hostname.toLowerCase()
|
|
const defaultPort = scheme === 'https:' ? '443' : scheme === 'http:' ? '80' : ''
|
|
const port = url.port === defaultPort ? '' : url.port
|
|
const path = url.pathname.replace(/\/+$/, '')
|
|
const search = url.search
|
|
|
|
return `${scheme}//${host}${port === '' ? '' : `:${port}`}${path}${search}`
|
|
}
|
|
|
|
/**
|
|
* Derive the vector-space fingerprint.
|
|
*
|
|
* @param {object} input
|
|
* @param {string} input.endpoint configured endpoint (normalised internally)
|
|
* @param {string} [input.model]
|
|
* @param {number} input.dimension
|
|
* @param {string} [input.fingerprint] explicit override (HA has the same escape hatch)
|
|
* @returns {string}
|
|
*/
|
|
export function deriveFingerprint({ endpoint, model = '', dimension, fingerprint }) {
|
|
if (typeof fingerprint === 'string' && fingerprint !== '') return fingerprint
|
|
const normalised = normalizeEndpoint(endpoint)
|
|
const digest = createHash('sha256').update(normalised, 'utf8').digest('hex').slice(0, 8)
|
|
return `http:${model}:${dimension}:${digest}`
|
|
}
|
|
|
|
/**
|
|
* Build an embedder over an HTTP endpoint.
|
|
*
|
|
* Returns the same three-member shape `recallBlocksHybrid` expects — nothing
|
|
* here leaks into scoring, storage, or grouping.
|
|
*
|
|
* @param {object} config
|
|
* @param {string} config.endpoint
|
|
* @param {number} config.dimension
|
|
* @param {string} [config.model]
|
|
* @param {string} [config.apiKey]
|
|
* @param {string} [config.fingerprint] override
|
|
* @param {number} [config.timeoutMs]
|
|
* @returns {{ embedText: (text: string) => Promise<number[]>,
|
|
* info: () => {fingerprint: string, dimension: number},
|
|
* close: () => Promise<void> }}
|
|
*/
|
|
export function createHttpEmbedder(config) {
|
|
const {
|
|
endpoint,
|
|
dimension,
|
|
model = '',
|
|
apiKey = '',
|
|
fingerprint,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
} = config ?? {}
|
|
|
|
if (typeof endpoint !== 'string' || endpoint.trim() === '') {
|
|
throw new Error('dsh-memory: http embedder requires an endpoint')
|
|
}
|
|
if (!Number.isInteger(dimension) || dimension <= 0) {
|
|
throw new Error(`dsh-memory: http embedder requires a positive integer dimension, got ${dimension}`)
|
|
}
|
|
|
|
const normalisedEndpoint = normalizeEndpoint(endpoint)
|
|
// ★ Computed ONCE and reused. The same string must reach both
|
|
// `memory_blocks.fingerprint` and `graph_centroid.fingerprint`; deriving it
|
|
// per call risks two tables holding two spellings, which makes
|
|
// `LoadCentroid` miss silently (no error, centering just never applies).
|
|
const fp = deriveFingerprint({ endpoint: normalisedEndpoint, model, dimension, fingerprint })
|
|
|
|
let closed = false
|
|
const info = () => ({ fingerprint: fp, dimension })
|
|
|
|
async function embedText(text) {
|
|
if (closed) throw new Error('dsh-memory: http embedder closed')
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
try {
|
|
const response = await fetch(normalisedEndpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(apiKey === '' ? {} : { Authorization: `Bearer ${apiKey}` }),
|
|
},
|
|
body: JSON.stringify({
|
|
model,
|
|
modality: 'text',
|
|
side: 'query',
|
|
text: String(text ?? ''),
|
|
}),
|
|
signal: controller.signal,
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => '')
|
|
throw new Error(`dsh-memory: embed endpoint returned ${response.status}: ${body.slice(0, 200)}`)
|
|
}
|
|
|
|
const raw = await response.text()
|
|
if (raw.length > MAX_RESPONSE_BYTES) {
|
|
throw new Error(`dsh-memory: embed response exceeds ${MAX_RESPONSE_BYTES} bytes`)
|
|
}
|
|
const payload = JSON.parse(raw)
|
|
|
|
// Both shapes are accepted (HA: http_embedder.go:81-87).
|
|
const vector = Array.isArray(payload?.embedding)
|
|
? payload.embedding
|
|
: Array.isArray(payload?.data?.[0]?.embedding)
|
|
? payload.data[0].embedding
|
|
: undefined
|
|
|
|
if (!Array.isArray(vector)) {
|
|
throw new Error('dsh-memory: embed response carries no embedding array')
|
|
}
|
|
if (vector.length !== dimension) {
|
|
// Hard error, never truncate or pad: a silently mis-sized vector is a
|
|
// meaningless score that looks plausible (HA: block_recall.go:135-138).
|
|
throw new Error(`dsh-memory: embed dimension ${vector.length}, want ${dimension}`)
|
|
}
|
|
return vector
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
return {
|
|
embedText,
|
|
info,
|
|
async close() {
|
|
closed = true
|
|
},
|
|
}
|
|
}
|