diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 0c607ba..d51d4a7 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -12,6 +12,8 @@ - **边权重演化 Weight Evolution(issue #24 块2)**:`entity_relations` 加 `weight`/`source` 两列(幂等迁移,存量行按 manual 1.0 认账)——建边来源决定初值(manual/confirmed 1.0、llm 0.4、tag 0.3),LLM 抽取自动建的边标 `source='llm'`;演化走 `bumpRelationWeight`(只加不减、封顶 1.0,单条 `UPDATE ... MIN(1.0, weight + ?)` 原子抬升,负/非有限增量在存储边界拒绝)。新增两键 `graphWeightEnabled`/`graphWeightDelta` 注册进 feature_flags 白名单,面板可启停;lightMode 强制关。触达侧接线见块4(被动确认)。 - **关联提示防幻觉 Injection Budget(issue #24 块3)**:图召回候选进注入时默认只参与排序、不改变注入块构成(`graphInjectHint` 关,保守档);开启后以固定句式「[检索线索]」标注、并在首个线索行前加引导语(链路信息非事实断言),条数由独立预算 `graphInjectBudget` 约束——既不受 document 预算约束、也不挤占 `maxItems` 槽位。线索行不进 pin 池(pin 会在保守档下把它放回块内,绕过开关)。新增两键注册进 feature_flags 白名单;lightMode 强制关。 +- **被动确认 Passive Confirmation(issue #24 块4)**:把「人工认可/删除/忽略」的确认门槛收敛成例外管理——正常触达(记忆被召回/注入)即视为对挂联关系边的被动确认,`store.getRelationsByMemory` 反查关联边后 `bumpRelationWeight` 抬一格(只加不减、封顶 1.0,自激回路由封顶遏制)。`graphPassiveConfirm` 与 heat 是独立闸门(`heatEnabled=false` 时仍可演化边权),且受块2 总闸 `graphWeightEnabled` 约束——两键同开才生效,单开通道键绕不过「演化默认关」。仅异常路径暴露给用户复核的复核 UI 留后续块。新增键注册进 feature_flags 白名单;lightMode 强制关。 + ## 🧹 工程 - **基线持久化回归集 9 条,全量测试 1441 → 1450**:无种子对照组与播种组同库同写入(一个仍触发、一个被拦下——锁的正是这个差异,否则播种被忽略时两条断言同真同假)、阈值照常累积(+10 条仍触发)、run 自报基线优先于现场计算(运行后再长 15 条仍不触发)、成功轮落库 / 失败轮不落、sleep 行不进 auto 基线、半个基线被拒、老库补列幂等、非法种子(半个 / 负数 / 非整数 / 非对象)一律按「没有基线」处理、收尾取证失败既不反噬审计行也不写假基线、`index.js` 接线源码锁。**变异检验**:把播种改成忽略种子(`toBaseline(baselineSeed)` 恒为空)→ 2 条变红;把「成功轮落基线」分支短路 → 1 条变红。 diff --git a/dsh-mneme/lib/config.js b/dsh-mneme/lib/config.js index 8002106..b2018c7 100644 --- a/dsh-mneme/lib/config.js +++ b/dsh-mneme/lib/config.js @@ -469,6 +469,10 @@ export const Config = z.object({ graphWeightEnabled: z.boolean().default(false), // 单次有效触达的抬升幅度。 graphWeightDelta: z.number().min(0).max(1).default(0.1), + // 被动确认(issue #24 块4):heat 触达(记忆被召回/注入=被用)即对该记忆 + // 挂联的关系边做被动确认——bumpRelationWeight 抬一格。默认关=零行为变化; + // 开=正常触达演化边权,「关系是用出来的」闭环落地(异常路径复核留后续块)。 + graphPassiveConfirm: z.boolean().default(false), // --- 图谱锚定层(issue #24 · 块1 Activation Anchoring)------------------- // entityRecallEnabled 之上再开一层:以查询命中的实体为种子,沿实体关系表 @@ -743,6 +747,8 @@ const LIGHT_MODE_OFF = [ "graphWeightEnabled", // 轻量模式不开关联提示标注(#24 块3,常驻注入文本多一段提示成本)。 "graphInjectHint", + // 轻量模式不开被动确认(#24 块4,依赖实体抽取产出;抽取关则无 v08 边可抬升)。 + "graphPassiveConfirm", // 轻量模式不开叙述条(额外 LLM 调用;#164 对齐,opt-in)。 "dreamNarrativeEnabled", // 轻量模式不开 document 指针行(#230,opt-in:注册/注入/检索增强全随闸)。 diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index cb338b4..ed7fd12 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -593,11 +593,28 @@ export function createService({ store, mirror, config, onWrite, logger, document * on the hot recall path). A touch failure must never break search/inject. */ function touchRecalled(memories) { - if (config?.heatEnabled === false || !Array.isArray(memories) || memories.length === 0) return; + if (!Array.isArray(memories) || memories.length === 0) return; + // heatEnabled=false 只关 touchLastAccess 的热度消费(既有语义),但 #24 + // 块4 被动确认独立于 heat——graphPassiveConfirm 开时照常 bump 关联边。 + // graphWeightEnabled 是块2 立的演化总闸:它关着时任何触达都不该改边权,被动 + // 确认只是它底下的一个通道(后续块还会接别的确认通道),两键同开才 bump, + // 否则单开通道键就等于绕过「演化默认关」的承诺。 + // 两条路径都 best-effort,失败绝不阻断检索/注入。 + const heatOn = config?.heatEnabled !== false; + const passive = config?.graphPassiveConfirm === true && config?.graphWeightEnabled === true; + const delta = config?.graphWeightDelta ?? 0.1; for (const m of memories) { if (!m?.id) continue; try { - store.touchLastAccess(m.id); + if (heatOn) store.touchLastAccess(m.id); + if (passive) { + // 正常触达=对该记忆挂联关系边的被动确认;bump 只加不减封顶 1.0, + // 自激回路由封顶天然遏制。「仅异常路径暴露给用户复核」的确认记录 + // 侧在此落地,复核 UI 留后续块。 + for (const rel of store.getRelationsByMemory(m.id)) { + store.bumpRelationWeight(rel.id, delta); + } + } } catch { /* touch is best effort */ } } } diff --git a/dsh-mneme/lib/settings.js b/dsh-mneme/lib/settings.js index bbf1c6a..7f8aba0 100644 --- a/dsh-mneme/lib/settings.js +++ b/dsh-mneme/lib/settings.js @@ -61,12 +61,15 @@ const FEATURE_FLAG_BOOLEANS = [ // Issue #24 块1:图谱锚定层级联——命中实体作种子、沿关系表向邻居扩散 // (默认关;依赖实体抽取产出,lightMode 强制关闭)。 "graphAnchoringEnabled", - // Issue #24 块2:边权重演化——touch 门控把被寻回路径的边权重抬升 - // (默认关;依赖实体抽取产出,lightMode 强制关闭)。 + // Issue #24 块2:边权重演化总闸——关着时任何触达都不改边权(默认关; + // 依赖实体抽取产出,lightMode 强制关闭)。块4 的被动确认是它底下的一个通道。 "graphWeightEnabled", // Issue #24 块3:关联提示防幻觉——图召回线索行以「[检索线索]」标注进注入 // (默认关=线索只参与排序不进块;依赖实体抽取产出,lightMode 强制关闭)。 "graphInjectHint", + // Issue #24 块4:被动确认通道——正常触达即对该记忆挂联的边 bump 一格 + // (默认关;需总闸 graphWeightEnabled 同开)。 + "graphPassiveConfirm", // Issue #164:叙述条——dream 期间按 tag 主题簇合成叙述落库(source= // narrative,evidence 回链簇内记忆;按需检索不常驻注入;默认关)。 "dreamNarrativeEnabled", diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index f0e375e..abd88c9 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -2553,6 +2553,7 @@ export function createStore(path) { ).all(entityId, entityId).map(toRelation); } + /** /** * 批量邻接(issue #24 · 块1 锚定层级联):给定多条实体 id,一并取全部关系行 * 并归并成「每个实体 → 相邻实体 id 去重列表」的邻接表。供 anchoring.js 的 @@ -2585,6 +2586,19 @@ export function createStore(path) { if (!list.includes(neighbor)) list.push(neighbor); } + /** + * 按记忆反查关联边(issue #24 块4 被动确认):一条记忆被召回/注入(触达) + * 时,挂在同一记忆上的关系行即「这条关系被用过了」的证据——正常触达视为 + * 被动确认,调用方据此 bumpRelationWeight。返回 toRelation 行(含 weight/ + * source)。索引 idx_relations_memory 已建(#219)。 + */ + function getRelationsByMemory(memoryId) { + if (!memoryId) return []; + return db.prepare( + "SELECT * FROM entity_relations WHERE memory_id = ?" + ).all(memoryId).map(toRelation); + } + /** All entities (optionally name-filtered, newest first). Used by sleep phase 4 * orphan detection: an entity with zero relations is a candidate for relation * completion. */ @@ -2971,6 +2985,7 @@ export function createStore(path) { migrateAttrsToMemory, getRelations, getEntityNeighbors, + getRelationsByMemory, setMirrorState, getMirrorState, markMirrorDirty, diff --git a/dsh-mneme/src/config.js b/dsh-mneme/src/config.js index 8002106..b2018c7 100644 --- a/dsh-mneme/src/config.js +++ b/dsh-mneme/src/config.js @@ -469,6 +469,10 @@ export const Config = z.object({ graphWeightEnabled: z.boolean().default(false), // 单次有效触达的抬升幅度。 graphWeightDelta: z.number().min(0).max(1).default(0.1), + // 被动确认(issue #24 块4):heat 触达(记忆被召回/注入=被用)即对该记忆 + // 挂联的关系边做被动确认——bumpRelationWeight 抬一格。默认关=零行为变化; + // 开=正常触达演化边权,「关系是用出来的」闭环落地(异常路径复核留后续块)。 + graphPassiveConfirm: z.boolean().default(false), // --- 图谱锚定层(issue #24 · 块1 Activation Anchoring)------------------- // entityRecallEnabled 之上再开一层:以查询命中的实体为种子,沿实体关系表 @@ -743,6 +747,8 @@ const LIGHT_MODE_OFF = [ "graphWeightEnabled", // 轻量模式不开关联提示标注(#24 块3,常驻注入文本多一段提示成本)。 "graphInjectHint", + // 轻量模式不开被动确认(#24 块4,依赖实体抽取产出;抽取关则无 v08 边可抬升)。 + "graphPassiveConfirm", // 轻量模式不开叙述条(额外 LLM 调用;#164 对齐,opt-in)。 "dreamNarrativeEnabled", // 轻量模式不开 document 指针行(#230,opt-in:注册/注入/检索增强全随闸)。 diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index cb338b4..ed7fd12 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -593,11 +593,28 @@ export function createService({ store, mirror, config, onWrite, logger, document * on the hot recall path). A touch failure must never break search/inject. */ function touchRecalled(memories) { - if (config?.heatEnabled === false || !Array.isArray(memories) || memories.length === 0) return; + if (!Array.isArray(memories) || memories.length === 0) return; + // heatEnabled=false 只关 touchLastAccess 的热度消费(既有语义),但 #24 + // 块4 被动确认独立于 heat——graphPassiveConfirm 开时照常 bump 关联边。 + // graphWeightEnabled 是块2 立的演化总闸:它关着时任何触达都不该改边权,被动 + // 确认只是它底下的一个通道(后续块还会接别的确认通道),两键同开才 bump, + // 否则单开通道键就等于绕过「演化默认关」的承诺。 + // 两条路径都 best-effort,失败绝不阻断检索/注入。 + const heatOn = config?.heatEnabled !== false; + const passive = config?.graphPassiveConfirm === true && config?.graphWeightEnabled === true; + const delta = config?.graphWeightDelta ?? 0.1; for (const m of memories) { if (!m?.id) continue; try { - store.touchLastAccess(m.id); + if (heatOn) store.touchLastAccess(m.id); + if (passive) { + // 正常触达=对该记忆挂联关系边的被动确认;bump 只加不减封顶 1.0, + // 自激回路由封顶天然遏制。「仅异常路径暴露给用户复核」的确认记录 + // 侧在此落地,复核 UI 留后续块。 + for (const rel of store.getRelationsByMemory(m.id)) { + store.bumpRelationWeight(rel.id, delta); + } + } } catch { /* touch is best effort */ } } } diff --git a/dsh-mneme/src/settings.js b/dsh-mneme/src/settings.js index bbf1c6a..7f8aba0 100644 --- a/dsh-mneme/src/settings.js +++ b/dsh-mneme/src/settings.js @@ -61,12 +61,15 @@ const FEATURE_FLAG_BOOLEANS = [ // Issue #24 块1:图谱锚定层级联——命中实体作种子、沿关系表向邻居扩散 // (默认关;依赖实体抽取产出,lightMode 强制关闭)。 "graphAnchoringEnabled", - // Issue #24 块2:边权重演化——touch 门控把被寻回路径的边权重抬升 - // (默认关;依赖实体抽取产出,lightMode 强制关闭)。 + // Issue #24 块2:边权重演化总闸——关着时任何触达都不改边权(默认关; + // 依赖实体抽取产出,lightMode 强制关闭)。块4 的被动确认是它底下的一个通道。 "graphWeightEnabled", // Issue #24 块3:关联提示防幻觉——图召回线索行以「[检索线索]」标注进注入 // (默认关=线索只参与排序不进块;依赖实体抽取产出,lightMode 强制关闭)。 "graphInjectHint", + // Issue #24 块4:被动确认通道——正常触达即对该记忆挂联的边 bump 一格 + // (默认关;需总闸 graphWeightEnabled 同开)。 + "graphPassiveConfirm", // Issue #164:叙述条——dream 期间按 tag 主题簇合成叙述落库(source= // narrative,evidence 回链簇内记忆;按需检索不常驻注入;默认关)。 "dreamNarrativeEnabled", diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index f0e375e..abd88c9 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -2553,6 +2553,7 @@ export function createStore(path) { ).all(entityId, entityId).map(toRelation); } + /** /** * 批量邻接(issue #24 · 块1 锚定层级联):给定多条实体 id,一并取全部关系行 * 并归并成「每个实体 → 相邻实体 id 去重列表」的邻接表。供 anchoring.js 的 @@ -2585,6 +2586,19 @@ export function createStore(path) { if (!list.includes(neighbor)) list.push(neighbor); } + /** + * 按记忆反查关联边(issue #24 块4 被动确认):一条记忆被召回/注入(触达) + * 时,挂在同一记忆上的关系行即「这条关系被用过了」的证据——正常触达视为 + * 被动确认,调用方据此 bumpRelationWeight。返回 toRelation 行(含 weight/ + * source)。索引 idx_relations_memory 已建(#219)。 + */ + function getRelationsByMemory(memoryId) { + if (!memoryId) return []; + return db.prepare( + "SELECT * FROM entity_relations WHERE memory_id = ?" + ).all(memoryId).map(toRelation); + } + /** All entities (optionally name-filtered, newest first). Used by sleep phase 4 * orphan detection: an entity with zero relations is a candidate for relation * completion. */ @@ -2971,6 +2985,7 @@ export function createStore(path) { migrateAttrsToMemory, getRelations, getEntityNeighbors, + getRelationsByMemory, setMirrorState, getMirrorState, markMirrorDirty, diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js index e445512..4603cc9 100644 --- a/dsh-mneme/test/api.test.js +++ b/dsh-mneme/test/api.test.js @@ -671,8 +671,9 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d // issue #254 新增 writeAdmission.enabled/writeAdmission.enforce, // issue #34 恢复(#333)新增 injectTimePrefix、 // issue #24 块1 新增 graphAnchoringEnabled/graphSeedCap/graphCascadeDepth、 - // 块2 新增 graphWeightEnabled/graphWeightDelta、块3 新增 graphInjectHint/graphInjectBudget) - assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2); + // 块2 新增 graphWeightEnabled/graphWeightDelta、块3 新增 graphInjectHint/graphInjectBudget、 + // 块4 新增 graphPassiveConfirm) + assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1); assert.equal(data.effective.dreamSkipInvalid, true); assert.equal(data.effective.allowCrossTypeMerge, false); assert.equal(data.effective.dreamMinIntervalMinutes, 0); diff --git a/dsh-mneme/test/graph-passive-confirm.test.js b/dsh-mneme/test/graph-passive-confirm.test.js new file mode 100644 index 0000000..fe3b3e2 --- /dev/null +++ b/dsh-mneme/test/graph-passive-confirm.test.js @@ -0,0 +1,92 @@ +// issue #24 块4 被动确认(graphPassiveConfirm)测试。 +// 覆盖:默认关=零行为变化 / 开启=命中记忆的关联边 bump / heat 关但 passive +// 开仍 bump(独立闸门)/ bump 只加不减封顶 / 无关联边的记忆触达安全降级。 +import test from "node:test"; +import assert from "node:assert/strict"; +import { createStore } from "../src/store.js"; +import { createService } from "../src/service.js"; + +function setup(config = {}) { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config }); + return { store, service }; +} + +function putMem(store, service, { title, content, importance = 3 }) { + return service.saveWithDedupe({ type: "project", title, content, importance }).memory; +} + +function linkRelToMem(store, { fromName, toName, memoryId, source = "llm" }) { + const a = store.findEntityByName(fromName) ?? store.createEntity({ name: fromName, type: "technology" }); + const b = store.findEntityByName(toName) ?? store.createEntity({ name: toName, type: "technology" }); + return store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "uses", memory_id: memoryId, source }); +} + +/** 跑一次检索(内部 touchRecalled 触发触达),返回 rows。 */ +async function searchOnce(service, query) { + return service.searchMemories(query, { mode: "auto" }); +} + +test("graphPassiveConfirm=false (default): touched memory does NOT bump relation weight", async () => { + const { store, service } = setup(); + const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" }); + const rel = linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" }); + assert.equal(rel.weight, 0.4); + await searchOnce(service, "数据库迁移"); + // 默认关:检索触达不 bump(weight 保持 0.4)。 + assert.equal(store.getRelationsByMemory(mem.id)[0].weight, 0.4, "weight unchanged by default"); + store.close(); +}); + +test("graphPassiveConfirm=true: touched memory bumps its linked relation weight", async () => { + const { store, service } = setup({ graphWeightEnabled: true, graphPassiveConfirm: true, graphWeightDelta: 0.1 }); + const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" }); + const rel = linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" }); + assert.equal(rel.weight, 0.4); + const rows = await searchOnce(service, "数据库迁移"); + assert.ok(rows.some((r) => r.id === mem.id), "memory surfaced"); + assert.ok(store.getRelationsByMemory(mem.id)[0].weight > 0.4, "weight bumped after touch"); + store.close(); +}); + +test("heatEnabled=false does NOT gate passive confirm (independent gates)", async () => { + const { store, service } = setup({ heatEnabled: false, graphWeightEnabled: true, graphPassiveConfirm: true, graphWeightDelta: 0.1 }); + const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" }); + linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" }); + await searchOnce(service, "数据库迁移"); + assert.ok(store.getRelationsByMemory(mem.id)[0].weight > 0.4, "bumped even with heat off"); + // 但 touchLastAccess 不写(heat 关的既有语义保留:last_accessed_at 仍为空)。 + assert.equal(store.getById(mem.id).last_accessed_at, undefined); + store.close(); +}); + +test("repeated touches cap at 1.0 (never exceeds)", () => { + const { store } = setup(); + const a = store.createEntity({ name: "A", type: "technology" }); + const b = store.createEntity({ name: "B", type: "technology" }); + const rel = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "llm" }); // 0.4 + for (let i = 0; i < 5; i++) { + store.bumpRelationWeight(rel.id, 0.3); // 0.4→0.7→1.0→1.0… + } + assert.equal(store.getRelations(a.id)[0].weight, 1.0, "capped at 1.0"); + store.close(); +}); + +// 总闸(块2 的 graphWeightEnabled)关着时被动确认不许生效——单开通道键就能演化 +// 边权的话,块2 立的「演化默认关」总闸形同虚设。 +test("graphWeightEnabled=false gates passive confirm (master switch)", async () => { + const { store, service } = setup({ graphPassiveConfirm: true, graphWeightDelta: 0.1 }); + const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" }); + linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" }); + await searchOnce(service, "数据库迁移"); + assert.equal(store.getRelationsByMemory(mem.id)[0].weight, 0.4, "master switch off → weight untouched"); + store.close(); +}); + +test("memory without relations survives touch (no crash)", async () => { + const { store, service } = setup({ graphPassiveConfirm: true }); + const mem = putMem(store, service, { title: "孤立条", content: "无关联边" }); + await searchOnce(service, "孤立条"); + assert.ok(store.getById(mem.id), "memory still present"); + store.close(); +}); \ No newline at end of file