From 8b41f562b55c841d4c876afcf7adfa1915767a7d Mon Sep 17 00:00:00 2001 From: MXAntian Date: Tue, 4 Aug 2026 12:46:37 +0800 Subject: [PATCH] fix(shrink): a path spelled more precisely is not a path dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard compared identifier sets directly, so writing `memory/index.mjs` out and `E:/Project/ws/memory/index.mjs` in read as a loss: old (set difference) dropped: ["memory/index.mjs", "scripts/run.sh"] new (suffix aware) dropped: [] Three real merges in one night of memory curation each warned, and each warning was wrong for this reason. Consolidating entries almost always expands relative references into absolute ones — so the edit a maintainer performs most often is precisely the one that fired the most bogus warnings, on a guard whose entire value is being read rather than dismissed. isStillCarried() treats a token as present when the new text names it or names something it is the tail of. The boundary is a path separator, so `send.mjs` is still reported when only `feishu-send.mjs` remains — different file, real loss. Verified the URL-actually-gone case still warns. Exported and imported into memory-health's (a3) audit rather than reimplemented there. Those two had already drifted apart once, and the nightly queue is only worth reading if it agrees with the gate that let the write through. 296 passed / 0 failed. Co-authored-by: 千夏 Co-Authored-By: Claude Opus 5 --- high-signal-tokens.mjs | 26 ++++++++++++++++- memory-health.mjs | 8 +++-- supersede-shrink.integration.test.mjs | 42 +++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/high-signal-tokens.mjs b/high-signal-tokens.mjs index 6ba87d6..9aae84b 100644 --- a/high-signal-tokens.mjs +++ b/high-signal-tokens.mjs @@ -56,6 +56,30 @@ export function extractHighSignalTokens(text) { * every prior version); when supplied it enables the ledger exemption below. * @returns {Array<{id,oldLen,newLen,ratio,dropped,droppedCount}>} one entry per suspicious pair */ +// A token is still carried if the new text names it OR names something it is the +// tail of. Writing `memory/index.mjs` out and `E:/Project/ws/memory/index.mjs` in +// is not a loss — it is the same file, said more precisely, and set difference +// alone calls it a drop. +// +// This matters more than it looks. Consolidating several memories almost always +// expands relative references into absolute ones, so the merge that a maintainer +// is most likely to perform is exactly the one that fires the most bogus +// warnings. A guard that cries wolf on good edits gets ignored on the bad ones, +// and this one exists to be read. +// +// Suffix must break on a separator: `send.mjs` is not carried by `feishu-send.mjs` +// (a different file), while `memory/index.mjs` is carried by `E:/x/memory/index.mjs`. +export function isStillCarried(token, newTokens) { + if (newTokens.has(token)) return true + for (const t of newTokens) { + if (t.length > token.length && t.endsWith(token)) { + const boundary = t[t.length - token.length - 1] + if (boundary === '/' || boundary === '\\') return true + } + } + return false +} + export function checkSupersedeShrink(newContent, olds) { const warnings = [] const newTokens = new Set(extractHighSignalTokens(newContent)) @@ -83,7 +107,7 @@ export function checkSupersedeShrink(newContent, olds) { const peakLen = Math.max(old.peakLen || 0, oldLen) if (oneToOne && newLen >= peakLen) continue - const dropped = extractHighSignalTokens(old.content).filter(t => !newTokens.has(t)) + const dropped = extractHighSignalTokens(old.content).filter(t => !isStillCarried(t, newTokens)) const ratio = oldLen ? +(newLen / oldLen).toFixed(2) : 1 const shrank = oneToOne && oldLen >= SHRINK_MIN_OLD_LEN && ratio < SHRINK_RATIO_FLOOR if (!dropped.length && !shrank) continue diff --git a/memory-health.mjs b/memory-health.mjs index 506cec9..4ca5de6 100644 --- a/memory-health.mjs +++ b/memory-health.mjs @@ -42,7 +42,7 @@ import { existsSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { createRequire } from 'node:module' -import { extractHighSignalTokens } from './high-signal-tokens.mjs' +import { extractHighSignalTokens, isStillCarried } from './high-signal-tokens.mjs' const require = createRequire(import.meta.url) const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -535,7 +535,11 @@ export function detectShrinkVictims(db, opts = {}) { const nowTokens = new Set(extractHighSignalTokens(r.content)) const lost = new Set() for (const p of priors) { - for (const t of extractHighSignalTokens(p.content)) if (!nowTokens.has(t)) lost.add(t) + // Same "is it still carried" rule as the write-time guard, imported rather + // than reimplemented — these two drifted apart once before and the audit + // queue is only trustworthy if it agrees with the gate that let the write + // through in the first place. + for (const t of extractHighSignalTokens(p.content)) if (!isStillCarried(t, nowTokens)) lost.add(t) } if (!lost.size) continue lostAny++ diff --git a/supersede-shrink.integration.test.mjs b/supersede-shrink.integration.test.mjs index c2e3fb6..12750fe 100644 --- a/supersede-shrink.integration.test.mjs +++ b/supersede-shrink.integration.test.mjs @@ -225,5 +225,47 @@ try { } dbRead.close() +// ── a token spelled more precisely is not a token dropped ── +// +// All three cases below are real merges from one night of memory curation. Each +// one warned, each warning was wrong, and the reason was always the same: the +// consolidated text expanded a relative reference into an absolute one, so plain +// set difference saw the short form vanish. +// +// That is the merge a maintainer performs most often, which made this the +// warning they would learn to ignore first — on a guard whose whole value is +// being read. +{ + const { checkSupersedeShrink, isStillCarried } = await import('./high-signal-tokens.mjs') + + const carried = [ + ['memory/index.mjs', 'E:/Project/ws/memory/index.mjs'], + ['session-summarize.mjs', 'E:/Project/ws/memory/scripts/session-summarize.mjs'], + ['scripts/run.sh', 'C:/tools/scripts/run.sh'], + ] + for (const [short, long] of carried) { + check(`"${short}" counts as carried by "${long}"`, isStillCarried(short, new Set([long]))) + } + + // The boundary is a path separator. Same suffix, different file — must still warn. + check('a different file that merely ends the same way is NOT carried', + !isStillCarried('send.mjs', new Set(['E:/Project/ws/feishu-send.mjs']))) + check('a bare substring is not carried either', + !isStillCarried('index.mjs', new Set(['reindex.mjs']))) + + const oldContent = 'runner lives at memory/index.mjs and the log rotates via scripts/run.sh, token in API_TOKEN, see https://ops.example.com/dash' + const newContent = 'runner lives at E:/Project/ws/memory/index.mjs and the log rotates via C:/tools/scripts/run.sh, token in API_TOKEN, see https://ops.example.com/dash — now also covers the nightly path' + const clean = checkSupersedeShrink(newContent, [{ id: '1', content: oldContent }]) + check('expanding relative paths to absolute raises no warning', + clean.length === 0, JSON.stringify(clean)) + + // And the real loss still lands: same expansion, but the URL is gone. + const lossy = 'runner lives at E:/Project/ws/memory/index.mjs and the log rotates via C:/tools/scripts/run.sh, token in API_TOKEN' + const warned = checkSupersedeShrink(lossy, [{ id: '1', content: oldContent }]) + check('a genuinely dropped identifier still warns', + warned.length === 1 && warned[0].dropped.some(d => d.includes('ops.example.com')), + JSON.stringify(warned)) +} + console.log(`\n${fail === 0 ? 'PASS' : 'FAIL'}: ${pass} passed / ${fail} failed`) process.exit(fail === 0 ? 0 : 1)