@a9i5k4/dsh-auto-memory 2.5.3 → 3.0.1
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 +189 -7
- package/README.zh-CN.md +189 -7
- package/docs/CONTRIBUTORS.html +471 -0
- package/docs/FRONTEND-CO-CREATION.md +191 -0
- package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
- package/docs/HANDOFF-CRITERIA.md +92 -0
- package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
- package/docs/INTEGRATION-ANALYSIS.md +350 -348
- package/docs/PROMO-PROMPT-3.0.md +100 -0
- package/docs/USER-GUIDE.en.md +58 -3
- package/docs/USER-GUIDE.zh-CN.md +59 -4
- package/docs/WHITEPAPER.md +207 -0
- package/docs/internal/ACCEPT-35-LIVE.md +143 -0
- package/docs/internal/ACCEPTANCE-20260914.md +90 -0
- package/docs/internal/ARCH-REVIEW-BRIEF.md +411 -0
- package/docs/internal/ARCH-REVIEW-REQUEST.md +201 -0
- package/docs/internal/ARCH-REVIEW-ROUND2.md +169 -0
- package/docs/internal/ARCH-REVIEW-ROUND3.md +206 -0
- package/docs/internal/ARCHITECTURE-FOR-ZCODE-20260920.md +397 -0
- package/docs/internal/ART-DIRECTION-DEEPSEEK-20260920.md +351 -0
- package/docs/internal/ART-DIRECTION-WIREFRAME.md +191 -181
- package/docs/internal/ART-DIRECTION-WIREFRAME.md.bak-superseded +181 -0
- package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +314 -0
- package/docs/internal/BATTLE-PLAN-20260917.md +871 -0
- package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +192 -0
- package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +72 -0
- package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +131 -0
- package/docs/internal/DECISIONS-20260914-SESSION.md +269 -0
- package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +219 -0
- package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +132 -0
- package/docs/internal/FEATURE-INVENTORY.md +531 -0
- package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +13 -0
- package/docs/internal/G-SERIES-EXECUTION-20260917.md +248 -0
- package/docs/internal/G3-DESIGN-20260918.md +82 -0
- package/docs/internal/G3-DISK-FORMAT-GAP-20260919.md +92 -0
- package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +74 -0
- package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +352 -0
- package/docs/internal/GPT-REVIEW-PROMPT.md +216 -0
- package/docs/internal/GROUP-WEBHOOK-SETUP.md +33 -0
- package/docs/internal/HANDOFF-TO-ZCODE-20260920.md +309 -0
- package/docs/internal/HERMES-DATA-VERIFICATION-20260919.md +120 -0
- package/docs/internal/HERMES-LEGACY-STATUS-20260919.md +74 -0
- package/docs/internal/ISSUE-55-58-VERIFICATION-20260918.md +175 -0
- package/docs/internal/ISSUE10-FIX-EXECUTION-20260919.md +389 -0
- package/docs/internal/ISSUE10-PLAN-20260919.md +254 -0
- package/docs/internal/ISSUE10B-FORENSICS-20260919.md +468 -0
- package/docs/internal/ISSUE9-PURGE-AND-R1-PLAIN-20260919.md +150 -0
- package/docs/internal/ISSUE9-RESIDUAL-FORENSICS-20260919.md +114 -0
- package/docs/internal/KICKOFF-P0.md +254 -0
- package/docs/internal/LESSON-TO-CANDIDATE-STATUS-20260919.md +79 -0
- package/docs/internal/MASTER-PLAN-3.0.md +411 -0
- package/docs/internal/MEMORY-GOVERNANCE-20260917.md +309 -0
- package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +85 -0
- package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +222 -0
- package/docs/internal/PENDING-FIXES-20260916.md +289 -0
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md +705 -0
- package/docs/internal/PRE-FRONTEND-CHECKLIST-20260919.md.bak-s10 +649 -0
- package/docs/internal/PROCEDURAL-MEMORY-AND-APPROVAL-DESIGN-20260918.md +225 -0
- package/docs/internal/PROGRESS-20260917.md +93 -0
- package/docs/internal/PROMPT-GAP-AUDIT-20260920.md +128 -0
- package/docs/internal/R1-DEGRADE-AUDIT-20260918.md +163 -0
- package/docs/internal/R1-READABILITY-FORENSICS-20260919.md +127 -0
- package/docs/internal/R2-EVIDENCE-DEEP-AUDIT-20260918.md +140 -0
- package/docs/internal/R3-DEGRADE-LEDGER-DESIGN-20260918.md +138 -0
- package/docs/internal/R4-RECALL-QUOTA-PLAN-20260918.md +218 -0
- package/docs/internal/RAG-KARPATHY-PROGRAM.md +229 -0
- package/docs/internal/REPORT-P0-NIGHTLY.md +212 -0
- package/docs/internal/REPORT-P5-ACCEPTANCE.md +31 -0
- package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +153 -0
- package/docs/internal/RESUME-20260918.md +171 -0
- package/docs/internal/RESUME-20260919.md +104 -0
- package/docs/internal/REVIEW-WB-GRAPH-SELF.md +81 -0
- package/docs/internal/RHINELAB-TO-DEEPSEEK-FEASIBILITY.md +198 -0
- package/docs/internal/ROADMAP-20260917-WEEK.md +439 -0
- package/docs/internal/ROADMAP.md +106 -0
- package/docs/internal/RUN-P0-NIGHTLY.md +227 -0
- package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +185 -0
- package/docs/internal/S10-GAP-INVENTORY-20260917.md +239 -0
- package/docs/internal/S10-GAPS-PLAIN-20260917.md +125 -0
- package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +360 -0
- package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +90 -0
- package/docs/internal/T6-EXECUTION-20260920.md +130 -0
- package/docs/internal/TELEMETRY-EFFECT-REPORT-DESIGN-20260918.md +146 -0
- package/docs/internal/THESIS-GAP-ANALYSIS-20260918.md +89 -0
- package/docs/internal/THESIS-OUTLINE-20260918.md +147 -0
- package/docs/internal/THREE-LAYER-CONTRACT.md +219 -0
- package/docs/internal/TODO-BACKLOG.md +263 -142
- package/docs/internal/TODO-GRAPH.html +715 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +493 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +703 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +715 -0
- package/docs/internal/UPSTREAM-ISSUE-PR-TRIAGE-20260919.md +297 -0
- package/docs/internal/UPSTREAM-ISSUES-3RD-AUDIT-20260920.md +104 -0
- package/docs/internal/WB-FORMAT-CONVENTION.md +112 -0
- package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +71 -0
- package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +56 -0
- package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +787 -0
- package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +112 -0
- package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +230 -0
- package/docs/prompts/M8-3-enable-verify.md +49 -49
- package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
- package/lib/acceptance.js +71 -0
- package/lib/activation-host.js +153 -18
- package/lib/activation-inbox.js +25 -7
- package/lib/board-mode.js +30 -0
- package/lib/client.js +1758 -90
- package/lib/config-io.js +156 -0
- package/lib/context-bridge.js +5 -2
- package/lib/context-host.js +86 -15
- package/lib/degrade.js +385 -0
- package/lib/dsh-home.js +143 -0
- package/lib/engine-identity.js +149 -0
- package/lib/engine-switch.js +247 -0
- package/lib/episodic-store.js +63 -12
- package/lib/evidence-store.js +10 -3
- package/lib/fact-store.js +22 -3
- package/lib/fs-retry.js +46 -0
- package/lib/index-sync.js +13 -1
- package/lib/index.js +3446 -263
- package/lib/intent-clean-safe.js +258 -0
- package/lib/intent-clean.js +12 -16
- package/lib/l0-extract.js +478 -149
- package/lib/l0-index-sync.js +195 -0
- package/lib/l0-index.js +349 -239
- package/lib/ledger-criteria.js +142 -0
- package/lib/m4-corpus.js +8 -2
- package/lib/m7-index-sync-host.js +73 -5
- package/lib/m7-wire.js +3 -3
- package/lib/memory-anchor.js +56 -1
- package/lib/memory-envelope.js +257 -0
- package/lib/memory-hub.js +138 -13
- package/lib/memory-index.js +4 -2
- package/lib/memory-mutation.js +246 -0
- package/lib/memory-writer.js +204 -24
- package/lib/note-status-apply.js +118 -0
- package/lib/note-status.js +196 -0
- package/lib/procedure-observation.js +48 -0
- package/lib/procedure-store.js +118 -20
- package/lib/python-setup.js +1 -1
- package/lib/python-sidecar-client.js +29 -3
- package/lib/recall-fusion.js +83 -12
- package/lib/rerank-host.js +160 -0
- package/lib/rules-edit.js +159 -0
- package/lib/rules-layer.js +261 -0
- package/lib/semantic-decide.js +41 -8
- package/lib/semantic-js.js +66 -6
- package/lib/shadow-host.js +3 -5
- package/lib/shadow-retrieval.js +3 -3
- package/lib/skill-export-host.js +153 -0
- package/lib/skill-export.js +239 -0
- package/lib/state-commit.js +245 -0
- package/lib/storage-manage.js +6 -0
- package/lib/subagent-gc.js +4 -8
- package/lib/temporal-parse.js +191 -159
- package/lib/tier-layer-inject.js +650 -0
- package/lib/tier0-catalog.js +735 -0
- package/lib/water-window.js +263 -186
- package/lib/wb-contract.js +691 -0
- package/lib/wb-sidecar.js +890 -0
- package/lib/ws-overview-rank.js +2 -2
- package/package.json +1 -1
- package/python/m7_embedding_v1.py +5 -5
- package/python/worker_semantic_v1.py +17 -6
- package/python/worker_v1.py +38 -4
package/lib/degrade.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* degrade.js · 降级留痕层(R3,2026-09-18)
|
|
3
|
+
*
|
|
4
|
+
* ── 要解决的问题 ──────────────────────────────────────────────
|
|
5
|
+
* 全仓普查发现 70 处 `catch` 只写 diag 不抛出,其中 10 处自述为「降级/回退/中性」。
|
|
6
|
+
* 检索链上**四条臂各自独立降级、各自静默** ⇒ 可同时失效而使用者只感到「检索不太对」。
|
|
7
|
+
* 实证案例(R2):evidence 读侧误判目录缺失 ⇒ importance 加权对某类用户**出厂即死**,
|
|
8
|
+
* 而表现只是每次 recall 写一行 diag。
|
|
9
|
+
*
|
|
10
|
+
* ── 定性 ─────────────────────────────────────────────────────
|
|
11
|
+
* fail-soft 本身是对的(记忆插件不得拖垮会话)。**缺陷在「降级不可见」**。
|
|
12
|
+
* 本模块不是要消灭降级,而是让「**哪条臂没在工作**」从推断变成**可查询的状态**。
|
|
13
|
+
*
|
|
14
|
+
* ── 与既有机制的边界(2026-09-18 前置检查已证实无重复)──────
|
|
15
|
+
* · `diag()` = 过程日志(滚动、即时、人读)—— **保留不变**
|
|
16
|
+
* · `debugView()` = 各 host 的**局部**状态投影(7 处,彼此分散)
|
|
17
|
+
* · `degrade`(本模块)= **跨臂统一台账**(聚合、可查询、回答"哪条臂失效")
|
|
18
|
+
* · `_lastIndexDegrade`(context-host.js:112)= 单值兼容投影,非收集器
|
|
19
|
+
*
|
|
20
|
+
* ── 判据(R1/R2 得出,本模块的最高纪律)──────────────────────
|
|
21
|
+
* **必须区分两类,绝不能一律报,否则噪音淹没信号**:
|
|
22
|
+
* · **预期内分支**:该状态是合法业务状态(无证据事件 / 查询无时间表达)⇒ **静默,不记**
|
|
23
|
+
* · **预期外失败**:该状态不该发生(引擎抛错 / 目录异常 / worker 拒绝)⇒ **记**
|
|
24
|
+
* 例:`parseTemporalQueryPre` 返回 null(查询含无时间表达)是预期内 ⇒ 不记;
|
|
25
|
+
* 但它**抛错**是预期外 ⇒ 记。调用方须把"返回 null"与"抛错"分开。
|
|
26
|
+
*
|
|
27
|
+
* ── 元规则(本模块自身的 fail-soft)──────────────────────────
|
|
28
|
+
* **留痕失败绝不可导致二次失败**:`record()` 内部整体 try/catch 吞掉一切。
|
|
29
|
+
* 宁可丢掉一条留痕,也绝不能因为留痕而打断检索。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** 身份常量(枚举类常量须配断言兜底 —— 本仓纪律)。 */
|
|
33
|
+
export const DEGRADE_SCHEMA_V1 = 'degrade_v1'
|
|
34
|
+
|
|
35
|
+
/** 环形缓冲上限:防内存无界增长。 */
|
|
36
|
+
export const DEGRADE_CAP_V1 = 200
|
|
37
|
+
|
|
38
|
+
/** 单条 reason 截断长度:与既有 diag 同口径,且防长文本撑爆状态文件。 */
|
|
39
|
+
export const DEGRADE_REASON_MAX_V1 = 200
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 臂状态枚举(fail-closed 校验)。
|
|
43
|
+
* - `active` 正常工作
|
|
44
|
+
* - `no-input` 无输入(**合法状态**,如尚无证据事件 / 查询无时间表达)
|
|
45
|
+
* - `degraded` 已降级(**预期外**,值应能在 counts 里找到对应 kind)
|
|
46
|
+
* - `disabled` 被配置关闭
|
|
47
|
+
* - `unknown` 无法判定(**不得**当作正常,面板应显式呈现)
|
|
48
|
+
*/
|
|
49
|
+
export const ARM_STATES_V1 = Object.freeze(['active', 'no-input', 'degraded', 'disabled', 'unknown'])
|
|
50
|
+
|
|
51
|
+
/** 已知降级 kind(仅作文档/断言用,**不限制**调用方传入新 kind)。 */
|
|
52
|
+
export const DEGRADE_KINDS_V1 = Object.freeze([
|
|
53
|
+
'semantic-arm', // 语义臂择优失败 → 回退词法
|
|
54
|
+
'evidence-arm', // evidence 聚合失败 → importance 中性
|
|
55
|
+
'l0-sync', // L0 索引同步失败 → 索引陈旧
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 创建降级台账。
|
|
60
|
+
*
|
|
61
|
+
* @param {object} [opts]
|
|
62
|
+
* @param {number} [opts.cap] 环形缓冲上限
|
|
63
|
+
* @param {Function} [opts.now] 取时函数(注入便于测试确定性)
|
|
64
|
+
*/
|
|
65
|
+
export function createDegradeSinkPre(opts = {}) {
|
|
66
|
+
const cap = Number.isFinite(opts.cap) && opts.cap > 0 ? Math.floor(opts.cap) : DEGRADE_CAP_V1
|
|
67
|
+
const now = typeof opts.now === 'function' ? opts.now : () => Date.now()
|
|
68
|
+
|
|
69
|
+
/** @type {Map<string, number>} kind → 累计次数(不受环形淘汰影响) */
|
|
70
|
+
const counts = new Map()
|
|
71
|
+
/** @type {Array<{kind:string,reason:string,at:number}>} 最近条目(有界) */
|
|
72
|
+
let recent = []
|
|
73
|
+
/** 因超出 cap 而被淘汰的条数(保证"有界"这件事本身可见,不静默丢数据) */
|
|
74
|
+
let evicted = 0
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 记录一次降级。**只用于预期外失败**(预期内分支请勿调用,直接静默)。
|
|
78
|
+
* 内部整体 fail-soft:任何异常都被吞掉,调用方无需 try/catch。
|
|
79
|
+
*/
|
|
80
|
+
function record(kind, reason) {
|
|
81
|
+
try {
|
|
82
|
+
const k = String(kind == null ? 'unknown' : kind)
|
|
83
|
+
counts.set(k, (counts.get(k) || 0) + 1)
|
|
84
|
+
if (recent.length >= cap) { recent.shift(); evicted += 1 }
|
|
85
|
+
recent.push({
|
|
86
|
+
kind: k,
|
|
87
|
+
reason: String(reason == null ? '' : reason).slice(0, DEGRADE_REASON_MAX_V1),
|
|
88
|
+
at: now(),
|
|
89
|
+
})
|
|
90
|
+
} catch (_) { /* 元规则:留痕失败不得影响主流程 */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 读快照(不可变副本,防外部改内部状态)。 */
|
|
94
|
+
function snapshot() {
|
|
95
|
+
let out
|
|
96
|
+
try {
|
|
97
|
+
out = {
|
|
98
|
+
schemaVersion: DEGRADE_SCHEMA_V1,
|
|
99
|
+
updatedAt: new Date(now()).toISOString(),
|
|
100
|
+
counts: Object.fromEntries(counts),
|
|
101
|
+
recent: recent.map((r) => ({ ...r, at: new Date(r.at).toISOString() })),
|
|
102
|
+
evicted,
|
|
103
|
+
cap,
|
|
104
|
+
}
|
|
105
|
+
} catch (_) {
|
|
106
|
+
// 快照失败也要给出**结构性合法**的最小对象,不能让读取方拿到 undefined
|
|
107
|
+
out = { schemaVersion: DEGRADE_SCHEMA_V1, updatedAt: null, counts: {}, recent: [], evicted, cap }
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 某 kind 的累计次数(断言友好)。 */
|
|
113
|
+
function countOf(kind) { try { return counts.get(String(kind)) || 0 } catch (_) { return 0 } }
|
|
114
|
+
|
|
115
|
+
/** 是否发生过任何降级。 */
|
|
116
|
+
function isEmpty() { return counts.size === 0 }
|
|
117
|
+
|
|
118
|
+
function reset() { counts.clear(); recent = []; evicted = 0 }
|
|
119
|
+
|
|
120
|
+
return { record, snapshot, countOf, isEmpty, reset, _capForTest: cap }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 派生**臂健康快照**(E-3 落地)。
|
|
125
|
+
*
|
|
126
|
+
* 这不是"降级记录",而是**状态陈述** —— 目的是让「某条臂没在工作」可见,
|
|
127
|
+
* 而不必靠"每次 recall 都报错"来推断。例:`evidence: 'no-input'` 一眼可见。
|
|
128
|
+
*
|
|
129
|
+
* @param {Record<string, string>} states 臂名 → 状态(须属 ARM_STATES_V1;非法值归一为 'unknown')
|
|
130
|
+
*/
|
|
131
|
+
export function deriveArmsHealthPre(states) {
|
|
132
|
+
const out = {}
|
|
133
|
+
try {
|
|
134
|
+
for (const [arm, st] of Object.entries(states || {})) {
|
|
135
|
+
const s = String(st == null ? '' : st)
|
|
136
|
+
out[String(arm)] = ARM_STATES_V1.includes(s) ? s : 'unknown'
|
|
137
|
+
}
|
|
138
|
+
} catch (_) { /* fail-soft */ }
|
|
139
|
+
return out
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* R3-②(2026-09-18):把台账**落盘**为可查询状态文件。
|
|
144
|
+
*
|
|
145
|
+
* 形态选择:**读驱动写**(由 `debugInfo()` 调用),不引入定时器、不新增常驻任务。
|
|
146
|
+
* 理由:① 降级是低频事件,无需实时落盘;② 用户查看诊断时正是"想知道发生了什么"的时刻,
|
|
147
|
+
* 此刻把最新快照写到磁盘 —— 既满足"可查询",又不增加空闲期 IO。
|
|
148
|
+
*
|
|
149
|
+
* ★ 与 record/snapshot 同一条元规则:**落盘失败绝不可影响调用方**
|
|
150
|
+
* —— 全程 try/catch,返回布尔而非抛错(调用方无需自行兜底)。
|
|
151
|
+
*
|
|
152
|
+
* @param {object} p
|
|
153
|
+
* @param {string} p.file 目标 JSON 文件绝对路径
|
|
154
|
+
* @param {object} p.snapshot 已算好的快照(通常来自 sink.snapshot())
|
|
155
|
+
* @param {Function} [p.mkdirSync] 注入的 fs.mkdirSync(便于测试与解耦)
|
|
156
|
+
* @param {Function} [p.writeFileSync] 注入的 fs.writeFileSync
|
|
157
|
+
* @returns {boolean} 是否成功写入(失败返回 false,**不抛**)
|
|
158
|
+
*/
|
|
159
|
+
export function persistDegradeLedgerPre(p) {
|
|
160
|
+
try {
|
|
161
|
+
const { file, snapshot, mkdirSync, writeFileSync } = p || {}
|
|
162
|
+
if (!file || typeof file !== 'string') return false
|
|
163
|
+
if (typeof mkdirSync !== 'function' || typeof writeFileSync !== 'function') return false
|
|
164
|
+
const dir = file.replace(/[\\/][^\\/]*$/, '')
|
|
165
|
+
if (dir) mkdirSync(dir, { recursive: true })
|
|
166
|
+
writeFileSync(file, JSON.stringify(snapshot, null, 2), 'utf8')
|
|
167
|
+
return true
|
|
168
|
+
} catch (_) {
|
|
169
|
+
// 元规则:留痕层自身的持久化失败,绝不可打断 debugInfo / 检索主流程。
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
175
|
+
// R4(2026-09-18)· 配额测量闭环
|
|
176
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
177
|
+
//
|
|
178
|
+
// ── 要解决的问题(用户原话)──────────────────────────────────────────
|
|
179
|
+
// 「配额这个问题也困扰我很久。有的时候配额太少,效果完全没有,或者有些大条目可能就被过滤掉了,
|
|
180
|
+
// 一点用都没有;有的时候配额多了,我又怕浪费 token」
|
|
181
|
+
// 「确实得基于长期的观察,科学的(测量),不能拍脑子。」
|
|
182
|
+
//
|
|
183
|
+
// ── 为什么放在本模块(而不是新建一个文件)──────────────────────────────
|
|
184
|
+
// S10.4「不新建状态源」:配额观测与降级台账**同属"跨轮可查询的观测面"**,
|
|
185
|
+
// 只是两个不同的消费者。故复用同一模块、同一落盘文件(多一个 `quota` 键),
|
|
186
|
+
// **不新增文件、不新增配置键、不新增常驻任务**。
|
|
187
|
+
//
|
|
188
|
+
// ── 与降级台账的判据边界(务必不要混)──────────────────────────────────
|
|
189
|
+
// · `record(kind, reason)` = **预期外失败**(引擎抛错、目录异常…)—— 只记异常
|
|
190
|
+
// · `observe(meta)`(本函数)= **常规业务观测**(本轮各层进了多少、丢了多少)—— 每轮都记
|
|
191
|
+
// 把常规观测塞进 `record` 会**污染降级判据**("有没有降级"将永远为非空),
|
|
192
|
+
// 故两者**并列而不混用**:各自的 counts/recent 互不干扰。
|
|
193
|
+
|
|
194
|
+
/** 配额探针的身份常量(枚举类常量须配断言兜底 —— 本仓纪律)。 */
|
|
195
|
+
export const QUOTA_PROBE_SCHEMA_V1 = 'quota_probe_v1'
|
|
196
|
+
|
|
197
|
+
/** 采样环上限:与降级台账同口径(有界,防内存无界增长)。 */
|
|
198
|
+
export const QUOTA_PROBE_CAP_V1 = 200
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 配额判定结论枚举(fail-closed 校验)。
|
|
202
|
+
* - `under-quota` 某层被反复丢弃 ⇒ 配额偏小,该层内容进不来
|
|
203
|
+
* - `over-quota` 各层都不丢且远未用满 ⇒ 配额偏大,白花 token
|
|
204
|
+
* - `balanced` 既有丢弃但未持续、占用也合理
|
|
205
|
+
* - `insufficient-data` **样本不足,不猜**(这是默认值 —— 宁可说不知道)
|
|
206
|
+
*/
|
|
207
|
+
export const QUOTA_VERDICTS_V1 = Object.freeze(['under-quota', 'over-quota', 'balanced', 'insufficient-data'])
|
|
208
|
+
|
|
209
|
+
/** 判定阈值(集中声明,便于断言锁定与后续按观测调参)。 */
|
|
210
|
+
export const QUOTA_THRESHOLDS_V1 = Object.freeze({
|
|
211
|
+
/** 至少这么多轮采样才敢下结论(少于它一律 insufficient-data)。 */
|
|
212
|
+
minSamples: 8,
|
|
213
|
+
/** 某层"出现丢弃"的轮数占比 ≥ 此值 ⇒ under-quota。 */
|
|
214
|
+
dropRateForUnder: 0.5,
|
|
215
|
+
/** 各层都不丢时,token 占用率 < 此值 ⇒ over-quota(远未用满)。 */
|
|
216
|
+
usageForOver: 0.5,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 创建**配额探针**:有界收集每轮 `tier0Meta` 的配额相关切片。
|
|
221
|
+
*
|
|
222
|
+
* 与降级台账同一元规则:**观测失败绝不可影响主流程**(全程 try/catch,返回布尔不抛)。
|
|
223
|
+
* 只保留判据所需字段(不整份存 tier0Meta),隐私面与降级台账一致(无正文、无路径)。
|
|
224
|
+
*
|
|
225
|
+
* @param {object} [opts]
|
|
226
|
+
* @param {number} [opts.cap] 采样环上限
|
|
227
|
+
* @param {Function} [opts.now] 取时函数(注入便于测试确定性)
|
|
228
|
+
*/
|
|
229
|
+
export function createQuotaProbePre(opts = {}) {
|
|
230
|
+
const cap = Number.isFinite(opts.cap) && opts.cap > 0 ? Math.floor(opts.cap) : QUOTA_PROBE_CAP_V1
|
|
231
|
+
const now = typeof opts.now === 'function' ? opts.now : () => Date.now()
|
|
232
|
+
/** @type {Array<object>} 最近采样(有界) */
|
|
233
|
+
let samples = []
|
|
234
|
+
/** 因超上限被淘汰的条数("有界"这件事本身可见,不静默丢数据) */
|
|
235
|
+
let evicted = 0
|
|
236
|
+
|
|
237
|
+
function observe(meta) {
|
|
238
|
+
try {
|
|
239
|
+
if (!meta || typeof meta !== 'object') return false
|
|
240
|
+
const perLayer = {}
|
|
241
|
+
const src = meta.perLayer
|
|
242
|
+
if (src && typeof src === 'object') {
|
|
243
|
+
for (const [layer, m] of Object.entries(src)) {
|
|
244
|
+
if (!m || typeof m !== 'object') continue
|
|
245
|
+
perLayer[String(layer)] = {
|
|
246
|
+
candidates: Number(m.candidates) || 0,
|
|
247
|
+
picked: Number(m.picked) || 0,
|
|
248
|
+
dropped: Number(m.dropped) || 0,
|
|
249
|
+
tokens: Number(m.tokens) || 0,
|
|
250
|
+
cap: m.cap == null ? null : Number(m.cap),
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (samples.length >= cap) { samples.shift(); evicted += 1 }
|
|
255
|
+
samples.push({
|
|
256
|
+
at: now(),
|
|
257
|
+
tokens: Number(meta.tokens) || 0,
|
|
258
|
+
maxTokens: Number(meta.maxTokens) || 0,
|
|
259
|
+
items: Number(meta.items) || 0,
|
|
260
|
+
candidates: Number(meta.candidates) || 0,
|
|
261
|
+
dropped: Number(meta.dropped) || 0,
|
|
262
|
+
perLayer,
|
|
263
|
+
})
|
|
264
|
+
return true
|
|
265
|
+
} catch (_) { return false }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function snapshot() {
|
|
269
|
+
let out
|
|
270
|
+
try {
|
|
271
|
+
out = {
|
|
272
|
+
schemaVersion: QUOTA_PROBE_SCHEMA_V1,
|
|
273
|
+
updatedAt: new Date(now()).toISOString(),
|
|
274
|
+
samples: samples.map((s) => ({ ...s, at: new Date(s.at).toISOString(), perLayer: { ...s.perLayer } })),
|
|
275
|
+
evicted,
|
|
276
|
+
cap,
|
|
277
|
+
}
|
|
278
|
+
} catch (_) {
|
|
279
|
+
// 快照失败也要给出**结构性合法**的最小对象,不能让读取方拿到 undefined
|
|
280
|
+
out = { schemaVersion: QUOTA_PROBE_SCHEMA_V1, updatedAt: null, samples: [], evicted, cap }
|
|
281
|
+
}
|
|
282
|
+
return out
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function reset() { samples = []; evicted = 0 }
|
|
286
|
+
|
|
287
|
+
return { observe, snapshot, reset, _capForTest: cap }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 从配额探针快照**推导判定结论**(R4 · 纯函数、零 IO、永不抛)。
|
|
292
|
+
*
|
|
293
|
+
* 这是「科学测量」的判据落点 —— 用户要求**不能拍脑袋**,所以:
|
|
294
|
+
* · 样本不足 ⇒ 一律 `insufficient-data`(**不猜**,这是默认值);
|
|
295
|
+
* · 某层**持续**被丢 ⇒ `under-quota`(该层内容长期进不来 ⇒ 配额偏小);
|
|
296
|
+
* · 各层都不丢、且 token **远未用满** ⇒ `over-quota`(配额偏大、白花 token);
|
|
297
|
+
* · 其余 ⇒ `balanced`。
|
|
298
|
+
*
|
|
299
|
+
* ★ 判据纪律(本仓):**宁可漏判,不可误伤** —— 阈值取保守值,
|
|
300
|
+
* 且把"凭什么这么判"的原始数据(dropRate/perLayer/usage)一并返回,供人复核。
|
|
301
|
+
*
|
|
302
|
+
* @param {object} snap `createQuotaProbePre().snapshot()` 的产物
|
|
303
|
+
* @param {object} [opts] 覆盖阈值(默认取 QUOTA_THRESHOLDS_V1)
|
|
304
|
+
* @returns {{version:string, verdict:string, samples:number, dropRate:number,
|
|
305
|
+
* usage:number, perLayer:object, reasons:string[]}}
|
|
306
|
+
*/
|
|
307
|
+
export function deriveQuotaVerdictPre(snap, opts = {}) {
|
|
308
|
+
const th = { ...QUOTA_THRESHOLDS_V1, ...(opts || {}) }
|
|
309
|
+
const base = {
|
|
310
|
+
version: 'quota_verdict_v1',
|
|
311
|
+
verdict: 'insufficient-data',
|
|
312
|
+
samples: 0,
|
|
313
|
+
dropRate: 0,
|
|
314
|
+
usage: 0,
|
|
315
|
+
perLayer: {},
|
|
316
|
+
reasons: [],
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const list = snap && Array.isArray(snap.samples) ? snap.samples : []
|
|
320
|
+
base.samples = list.length
|
|
321
|
+
if (!list.length) { base.reasons.push('无采样'); return base }
|
|
322
|
+
|
|
323
|
+
// 逐层聚合:出现丢弃的轮数 / token 占用 / 候选与命中
|
|
324
|
+
const agg = {}
|
|
325
|
+
let tokSum = 0, maxSum = 0
|
|
326
|
+
for (const s of list) {
|
|
327
|
+
tokSum += Number(s.tokens) || 0
|
|
328
|
+
maxSum += Number(s.maxTokens) || 0
|
|
329
|
+
const pl = s && s.perLayer && typeof s.perLayer === 'object' ? s.perLayer : {}
|
|
330
|
+
for (const [layer, m] of Object.entries(pl)) {
|
|
331
|
+
if (!agg[layer]) agg[layer] = { rounds: 0, dropRounds: 0, candidates: 0, picked: 0, dropped: 0, tokens: 0 }
|
|
332
|
+
const a = agg[layer]
|
|
333
|
+
a.rounds += 1
|
|
334
|
+
if ((Number(m.dropped) || 0) > 0) a.dropRounds += 1
|
|
335
|
+
a.candidates += Number(m.candidates) || 0
|
|
336
|
+
a.picked += Number(m.picked) || 0
|
|
337
|
+
a.dropped += Number(m.dropped) || 0
|
|
338
|
+
a.tokens += Number(m.tokens) || 0
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
for (const [layer, a] of Object.entries(agg)) {
|
|
342
|
+
base.perLayer[layer] = {
|
|
343
|
+
rounds: a.rounds,
|
|
344
|
+
dropRounds: a.dropRounds,
|
|
345
|
+
dropRate: a.rounds ? Number((a.dropRounds / a.rounds).toFixed(3)) : 0,
|
|
346
|
+
candidates: a.candidates,
|
|
347
|
+
picked: a.picked,
|
|
348
|
+
dropped: a.dropped,
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
base.usage = maxSum > 0 ? Number((tokSum / maxSum).toFixed(3)) : 0
|
|
352
|
+
|
|
353
|
+
// ★ 样本不足 ⇒ 不猜(用户要的是"基于长期观察",不是几轮就下结论)
|
|
354
|
+
if (list.length < th.minSamples) {
|
|
355
|
+
base.reasons.push('样本不足(' + list.length + '/' + th.minSamples + '),不下结论')
|
|
356
|
+
return base
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 某层长期被丢 ⇒ 该层配额偏小
|
|
360
|
+
const underLayers = Object.entries(base.perLayer)
|
|
361
|
+
.filter(([, a]) => a.rounds > 0 && a.dropRate >= th.dropRateForUnder)
|
|
362
|
+
.map(([l]) => l)
|
|
363
|
+
if (underLayers.length) {
|
|
364
|
+
base.verdict = 'under-quota'
|
|
365
|
+
base.reasons.push('层 ' + underLayers.join('/') + ' 持续被丢(dropRate ≥ ' + th.dropRateForUnder + ')⇒ 配额偏小')
|
|
366
|
+
return base
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 各层都不丢 + token 远未用满 ⇒ 配额偏大(浪费)
|
|
370
|
+
if (base.usage < th.usageForOver) {
|
|
371
|
+
base.verdict = 'over-quota'
|
|
372
|
+
base.reasons.push('无任何层被丢,且 token 占用率 ' + base.usage + ' < ' + th.usageForOver + ' ⇒ 配额偏大、白花 token')
|
|
373
|
+
return base
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
base.verdict = 'balanced'
|
|
377
|
+
base.reasons.push('有丢弃但未持续,且占用率 ' + base.usage + ' 合理')
|
|
378
|
+
return base
|
|
379
|
+
} catch (_) {
|
|
380
|
+
// 判定失败也要给出**结构性合法**的对象(verdict 保持 insufficient-data,不猜)
|
|
381
|
+
base.reasons.push('判定异常,按样本不足处理')
|
|
382
|
+
return base
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
package/lib/dsh-home.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-home.js —— **DSH_HOME 的唯一解析口径**(上游 issue #86-3 修复)。
|
|
3
|
+
*
|
|
4
|
+
* ## 背景(#86-3;已在 pre 线实跑核验)
|
|
5
|
+
*
|
|
6
|
+
* 修复前,全仓有 **7 处独立解析** `process.env.DSH_HOME`,口径互不相同:
|
|
7
|
+
*
|
|
8
|
+
* | 位置 | 环境变量缺失时的回落 |
|
|
9
|
+
* |---|---|
|
|
10
|
+
* | index.js:808(`dshHome()`) | `path.join(homedir(), '.dsh')` |
|
|
11
|
+
* | index.js:9256(模型根) | `path.join(homedir(), '.dsh')` |
|
|
12
|
+
* | index.js:9613(python-setup) | `path.join(homedir(), '.dsh')`,失败退 `homedir()` |
|
|
13
|
+
* | index.js:9623(python-sidecar) | `path.join(homedir(), '.dsh')`,失败退 **空串** |
|
|
14
|
+
* | semantic-js.js:73/176 | `path.join(homedir(), '.dsh')` |
|
|
15
|
+
* | activation-host.js:72 | 退 `homedir()` 再拼 `.dsh`,全失败退 **'.'** |
|
|
16
|
+
* | context-host.js:40 | 退 **`USERPROFILE || HOME`** 再拼(**前缀不同**) |
|
|
17
|
+
* | shadow-host.js:129 | 同 activation(但注释说漏拼过 `.dsh`) |
|
|
18
|
+
*
|
|
19
|
+
* ⇒ 后果:**同一台机器上,不同子系统可能把数据写到不同根目录**。
|
|
20
|
+
* 最典型的是 `context-host` 用 `USERPROFILE` 作基准,而其余用 `os.homedir()`——
|
|
21
|
+
* 两者在 Windows 上通常一致,但在容器/CI/被改过环境变量的进程里会分叉。
|
|
22
|
+
*
|
|
23
|
+
* ## 本模块的职责
|
|
24
|
+
*
|
|
25
|
+
* 提供**一个**函数 `resolveDshHomePre(override)`,所有站点都调它。
|
|
26
|
+
* 解析顺序(逐级回落,**绝不抛**):
|
|
27
|
+
*
|
|
28
|
+
* 1. `override`(显式传入,最高优先 —— 给测试注入与 engine 级配置留口)
|
|
29
|
+
* 2. `process.env.DSH_HOME`(trim 后非空)
|
|
30
|
+
* 3. `os.homedir()` + `/.dsh`
|
|
31
|
+
* 4. 环境变量 `USERPROFILE || HOME` + `/.dsh`(**保留 context-host 原有的兜底能力**,
|
|
32
|
+
* 只是把它从「基准」降级为「最后兜底」,从而与其余站点统一)
|
|
33
|
+
* 5. 全失败 ⇒ `'.dsh'`(相对路径,保证**永不返回空串**)
|
|
34
|
+
*
|
|
35
|
+
* ## 为什么把 `homedir()` 放在 `USERPROFILE` 之前
|
|
36
|
+
*
|
|
37
|
+
* `os.homedir()` 在 Windows 上**本身就是** `USERPROFILE`(Node 内部优先读它,
|
|
38
|
+
* 读不到才退 `HOMEDRIVE+HOMEPATH`)⇒ 两者绝大多数情况等价,
|
|
39
|
+
* 但 `homedir()` 还会正确处理 `HOME` 覆盖与权限异常 ⇒ **以它为准更稳**。
|
|
40
|
+
* 保留 `USERPROFILE||HOME` 仅作 `homedir()` 抛异常时的兜底。
|
|
41
|
+
*
|
|
42
|
+
* ## 纪律
|
|
43
|
+
* - 零运行时依赖(只 `node:os` / `node:path`)。
|
|
44
|
+
* - **永不抛、永不返回空串**(调用方大量直接 `path.join(dshHome(), ...)`)。
|
|
45
|
+
* - 只读环境变量,**不缓存**(测试会中途改 `process.env.DSH_HOME`)。
|
|
46
|
+
* - CRLF、无 BOM。
|
|
47
|
+
*/
|
|
48
|
+
import os from 'node:os'
|
|
49
|
+
import path from 'node:path'
|
|
50
|
+
|
|
51
|
+
/** 环境变量名(集中一处,便于将来改名)。 */
|
|
52
|
+
export const DSH_HOME_ENV_V1 = 'DSH_HOME'
|
|
53
|
+
|
|
54
|
+
/** 默认子目录名。 */
|
|
55
|
+
export const DSH_HOME_DIRNAME_V1 = '.dsh'
|
|
56
|
+
|
|
57
|
+
/** 全失败时的最后兜底(相对路径,保证返回非空)。 */
|
|
58
|
+
export const DSH_HOME_FALLBACK_V1 = '.dsh'
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 取 home 基准目录(用于拼 `.dsh`)。**永不抛**。
|
|
62
|
+
* @returns {string} 非空字符串,或空串(表示取不到基准)
|
|
63
|
+
*/
|
|
64
|
+
function homeBasePre() {
|
|
65
|
+
// ① os.homedir() —— 首选:Windows 上等价于 USERPROFILE,且能处理 HOME 覆盖
|
|
66
|
+
try {
|
|
67
|
+
const h = os.homedir()
|
|
68
|
+
if (h && String(h).trim()) return String(h).trim()
|
|
69
|
+
} catch (_) {
|
|
70
|
+
// 落到 ②
|
|
71
|
+
}
|
|
72
|
+
// ② USERPROFILE / HOME —— 兼容 homedir() 抛异常的极端环境
|
|
73
|
+
try {
|
|
74
|
+
const e = process.env.USERPROFILE || process.env.HOME || ''
|
|
75
|
+
if (e && String(e).trim()) return String(e).trim()
|
|
76
|
+
} catch (_) {}
|
|
77
|
+
return ''
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* **唯一入口**:解析 DSH_HOME。
|
|
82
|
+
*
|
|
83
|
+
* @param {string} [override] 显式覆盖(测试注入 / engine 级配置);空串视为未提供。
|
|
84
|
+
* @returns {string} 非空路径字符串(**永不抛、永不返回空串**)
|
|
85
|
+
*/
|
|
86
|
+
export function resolveDshHomePre(override) {
|
|
87
|
+
// ① 显式覆盖优先
|
|
88
|
+
try {
|
|
89
|
+
if (override != null && String(override).trim()) return String(override).trim()
|
|
90
|
+
} catch (_) {}
|
|
91
|
+
// ② 环境变量
|
|
92
|
+
try {
|
|
93
|
+
const env = process.env[DSH_HOME_ENV_V1]
|
|
94
|
+
if (env && String(env).trim()) return String(env).trim()
|
|
95
|
+
} catch (_) {}
|
|
96
|
+
// ③ / ④ 基准目录 + .dsh
|
|
97
|
+
const base = homeBasePre()
|
|
98
|
+
if (base) {
|
|
99
|
+
try {
|
|
100
|
+
return path.join(base, DSH_HOME_DIRNAME_V1)
|
|
101
|
+
} catch (_) {}
|
|
102
|
+
}
|
|
103
|
+
// ⑤ 最后兜底
|
|
104
|
+
return DSH_HOME_FALLBACK_V1
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* engine 级便捷包装:优先用 `engine.__dshHomeOverride`,其次环境变量,最后默认。
|
|
109
|
+
*
|
|
110
|
+
* 之所以要这一层:`activation-host` / `context-host` / `shadow-host` 都是
|
|
111
|
+
* 「engine + 可选 __homedirFn」的形态,统一改调本函数可让三者的口径完全一致,
|
|
112
|
+
* 同时**保留** `__homedirFn` 这个既有测试注入点(不再各自手写回落链)。
|
|
113
|
+
*/
|
|
114
|
+
export function resolveDshHomeForEnginePre(engine) {
|
|
115
|
+
const e = engine || {}
|
|
116
|
+
// ★★ 优先级必须与**原实现**一致:`env` 优先于 `__homedirFn`。
|
|
117
|
+
// 原写法是 `const env = process.env.DSH_HOME; if (env.trim()) return env.trim();
|
|
118
|
+
// const base = engine.__homedirFn ? ... : ...` ⇒ env 先判。
|
|
119
|
+
// ⚠️ 2026-09-20 首次实现把 __homedirFn 提到 env 之前,导致用 `process.env.DSH_HOME`
|
|
120
|
+
// 注入的测试(如 smoke-test-m53)被真实 homedir 覆盖,证据写到了**真实用户目录**
|
|
121
|
+
// (症状:C4/C5/C6 evidence 落盘数为 0,离真因很远)。
|
|
122
|
+
// ① 显式 engine 级覆盖(新增能力,原实现没有,放最前不影响兼容)
|
|
123
|
+
try {
|
|
124
|
+
if (e.__dshHomeOverride != null && String(e.__dshHomeOverride).trim()) {
|
|
125
|
+
return String(e.__dshHomeOverride).trim()
|
|
126
|
+
}
|
|
127
|
+
} catch (_) {}
|
|
128
|
+
// ② 环境变量(与原实现同优先级)
|
|
129
|
+
try {
|
|
130
|
+
const env = process.env[DSH_HOME_ENV_V1]
|
|
131
|
+
if (env && String(env).trim()) return String(env).trim()
|
|
132
|
+
} catch (_) {}
|
|
133
|
+
// ③ 既有注入点 __homedirFn:返回的是「home 基准目录」,仍需拼 .dsh
|
|
134
|
+
try {
|
|
135
|
+
if (typeof e.__homedirFn === 'function') {
|
|
136
|
+
const base = e.__homedirFn()
|
|
137
|
+
if (base && String(base).trim()) return path.join(String(base).trim(), DSH_HOME_DIRNAME_V1)
|
|
138
|
+
}
|
|
139
|
+
} catch (_) {}
|
|
140
|
+
// ④ 默认链(homedir → USERPROFILE/HOME → '.dsh')
|
|
141
|
+
return resolveDshHomePre()
|
|
142
|
+
}
|
|
143
|
+
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 引擎身份(engine_identity_v1)—— P2「引擎隔离」的地基(V2-P2 卡 / 评审 §3.5)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它(权威依据):
|
|
5
|
+
* V2-P2 卡明确指出:单个 `PROVIDER_ID_INT8`(`python-setup.js:55`)**不足以**标识
|
|
6
|
+
* 模型内容 / tokenizer / 预处理;两层缓存若只凭 `chunkId` 或**裸输入哈希**读取,
|
|
7
|
+
* **仍会串用两套向量**(e5 384 维与 bge-m3 1024 维混进同一次排序 = T2-9 判失败)。
|
|
8
|
+
* 因此身份必须够宽,至少覆盖:
|
|
9
|
+
* 模型与权重摘要 · tokenizer 版本 · 精度格式 · 维度 · 池化 · 归一化 · 输入处理版本。
|
|
10
|
+
*
|
|
11
|
+
* 本模块只做三件事(纯函数、零 IO、零依赖,便于宿主与测试共用):
|
|
12
|
+
* 1) `computeEngineIdentityPre(desc)` —— 宽身份描述 → 稳定身份串 `engid_<32hex>`
|
|
13
|
+
* (canonical 排序 JSON + sha256,同输入逐字节同输出;任一字段变化 → 身份必变)。
|
|
14
|
+
* 2) `aliasKeyPre(engineIdentity, chunkId)` —— 两级引用的**第一级**:
|
|
15
|
+
* `engineIdentity + chunkId → alias`。记录变化导致未改块重新编号(chunkId 变)时,
|
|
16
|
+
* 只更新 alias,**不重新编码相同输入**(不改现有 chunkId 公式)。
|
|
17
|
+
* 3) `vectorKeyPre(engineIdentity, exactEncoderInput)` —— **第二级**:
|
|
18
|
+
* `alias → hash(exactEncoderInput) → 向量对象`。相同输入且相同引擎 ⇒ 同一向量对象,
|
|
19
|
+
* 可直接复用;引擎不同 ⇒ vectorKey 必不同 ⇒ 不可能跨引擎串用(T2-9 的零成本实现)。
|
|
20
|
+
*
|
|
21
|
+
* 成本口径(评审 §3.5 采纳的措辞,不得写「成本为零」):
|
|
22
|
+
* **身份比较开销小;全量重建成本由本机承担。**
|
|
23
|
+
*
|
|
24
|
+
* UTF-8 无 BOM。
|
|
25
|
+
*/
|
|
26
|
+
import { createHash } from 'node:crypto'
|
|
27
|
+
|
|
28
|
+
export const ENGINE_IDENTITY_VERSION = 'engine_identity_v1'
|
|
29
|
+
|
|
30
|
+
/** 身份串前缀:有意与 corpus 的 `idx_` / L0 的 `l0idx_` 区分,避免三套版本语义混淆。 */
|
|
31
|
+
export const ENGINE_IDENTITY_PREFIX = 'engid_'
|
|
32
|
+
|
|
33
|
+
/** 身份必须覆盖的字段(缺一即身份不完整;缺失字段按空串参与哈希,不给"省略=通过"的口子)。 */
|
|
34
|
+
export const ENGINE_IDENTITY_FIELDS = Object.freeze([
|
|
35
|
+
'provider', // 通道/提供方标识(如 bge-m3-onnx-int8-v1 / js-e5-small-q8-v1)
|
|
36
|
+
'model', // 模型名(如 bge-m3 / multilingual-e5-small)
|
|
37
|
+
'weightsDigest', // 权重摘要(sha256 或廉价指纹;见 weightsDigestOfFilePre 的口径说明)
|
|
38
|
+
'tokenizer', // tokenizer 版本/来源(如 xenova-bge-m3-fast / e5-small-tokenizer-v1)
|
|
39
|
+
'precision', // 精度格式(int8 / q8 / fp32)
|
|
40
|
+
'dim', // 向量维度(384 / 1024)
|
|
41
|
+
'pooling', // 池化方式(cls / mean)
|
|
42
|
+
'normalized', // 是否已归一化(true/false)
|
|
43
|
+
'inputVersion', // 输入处理版本(前缀、截断、清洗等预处理口径)
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
const sha256Hex = (s) => createHash('sha256').update(String(s == null ? '' : s), 'utf8').digest('hex')
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 规范化身份描述:只保留白名单字段,值统一为字符串(数字/布尔 canonical 化),
|
|
50
|
+
* 键按固定顺序(ENGINE_IDENTITY_FIELDS,而非字母序)序列化 —— 顺序固定 = 哈希稳定。
|
|
51
|
+
*/
|
|
52
|
+
export function canonicalEngineDescPre(desc) {
|
|
53
|
+
const d = desc && typeof desc === 'object' ? desc : {}
|
|
54
|
+
const out = {}
|
|
55
|
+
for (const k of ENGINE_IDENTITY_FIELDS) {
|
|
56
|
+
const v = d[k]
|
|
57
|
+
if (v === null || v === undefined) { out[k] = ''; continue }
|
|
58
|
+
if (typeof v === 'boolean') { out[k] = v ? 'true' : 'false'; continue }
|
|
59
|
+
if (typeof v === 'number') { out[k] = Number.isFinite(v) ? String(v) : ''; continue }
|
|
60
|
+
out[k] = String(v)
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 宽身份计算:canonical 描述 → `engid_<first32hex(sha256)>`。
|
|
67
|
+
* 任一字段(含维度、精度、tokenizer、输入版本)变化 → 身份必变;同输入确定性。
|
|
68
|
+
*/
|
|
69
|
+
export function computeEngineIdentityPre(desc) {
|
|
70
|
+
const canon = canonicalEngineDescPre(desc)
|
|
71
|
+
return ENGINE_IDENTITY_PREFIX + sha256Hex(JSON.stringify(canon)).slice(0, 32)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 身份合法性:必须是本模块产出的前缀形态(防把任意字符串当身份塞进缓存)。 */
|
|
75
|
+
export function isEngineIdentityPre(x) {
|
|
76
|
+
return typeof x === 'string' && /^engid_[0-9a-f]{32}$/.test(x)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 两个身份是否同一引擎。非法的身份一律视为不匹配(fail closed:宁可重建,不冒串用风险)。 */
|
|
80
|
+
export function engineIdentityMatchesPre(a, b) {
|
|
81
|
+
if (!isEngineIdentityPre(a) || !isEngineIdentityPre(b)) return false
|
|
82
|
+
return a === b
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 第一级引用:`engineIdentity + chunkId → aliasKey`。
|
|
87
|
+
* 用途:记录内容变化 → memoryId/recordDigest 变化 → chunkId 变;此键随引擎与 chunkId 走,
|
|
88
|
+
* 是"记录 → 向量"的可更新指针(记录重编号时只改这一层)。
|
|
89
|
+
*/
|
|
90
|
+
export function aliasKeyPre(engineIdentity, chunkId) {
|
|
91
|
+
return String(engineIdentity || '') + '|' + String(chunkId == null ? '' : chunkId)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 第二级引用:`engineIdentity + exactEncoderInput → vectorKey`。
|
|
96
|
+
* exactEncoderInput = **真正送进编码器的文本**(含引擎内部前缀之后的形态;调用方负责给出
|
|
97
|
+
* 与编码器输入一致的字符串)。相同引擎 + 相同输入 ⇒ 相同 vectorKey ⇒ 复用向量对象。
|
|
98
|
+
* 该键不含 chunkId,因此**块重新编号不会导致重复编码**(T2-2 的核心)。
|
|
99
|
+
*/
|
|
100
|
+
export function vectorKeyPre(engineIdentity, exactEncoderInput) {
|
|
101
|
+
return String(engineIdentity || '') + '|' + sha256Hex(exactEncoderInput)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 权重摘要的**廉价指纹**(性能口径,必须如实标注):
|
|
106
|
+
* 对 2GB 级 onnx 做全量 sha256 代价过高,这里用 `size:mtimeMs:文件名` 的 sha256 作为
|
|
107
|
+
* **权重变更指纹**(同一份权重稳定、文件被替换/重下必变)。
|
|
108
|
+
* 说明:这是**指纹(fingerprint)而非内容摘要(digest)**;要真摘要需在切换时一次性计算
|
|
109
|
+
* 并落盘缓存(P4 若需要再补),本阶段不引入 2GB 级读盘开销。
|
|
110
|
+
*/
|
|
111
|
+
export function weightsDigestOfFilePre(stat, name) {
|
|
112
|
+
const size = Number(stat && stat.size) || 0
|
|
113
|
+
const mtime = Number(stat && (stat.mtimeMs != null ? stat.mtimeMs : stat.mtime)) || 0
|
|
114
|
+
return 'fp_' + sha256Hex(size + ':' + mtime + ':' + String(name == null ? '' : name)).slice(0, 32)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 端侧 JS 引擎(e5-small q8,384 维已归一化)的规范身份描述。 */
|
|
118
|
+
export const JS_E5_IDENTITY_DESC_V1 = Object.freeze({
|
|
119
|
+
provider: 'js-e5-small-q8-v1',
|
|
120
|
+
model: 'multilingual-e5-small',
|
|
121
|
+
weightsDigest: 'manifest-e5-small-q8-v1', // 端侧资产清单版本;模型文件摘要由下载清单锁定
|
|
122
|
+
tokenizer: 'e5-small-tokenizer-v1',
|
|
123
|
+
precision: 'q8',
|
|
124
|
+
dim: 384,
|
|
125
|
+
pooling: 'mean',
|
|
126
|
+
normalized: true,
|
|
127
|
+
inputVersion: 'e5-passage-prefix-v1', // 引擎内部加 `passage: ` 前缀(见 semantic-js.js:351 注释)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
/** Python 引擎(bge-m3 onnx int8,1024 维)的身份描述构造器(weightsDigest 由调用方给指纹)。 */
|
|
131
|
+
export function pyBgeM3IdentityDescPre(weightsDigest) {
|
|
132
|
+
return {
|
|
133
|
+
provider: 'bge-m3-onnx-int8-v1',
|
|
134
|
+
model: 'bge-m3',
|
|
135
|
+
weightsDigest: weightsDigest || '',
|
|
136
|
+
tokenizer: 'xenova-bge-m3-fast',
|
|
137
|
+
precision: 'int8',
|
|
138
|
+
dim: 1024,
|
|
139
|
+
pooling: 'cls',
|
|
140
|
+
normalized: true,
|
|
141
|
+
inputVersion: 'bge-m3-raw-v1',
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 供诊断/测试的最小投影(不泄内容:只有身份串与字段读数)。 */
|
|
146
|
+
export function describeEngineIdentityPre(desc) {
|
|
147
|
+
const canon = canonicalEngineDescPre(desc)
|
|
148
|
+
return { identity: computeEngineIdentityPre(desc), fields: canon, version: ENGINE_IDENTITY_VERSION }
|
|
149
|
+
}
|