/** * [ref] —— embedder **指纹门**(清空重建形,clay 终裁 [ref]⑤ 方向;稿=sema-internal * `server/design/design-234-embedder-fingerprint-20260812.md` v1+v2)。 * * ## 病灶 * * 向量行落在 `agent_memory_engine_entry.embedding`,**行上没有任何「产自哪个 embedder」的记账**。既有 * 防线只有 embedder 的维度门(长度≠`MEMORY_EMBEDDER_DIM` ⇒ 抛),它只拦「换模型且维度恰好不同」; * **同维度换模型**(768 家族之间、1024 家族之间迁移=最常见形)完全静默——旧空间与新空间的向量在同一 * 列里做 cosine,检索排序变噪音,没有任何一层会说出来。 * * ## 形 * * 指纹 = identity 两元组 `{ model, dimensions }`,**明文**存(不 hash:诊断可读性 > 紧凑,日志要能直接 * 念出「从 X 换到了 Y」)。**endpoint 不进指纹**——换供应商域名/代理/端口不换语义空间,进了会造成大量 * 误清;残余成文:同名模型在不同供应商若是不同实现(自托管 finetune 撞名)指纹辨不出,operator 明知 * 异实现时改 `MEMORY_EMBEDDER_MODEL` 名或手动清列(USAGE 有一行 SQL)。 * * 存储位 = **新单行元表**(additive,零删库重建窗)。不给主表加列:主表改形=触发本仓「无 ALTER、删库 * 重建」BREAKING 窗;行级记账在「清空重建」语义下也无增益(全量清 ⇒ 不存在混合态)。 * * ## 属主边界 * * `memory-engine-pg.ts` 是 core 移入件(minimal-diff move-in 纪律)⇒ 元表 DDL + 比对清空腿全在**本 * 文件**(server 侧),移入文件零改动;表名常量本文件自有(`PG_MEMORY_ENGINE_TABLES` 不动)。 * 列名 `meta_key`/`meta_value`:`key` 是 MySQL 保留字,将来若补 TiDB 腿零反引号坑(K2)。 * * ## 六态(v2 总表) * * | 态 | 判定 | 动作 | * |---|---|---| * | A | embedder 配置缺席 | **门不装**(连 ensure 都不调),元表不动——装配点的事,不在本文件 | * | B | 元表有行、identity 逐键相等 | 照常,**零写** | * | C | 元表有行、不等 | 清列 → upsert 新 identity(`reason:"changed"`) | * | D | 元表无行、库内**有**非 NULL 向量 | 同 C 清(存量库无法证明同源=保守清,`reason:"unattributed"`) | * | E | 元表无行、零向量行 | 只写 identity,不清(`memory_embedder_identity_armed`) | * | F | 元表有行但 value 解析失败 | 走清空臂(`reason:"unreadable"`)——**绝不当成匹配**(边界必 schema) | * * (F 的判据是 `model`/`dimensions` 缺失或类型不对;**多出来的未知键不算坏**,见 schema 处的注。) * * ## 三条不可动的判据 * * 1. **次序:先清列、后写元表**。反序若中间崩溃 = 新 identity 已记而旧向量还在 = 正是被修的病(此后 * 每次 boot 恒判 B,残留态永不复检);正序中间崩溃 = 下次 boot 无元表行、向量已 NULL ⇒ E 态补记, * 幂等安全。 * 2. **CAS 循环**(v2 C1-2):元表写后**重读**,读回≠自写 ⇒ 另一副本并发写了**不同** identity(部署 * 配置漂移)⇒ 响亮 `memory_embedder_identity_conflict`(两值并示)+ 重跑比对,上限 * {@link CAS_MAX_ROUNDS} 轮,超限抛(拒启)。运行期旧副本混写非 CAS 可拦 ⇒ 归停机纪律(USAGE)。 * 3. **门自身失败 = 抛(拒启)**。吞掉 = 元表停留旧值 = 下次 boot 再清一次,把一次性代价变成每次 boot * 的代价;且「门在场但没跑成」静默 = fail-open。 * * ## 两条已知残余(codex 交叉复审 R1-F1/R1-F3,均判为真;处置=成文,不落码面) * * - **跨门回滚**(F1):指纹门**之前**的版本(7.14.x/7.15.x:有 embedder、无本门)照写 `embedding` 列却 * 不认识元表 ⇒「装本版(元表=B)→ 回滚旧版跑一段(写 A 空间向量)→ 升回本版仍配 B」走完,元表与配置 * 都说 B 而列里是 A 空间 ⇒ 此后恒判 B 态。**与滚动窗是同一族**(第二写者不维护指纹),码面根治只有 * 行级记账列 —— 稿已列出界(触主表删库重建窗)。纪律面处置与滚动窗同:回滚前后手动清列,或删掉 * 元表那一行让下次 boot 按 D 态保守清。USAGE 已成文。 * - **崩溃丢审计行**(F3):「清过就必响亮」以**进程活到 emitOutcome** 为界。清列与写元表是两条各自 * autocommit 的语句,日志在回读之后才发 ⇒ 清完即崩 ⇒ 下次 boot 元表无行、向量已 NULL ⇒ 走 E 态报 * 「没有向量可清」;写完元表即崩 ⇒ 下次 boot 判 B 态、**一条日志都不发**。**数据面安全**(次序钉买的 * 正是这个:绝不残留旧空间),丢的只有那一次的 cleared 计数。刻意**不做**两阶段 pending marker —— * 为纯审计价值给 boot 路加一张表 + 两阶段协议,代价与收益不成比例;承诺句在 USAGE 已标出这条边界。 * * 计数取法:`PgQueryResult` 刻意无 `rowCount` ⇒ 用 CTE `WITH c AS (UPDATE … RETURNING 1) SELECT * count(*)::int`(禁 `RETURNING id` 全表拉回)。计数**按整次调用累计**、原因锚在第一次真清的那一轮 —— * 「清过就必响亮」对终局判 B 的那一支同样成立(codex R2 finding 2)。 * * ## 射程外(稿 §出界) * * 主动回填件(清空后不回填,行被 patch 时自然重嵌)/ 行级 embedder 记账列(结构性根治滚动窗)/ * pgvector 接线件的列 dim 校验 / TiDB 向量面(v1 lexical only)。 * * 🔴 **不回填的真代价([ref] 定界车 2026-08-14 实测更正,稿 §4/§V3 说轻了一档)**:稿把代价写成「那些行 * 长期停留 lexical 档」,读起来像**排序退化**;真码是**可达性悬崖**——`memory-engine-pg.ts` 的进程内腿 * 对无向量行落 `jaccardDistance`,core 的该函数在**零词交集**时返回 `null`,`search` 当场 `continue`, * 行被**丢出结果集**;仍有向量的行则走余弦、不被这道门丢(前提是查询侧也嵌得出来——embedder 故障那一 * 刻查询拿不到向量,整轮退词面档,零交集的行一起丢)。即清空之后、重嵌之前,那些行对改述/同义/跨 * 语言查询**检索不到**——而那恰恰是向量档存在的理由。真库端到端实证:db-integration 套件「清空后的 * 代价」一钉(清空前 "zulu" 命中该行,清空后同查询恒空,有字面交集的查询仍命中)。 * ⚠️ 这条只更正**成文口径**,不改裁定:主动回填仍在射程外(clay 已否);但将来复裁该件时,输入应当是 * 「可达性悬崖」而不是「排序退化」。 */ import { z } from "zod"; import type { Logger } from "../observability/logger.js"; import type { PgQueryFn } from "./pg-query.js"; /** 元表名(本文件自有 const —— core 移入文件的 `PG_MEMORY_ENGINE_TABLES` 零改动)。 */ export declare const PG_MEMORY_ENGINE_META_TABLE = "agent_memory_engine_meta"; /** 单行元表里 embedder 指纹那一行的键。 */ export declare const EMBEDDER_IDENTITY_META_KEY = "embedder_identity"; /** CAS 循环上限(v2 C1-2)。超限 = 并发副本带着**不同** identity 在互相覆盖 ⇒ 拒启。 */ export declare const CAS_MAX_ROUNDS = 3; /** * 指纹 schema —— 读回的 `meta_value` 必须过它(手改坏值/回滚残留一律走 F 态,禁 `JSON.parse as` 裸断言)。 * * ⚠️ **未知键刻意放过**(zod 对象默认 strip,不加 `.strict()`):将来某个版本若往这行里多写一个键, * 老副本读到它应当照常按 `model`/`dimensions` 比对,而不是判 F 态把整列清掉 —— 「新副本加了个字段」 * 与「这行坏了」不是一回事,后者才是 F 态要抓的。载荷只有这两个键,多出来的不参与判定。 */ declare const EmbedderIdentitySchema: z.ZodObject<{ model: z.ZodString; dimensions: z.ZodNumber; }, z.core.$strip>; /** `{ model, dimensions }` —— 明文指纹(endpoint 刻意不进,见头注)。 */ export type EmbedderIdentity = z.infer; /** 比对结果的态(六态里除 A 之外的五个 —— A 在装配点,门根本不装)。 */ export type EmbedderFingerprintState = "match" | "changed" | "unattributed" | "unreadable" | "armed"; /** 一次比对的结果(装配点只需要它成功返回;字段供测试与将来观测面读)。 */ export interface EmbedderFingerprintReport { /** 命中的态。 */ state: EmbedderFingerprintState; /** 本次调用**累计**清空的向量行数(跨 CAS 轮累加;从未清过 = 0)。 */ cleared: number; /** 清/写之前元表里的 identity;无行或不可解析 ⇒ null。 */ previous: EmbedderIdentity | null; /** 本次写入(或已匹配)的 identity。 */ identity: EmbedderIdentity; /** 收敛用了几轮(1 = 一次过;>1 = 中途撞到并发不同 identity)。 */ rounds: number; } /** 比对腿的部署腿注入(日志 + 时钟)。日志缺席 = 纯静默跑(测试/脚本形),装配点必传。 */ export interface ReconcileEmbedderFingerprintOptions { /** boot logger(`info` 报换模型/首装,`error` 报并发 identity 冲突)。 */ log?: Pick; /** 耗时读数的时钟(默认 `Date.now`)——**整次调用**的耗时进日志(含 CAS 重试轮), * 兼作锁窗量纲的实测积累(v2 V3)。 */ now?: () => number; } /** * 元表 DDL —— **只发 CREATE**(K1:混入 SELECT/UPDATE 会让 `scripts/dump-schema.ts` 的录制门当场抛)。 * 幂等,与其余 `ensurePg*Schema` 同姿势;新腿已注册 `PG_LEGS` + `docs/schema/baseline-pg.sql`。 */ export declare function ensurePgMemoryEmbedderMetaSchema(query: PgQueryFn): Promise; /** * 指纹比对腿 —— boot 一次(env 族是 boot 快照,运行中不热换)。**门自身失败一律抛**(拒启)。 * * ⚠️ 调用姿势:`ensurePgMemoryEmbedderMetaSchema` 之后、memory backend 构造之前;embedder 配置**在场** * 才调(A 态连 ensure 都不调)。 */ export declare function reconcileEmbedderFingerprint(query: PgQueryFn, identity: EmbedderIdentity, opts?: ReconcileEmbedderFingerprintOptions): Promise; export {}; //# sourceMappingURL=memory-embedder-fingerprint.d.ts.map