diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 1dd5b320..2d8dac6b 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -7,6 +7,7 @@ - **standalone API `GET /context`(issue #370,#363 承诺)**:把宿主注入管线开放为端点——Mneme-Bridge 这类网页端桥接可以删掉自建的 pins 启发式了。`GET /context?q&topK&threshold&agent_scope&workspace_scope` 返回 `injectCandidates` 全语义(优先级分层 / 编码门控 / heat / scope 软加权 / hybrid 语义路 / pin 前置)+ 画像 + 规则的结构化聚合 `{profile, rules, items, pinnedCount}`,不含渲染文本(语言 / 时间前缀 / 热记忆是宿主会话概念,调用方自渲染)。与宿主注入的两点 HTTP 面差异:查询向量即时嵌入——首次调用即走语义路,不等异步 prefetch(宿主注入渲染必须同步,inject.js Bug4);不做跨轮轮换(daemon 无会话状态,调用方自管)。`threshold` 是 importance 阈值(默认 3,与 `/search` 的相似度 threshold 同名不同义);scope 两参全缺 = 不解析(软加权与 strictScope 硬过滤都不触发,与宿主会话身份取不到时同构),任一给出则缺维按 fail-closed。recall_runs 记账随 `injectCandidates` 内建(#217 曝光型访问事件,`mode='inject'` 与检索命中同表分账)——宿主注入同样记,端点自动同口径。`/search` 顺手补 scope 透传(`searchMemories` 本就收参、8790 此前没有入口;不传参数行为逐字节不变);`createStandaloneApi` 新增可选 `embedder` 注入(宿主与 `dsh-mneme-serve` 各传 semantic 装配的同一实例),缺省时 `/context` 降级规则 + BM25 档,嵌入失败同理绝不 500。宿主外部访问与 daemon 同享该路由;测试锁形状(四键)、空 q 规则档、threshold 语义、pin 前置、scope 硬过滤、嵌入接线与失败降级,daemon 侧全链路含 recall_runs 分账锁。 - **daemon 向量检索(PR2,#363)**:`dsh-mneme-serve` 的 `/search` 接入完整语义管线——embedder/reranker 装配与 boot 自动回填从 `index.js` **纯搬移**至 `src/semantic.js`(宿主与 daemon 共用同一份,调用时序契约原样;`backfillMissingEmbeddings` 经 index.js barrel 再出口,测试调用方零改动),daemon 侧新增 `createVectorIndex` 接线与 `--embed` 参数:`local`(默认,自管 runtime/嵌入模型缺失时经 `provisionRuntime` download 档自动取件,可用 `DSH_MNEME_RUNTIME_TARBALL_DIR`/`DSH_MNEME_RUNTIME_MIRROR` 换离线/镜像来源;失败降级关键词并打可操作日志)、`ollama`、`openai`(读宿主面板 vector-config)、`off`。向量轴有注入假 embedder 的回归锁;`createServeRuntime` 因此转为 async、语义键默认值在 `daemonSemanticCfg` 逐键锚定 config.js。 - **独立服务 daemon(`dsh-mneme-serve`,#363)**:mneme 现在能在 DSH 宿主之外常驻——`src/serve.js` 的 `createServeRuntime` 用最小装配(store → settings → mirror → service → maintenance → standalone API,每步锚定 index.js 装配行号)把数据面跑成独立进程,第三方集成(网页端桥接等)不必为挂载记忆库而保持 DSH 开机。第一期刻意无 LLM:巩固(autoDream)与蒸馏结构性不在 daemon 内,这是与宿主「单写者」的机械保证,不靠用户自觉。token 与 DSH 面板/CLI 共用同一 kv 凭证,端口/主机解析链与外部访问一致;`createStandaloneApi` 新增 `strictPort` 选项——daemon 的配置端口被占即报错退出而非顺延(第三方把 URL 写死,静默换端口等于坏),不传该选项的宿主旁路行为不变。`/search` 照常落 recall_runs,第三方检索的复用统计不缺数。多进程共存(daemon 与宿主同库互写互读)有专门回归锁;已知限制(双进程去重竞态、镜像双写、版本偏斜)见 docs/DAEMON.md。 +- **`injectHeatEnabled`(issue #218 / E5 效用考卷,opt-in 默认关)**:注入侧 heat 拆出独立开关——E5 实测 heat 乘进注入排序在真实年龄混合下饿死老约束(现行量级 ≡ 拟合参数,importance-only 遵从 +12.7pp),默认关后 `heatEnabled` 开启时注入乘数恒 1(与 heat 关闭逐字节一致),显式开启恢复旧序;召回侧时钟与 sleep 降级联判不受影响。 ## 🧹 工程 diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index 8992d5bb..40aebcc3 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -405,7 +405,7 @@ dsh web | `bm25SearchEnabled` | `true` | BM25 稀疏第三路召回(v0.5.0):ASCII 词元 + CJK bigram,IDF 加权,散词/ID/代码片段查询不再依赖子串命中 | | `adaptiveThresholdEnabled` | `true` | 自适应相似度阈值(v0.5.0):按查询形态动态截断(前缀 0.5 / 短查询 0.7 / 长查询 0.6 / 头部分差大放宽 0.5),显式传 `threshold` 走旧行为 | | `hotMemoryEnabled` | `true` | 会话级短期热记忆总开关(v0.5.0):关闭后热记忆块不再注入(长期召回不受影响) | -| `heatEnabled` | `false` | 热度模型总开关(v0.7.0 / v0.7.20 回归,**默认关**——v0.7.12 起用户已习惯无 heat 行为):开启后提供热度字段 / sleep 热联合降级保护 / 前端热度投影,并在注入排序的优先级层内乘热度(#218;`order=chrono` 分页序与召回融合序不动);关闭则跳过 heat 计算与热度触达,sleep 降级退回纯时间分层。也走 feature_flags 白名单(面板可启停=回滚开关),lightMode 预设强制关 | +| `heatEnabled` | `false` | 热度模型总开关(v0.7.0 / v0.7.20 回归,**默认关**——v0.7.12 起用户已习惯无 heat 行为):开启后提供热度字段 / sleep 热联合降级保护 / 前端热度投影;注入排序乘热度已拆为独立开关 `injectHeatEnabled`(#218 / E5 考卷:heat 乘进注入排序在真实年龄混合下饿死老约束,默认关,显式开启恢复旧序)。关闭则跳过 heat 计算与热度触达,sleep 降级退回纯时间分层。也走 feature_flags 白名单(面板可启停=回滚开关),lightMode 预设强制关 | | `heatGlobalBeta` | `1.0` | 广义指数形状参数 β(`H=exp(-λ·Δt^β)`,issue #218 拍板):β=1 纯指数;以 Δt>1 小时为准,β<1 衰减更慢(亚线性长尾)、β>1 衰减更快(超线性);0<Δt<1 的首小时内方向相反(Δt^β 随 β 增大而变小) | | `heatTypeDecay` | 内置 TYPE_DECAY | per-type 衰减因子 λ;λ=0 的类型免疫(preference/pattern/summary 热度恒 1.0,sleep 永不降级) | | `sleepHeatThreshold` | `0.05` | sleep 降级联合判定热度下限:heat<该值 **且** importance<5 才允许降级 | diff --git a/dsh-mneme/docs/CONFIGURATION.md b/dsh-mneme/docs/CONFIGURATION.md index 5b9a39fd..911e929d 100644 --- a/dsh-mneme/docs/CONFIGURATION.md +++ b/dsh-mneme/docs/CONFIGURATION.md @@ -50,7 +50,7 @@ | 键 | 默认 | 作用 | 开启后果 / 冲突 | |---|---|---|---| -| `autoSummarize` | `true` | 会话/回合结束自动蒸馏记忆 | 关 = 只剩工具与命令写入口 | +| `autoSummarize` | `true` | 会话/回合结束自动蒸馏记忆。E10 EdgeMem 考卷(#339)实测:对「未来才问的具体值」类细节,零 LLM 原样写入保真 100% 而蒸馏丢 17/120——蒸馏的归纳收益在别的口径,二者是并存关系而非替代(原样写入并存的产品化见讨论 #361) | 关 = 只剩工具与命令写入口 | | `summarizeProvider` / `summarizeModel` | 空 | 蒸馏专用模型路由 | 两者都非空才生效;空 = 用会话当前模型 | | `distillMaxChars` | `24000` | 喂给蒸馏 LLM 的完整转录上限(字符,1000–200000) | 调大更完整、更贵 | | `summarizeMinIntervalMinutes` | `0` | 同会话两次蒸馏的最小间隔(分钟,≤10080) | 0 = 不限(#127 节流三键之一) | @@ -203,7 +203,8 @@ | 键 | 默认 | 作用 | 开启后果 / 冲突 | |---|---|---|---| -| `heatEnabled` **light** | `false` | 热度曲线(遗忘):热度字段 / sleep 降级联判 / 注入优先级层乘 heat(#218) | opt-in;关 = 注入乘数恒 1、sleep 退回纯时间分层 | +| `heatEnabled` **light** | `false` | 热度曲线(遗忘):热度字段 / sleep 降级联判(#218) | opt-in;关 = sleep 退回纯时间分层 | +| `injectHeatEnabled` | `false` | 注入排序乘 heat(#218 / E5 考卷:heat 乘进注入排序在真实年龄混合下饿死老约束,拆独立开关默认关) | 依赖 `heatEnabled`;关 = 注入乘数恒 1(与 heat 关闭逐字节一致);开 = 恢复层内乘 heat 旧序 | | `heatGlobalBeta` | `1.0` | 广义指数形状参数 β(0.5–2) | 专家调优项,不进面板白名单 | | `heatTypeDecay` | 内置默认 | per-type 衰减 λ(键为 type 字符串) | λ=0 的类型免疫(热度恒 1、sleep 永不降级) | diff --git a/dsh-mneme/lib/config.js b/dsh-mneme/lib/config.js index 5c8b648f..ebbf76c2 100644 --- a/dsh-mneme/lib/config.js +++ b/dsh-mneme/lib/config.js @@ -708,6 +708,13 @@ export const Config = z.object({ // 恒 1,sleep 降级退回纯时间分层。 // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。 heatEnabled: z.boolean().default(false), + // issue #218 / E5 效用考卷:heat 乘进注入排序在真实年龄混合下饿死老约束 + // (现行量级 ≡ 拟合参数,注入集逐条相同;importance-only 遵从 +12.7pp), + // 故注入侧 heat 改为独立 opt-in:默认关 = heatEnabled 开启后热度字段/sleep + // 保护/前端投影照常,但注入排序乘数恒 1(与 heat 关闭逐字节一致); + // 显式开启才恢复「层内乘 heat」的旧序。召回侧时钟(touchLastAccess)与 + // sleep 降级联判不受本键影响——它们不参与注入排序。 + injectHeatEnabled: z.boolean().default(false), // 广义指数形状参数 β(heat = exp(-λ·Δt^β),issue #218 拍板):β=1 纯指数。 // 快慢以 Δt>1 小时为准——β<1 衰减更慢(亚线性长尾)、β>1 更快(超线性); // 0<Δt<1 的首小时内方向相反(Δt^β 随 β 增大而变小)。专家调优项,不进面板白名单。 diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index b46f65af..0fdc1bfd 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -1530,7 +1530,11 @@ export function createService({ store, mirror, config, onWrite, logger, document // 合序都不动)。heat 关闭时不建表、不算 heat,权重恒 1,排序与改动前一致。 // selectiveInject 相似度重排(下方)开启且向量可用时相似度优先——heat 只 // 生效于规则路排序与语义路回填序。 - const heatMap = config?.heatEnabled === true + // issue #218 / E5 效用考卷:heat 乘进注入排序在真实年龄混合下饿死老约束 + // (现行量级 ≡ 拟合参数,importance-only 遵从 +12.7pp),故注入侧 heat 拆出 + // 独立开关 injectHeatEnabled(默认关):不开时权重恒 1,排序与 heat 关闭 + // 逐字节一致;召回侧时钟(touchLastAccess)与 sleep 降级联判不受影响。 + const heatMap = config?.heatEnabled === true && config?.injectHeatEnabled === true ? new Map(filtered.map((m) => [m.id, computeHeat(m, Date.now(), config)])) : null; const heatOf = (m) => (heatMap ? heatMap.get(m.id) ?? 1 : 1); diff --git a/dsh-mneme/lib/settings.js b/dsh-mneme/lib/settings.js index c214535c..994aa253 100644 --- a/dsh-mneme/lib/settings.js +++ b/dsh-mneme/lib/settings.js @@ -83,6 +83,8 @@ const FEATURE_FLAG_BOOLEANS = [ "autoDreamFailureBackoff", "sleepModeEnabled", "heatEnabled", + // issue #218 / E5:注入侧 heat 独立开关(默认关,老用户 injectHeatEnabled=true 回滚)。 + "injectHeatEnabled", "hybridInject", "selectiveInjectEnabled", "searchSemanticDedup", diff --git a/dsh-mneme/src/config.js b/dsh-mneme/src/config.js index 5c8b648f..ebbf76c2 100644 --- a/dsh-mneme/src/config.js +++ b/dsh-mneme/src/config.js @@ -708,6 +708,13 @@ export const Config = z.object({ // 恒 1,sleep 降级退回纯时间分层。 // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。 heatEnabled: z.boolean().default(false), + // issue #218 / E5 效用考卷:heat 乘进注入排序在真实年龄混合下饿死老约束 + // (现行量级 ≡ 拟合参数,注入集逐条相同;importance-only 遵从 +12.7pp), + // 故注入侧 heat 改为独立 opt-in:默认关 = heatEnabled 开启后热度字段/sleep + // 保护/前端投影照常,但注入排序乘数恒 1(与 heat 关闭逐字节一致); + // 显式开启才恢复「层内乘 heat」的旧序。召回侧时钟(touchLastAccess)与 + // sleep 降级联判不受本键影响——它们不参与注入排序。 + injectHeatEnabled: z.boolean().default(false), // 广义指数形状参数 β(heat = exp(-λ·Δt^β),issue #218 拍板):β=1 纯指数。 // 快慢以 Δt>1 小时为准——β<1 衰减更慢(亚线性长尾)、β>1 更快(超线性); // 0<Δt<1 的首小时内方向相反(Δt^β 随 β 增大而变小)。专家调优项,不进面板白名单。 diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index b46f65af..0fdc1bfd 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -1530,7 +1530,11 @@ export function createService({ store, mirror, config, onWrite, logger, document // 合序都不动)。heat 关闭时不建表、不算 heat,权重恒 1,排序与改动前一致。 // selectiveInject 相似度重排(下方)开启且向量可用时相似度优先——heat 只 // 生效于规则路排序与语义路回填序。 - const heatMap = config?.heatEnabled === true + // issue #218 / E5 效用考卷:heat 乘进注入排序在真实年龄混合下饿死老约束 + // (现行量级 ≡ 拟合参数,importance-only 遵从 +12.7pp),故注入侧 heat 拆出 + // 独立开关 injectHeatEnabled(默认关):不开时权重恒 1,排序与 heat 关闭 + // 逐字节一致;召回侧时钟(touchLastAccess)与 sleep 降级联判不受影响。 + const heatMap = config?.heatEnabled === true && config?.injectHeatEnabled === true ? new Map(filtered.map((m) => [m.id, computeHeat(m, Date.now(), config)])) : null; const heatOf = (m) => (heatMap ? heatMap.get(m.id) ?? 1 : 1); diff --git a/dsh-mneme/src/settings.js b/dsh-mneme/src/settings.js index c214535c..994aa253 100644 --- a/dsh-mneme/src/settings.js +++ b/dsh-mneme/src/settings.js @@ -83,6 +83,8 @@ const FEATURE_FLAG_BOOLEANS = [ "autoDreamFailureBackoff", "sleepModeEnabled", "heatEnabled", + // issue #218 / E5:注入侧 heat 独立开关(默认关,老用户 injectHeatEnabled=true 回滚)。 + "injectHeatEnabled", "hybridInject", "selectiveInjectEnabled", "searchSemanticDedup", diff --git a/dsh-mneme/test/api.test.js b/dsh-mneme/test/api.test.js index 7ef65310..7a63a649 100644 --- a/dsh-mneme/test/api.test.js +++ b/dsh-mneme/test/api.test.js @@ -674,11 +674,13 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d // 块2 新增 graphWeightEnabled/graphWeightDelta、块3 新增 graphInjectHint/graphInjectBudget、 // 块4 新增 graphPassiveConfirm、 // issue #339 新增 dreamMergeGuard、 + // issue #218/E5 新增 injectHeatEnabled、 // issue #164 A2 新增 sensitiveScanEnabled(顶层扁平键,走 configFlagValue 默认分支)) - 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 + 1 + 1); + 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 + 1 + 1 + 1); assert.equal(data.effective.dreamSkipInvalid, true); assert.equal(data.effective.allowCrossTypeMerge, false); assert.equal(data.effective.dreamMergeGuard, false); + assert.equal(data.effective.injectHeatEnabled, false); assert.equal(data.effective.dreamMinIntervalMinutes, 0); assert.equal(data.effective.autoDreamFailureBackoff, false); assert.equal(data.effective.dreamMaxTokens, 131072); diff --git a/dsh-mneme/test/inject-heat.test.js b/dsh-mneme/test/inject-heat.test.js index 11fe1d69..ff20239e 100644 --- a/dsh-mneme/test/inject-heat.test.js +++ b/dsh-mneme/test/inject-heat.test.js @@ -6,8 +6,13 @@ import { createService } from "../src/service.js"; // #218 v1:heat 作为注入排序的**同级内乘数**(issue #218 拍板口径)。 // 规则路 comparator 的层内权重 importance×quality 乘 heat;priority 分层 // (summary=0 / preference=1 / coding 同级 / 其余 2)与 store 的 order=chrono -// 分页序不动。heatEnabled=false(默认)时权重恒 1,排序与改动前一致—— -// 本文件的关闭用例与 heat.test.js 的曲线断言共同锁住验收第 1 条。 +// 分页序不动。 +// +// issue #218 / E5 效用考卷:heat 乘进注入排序在真实年龄混合下饿死老约束 +// (现行量级 ≡ 拟合参数,importance-only 遵从 +12.7pp),注入侧 heat 拆出 +// 独立开关 injectHeatEnabled(默认关)——默认关档下 heatEnabled=true 的库 +// 注入排序与 heat 关闭逐字节一致(平价锁);显式开启才恢复旧序。 +// 召回侧时钟(touchLastAccess)与 sleep 降级联判不受本键影响。 const HOUR = 3600000; @@ -19,40 +24,51 @@ function setup(config = {}) { const titles = (candidates) => candidates.map((c) => c.title); -test("heat off(默认):层内维持 importance 序,与改动前一致", () => { - const { store, service } = setup(); +// 三个用例共用的「冷高重要性 + 新低重要性」库。 +function seedColdAndFresh(service) { const cold = service.saveWithDedupe({ type: "decision", title: "冷的高重要性决策", content: "旧", importance: 4 }).memory; service.saveWithDedupe({ type: "decision", title: "新的低重要性决策", content: "新", importance: 3 }); service.touchLastAccess(cold.id, new Date(Date.now() - 3000 * HOUR).toISOString()); // 新记忆不触达:ref 退 created_at(≈现在),heat≈1,不影响本用例 +} + +test("heat off(默认):层内维持 importance 序,与改动前一致", () => { + const { store, service } = setup(); + seedColdAndFresh(service); const picked = service.injectCandidates({ maxItems: 5, threshold: 3 }); assert.deepEqual(titles(picked), ["冷的高重要性决策", "新的低重要性决策"]); store.close(); }); -test("heat on:同层内新鲜低重要性记忆越过冷的高重要性记忆", () => { +test("heat on 且 injectHeatEnabled 关(新默认):注入序与 heat 关闭逐字节一致(E5 平价锁)", () => { const { store, service } = setup({ heatEnabled: true }); - const cold = service.saveWithDedupe({ type: "decision", title: "冷的高重要性决策", content: "旧", importance: 4 }).memory; - service.saveWithDedupe({ type: "decision", title: "新的低重要性决策", content: "新", importance: 3 }); - service.touchLastAccess(cold.id, new Date(Date.now() - 3000 * HOUR).toISOString()); + seedColdAndFresh(service); + const picked = service.injectCandidates({ maxItems: 5, threshold: 3 }); + // E5 效用考卷:heat 乘进注入排序饿死老约束——默认不再乘,冷的高重要性决策 + // 回到它 importance 应在的位置(与 heat off 用例逐条相同)。 + assert.deepEqual(titles(picked), ["冷的高重要性决策", "新的低重要性决策"]); + store.close(); +}); + +test("heat on 且 injectHeatEnabled 开:恢复旧序(老用户回滚口)", () => { + const { store, service } = setup({ heatEnabled: true, injectHeatEnabled: true }); + seedColdAndFresh(service); const picked = service.injectCandidates({ maxItems: 5, threshold: 3 }); // λ=0.002、3000h → 冷记忆权重 4×e^-6≈0.01,新鲜记忆 3×≈1 → 反超 assert.deepEqual(titles(picked), ["新的低重要性决策", "冷的高重要性决策"]); store.close(); }); -test("heat on 且 λ=0(免疫类型):乘数恒 1,importance 序不受影响", () => { - const { store, service } = setup({ heatEnabled: true, heatTypeDecay: { decision: 0 } }); - const cold = service.saveWithDedupe({ type: "decision", title: "冷的高重要性决策", content: "旧", importance: 4 }).memory; - service.saveWithDedupe({ type: "decision", title: "新的低重要性决策", content: "新", importance: 3 }); - service.touchLastAccess(cold.id, new Date(Date.now() - 3000 * HOUR).toISOString()); +test("heat on 且 injectHeatEnabled 开 且 λ=0(免疫类型):乘数恒 1,importance 序不受影响", () => { + const { store, service } = setup({ heatEnabled: true, injectHeatEnabled: true, heatTypeDecay: { decision: 0 } }); + seedColdAndFresh(service); const picked = service.injectCandidates({ maxItems: 5, threshold: 3 }); assert.deepEqual(titles(picked), ["冷的高重要性决策", "新的低重要性决策"]); store.close(); }); -test("heat 不跨层:再热的 decision 也不越过 summary 层", () => { - const { store, service } = setup({ heatEnabled: true }); +test("heat 不跨层:再热的 decision 也不越过 summary 层(injectHeatEnabled 开)", () => { + const { store, service } = setup({ heatEnabled: true, injectHeatEnabled: true }); service.saveWithDedupe({ type: "summary", title: "会话总览", content: "总览", importance: 2 }); service.saveWithDedupe({ type: "decision", title: "滚烫决策", content: "新", importance: 3 }); const picked = service.injectCandidates({ maxItems: 5, threshold: 3 });