@a9i5k4/dsh-auto-memory 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -13
- package/README.zh-CN.md +30 -13
- package/docs/FRONTEND-CO-CREATION.md +191 -0
- package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
- package/docs/HANDBOOK.md +88 -52
- package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
- package/docs/PROMO-PROMPT-3.0.md +100 -0
- package/docs/USER-GUIDE.en.md +11 -11
- package/docs/USER-GUIDE.zh-CN.md +11 -11
- package/docs/WHITEPAPER.md +207 -0
- package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
- package/docs/screenshots/promo/promo-0-banner-v4.png +0 -0
- package/docs/screenshots/promo/promo-1b-auto-recall.png +0 -0
- package/lib/activation-host.js +69 -10
- package/lib/board-mode.js +1 -1
- package/lib/client.js +1697 -285
- package/lib/config-io.js +156 -0
- package/lib/context-bridge.js +3 -0
- package/lib/context-host.js +23 -10
- package/lib/degrade.js +385 -0
- package/lib/dsh-home.js +143 -0
- package/lib/episodic-store.js +142 -18
- package/lib/evidence-store.js +8 -1
- package/lib/fact-store.js +484 -43
- package/lib/hub-io.js +217 -0
- package/lib/index-sync.js +13 -1
- package/lib/index.js +1730 -202
- package/lib/intent-clean-safe.js +258 -40
- package/lib/l0-extract.js +231 -16
- package/lib/m4-corpus.js +8 -2
- package/lib/m7-index-sync-host.js +8 -1
- package/lib/memory-envelope.js +6 -1
- package/lib/memory-hub.js +164 -17
- package/lib/memory-index.js +4 -2
- package/lib/note-status-apply.js +118 -0
- package/lib/note-status.js +204 -0
- package/lib/procedure-store.js +333 -31
- package/lib/procedure-switch.js +38 -0
- package/lib/python-sidecar-client.js +314 -11
- package/lib/recall-fusion.js +83 -12
- package/lib/rules-edit.js +159 -0
- package/lib/semantic-decide.js +41 -8
- package/lib/semantic-js.js +51 -6
- package/lib/shadow-host.js +3 -5
- package/lib/skill-export-host.js +153 -0
- package/lib/skill-export.js +239 -0
- package/lib/storage-manage.js +6 -0
- package/lib/temporal-parse.js +191 -159
- package/lib/tier0-catalog.js +45 -3
- package/lib/wb-contract.js +198 -2
- package/lib/wb-sidecar.js +54 -3
- package/package.json +6 -2
- package/docs/internal/ACCEPT-35-LIVE.md +0 -143
- package/docs/internal/ACCEPTANCE-20260914.md +0 -90
- package/docs/internal/ARCH-REVIEW-BRIEF.md +0 -411
- package/docs/internal/ARCH-REVIEW-REQUEST.md +0 -201
- package/docs/internal/ARCH-REVIEW-ROUND2.md +0 -169
- package/docs/internal/ARCH-REVIEW-ROUND3.md +0 -206
- package/docs/internal/ART-DIRECTION-WIREFRAME.md +0 -181
- package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +0 -314
- package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +0 -192
- package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +0 -72
- package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +0 -131
- package/docs/internal/CUA-VISION-FIX-NOTES.md +0 -78
- package/docs/internal/DECISIONS-20260914-SESSION.md +0 -269
- package/docs/internal/DESIGN-OVERHAUL-PRE-RESEARCH.md +0 -292
- package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +0 -219
- package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +0 -132
- package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +0 -13
- package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +0 -74
- package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +0 -352
- package/docs/internal/GPT-REVIEW-PROMPT.md +0 -216
- package/docs/internal/GROUP-DIGEST-SETUP.md +0 -62
- package/docs/internal/GROUP-LISTENER-SETUP.md +0 -49
- package/docs/internal/GROUP-WEBHOOK-SETUP.md +0 -93
- package/docs/internal/HANDOFF-TO-ZCODE.md +0 -168
- package/docs/internal/KICKOFF-P0.md +0 -254
- package/docs/internal/MASTER-PLAN-3.0.md +0 -411
- package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +0 -85
- package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +0 -222
- package/docs/internal/NEXT-VERSION-TODO.md +0 -95
- package/docs/internal/OFFICIAL-DISCUSSION-DRAFT.md +0 -80
- package/docs/internal/PENDING-FIXES-20260916.md +0 -289
- package/docs/internal/RAG-KARPATHY-PROGRAM.md +0 -229
- package/docs/internal/RELEASE-PROCESS.md +0 -99
- package/docs/internal/REPORT-P0-NIGHTLY.md +0 -212
- package/docs/internal/REPORT-P5-ACCEPTANCE.md +0 -31
- package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +0 -153
- package/docs/internal/REVIEW-WB-GRAPH-SELF.md +0 -81
- package/docs/internal/ROADMAP-20260917-WEEK.md +0 -305
- package/docs/internal/ROADMAP.md +0 -106
- package/docs/internal/RUN-P0-NIGHTLY.md +0 -227
- package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +0 -175
- package/docs/internal/S10-GAPS-PLAIN-20260917.md +0 -125
- package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +0 -360
- package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +0 -90
- package/docs/internal/SUBAGENT-REPORT-ROUTING-PRE-RESEARCH.md +0 -261
- package/docs/internal/THREE-LAYER-CONTRACT.md +0 -210
- package/docs/internal/TODO-BACKLOG.md +0 -263
- package/docs/internal/TODO-GRAPH.html +0 -715
- package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +0 -493
- package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +0 -710
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +0 -710
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +0 -703
- package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +0 -710
- package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +0 -715
- package/docs/internal/WB-FORMAT-CONVENTION.md +0 -112
- package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +0 -71
- package/docs/internal/WB-GRAPH-INTEGRATION-PLAN.md +0 -386
- package/docs/internal/WB-GRAPH-RESEARCH-BRIEF.md +0 -118
- package/docs/internal/WB-GRAPH-RESEARCH-EXTERNAL.md +0 -228
- package/docs/internal/WB-GRAPH-RESEARCH-LOCAL.md +0 -190
- package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +0 -56
- package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +0 -787
- package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +0 -112
- package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +0 -230
|
@@ -9,7 +9,19 @@
|
|
|
9
9
|
* 错误 epoch/未知 requestId/重复或过期 response 全部计账丢弃,绝不注入上层。
|
|
10
10
|
* - 四种身份不混用:requestId(transport)/observationId(M5)/activationId(M6)/syncId(index)。
|
|
11
11
|
* - timeout/AbortSignal(cancel 通知)/latest-wins(由上层 M5 bridge 驱动)/crash recovery/circuit breaker。
|
|
12
|
-
*
|
|
12
|
+
* - ★ #107 看门狗:连续超时(心跳缺失)达阈值 ⇒ **kill 旧进程 + 下次请求重生新进程**。旧实现
|
|
13
|
+
* 超时只发 cancel 通知、从不 kill ⇒ 挂死的 worker 永远无人收尸,后续每个请求各自等到超时。
|
|
14
|
+
* stats 把「看门狗主动击杀」与「崩溃退出」分开记;熔断只由**终态成功**清零,段内进度帧
|
|
15
|
+
* (index_sync_begin / index_sync_page)的 ack 成功不清零 —— 否则每个 Segment 一次 begin 就把
|
|
16
|
+
* 累计失败抹平,峰值恒 1、阈值 3 永不达,熔断形同虚设。
|
|
17
|
+
* - ★ #108 stderr 可见:exit/fatal 分支把 stderr 尾部**原样**落一条 diag(同一次退出只记一条,
|
|
18
|
+
* 按行与字符双重截断防刷屏),并把截断后文本暴露为 stats.lastStderrTail /
|
|
19
|
+
* debugView().lastStderrTail;同时保留 stderrTailBytes 以区分「确实没输出」与「输出被截」。
|
|
20
|
+
* 无 shell;无 HTTP;stdout 只进协议解析器;stderr 有界诊断 + 有界 diag 环形缓冲(不再只留字节数)。
|
|
21
|
+
* ★ 影响面纪律:本模块是「JS 语义默认 / Python 发烧友可选」里**Python 那一侧**的传输层,不参与
|
|
22
|
+
* JS 语义路径;下述改动只影响 Python 档自身的行为,不改变 JS 端任何行为,也不让 python 侧的
|
|
23
|
+
* 存在或健康度成为 JS 语义生效的前提(未启动时零进程零 IO,调用方按需 lazy start)。
|
|
24
|
+
* UTF-8 无 BOM。
|
|
13
25
|
*/
|
|
14
26
|
import { spawn } from 'node:child_process'
|
|
15
27
|
import { randomBytes } from 'node:crypto'
|
|
@@ -22,6 +34,31 @@ import {
|
|
|
22
34
|
|
|
23
35
|
const B = M7_TRANSPORT_BUDGET_V1
|
|
24
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 看门狗/诊断策略(**客户端本地策略量**,刻意不放进 M7_TRANSPORT_BUDGET_V1)。
|
|
39
|
+
* 理由有二:①传输预算按契约冻结、变更须升协议版本,而「多久判挂死」是客户端自愈策略、不是 wire 契约;
|
|
40
|
+
* ②wire 文件同期另有 PR(#122/#124)在改,本任务不去碰它以避开写域冲突。
|
|
41
|
+
*/
|
|
42
|
+
const SIDECAR_WATCHDOG_V1 = Object.freeze({
|
|
43
|
+
stallThreshold: 3, // 连续超时(心跳缺失)达此数 ⇒ 判定挂死,看门狗击杀旧进程
|
|
44
|
+
stderrTailChars: 4096, // stderr 环形尾部保留长度(既有语义,原为字面量 4096)
|
|
45
|
+
diagRingCapacity: 16, // diag 环形缓冲条数(超出丢最旧)
|
|
46
|
+
diagTailLines: 8, // 单条 diag 最多保留的 stderr 行数(防刷屏)
|
|
47
|
+
diagTailChars: 1200, // 单条 diag 最多保留的 stderr 字符数
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** 退出归因优先级:高优先级不被低优先级覆盖。 */
|
|
51
|
+
const EXIT_KIND_RANK_V1 = Object.freeze({ crash: 0, restart: 1, dispose: 1, fatal: 2, watchdog: 3 })
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* ★ #107 熔断清零口径:只有「终态成功」才把累计失败清零。
|
|
55
|
+
* index_sync_begin / index_sync_page 是**段内进度帧**(每个 Segment 都会 begin 一次),它们的 ack
|
|
56
|
+
* 成功只证明 worker 读到并回了一行,不能证明它健康。旧实现无差别清零 ⇒ 每个 Segment 的 begin 一经
|
|
57
|
+
* 成功就把累计失败抹平,峰值恒 1、阈值 3(breakerFailureThreshold)永不达,熔断形同虚设。
|
|
58
|
+
* health / context_push / index_sync_commit 才是各自工作单元的终态应答。
|
|
59
|
+
*/
|
|
60
|
+
const TERMINAL_SUCCESS_TYPES_V1 = new Set(['health', 'context_push', 'index_sync_commit'])
|
|
61
|
+
|
|
25
62
|
/** 捆绑 fake worker 的默认绝对路径(python/worker_v1.py)。 */
|
|
26
63
|
export function defaultWorkerScriptPathPre() {
|
|
27
64
|
try {
|
|
@@ -53,6 +90,19 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
53
90
|
const seenActivationIdSet = new Set()
|
|
54
91
|
let writeChain = Promise.resolve()
|
|
55
92
|
let lastSentFrame = null
|
|
93
|
+
// ---- #107 看门狗 / #108 stderr 可见:状态 ----
|
|
94
|
+
let genSeq = 0 // 世系序号发生器
|
|
95
|
+
let generation = 0 // 当前世系号(0 = 从未启动过)
|
|
96
|
+
let consecutiveTimeouts = 0 // 连续超时(心跳缺失)计数
|
|
97
|
+
let halfOpenPending = false // 熔断冷却到期后待用 health() 探活
|
|
98
|
+
let respawnPending = false // 看门狗击杀后待重生(下次 spawn 计一次 watchdog.respawns)
|
|
99
|
+
let lastStderr = { tail: '', bytes: 0, truncated: false, at: 0, generation: 0, totalLines: 0, keptLines: 0 }
|
|
100
|
+
let lastExitDiagKey = '' // 「同一次退出只记一条」去重键 = 世系号 + 退出种类
|
|
101
|
+
let lastExitDiagRef = null // 该条 diag 记录引用(供 exit 之后迟到的 stderr 就地刷新)
|
|
102
|
+
let lastExitGen = 0 // 最近一次退出的世系号
|
|
103
|
+
const genOfProc = new WeakMap() // ChildProcess -> 世系号
|
|
104
|
+
const exitKindByGen = new Map() // 世系号 -> 退出种类(crash/restart/dispose/fatal/watchdog)
|
|
105
|
+
const diagRing = [] // 有界 diag 环形缓冲(给人看的诊断,含中文归因)
|
|
56
106
|
const breaker = { consecutiveFailures: 0, openUntil: 0 }
|
|
57
107
|
const stats = {
|
|
58
108
|
starts: 0, exits: 0, framesIn: 0, requests: 0, succeeded: 0, activationsReceived: 0,
|
|
@@ -60,6 +110,17 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
60
110
|
dropped: { badJson: 0, badEnvelope: 0, staleEpoch: 0, unknownRequest: 0, typeMismatch: 0, duplicateActivation: 0 },
|
|
61
111
|
failed: { timeout: 0, crashed: 0, aborted: 0, circuitOpen: 0, unavailable: 0, backpressure: 0, protocol: 0, workerError: 0, disposed: 0 },
|
|
62
112
|
lastExit: null, lastFatal: null,
|
|
113
|
+
// ★ #107 看门狗:把「主动重启」与「崩溃退出」分开记,不再混在一个 exits 计数里。
|
|
114
|
+
watchdog: { kills: 0, respawns: 0, lastReason: null, lastAt: 0, lastDetail: null },
|
|
115
|
+
exitKinds: { crash: 0, restart: 0, dispose: 0, fatal: 0, watchdog: 0, spawnError: 0 },
|
|
116
|
+
breakerProbes: { attempted: 0, ok: 0, failed: 0 },
|
|
117
|
+
lastExitNote: null,
|
|
118
|
+
// ★ #108 stderr:lastStderrTail 是**截断后可读文本**(不再只有字节数);
|
|
119
|
+
// stderrTailBytes 仍在 debugView 上,用来区分「没输出」与「输出被截」。
|
|
120
|
+
lastStderrTail: '',
|
|
121
|
+
lastStderrTailBytes: 0,
|
|
122
|
+
lastStderrTailTruncated: false,
|
|
123
|
+
lastStderrTailAt: 0,
|
|
63
124
|
}
|
|
64
125
|
|
|
65
126
|
function resolveOpt(k) { return opt(k, null) }
|
|
@@ -75,14 +136,170 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
75
136
|
|
|
76
137
|
function breakerOpen() { return Date.now() < breaker.openUntil }
|
|
77
138
|
|
|
139
|
+
// ============ #107 看门狗 / #108 stderr 可见:诊断与自愈 ============
|
|
140
|
+
// 说明:以下全部只服务 Python 侧车自身的自愈与可观测性,不参与 JS 语义路径。
|
|
141
|
+
|
|
142
|
+
/** 有界 diag 环形缓冲:落一条**给人看**的中文诊断;opts.onDiag 在场时同步外发一份。 */
|
|
143
|
+
function pushDiag(level, text) {
|
|
144
|
+
const rec = { at: Date.now(), level, sidecar: 'python-sidecar-pre', generation, message: String(text) }
|
|
145
|
+
diagRing.push(rec)
|
|
146
|
+
while (diagRing.length > Math.max(1, Number(opt('diagRingCapacity', SIDECAR_WATCHDOG_V1.diagRingCapacity)))) diagRing.shift()
|
|
147
|
+
const sink = resolveOpt('onDiag')
|
|
148
|
+
if (typeof sink === 'function') { try { sink({ at: rec.at, level: rec.level, generation, message: rec.message }) } catch (_) {} }
|
|
149
|
+
return rec
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 把 stderr 尾部整理成可读文本:保留尾 N 行(默认 8)且不超过 M 字符(默认 1200)。
|
|
154
|
+
* 被截断时在正文前留一行说明 —— 避免读者误以为「这就是全部输出」。
|
|
155
|
+
*/
|
|
156
|
+
function tailForDiag(raw) {
|
|
157
|
+
const NL = String.fromCharCode(10)
|
|
158
|
+
const CR = String.fromCharCode(13)
|
|
159
|
+
const src = String(raw == null ? '' : raw)
|
|
160
|
+
const bytes = src.length
|
|
161
|
+
// 刻意不用正则字面量:本仓所有 .js 均为 CRLF,避免任何转义歧义。
|
|
162
|
+
const all = src.split(NL).map((l) => (l.endsWith(CR) ? l.slice(0, -1) : l))
|
|
163
|
+
while (all.length && all[all.length - 1] === '') all.pop()
|
|
164
|
+
const maxLines = Math.max(1, Number(opt('diagTailLines', SIDECAR_WATCHDOG_V1.diagTailLines)))
|
|
165
|
+
const maxChars = Math.max(1, Number(opt('diagTailChars', SIDECAR_WATCHDOG_V1.diagTailChars)))
|
|
166
|
+
const kept = all.slice(-maxLines)
|
|
167
|
+
let truncated = kept.length < all.length
|
|
168
|
+
let text = kept.join(NL)
|
|
169
|
+
if (text.length > maxChars) { text = text.slice(-maxChars); truncated = true }
|
|
170
|
+
const head = truncated ? '…[已按行/字符截断,下面不是全部输出]…' + NL : ''
|
|
171
|
+
return { text: head + text, truncated, keptLines: kept.length, totalLines: all.length, bytes }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 退出归因中文说明(回答「哪一侧车、哪一代、怎么退的、退出码多少」)。 */
|
|
175
|
+
function humanExitNote(gen, kind, code, signalName) {
|
|
176
|
+
const why = kind === 'watchdog' ? '被看门狗主动击杀(连续超时=心跳缺失达阈值,判定挂死)'
|
|
177
|
+
: kind === 'fatal' ? '因协议致命错误被就地击杀(帧纪律违规)'
|
|
178
|
+
: kind === 'restart' ? '被 restart() 主动重启(刻意换代,不是故障)'
|
|
179
|
+
: kind === 'dispose' ? '随 dispose() 收尾关闭(刻意关闭,不是故障)'
|
|
180
|
+
: '自行退出(非看门狗击杀)——即「崩溃退出」'
|
|
181
|
+
const c = (code === null || code === undefined) ? 'null' : String(code)
|
|
182
|
+
const s = signalName ? String(signalName) : 'null'
|
|
183
|
+
return '第 ' + gen + ' 代 Python 语义侧车' + why + ':退出码 code=' + c + ',signal=' + s
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 退出归因写入(优先级 watchdog > fatal > dispose/restart > crash;低优先级不覆盖高优先级)。 */
|
|
187
|
+
function markExitKind(gen, kind) {
|
|
188
|
+
if (!gen) return
|
|
189
|
+
const cur = exitKindByGen.get(gen) || 'crash'
|
|
190
|
+
if ((EXIT_KIND_RANK_V1[kind] || 0) < (EXIT_KIND_RANK_V1[cur] || 0)) return
|
|
191
|
+
exitKindByGen.set(gen, kind)
|
|
192
|
+
while (exitKindByGen.size > 64) exitKindByGen.delete(exitKindByGen.keys().next().value)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* ★ #108 关键修复:把 stderr 尾部**原样**落一条 diag。
|
|
197
|
+
* 旧实现只把 4KB 尾部收进 `stderrTail` 变量、对外仅暴露 `stderrTailBytes` 一个整数,
|
|
198
|
+
* **内容没有任何读者** ⇒ Python traceback 永远看不到,用户只能看到「字节数非 0」。
|
|
199
|
+
* 同一次退出只记一条:以「世系号 + 退出种类」为键,命中则**就地刷新**(退出之后 stdio 往往
|
|
200
|
+
* 还在排空,close 晚于 exit、尾字节会迟到;就地刷新既补全内容又不新开第二条)。
|
|
201
|
+
*/
|
|
202
|
+
function logExitDiag(gen, kind, reasonText) {
|
|
203
|
+
const t = tailForDiag(stderrTail)
|
|
204
|
+
lastStderr = { tail: t.text, bytes: t.bytes, truncated: t.truncated, at: Date.now(), generation: gen, totalLines: t.totalLines, keptLines: t.keptLines }
|
|
205
|
+
stats.lastStderrTail = t.text
|
|
206
|
+
stats.lastStderrTailBytes = t.bytes
|
|
207
|
+
stats.lastStderrTailTruncated = t.truncated
|
|
208
|
+
stats.lastStderrTailAt = lastStderr.at
|
|
209
|
+
const note = t.bytes === 0
|
|
210
|
+
? 'stderr 零输出(确实没打印,不是被截断)'
|
|
211
|
+
: (t.truncated
|
|
212
|
+
? 'stderr 尾部累计 ' + t.bytes + ' 字符 / ' + t.totalLines + ' 行,此处只保留最后 ' + t.keptLines + ' 行共 ' + t.text.length + ' 字符(已按行与字符双重截断,防刷屏)'
|
|
213
|
+
: 'stderr 尾部累计 ' + t.bytes + ' 字符 / ' + t.totalLines + ' 行,已全部保留')
|
|
214
|
+
const NL = String.fromCharCode(10)
|
|
215
|
+
const body = String(reasonText) + ';' + note + (t.text ? NL + '—— stderr 原文 ——' + NL + t.text : '')
|
|
216
|
+
const key = gen + ':' + kind
|
|
217
|
+
const level = (kind === 'crash' || kind === 'watchdog' || kind === 'fatal') ? 'error' : 'info'
|
|
218
|
+
if (lastExitDiagKey === key && lastExitDiagRef) {
|
|
219
|
+
lastExitDiagRef.message = body
|
|
220
|
+
lastExitDiagRef.at = lastStderr.at
|
|
221
|
+
lastExitDiagRef.level = level
|
|
222
|
+
return lastExitDiagRef
|
|
223
|
+
}
|
|
224
|
+
lastExitDiagKey = key
|
|
225
|
+
lastExitGen = gen
|
|
226
|
+
lastExitDiagRef = pushDiag(level, body)
|
|
227
|
+
return lastExitDiagRef
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* ★ #107 看门狗:判定侧车挂死 ⇒ **kill 旧进程 + 下次请求重生新进程**。
|
|
232
|
+
* 旧实现在超时分支只 `notify('cancel')`、从不 kill ⇒ 挂死的 worker 永远无人收尸,后续每个
|
|
233
|
+
* 请求各自等到自己的超时、再各自发一条无用的 cancel;对用户的表现是「Python 档语义静默失效」。
|
|
234
|
+
* 刻意不在此处立即 spawn:避免在熔断打开期制造进程(既有套件断言「打开期零 spawn」);
|
|
235
|
+
* 重建交给下一次 ensureStarted() —— 那里必然带新 epoch,index 重同步也随之触发。
|
|
236
|
+
*/
|
|
237
|
+
function disarmHungChild(trigger, detail) {
|
|
238
|
+
const proc = child
|
|
239
|
+
consecutiveTimeouts = 0
|
|
240
|
+
if (!proc) return
|
|
241
|
+
markExitKind(generation, 'watchdog')
|
|
242
|
+
child = null
|
|
243
|
+
epoch = null
|
|
244
|
+
buffer = Buffer.alloc(0)
|
|
245
|
+
respawnPending = true
|
|
246
|
+
stats.watchdog.kills++
|
|
247
|
+
stats.watchdog.lastReason = trigger
|
|
248
|
+
stats.watchdog.lastAt = Date.now()
|
|
249
|
+
stats.watchdog.lastDetail = detail || null
|
|
250
|
+
const thr = Math.max(1, Number(opt('watchdogStallThreshold', SIDECAR_WATCHDOG_V1.stallThreshold)))
|
|
251
|
+
pushDiag('error', '第 ' + generation + ' 代 Python 语义侧车连续 ' + thr + ' 次心跳缺失(' + trigger + ')⇒ 看门狗主动击杀旧进程;下次请求将重生新进程(新 epoch),未经确认的旧 in-flight 请求按 crashed 结算。')
|
|
252
|
+
logExitDiag(generation, 'watchdog', humanExitNote(generation, 'watchdog', null, null))
|
|
253
|
+
try { proc.stdin.destroy() } catch (_) {}
|
|
254
|
+
try { proc.kill() } catch (_) {}
|
|
255
|
+
rejectAll('crashed')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 心跳缺失证据:只把「超时」算挂死(abort 是调用方主动取消,不构成挂死证据)。 */
|
|
259
|
+
function noteStall(type, requestId, timeoutMs) {
|
|
260
|
+
consecutiveTimeouts++
|
|
261
|
+
const thr = Math.max(1, Number(opt('watchdogStallThreshold', SIDECAR_WATCHDOG_V1.stallThreshold)))
|
|
262
|
+
if (consecutiveTimeouts < thr) return
|
|
263
|
+
disarmHungChild('连续超时', { type: String(type), requestId: String(requestId), timeoutMs: Number(timeoutMs), consecutiveTimeouts })
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* ★ #107 熔断半开探测:冷却到期后不放行盲发,先用 health()(有界)自证 worker 活着再放行。
|
|
268
|
+
* 这是 health() 在仓内的**唯一真实调用点**。type==='health' 不入此分支 ⇒ 不会递归,
|
|
269
|
+
* 也不会把探针自身的失败再算作一次新的挂死证据。
|
|
270
|
+
*/
|
|
271
|
+
async function probeThenSend(type, payload, rOpts) {
|
|
272
|
+
halfOpenPending = false
|
|
273
|
+
stats.breakerProbes.attempted++
|
|
274
|
+
const probeTimeout = Math.min(Math.max(1, Number(opt('requestTimeoutMs', B.requestTimeoutMs))), 2000)
|
|
275
|
+
const probe = await request('health', {}, { timeoutMs: probeTimeout })
|
|
276
|
+
if (probe && probe.ok) {
|
|
277
|
+
stats.breakerProbes.ok++
|
|
278
|
+
breaker.consecutiveFailures = 0
|
|
279
|
+
consecutiveTimeouts = 0
|
|
280
|
+
return request(type, payload, rOpts)
|
|
281
|
+
}
|
|
282
|
+
stats.breakerProbes.failed++
|
|
283
|
+
breaker.openUntil = Date.now() + Number(opt('breakerCooldownMs', B.breakerCooldownMs))
|
|
284
|
+
halfOpenPending = true
|
|
285
|
+
stats.failed.circuitOpen++
|
|
286
|
+
pushDiag('error', '熔断半开探测未通过:Python 语义侧车 health 探针返回 ' + String((probe && probe.code) || 'unknown') + ' ⇒ 重新打开熔断 ' + Number(opt('breakerCooldownMs', B.breakerCooldownMs)) + 'ms;本次请求按 circuit-open 返回。')
|
|
287
|
+
return { ok: false, code: 'circuit-open', retryInMs: breaker.openUntil - Date.now() }
|
|
288
|
+
}
|
|
289
|
+
|
|
78
290
|
/** lazy start:no-shell spawn;仅由显式启用的调用路径触达。 */
|
|
79
291
|
function ensureStarted() {
|
|
80
292
|
if (disposed) return { ok: false, code: 'disposed' }
|
|
81
293
|
if (child && (child.killed || (child.stdin && child.stdin.destroyed))) {
|
|
82
294
|
// 上一个进程正在收尾(exit 事件未到):按已死处理,允许立即重生
|
|
295
|
+
// ★ issue #72 修复(2026-09-19):此处必须**同时清空共享 buffer**。
|
|
296
|
+
// 旧实现只置 `epoch = null`(等 `:121` 的 exit 事件去清 buffer),但这条分支的前提正是
|
|
297
|
+
// **exit 尚未到达** ⇒ 旧 worker 残留在 `buffer` 里的**无换行尾字节**会被新 worker 的首帧
|
|
298
|
+
// `concat` 进去 ⇒ 该行 badJson ⇒ 应答被吞、请求白等到超时(默认 5000ms)。
|
|
83
299
|
try { child.kill() } catch (_) {}
|
|
84
300
|
child = null
|
|
85
301
|
epoch = null
|
|
302
|
+
buffer = Buffer.alloc(0) // ★ 丢弃旧代残尾,避免污染新 worker 首帧
|
|
86
303
|
}
|
|
87
304
|
if (child) return { ok: true }
|
|
88
305
|
const scriptPath = String(resolveOpt('scriptPath') || defaultWorkerScriptPathPre())
|
|
@@ -101,9 +318,16 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
101
318
|
}
|
|
102
319
|
child = proc
|
|
103
320
|
stats.starts++
|
|
321
|
+
generation = ++genSeq
|
|
322
|
+
genOfProc.set(proc, generation)
|
|
323
|
+
exitKindByGen.set(generation, 'crash')
|
|
324
|
+
stderrTail = '' // ★ #108:stderr 尾部按**世系**隔离,避免把上代 traceback 误挂到本代
|
|
325
|
+
consecutiveTimeouts = 0
|
|
326
|
+
if (respawnPending) { stats.watchdog.respawns++; respawnPending = false }
|
|
104
327
|
proc.stdout.on('data', (chunk) => { try { feed(chunk) } catch (_) { fatal('protocol') } })
|
|
105
328
|
proc.stderr.on('data', (chunk) => {
|
|
106
|
-
stderrTail = (stderrTail + chunk.toString('utf8'))
|
|
329
|
+
stderrTail = (stderrTail + chunk.toString('utf8'))
|
|
330
|
+
.slice(-Math.max(256, Number(opt('stderrTailChars', SIDECAR_WATCHDOG_V1.stderrTailChars))))
|
|
107
331
|
})
|
|
108
332
|
proc.on('error', () => {
|
|
109
333
|
// spawn 失败(ENOENT 等):结构化失败,不计崩溃重启
|
|
@@ -115,12 +339,19 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
115
339
|
noteFailure('unavailable')
|
|
116
340
|
})
|
|
117
341
|
proc.on('exit', (code, signalName) => {
|
|
342
|
+
const gen = genOfProc.get(proc) || 0
|
|
343
|
+
const kind = exitKindByGen.get(gen) || 'crash'
|
|
344
|
+
// ★ #107:无论是否当前世系都先记账 —— 「哪一侧车、第几代、怎么退的、退出码多少」
|
|
345
|
+
// stats.exits 仍是总退出数;active 的归因细分在 stats.exitKinds。
|
|
346
|
+
stats.exits++
|
|
347
|
+
stats.exitKinds[kind] = (stats.exitKinds[kind] || 0) + 1
|
|
348
|
+
stats.lastExit = { code, signal: signalName, generation: gen, kind }
|
|
349
|
+
stats.lastExitNote = humanExitNote(gen, kind, code, signalName)
|
|
350
|
+
logExitDiag(gen, kind, stats.lastExitNote)
|
|
118
351
|
if (child !== proc) return
|
|
119
352
|
child = null
|
|
120
353
|
epoch = null
|
|
121
354
|
buffer = Buffer.alloc(0)
|
|
122
|
-
stats.exits++
|
|
123
|
-
stats.lastExit = { code, signal: signalName }
|
|
124
355
|
rejectAll('crashed')
|
|
125
356
|
})
|
|
126
357
|
return { ok: true }
|
|
@@ -128,13 +359,21 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
128
359
|
|
|
129
360
|
function rejectAll(code) {
|
|
130
361
|
for (const [, entry] of pending) settle(entry, { ok: false, code })
|
|
131
|
-
noteFailure(code)
|
|
362
|
+
// ★ #107:不再在循环外额外 noteFailure(code) —— settle() 已按每个失败请求各计一次,
|
|
363
|
+
// 旧实现在此重复计数 ⇒ 崩溃时挂 K 个请求会被算成 K+1 次失败,熔断阈值提前触发(假熔断)。
|
|
132
364
|
}
|
|
133
365
|
|
|
134
366
|
function fatal(kind) {
|
|
135
367
|
stats.lastFatal = kind
|
|
136
368
|
buffer = Buffer.alloc(0) // 丢弃残留半帧,防止污染重生进程的解析流
|
|
137
|
-
|
|
369
|
+
// ★ #108:致命分支同样把 stderr 尾部原样落一条 diag;与随后 exit 同键(世系号+fatal)
|
|
370
|
+
// ⇒ 同一次退出只记一条。
|
|
371
|
+
logExitDiag(generation, 'fatal', humanExitNote(generation, 'fatal', null, null) + ' 触发原因=' + String(kind))
|
|
372
|
+
if (child) {
|
|
373
|
+
markExitKind(generation, 'fatal')
|
|
374
|
+
try { child.stdin.destroy() } catch (_) {}
|
|
375
|
+
try { child.kill() } catch (_) {}
|
|
376
|
+
}
|
|
138
377
|
rejectAll('protocol')
|
|
139
378
|
}
|
|
140
379
|
|
|
@@ -161,7 +400,16 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
161
400
|
const v = validateTransportFramePre(obj, { direction: 'in' })
|
|
162
401
|
if (!v.ok) { stats.dropped.badEnvelope++; return }
|
|
163
402
|
const frame = v.frame
|
|
164
|
-
|
|
403
|
+
// ★ issue #72 修复(2026-09-19):把入站 epoch 门从 **fail-open 改为 fail-closed**,与文件头 :7 的承诺一致。
|
|
404
|
+
// 旧写法 `if (epoch !== null && frame.workerEpoch !== epoch)`:**`epoch === null` 时整条门失效**,
|
|
405
|
+
// 任何合法 envelope 的帧都会穿透到 handler。epoch 为 null 的窗口是真实存在的
|
|
406
|
+
// (未启动 / `exit` 事件已置 null 但旧 stdio 尚未排空 / restart 后到新 spawn 之间),
|
|
407
|
+
// 而 `activation_request` 上游 `context-host` **无二次校验**(同 activationId 有 dedup 兜底,
|
|
408
|
+
// 但**新 activationId 可穿透**)⇒ 幽灵激活。
|
|
409
|
+
// 新判据:**epoch 为空 ⇒ 一律丢弃**。理由:spawn 时同步赋 epoch(:91 `epoch = 'wk_' + …` 在
|
|
410
|
+
// `spawn()` **之前**执行),出站帧也就带上了 epoch;对端 worker 以 `--expect-epoch` 自证身份后才回帧
|
|
411
|
+
// ⇒ **正常路径不存在"帧先于 epoch"**,丢弃不会误伤任何合法流量。
|
|
412
|
+
if (!epoch || frame.workerEpoch !== epoch) { stats.dropped.staleEpoch++; return }
|
|
165
413
|
if (frame.type === 'activation_request') {
|
|
166
414
|
const activation = frame.payload && frame.payload.activation
|
|
167
415
|
const aid = activation && activation.activationId
|
|
@@ -194,7 +442,17 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
194
442
|
if (entry.timer) clearTimeout(entry.timer)
|
|
195
443
|
if (entry.onAbort) { try { entry.signal.removeEventListener('abort', entry.onAbort) } catch (_) {} }
|
|
196
444
|
pending.delete(entry.requestId)
|
|
197
|
-
if (result.ok) {
|
|
445
|
+
if (result.ok) {
|
|
446
|
+
stats.succeeded++
|
|
447
|
+
// ★ #107 熔断清零口径:只有「终态成功」清零累计失败;段内进度帧(index_sync_begin /
|
|
448
|
+
// index_sync_page)的 ack 成功**不清零** —— 否则每个 Segment 一次 begin 就把累计失败
|
|
449
|
+
// 抹平,峰值恒 1、阈值 3 永不达,熔断形同虚设。
|
|
450
|
+
if (TERMINAL_SUCCESS_TYPES_V1.has(entry.type)) {
|
|
451
|
+
breaker.consecutiveFailures = 0
|
|
452
|
+
consecutiveTimeouts = 0
|
|
453
|
+
halfOpenPending = false
|
|
454
|
+
}
|
|
455
|
+
}
|
|
198
456
|
else {
|
|
199
457
|
const bucket = stats.failed[result.code]
|
|
200
458
|
if (bucket === undefined) stats.failed.protocol++
|
|
@@ -208,9 +466,18 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
208
466
|
lastSentFrame = frame
|
|
209
467
|
if (!child || !child.stdin || child.stdin.destroyed) return false
|
|
210
468
|
const line = Buffer.from(JSON.stringify(frame) + '\n', 'utf8')
|
|
469
|
+
// ★ issue #75 CC-9 修复(2026-09-19):**建帧时捕获当时的 child 引用**,flush 时校验身份。
|
|
470
|
+
// 旧实现:`writeChain.then(...)` 回调里解引用**当时的**模块级 `child` 变量。
|
|
471
|
+
// 若 writeChain 排队期间发生 respawn(`ensureStarted` 的收尾中重生 / `restart()`),
|
|
472
|
+
// 回调会读到**新** child,把**旧 epoch 的帧**写进新 worker 的 stdin ⇒ 对端回帧 epoch-mismatch
|
|
473
|
+
// ⇒ 被判 `staleEpoch` 丢弃 ⇒ 该请求挂满自身超时(默认 5000ms)。
|
|
474
|
+
// 现改为闭包捕获 `sentTo`,且 flush 时 `child !== sentTo` 即视为该帧已随旧进程作废。
|
|
475
|
+
const sentTo = child
|
|
476
|
+
const sentStdin = child.stdin
|
|
211
477
|
writeChain = writeChain.then(() => new Promise((done) => {
|
|
212
|
-
if (
|
|
213
|
-
|
|
478
|
+
if (child !== sentTo) { done(); return } // 已换代:本帧作废,不得写入新 worker
|
|
479
|
+
if (!sentStdin || sentStdin.destroyed) { done(); return }
|
|
480
|
+
sentStdin.write(line, () => done())
|
|
214
481
|
}))
|
|
215
482
|
writeChain = writeChain.catch(() => {})
|
|
216
483
|
return true
|
|
@@ -227,6 +494,19 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
227
494
|
stats.failed.circuitOpen++
|
|
228
495
|
return Promise.resolve({ ok: false, code: 'circuit-open', retryInMs: breaker.openUntil - Date.now() })
|
|
229
496
|
}
|
|
497
|
+
// ★ #107 熔断半开探测:冷却到期后不放行盲发,先用 health()(有界)自证 worker 活着再放行。
|
|
498
|
+
// 这是 health() 在仓内的**唯一真实调用点** —— 旧实现 health() 全仓零调用,文件里那句
|
|
499
|
+
// 「health 探针(breaker half-open 用;有界响应)」是空头承诺。type==='health' 不入此分支
|
|
500
|
+
// ⇒ 不会递归,也不会把探针本身的失败再算作一次挂死证据。
|
|
501
|
+
if (halfOpenPending && type !== 'health') return probeThenSend(type, payload, rOpts)
|
|
502
|
+
return sendNow(type, payload, rOpts)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* 真正的建帧 + 入队。与旧 request() 同体,刻意保持「调用后同步可见 _lastFrameForTest()」
|
|
507
|
+
* 的既有契约(既有套件靠它在 await 之前做帧级断言)。
|
|
508
|
+
*/
|
|
509
|
+
function sendNow(type, payload, rOpts = {}) {
|
|
230
510
|
const started = ensureStarted()
|
|
231
511
|
if (!started.ok) {
|
|
232
512
|
stats.failed[started.code] = (stats.failed[started.code] || 0) + 1
|
|
@@ -239,7 +519,7 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
239
519
|
const mf = makeRequestFramePre({ type, payload, requestId, workerEpoch: epoch, sentAt })
|
|
240
520
|
if (!mf.ok) { stats.failed.protocol++; return Promise.resolve({ ok: false, code: 'protocol', reason: mf.reason }) }
|
|
241
521
|
return new Promise((resolve) => {
|
|
242
|
-
const entry = { requestId, expectedType: RESPONSE_TYPE_FOR_V1[type], resolve, settled: false, timer: null, signal: rOpts.signal || null, onAbort: null }
|
|
522
|
+
const entry = { requestId, type, expectedType: RESPONSE_TYPE_FOR_V1[type], resolve, settled: false, timer: null, signal: rOpts.signal || null, onAbort: null }
|
|
243
523
|
pending.set(requestId, entry)
|
|
244
524
|
stats.requests++
|
|
245
525
|
const written = writeFrame(mf.frame)
|
|
@@ -248,6 +528,9 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
248
528
|
entry.timer = setTimeout(() => {
|
|
249
529
|
settle(entry, { ok: false, code: 'timeout', timeoutMs })
|
|
250
530
|
notify('cancel', { requestId })
|
|
531
|
+
// ★ #107:旧实现到此为止(只发 cancel,不 kill)⇒ 挂死的 worker 无人收尸。
|
|
532
|
+
// 这里补上心跳缺失证据的累计与看门狗收尸(达阈值才动,避免偶发慢帧被误杀)。
|
|
533
|
+
noteStall(type, requestId, timeoutMs)
|
|
251
534
|
}, timeoutMs)
|
|
252
535
|
if (entry.signal) {
|
|
253
536
|
if (entry.signal.aborted) {
|
|
@@ -282,7 +565,14 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
282
565
|
if (child) { try { child.kill() } catch (_) {} }
|
|
283
566
|
child = null
|
|
284
567
|
epoch = null
|
|
568
|
+
// ★ issue #72 修复:restart 同样要清 buffer(kill 后 exit 事件可能滞后,
|
|
569
|
+
// 旧代残尾会污染新 worker 首帧 ⇒ 该行 badJson ⇒ 请求白等到超时)。
|
|
570
|
+
buffer = Buffer.alloc(0)
|
|
285
571
|
stats.restarts++
|
|
572
|
+
markExitKind(generation, 'restart')
|
|
573
|
+
breaker.consecutiveFailures = 0
|
|
574
|
+
consecutiveTimeouts = 0
|
|
575
|
+
pushDiag('info', 'restart() 主动重启 Python 语义侧车:旧进程已击杀、新 epoch 待下次请求重生(原因:' + String(reason === undefined || reason === null ? '未给' : reason) + ')。')
|
|
286
576
|
void reason
|
|
287
577
|
}
|
|
288
578
|
|
|
@@ -296,7 +586,18 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
296
586
|
epoch: epoch ? epoch.slice(0, 12) + '…' : null,
|
|
297
587
|
pending: pending.size,
|
|
298
588
|
breaker: { open: breakerOpen(), consecutiveFailures: breaker.consecutiveFailures, cooldownMs: Number(opt('breakerCooldownMs', B.breakerCooldownMs)) },
|
|
589
|
+
generation,
|
|
590
|
+
// ★ #108:stderrTailBytes 保留原语义(当前世系累计保留的尾部长度)——0 表示确实没输出,
|
|
591
|
+
// 非 0 且 lastStderrTailTruncated=true 表示有输出但被截断。文本本体见 lastStderrTail。
|
|
299
592
|
stderrTailBytes: stderrTail.length,
|
|
593
|
+
lastStderrTail: lastStderr.tail,
|
|
594
|
+
lastStderrTailBytes: lastStderr.bytes,
|
|
595
|
+
lastStderrTailTruncated: lastStderr.truncated,
|
|
596
|
+
lastStderrTailAt: lastStderr.at,
|
|
597
|
+
consecutiveTimeouts,
|
|
598
|
+
halfOpenProbePending: halfOpenPending,
|
|
599
|
+
watchdog: JSON.parse(JSON.stringify(stats.watchdog)),
|
|
600
|
+
recentDiag: diagRing.slice(-4).map((d) => ({ at: d.at, level: d.level, message: d.message })),
|
|
300
601
|
stats: JSON.parse(JSON.stringify(stats)),
|
|
301
602
|
}
|
|
302
603
|
}
|
|
@@ -304,6 +605,7 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
304
605
|
function dispose(reason) {
|
|
305
606
|
if (disposed) return
|
|
306
607
|
disposed = true
|
|
608
|
+
markExitKind(generation, 'dispose')
|
|
307
609
|
if (child) { try { child.kill() } catch (_) {} }
|
|
308
610
|
child = null
|
|
309
611
|
epoch = null
|
|
@@ -322,5 +624,6 @@ export function createPythonSidecarClientPre(opts = {}) {
|
|
|
322
624
|
onActivation(handler) { activationHandlers.add(handler); return () => activationHandlers.delete(handler) },
|
|
323
625
|
_statsForTest: stats,
|
|
324
626
|
_pendingForTest: pending,
|
|
627
|
+
_diagRingForTest: diagRing,
|
|
325
628
|
}
|
|
326
629
|
}
|
package/lib/recall-fusion.js
CHANGED
|
@@ -21,6 +21,19 @@ export const FUSION_RRF_K_V1 = 60
|
|
|
21
21
|
/** rank 尺度常数:rank/divisor 把秩归到 (0,1] 量级(默认与 k 同值)。 */
|
|
22
22
|
export const FUSION_RRF_DIVISOR_V1 = 60
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* ★M2.5b:层次优先级序(与注入侧一致)。
|
|
26
|
+
*
|
|
27
|
+
* 出处:`tier-layer-inject.js:58 TIER_LAYER_ORDER_V1` —— **必须与之逐字相同**。
|
|
28
|
+
*
|
|
29
|
+
* 为什么不直接 import:本模块头注释承诺「**零依赖**、纯函数、零 IO」,
|
|
30
|
+
* 引入跨模块依赖会破坏该契约(且 tier-layer-inject 自身还有依赖链)。
|
|
31
|
+
* 为了既不破契约、又不产生「双源漂移」,此处**本地复刻**并由测试**断言两者相等**
|
|
32
|
+
* (见 smoke-test-m25b-layer-arm-pre.mjs 的「常量一致性」断言)——
|
|
33
|
+
* 任何一侧改动而另一侧未同步,测试立刻红。
|
|
34
|
+
*/
|
|
35
|
+
export const FUSION_LAYER_ORDER_V1 = Object.freeze(['project', 'whiteboard', 'user', 'reflection', 'log'])
|
|
36
|
+
|
|
24
37
|
/** 版本标识。 */
|
|
25
38
|
export const RECALL_FUSION_VERSION = 'rrf_fusion_v1'
|
|
26
39
|
|
|
@@ -45,20 +58,63 @@ export function rankFusionRRFPre(pairs, opts = {}) {
|
|
|
45
58
|
const list = Array.isArray(pairs) ? pairs.filter((p) => p && typeof p.memoryId === 'string') : []
|
|
46
59
|
|
|
47
60
|
// 每臂独立排名:非空有限值按分数降序(平局 memoryId 升序)取秩(1 起);缺席者无秩。
|
|
48
|
-
|
|
61
|
+
// ★M2.5b:新增 `strMode` —— 层次臂的值是**字符串**,默认的「只收有限数」过滤会把它全部滤掉
|
|
62
|
+
// (实测踩坑:layer 臂曾因此静默恒为 0,套件 [3] 抓到)。strMode=true 时接受非空字符串。
|
|
63
|
+
const rankArm = (key, cmp, strMode) => {
|
|
49
64
|
const entries = []
|
|
50
65
|
for (const p of list) {
|
|
51
66
|
const v = p[key]
|
|
52
|
-
if (
|
|
67
|
+
if (strMode) {
|
|
68
|
+
if (typeof v === 'string' && v) entries.push({ memoryId: p.memoryId, v })
|
|
69
|
+
} else if (typeof v === 'number' && Number.isFinite(v)) {
|
|
70
|
+
entries.push({ memoryId: p.memoryId, v })
|
|
71
|
+
}
|
|
53
72
|
}
|
|
54
|
-
|
|
73
|
+
const tie = (a, b) => (a.memoryId < b.memoryId ? -1 : a.memoryId > b.memoryId ? 1 : 0)
|
|
74
|
+
entries.sort(cmp || ((a, b) => (b.v !== a.v ? b.v - a.v : tie(a, b))))
|
|
55
75
|
const ranks = new Map()
|
|
56
76
|
entries.forEach((e, i) => ranks.set(e.memoryId, i + 1))
|
|
57
77
|
return ranks
|
|
58
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* ★M2.5b:层次臂的**序** = 注入侧的层次优先级序(二者必须一致)。
|
|
82
|
+
* 出处:`tier-layer-inject.js:58 TIER_LAYER_ORDER_V1`
|
|
83
|
+
* = ['project','whiteboard','user','reflection','log']。
|
|
84
|
+
* 语义:**rank 1 = 最高优先级层**。未列出的层(含新增层)排在已知层之后,按名称升序(确定性)。
|
|
85
|
+
* 注意:这里只定义**偏好顺序**,不定义权重;权重问题走 rank-space(见下),
|
|
86
|
+
* 需要调权重时应改分档/序,而不是给分数乘系数(Hindsight #3956)。
|
|
87
|
+
*/
|
|
88
|
+
const layerRankOf = (name) => {
|
|
89
|
+
const i = FUSION_LAYER_ORDER_V1.indexOf(name)
|
|
90
|
+
return i >= 0 ? i : FUSION_LAYER_ORDER_V1.length
|
|
91
|
+
}
|
|
92
|
+
const cmpLayer = (a, b) => {
|
|
93
|
+
const la = layerRankOf(String(a.v))
|
|
94
|
+
const lb = layerRankOf(String(b.v))
|
|
95
|
+
if (la !== lb) return la - lb
|
|
96
|
+
const sa = String(a.v), sb = String(b.v)
|
|
97
|
+
if (sa !== sb) return sa < sb ? -1 : 1
|
|
98
|
+
return a.memoryId < b.memoryId ? -1 : a.memoryId > b.memoryId ? 1 : 0
|
|
99
|
+
}
|
|
100
|
+
|
|
59
101
|
const denseRanks = rankArm('dense')
|
|
60
102
|
const lexRanks = rankArm('lex')
|
|
61
103
|
|
|
104
|
+
// ★M2.5b 层次臂(2026-09-18):可选第四臂。layer 为层次名('project'/'user'/'log'/
|
|
105
|
+
// 'reflection'/'whiteboard')或缺失。与时间臂同一条**退化安全**纪律:
|
|
106
|
+
// 所有 pair 均缺 layer **或 layer 全相等** → 不构建 layer 秩,行为与三臂版**逐字节一致**
|
|
107
|
+
// (fused 不加 0 项、输出对象不含新字段)。
|
|
108
|
+
// 为什么需要它(实测不一致,2026-09-18):Tier-0 **注入**侧有层次优先级
|
|
109
|
+
// (tier-layer-inject.js:58 TIER_LAYER_ORDER_V1 = ['project','whiteboard','user','reflection','log']
|
|
110
|
+
// + tier0-catalog.js:419-455 的 caps/floors),而 **排序**侧对层次完全无感
|
|
111
|
+
// ⇒ 结论层(project)与流水层(log)在融合里**平等竞争**,笔记层常被日志挤掉。
|
|
112
|
+
// rank-space(Hindsight #3956 禁止 score-space 加权):层次只贡献**秩**,不乘分。
|
|
113
|
+
const layers = []
|
|
114
|
+
for (const p of list) { if (typeof p.layer === 'string' && p.layer) layers.push(p.layer) }
|
|
115
|
+
const hasLayer = layers.length > 0 && layers.some((v) => v !== layers[0])
|
|
116
|
+
const layerRanks = hasLayer ? rankArm('layer', cmpLayer, true) : null
|
|
117
|
+
|
|
62
118
|
// 时间臂(P12 后新增,2026-09-09):可选第三臂。temp 为 1(命中时间范围)/0(未命中)/
|
|
63
119
|
// 缺失(无时间臂或非日期来源)。所有 pair 均缺 temp **或 temp 全相等**(全 0/全 1)→
|
|
64
120
|
// 不构建 temp 秩,行为与两臂版逐字节一致(fused 不加 0 项、输出对象不含新字段)——
|
|
@@ -84,16 +140,31 @@ export function rankFusionRRFPre(pairs, opts = {}) {
|
|
|
84
140
|
rankDense: rd === undefined ? null : rd,
|
|
85
141
|
rankLex: rl === undefined ? null : rl,
|
|
86
142
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
143
|
+
// 时间臂与层次臂**互不依赖**:各自独立判 has*,可单独生效、可同时生效。
|
|
144
|
+
let out = base
|
|
145
|
+
if (hasTemp) {
|
|
146
|
+
const rt = tempRanks.get(p.memoryId)
|
|
147
|
+
const rrfTemp = rt === undefined ? 0 : 1 / (k + rt / divisor)
|
|
148
|
+
out = {
|
|
149
|
+
...out,
|
|
150
|
+
fused: out.fused + rrfTemp,
|
|
151
|
+
rrfTemp,
|
|
152
|
+
tempRaw: typeof p.temp === 'number' && Number.isFinite(p.temp) ? p.temp : null,
|
|
153
|
+
rankTemp: rt === undefined ? null : rt,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (hasLayer) {
|
|
157
|
+
const rlay = layerRanks.get(p.memoryId)
|
|
158
|
+
const rrfLayer = rlay === undefined ? 0 : 1 / (k + rlay / divisor)
|
|
159
|
+
out = {
|
|
160
|
+
...out,
|
|
161
|
+
fused: out.fused + rrfLayer,
|
|
162
|
+
rrfLayer,
|
|
163
|
+
layerRaw: typeof p.layer === 'string' && p.layer ? p.layer : null,
|
|
164
|
+
rankLayer: rlay === undefined ? null : rlay,
|
|
165
|
+
}
|
|
96
166
|
}
|
|
167
|
+
return out
|
|
97
168
|
})
|
|
98
169
|
.sort((x, y) => (y.fused !== x.fused ? y.fused - x.fused : (x.memoryId < y.memoryId ? -1 : x.memoryId > y.memoryId ? 1 : 0)))
|
|
99
170
|
}
|