From 42c547c03256bd843ed69964a5d8c0a9e7044dce Mon Sep 17 00:00:00 2001 From: modusensus Date: Fri, 13 Nov 2026 16:55:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(#692):=20=E4=B8=A4=E4=B8=AA=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=BC=80=E7=9A=84=E5=BC=80=E5=85=B3=E6=94=B9=E6=8C=89?= =?UTF-8?q?=20!=3D=3D=20false=20=E8=AF=BB=EF=BC=8C=E8=A3=B8=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=8B=E9=BB=98=E8=AE=A4=E5=80=BC=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=90=BD=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dreamMergeGuard(dream 主链路与 sleep 链路各一处)与 resilientModelDownload 的默认值只写在 zod schema 里:经 Config 的调用方拿得到,绕过 Config 的调用方 (测试、第三方程序化 createService)拿到 undefined,被 `=== true` 当 false, 于是「默认开」等于默认关。两处统一改成 `!== false`。 resilientModelDownload 的判据收成 `resilientDownloadEnabled(cfg)` 放在装配层 (src/semantic.js):LocalEmbedder / Reranker 的构造函数刻意保持「只在显式 true 时启用」(issue #194 的直连构造契约,test/local-embedder.test.js 锁着), 默认值只在装配层落定。 第三处 memoryQualityFilter.enabled 本批不动——它挂在自身带 .default({}) 的 对象里,经 Config 的调用方一直是开的;翻裸配置等于重定义程序化契约,实测牵动 421 条既有断言,留在 issue 里单独拍板。 测试 3,496 → 3,499;变异自证:两处各自改回 `=== true` → 2 红。 用户 2026-11-13 反馈 --- dsh-mneme/CHANGELOG.md | 6 ++ dsh-mneme/lib/dream.js | 5 +- dsh-mneme/lib/dream/sleep.js | 4 +- dsh-mneme/lib/semantic.js | 16 ++- dsh-mneme/src/dream.js | 5 +- dsh-mneme/src/dream/sleep.js | 4 +- dsh-mneme/src/semantic.js | 16 ++- .../test/issue-692-default-on-reads.test.js | 101 ++++++++++++++++++ 8 files changed, 147 insertions(+), 10 deletions(-) create mode 100644 dsh-mneme/test/issue-692-default-on-reads.test.js diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 09e109dd..10de65ea 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +- **两个「默认开」的开关用 `=== true` 读取,绕过 Config 的裸配置调用方下默认值落不了地(#692)**。`dreamMergeGuard`(`src/dream.js` 的 dream 主链路、`src/dream/sleep.js` 的 sleep 链路各一处)与 `resilientModelDownload` 的默认值都只写在 zod schema(`src/config.js`)里:走宿主没问题——`src/index.js` 先经 `Config(config)` 解析,默认值已落进对象;但**绕过 Config 的调用方**(测试、第三方程序化 `createService`、直接 import 单元的装配)拿到的是 `undefined`,`=== true` 把它当 false,于是「默认开」等于默认关。两处统一改成 `!== false`(与 `autoInject` / `autoDream` / `hybridInject` / `heatEnabled` / `summarizeBatchCrossTypeDedupe` 同一惯例)。`resilientModelDownload` 的落点特意**不在构造函数**:`LocalEmbedder` / `Reranker` 刻意「只在显式 true 时启用」(直连构造的旧调用方保持裸 fetch,见 `src/local-embedder.js` 的 issue #194 注释,`test/local-embedder.test.js:79` 锁着这个契约),所以判据收成 `resilientDownloadEnabled(cfg)` 放在装配层(`src/semantic.js`),两条装配路径共用。schema 里的默认值一律不动,关掉仍然靠显式 `false`。 + + **第三处 `memoryQualityFilter.enabled` 本批不动**,理由与上面两处不同:它挂在一个**自身带 `.default({})` 的对象**里,经 Config 的调用方(也就是宿主与所有正常装配)一直是开的;`=== true` 与 `!== false` 的差别只出现在「整个对象都不传」的裸配置调用方身上。把那半边也翻过来,等于重定义「程序化 `createService` 的默认契约」——实测会让既有断言 **421 条变红**(大量 fixture 用两三字短正文,靠过滤器默认关才能留在库里被检索到)。这属于要单独拍板的接口决定,留在 issue #692 里跟进,不与本批两处混在一起。 + + **测试** 3,496 → **3,499**(+3):`test/issue-692-default-on-reads.test.js` 三条——裸配置下 guarded 类型(`constraint`)的 merge 被 skip 且理由点名 `dreamMergeGuard`、显式 `dreamMergeGuard: false` 时同一条 merge 落库、装配判据三态(`{}` / `undefined` / `true` 为开,`false` 为关)。变异自证:两处各自改回 `=== true` → 2 红(默认开那两条),还原 → 3/3 绿;「改成恒真」会被那两条反向对照抓住。 + - **跨类型同义双录:蒸馏提示词里「宁可拆成多条」把同一件事记了两遍,而三处去重各守一道类型闸(#687);实体表 75% 的实体只被提过一次、名字只按大小写敏感精确匹配(#688)**。两条 issue 报告的是同一个根病:**记忆的「类型」被当成了「对象身份」**。#687 上报者(xueyue33)给的分布很刺眼——他自己库里 `sim>=0.85` 的 81 对近重复中 **72 对跨类型**、`>=0.90` 的 7 对中 6 对跨类型;`sim>=0.88` 的 24 对里 21 对跨类型。四处夹缝逐个核过都成立:`src/lang.js` 的 `codingSummary` 明确让模型「宁可拆成多条」而 `pitfall`(现象+根因+规避)与 `constraint`(约束+来源)本就是同一结论的两种写法;`src/service.js:1480` 的 `findSessionDuplicate` 用 `store.list({ type: memory.type, source, limit: 200 })` 锁死同类型同会话;`src/dream/clustering.js:117` 的 `findPotentialConflicts` 首行 `if (memories[i].type !== memories[j].type) continue;`;dream 巩固提示词同样写着「仅合并同类型」。**我们不开常开类型闸**:本机活跃库 9,275 条带向量的记忆里跨类型 `cosine>=0.90` 共 45 对,逐对读正文后 **36 对(80%)确为同义,9 对合并会丢信息**(5 对时间序列/状态快照、2 对症状 vs 修复、1 对结论反转、1 对版本号不同)——也就是说「凡是跨类型高相似就合并」每 5 对要吞掉 1 对真互补的信息,而这类对只占活跃总量 0.97%,收益与风险不对称。落地的三层是**源头互斥 + 批内去重 + 存量一次性处置**:① `codingSummary` 提示词(中英双语)补一条互斥约束——同一结论只记一条,`pitfall` 与 `constraint` 不双写,时间序列/状态快照写进同一条;② 蒸馏落盘前做**批内**跨类型去重,只对 `pitfall↔constraint`、`history↔project` 两组生效(正是实测最集中的应用对:9 对 + 8 对),`planBatchCrossTypeDedupe(entries, { minSim })` 是纯函数——按相似度降序、每条最多被退休一次、不许链式藏行(A 取代 B 的同时 B 又取代过 C 会把 C 永久埋在链底);门槛 `BATCH_CROSS_TYPE_MIN_SIM = 0.85`(textSim,不是余弦,词面重合是这两组对的主特征)、`BATCH_CROSS_TYPE_MIN_CHARS = 24`、`BATCH_CROSS_TYPE_MIN_LEN_RATIO = 0.5`;③ 处置走 `store.supersedeChain` 只写 `superseded_by` / `superseded_at`,**保留正文更全的一条、被取代者正文零改写、不归档、不 merge**——判错时旧行仍在库里、显式搜索照旧读得到,且 `supersededIds()` 的 `JOIN ... AND w.archived=0` 让继任者一旦被归档旧行自动回到可见,等于自带回退。刻意**没有**复用 `src/dream/decisions.js` 的 `supersedeInfoFirstPair`:它走 `service.update` 会在正文尾部追加「已被取代」前缀,与「正文零改写」直接冲突。**默认开**(2026-11-13 拍板):这个开关存在的意义就是让新写入不再双录,默认关等于修了不生效,所以 `src/config.js` 给 `true`、设 `false` 是 kill switch,且关时审计行的 `batchDeduped` / `batchPairs` 两个键根本不出现——加开关之前的行为逐字节不变。两个键同时登记进 `settings.js` 的 `FEATURE_FLAG_BOOLEANS` 与 `FEATURE_FLAG_NUMBER_RANGES`,面板上能改。存量 17 对**不由代码处置**:只读脚本 `scripts/cross-type-dedupe-dryrun.mjs` 出清单(输出**禁止落在仓库内**,清单里含记忆原文),复核后由人决定、按纯 SQL 打链。 **#688 的修法只落在写入侧**。上报者库里 12,700+ 实体、38,500+ 关系、孤儿 41%、`mention_count=1` 占 72%;本机 live 库(只读打开)实测实体 **25,474**、关系 **59,715**、`mention_count=1` 占 **75%**、大小写/空白重复 262 组;而按 `entity_relations.memory_id` 真实连接口径,孤儿是 **17.9%(4,560 行)** 而不是 41%——口径不同,这里写清楚。存量**不做合并**:实体 id 是 `randomUUID()`,合并要重指 59,715 行关系且 `entity_relations` 没有唯一约束,重指过程一旦中断会造出重复边,收益(0.68% 的实体)与风险同样不对称。改的是查找键——`findEntityByName` 从 `WHERE name = ?` 改成 `lower(trim(name))` + `type` 的等价比较并为此建表达式索引(`DeepSeek-V3` 与 `DeepSeek-V4` 是两个不同对象,不剥版本号后缀;`createEntity` 落库只去首尾空白、**保留原大小写**用于展示)。类型维度不能省:实测去掉 type 会误并 95 组(`constraint` 与 `pitfall` 常同名)。另外 `entities.canonical_memory_id` 是 v0.7.1(`5b5a2aa4`)引入的预留列,**从没接通线**——`src/store.js` 里只有 DDL、行映射、`INSERT`、`UPDATE` 四处 plumbing,没有任何调用方写它或读它,25,474 行全 NULL;这次按幂等迁移删列(`PRAGMA table_info` 判断 + `ALTER TABLE ... DROP COLUMN`,宿主 SQLite 3.51.3 支持)。迁移成本实测:25,474 行旧 schema 表首次 `createStore` **396ms**(含建表达式索引),二次 24ms。 diff --git a/dsh-mneme/lib/dream.js b/dsh-mneme/lib/dream.js index 16c1a4cd..8dd7b722 100644 --- a/dsh-mneme/lib/dream.js +++ b/dsh-mneme/lib/dream.js @@ -1437,7 +1437,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar skipInvalid: config.dreamSkipInvalid !== false, allowCrossTypeMerge: config.allowCrossTypeMerge === true, // issue #339 / E8:guarded 类型 merge 护栏(默认开,dreamMergeGuard)。 - mergeGuard: config.dreamMergeGuard === true + // issue #692:按 `!== false` 读——裸配置调用方(测试、程序化建 service) + // 拿到的是 undefined,用 `=== true` 会让「默认开」落不了地。与 autoInject / + // autoDream / hybridInject / heatEnabled 那批默认开的键同一写法。 + mergeGuard: config?.dreamMergeGuard !== false }); // Issue #135:模型把 UUID 缩写成前缀时,唯一前缀已在校验前被解析回完整 id。 // 解析量是模型输出质量的一个直接信号(>0 意味着模型在缩写 id),记一条 info diff --git a/dsh-mneme/lib/dream/sleep.js b/dsh-mneme/lib/dream/sleep.js index 32abef2c..096e77e7 100644 --- a/dsh-mneme/lib/dream/sleep.js +++ b/dsh-mneme/lib/dream/sleep.js @@ -848,8 +848,8 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu skipInvalid: config.dreamSkipInvalid !== false, allowCrossTypeMerge: config.allowCrossTypeMerge === true, // issue #339 / E8:guarded 类型 merge 护栏(默认开,dreamMergeGuard), - // 与 dream 主链路同一判据。 - mergeGuard: config.dreamMergeGuard === true, + // 与 dream 主链路同一判据。issue #692:`!== false` 读,裸配置下默认开同样生效。 + mergeGuard: config?.dreamMergeGuard !== false, // Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住 // 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。 allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS diff --git a/dsh-mneme/lib/semantic.js b/dsh-mneme/lib/semantic.js index e858092f..5d68e917 100644 --- a/dsh-mneme/lib/semantic.js +++ b/dsh-mneme/lib/semantic.js @@ -395,6 +395,16 @@ export function scheduleBootBackfill({ * init 异步失败经 #118 重试后 service 侧降级,此处引用仍在(dream/sleep 语义面 * 与搬移前一致)。dispose 清两个引导期计时器。 */ +/** + * issue #692:`resilientModelDownload` 在 schema 里默认开,裸配置调用方(测试、 + * 程序化建 service)拿到的是 undefined。LocalEmbedder / Reranker 的构造函数刻意 + * 「只在显式 true 时启用」(直连构造的旧调用方保持裸 fetch,见 local-embedder.js + * 的 issue #194 注释),所以默认值必须在装配层落定——判据收在这里,两处装配共用。 + */ +export function resilientDownloadEnabled(cfg) { + return cfg?.resilientModelDownload !== false; +} + export function createSemantic({ store, service, settings, cfg, logger, vectorIndex, applyHumanEdits, lightMode = false }) { let embedder = null; let reranker = null; @@ -442,7 +452,8 @@ export function createSemantic({ store, service, settings, cfg, logger, vectorIn runtimeDir: cfg.runtimeDir, // #188:embedModelMirror 接成 transformers 的下载镜像(此前死配置)。 remoteHost: cfg.embedModelMirror, - resilientModelDownload: cfg.resilientModelDownload, + // issue #692:默认开;裸配置下由这里的判据落定(构造函数只认显式 true)。 + resilientModelDownload: resilientDownloadEnabled(cfg), baseUrl: cfg.ollamaBaseUrl, logger }); @@ -499,7 +510,8 @@ export function createSemantic({ store, service, settings, cfg, logger, vectorIn // embedModelMirror 此前是死配置,现接成 transformers 的下载镜像。 useDtype: cfg.rerankDtype, remoteHost: cfg.embedModelMirror, - resilientModelDownload: cfg.resilientModelDownload, + // issue #692:默认开;裸配置下由这里的判据落定(构造函数只认显式 true)。 + resilientModelDownload: resilientDownloadEnabled(cfg), logger }); service.setReranker(reranker); diff --git a/dsh-mneme/src/dream.js b/dsh-mneme/src/dream.js index 16c1a4cd..8dd7b722 100644 --- a/dsh-mneme/src/dream.js +++ b/dsh-mneme/src/dream.js @@ -1437,7 +1437,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar skipInvalid: config.dreamSkipInvalid !== false, allowCrossTypeMerge: config.allowCrossTypeMerge === true, // issue #339 / E8:guarded 类型 merge 护栏(默认开,dreamMergeGuard)。 - mergeGuard: config.dreamMergeGuard === true + // issue #692:按 `!== false` 读——裸配置调用方(测试、程序化建 service) + // 拿到的是 undefined,用 `=== true` 会让「默认开」落不了地。与 autoInject / + // autoDream / hybridInject / heatEnabled 那批默认开的键同一写法。 + mergeGuard: config?.dreamMergeGuard !== false }); // Issue #135:模型把 UUID 缩写成前缀时,唯一前缀已在校验前被解析回完整 id。 // 解析量是模型输出质量的一个直接信号(>0 意味着模型在缩写 id),记一条 info diff --git a/dsh-mneme/src/dream/sleep.js b/dsh-mneme/src/dream/sleep.js index 32abef2c..096e77e7 100644 --- a/dsh-mneme/src/dream/sleep.js +++ b/dsh-mneme/src/dream/sleep.js @@ -848,8 +848,8 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu skipInvalid: config.dreamSkipInvalid !== false, allowCrossTypeMerge: config.allowCrossTypeMerge === true, // issue #339 / E8:guarded 类型 merge 护栏(默认开,dreamMergeGuard), - // 与 dream 主链路同一判据。 - mergeGuard: config.dreamMergeGuard === true, + // 与 dream 主链路同一判据。issue #692:`!== false` 读,裸配置下默认开同样生效。 + mergeGuard: config?.dreamMergeGuard !== false, // Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住 // 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。 allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS diff --git a/dsh-mneme/src/semantic.js b/dsh-mneme/src/semantic.js index e858092f..5d68e917 100644 --- a/dsh-mneme/src/semantic.js +++ b/dsh-mneme/src/semantic.js @@ -395,6 +395,16 @@ export function scheduleBootBackfill({ * init 异步失败经 #118 重试后 service 侧降级,此处引用仍在(dream/sleep 语义面 * 与搬移前一致)。dispose 清两个引导期计时器。 */ +/** + * issue #692:`resilientModelDownload` 在 schema 里默认开,裸配置调用方(测试、 + * 程序化建 service)拿到的是 undefined。LocalEmbedder / Reranker 的构造函数刻意 + * 「只在显式 true 时启用」(直连构造的旧调用方保持裸 fetch,见 local-embedder.js + * 的 issue #194 注释),所以默认值必须在装配层落定——判据收在这里,两处装配共用。 + */ +export function resilientDownloadEnabled(cfg) { + return cfg?.resilientModelDownload !== false; +} + export function createSemantic({ store, service, settings, cfg, logger, vectorIndex, applyHumanEdits, lightMode = false }) { let embedder = null; let reranker = null; @@ -442,7 +452,8 @@ export function createSemantic({ store, service, settings, cfg, logger, vectorIn runtimeDir: cfg.runtimeDir, // #188:embedModelMirror 接成 transformers 的下载镜像(此前死配置)。 remoteHost: cfg.embedModelMirror, - resilientModelDownload: cfg.resilientModelDownload, + // issue #692:默认开;裸配置下由这里的判据落定(构造函数只认显式 true)。 + resilientModelDownload: resilientDownloadEnabled(cfg), baseUrl: cfg.ollamaBaseUrl, logger }); @@ -499,7 +510,8 @@ export function createSemantic({ store, service, settings, cfg, logger, vectorIn // embedModelMirror 此前是死配置,现接成 transformers 的下载镜像。 useDtype: cfg.rerankDtype, remoteHost: cfg.embedModelMirror, - resilientModelDownload: cfg.resilientModelDownload, + // issue #692:默认开;裸配置下由这里的判据落定(构造函数只认显式 true)。 + resilientModelDownload: resilientDownloadEnabled(cfg), logger }); service.setReranker(reranker); diff --git a/dsh-mneme/test/issue-692-default-on-reads.test.js b/dsh-mneme/test/issue-692-default-on-reads.test.js new file mode 100644 index 00000000..d3748700 --- /dev/null +++ b/dsh-mneme/test/issue-692-default-on-reads.test.js @@ -0,0 +1,101 @@ +// issue #692:「默认开」的开关在代码里用 `=== true` 读,而默认值只写在 zod schema +// (src/config.js)里。走宿主(index.js 先经 Config(config))没问题,但绕过 Config 的 +// 裸配置调用方拿到的是 undefined,于是「默认开」落不了地。 +// +// 这里守的是读侧口径:`!== false`(与 autoInject / autoDream / hybridInject / +// heatEnabled / summarizeBatchCrossTypeDedupe 同一惯例),并各验一次显式关掉的反向 +// 对照——否则「改成恒真」也会让默认开那半条通过。 +// +// 第三处 `memoryQualityFilter.enabled` 刻意不在本文件覆盖:它挂在**自身带 +// `.default({})` 的对象**里,经 Config 的调用方一直是开的;若把裸配置也翻成开, +// 等于重定义「程序化 createService 的默认契约」,现有断言里有 421 条按旧契约 +// 写(短正文不被归档、检索能命中),属于要单独拍板的事,见 issue #692 的说明。 +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createStore } from "../src/store.js"; +import { createService } from "../src/service.js"; +import { createDreamScheduler } from "../src/dream.js"; +import { resilientDownloadEnabled } from "../src/semantic.js"; + +/** 裸配置:不经 Config,键缺失(= undefined),与第三方程序化调用同形。 */ +function bareService(config = {}) { + const store = createStore(":memory:"); + const service = createService({ store, mirror: null, config }); + return { store, service }; +} + +/** + * audit.test.js 同款最小 ctx:consolidation 提示词以 "id=" 开头,summary 提示词 + * 以 "- title: content" 开头,用这个形状区分两次 LLM 调用。 + */ +function mockCtx({ onConsolidation, summaryText = "记忆库总览:用户偏好中文。" } = {}) { + return { + logger: { warn: () => {} }, + agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) }, + llm: { + async *stream(options) { + const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? ""; + if (userText.includes("| type=")) { + yield { type: "block-start", index: 0, blockType: "text" }; + yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" }; + yield { type: "block-end", index: 0, block: { type: "text" } }; + yield { type: "finish", reason: { kind: "stop" } }; + return; + } + yield { type: "text-delta", index: 0, text: summaryText }; + yield { type: "finish", reason: { kind: "stop" } }; + } + } + }; +} + +/** + * 一次 dream run,模型把两条 constraint 合并成一条。constraint 在 + * ARCHIVE_GUARDED_TYPES 里(src/dream/decisions.js:20),所以 mergeGuard 生效时 + * 这条决策必须被 skip 并写明理由。 + */ +async function runGuardedMerge(config) { + const { store, service } = bareService(config); + const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) }); + const { memory: a } = service.saveWithDedupe({ type: "constraint", title: "部署顺序", content: "先跑迁移再起服务。", importance: 4 }); + const { memory: b } = service.saveWithDedupe({ type: "constraint", title: "部署顺序(补)", content: "先跑迁移,再起服务,最后冒烟。", importance: 4 }); + const ctx = mockCtx({ + onConsolidation: () => JSON.stringify([ + { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "部署顺序", content: "先跑迁移,再起服务,最后冒烟。", importance: 4 } + ]) + }); + const result = await dream.runDream(ctx, service, config); + const run = store.listDreamRuns().at(-1); + // skip 明细:整单被判无效时在 result.decisions[].skipped,落了半批时在 run.skipped_reasons。 + const errors = [ + ...(result.decisions ?? []).flatMap((d) => (d.skipped ?? []).map((s) => s.error ?? "")), + ...(run.skipped_reasons ?? []).map((r) => r.error ?? "") + ].join("\n"); + return { result, run, errors, store }; +} + +// ---------------------------------------------------- dreamMergeGuard(默认开) + +test("#692 dreamMergeGuard: 裸配置下默认开,guarded 类型 merge 被 skip", async () => { + const { result, errors } = await runGuardedMerge({}); + assert.equal(result.applied ?? 0, 0, "guarded merge 不该落库"); + assert.match(errors, /dreamMergeGuard/, "skip 理由要点名护栏"); + assert.match(errors, /constraint/, "skip 理由要点名被保护的类型"); +}); + +test("#692 dreamMergeGuard: 显式 false 才放行(反向对照)", async () => { + const { result, errors } = await runGuardedMerge({ dreamMergeGuard: false }); + assert.equal(result.ok, true); + assert.equal(result.applied, 1, "显式关掉后同一条 merge 应当落库"); + assert.doesNotMatch(errors, /dreamMergeGuard/, "关掉后不该再出现护栏理由"); +}); + +// ------------------------------------------------ resilientModelDownload(默认开) + +test("#692 resilientModelDownload: 装配层判据在裸配置下为开,显式 false 才关", () => { + assert.equal(resilientDownloadEnabled({}), true); + assert.equal(resilientDownloadEnabled(undefined), true); + assert.equal(resilientDownloadEnabled({ resilientModelDownload: true }), true); + assert.equal(resilientDownloadEnabled({ resilientModelDownload: false }), false); +}); \ No newline at end of file