@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.28

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.
Files changed (64) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +47 -3
  4. package/dist/capabilities/anything.js +26 -0
  5. package/dist/capabilities/doctor.js +11 -4
  6. package/dist/capabilities/ground-transfer.js +168 -1
  7. package/dist/capabilities/provider-detail-sanitize.js +64 -0
  8. package/dist/capabilities/route-provider-conformance.js +341 -0
  9. package/dist/scripts/anything-path-measure-tests.js +133 -0
  10. package/dist/scripts/anything-path-measure.js +150 -0
  11. package/dist/scripts/anything-tests.js +136 -2
  12. package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
  13. package/dist/scripts/bootstrap-tests.js +71 -1
  14. package/dist/scripts/city-scenario-tier-tests.js +234 -0
  15. package/dist/scripts/doctor-tests.js +37 -1
  16. package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
  17. package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
  18. package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
  19. package/dist/scripts/dsh-llm-tests.js +437 -0
  20. package/dist/scripts/g5-guard-tests.js +20 -1
  21. package/dist/scripts/g5-guard.js +5 -1
  22. package/dist/scripts/m3-cohort-tests.js +714 -0
  23. package/dist/scripts/m3-cohort.js +310 -0
  24. package/dist/scripts/nightly-evidence-tests.js +44 -2
  25. package/dist/scripts/nightly-evidence.js +21 -4
  26. package/dist/scripts/outcome-projection-tests.js +427 -0
  27. package/dist/scripts/persona-sim-tests.js +603 -0
  28. package/dist/scripts/persona-sim.js +1081 -0
  29. package/dist/scripts/replay-async.js +2 -0
  30. package/dist/scripts/replay.js +2 -0
  31. package/dist/scripts/route-provider-conformance-tests.js +391 -0
  32. package/dist/scripts/session-zone-crash.js +49 -0
  33. package/dist/scripts/session-zone-ledger-tests.js +432 -0
  34. package/dist/scripts/session-zone-observation-tests.js +492 -0
  35. package/dist/scripts/session-zone-observe.js +135 -0
  36. package/dist/scripts/session-zone-wiring-tests.js +566 -0
  37. package/dist/scripts/session-zones-tests.js +17 -1
  38. package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
  39. package/dist/scripts/state-cli.js +16 -0
  40. package/dist/scripts/time-eval-tests.js +2 -0
  41. package/dist/scripts/unified-tests.js +37 -1
  42. package/dist/scripts/z3-race-tests.js +6 -1
  43. package/dist/src/city-scenario-tier.js +378 -0
  44. package/dist/src/dsh-llm.js +84 -14
  45. package/dist/src/index.js +160 -1
  46. package/dist/src/loop.js +16 -5
  47. package/dist/src/m3-cohort.js +1577 -0
  48. package/dist/src/outcome-projection.js +746 -0
  49. package/dist/src/session-zone-ledger.js +312 -0
  50. package/dist/src/session-zone-observation.js +354 -0
  51. package/dist/src/session-zone-wiring.js +658 -0
  52. package/dist/src/session-zones.js +7 -4
  53. package/dist/src/state-ledger.js +293 -40
  54. package/dist/src/unified.js +69 -5
  55. package/extension/manifest.json +2 -2
  56. package/package.json +1 -1
  57. package/ts/capabilities/anything.ts +27 -0
  58. package/ts/scripts/state-cli.ts +18 -0
  59. package/ts/src/contracts.ts +4 -0
  60. package/ts/src/dsh-llm.ts +111 -9
  61. package/ts/src/index.ts +175 -2
  62. package/ts/src/loop.ts +15 -5
  63. package/ts/src/state-ledger.ts +294 -40
  64. package/ts/src/unified.ts +91 -18
@@ -0,0 +1,312 @@
1
+ /**
2
+ * 会话双区记忆的账本落点(P4-2,docs/design/session-dual-zone-memory-design.md §2):
3
+ * 把 P4-1 纯核(session-zones.ts)的六个边界事件落到**既有** events 表上,零新表、
4
+ * 零 schema 迁移、对内核钉死的 state-ledger.ts 零改动——只走它的公开面
5
+ * (`db.transaction` / `readEvents` / `insertEvent` / `forgetSubject`)。
6
+ *
7
+ * - 写路径 = 单事务{fold 读当前 revs;守门闸;INSERT}:守门在事务内,拒绝即回滚,
8
+ * 账本无痕(与 motivation/wish 红线同纪律)。批量写共用一个事务 → 全有或全无。
9
+ * - 幂等键 = P4-1 的 `zoneIdemKey`(物理化为 events 表的 UNIQUE 部分索引):同一
10
+ * rev 的重放在守门层返回 appended:false,万一绕过守门也会在 UNIQUE 处变成物理
11
+ * no-op(insertEvent 返回 null)——两道都保证「同 rev 重放零新行」。
12
+ * - 读路径 = `readEvents(kind)` ×6 + P4-1 的确定性 fold(`readTrips`/
13
+ * `projectUtilityNow` 同款):无持久投影(P4 v1 单用户量级下读时 fold 够用)。
14
+ * 过期是读时视图,不是事件——墙钟动作不进 append-only 账本。
15
+ * - 删除 = 既有 `forgetSubject`(物理硬删 + 审计一行;红线 6「可删除」);
16
+ * 会话级遗忘把该会话全部主体放进**一次** forgetSubject 调用 → 仍只留一行审计。
17
+ * - 导出 = 从 fold 派生的两个视图(`hot-context.jsonl` / `notebook.json`),
18
+ * state-cli export 单向写出;**视图永不是写路径**(导出零新事件)。
19
+ *
20
+ * 边界:本模块不接会话捕获缝/persona 读回(P4-3),不做观测与指标(P4-4);
21
+ * 日志类事件在 state-ledger 的 `foldEvent` default 分支无投影,与
22
+ * `trip.logged`/`memory_utility.event` 完全同形。
23
+ */
24
+ import { ZONE_EVENT_KINDS, captureHotNote, dropHotNote, dropNotebookEntry, foldZoneEvents, makeHotNoteId, promoteHotNote, readNotebook, reviseHotNote, reviseNotebookEntry, zoneIdemKey, } from './session-zones.js';
25
+ /** 工作区三事件(forget 的 hot 主体 kinds) */
26
+ export const HOT_ZONE_EVENT_KINDS = [
27
+ 'hotctx.note.captured',
28
+ 'hotctx.note.revised',
29
+ 'hotctx.note.dropped',
30
+ ];
31
+ /** 笔记本三事件(forget 的 durable 主体 kinds) */
32
+ export const NOTEBOOK_ZONE_EVENT_KINDS = [
33
+ 'notebook.entry.promoted',
34
+ 'notebook.entry.revised',
35
+ 'notebook.entry.dropped',
36
+ ];
37
+ /** 单 kind 读上界:触顶即 fail-closed(截断的日志 fold 出来的 rev 是错的,不许写) */
38
+ export const ZONE_LOG_READ_LIMIT = 20_000;
39
+ /** 默认 actor(P4-3 的工具面会传各自的具名 actor) */
40
+ export const ZONE_DEFAULT_ACTOR = 'tool:gotry_session_zone';
41
+ /** 事件行 → ZoneEvent(形状不合法返回 null:确定性跳过,不猜) */
42
+ function rowToZoneEvent(row) {
43
+ let payload;
44
+ try {
45
+ payload = JSON.parse(row.payload);
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ if (typeof payload !== 'object' || payload === null)
51
+ return null;
52
+ const p = payload;
53
+ switch (row.kind) {
54
+ case 'hotctx.note.captured':
55
+ case 'hotctx.note.revised':
56
+ return typeof p.note === 'object' && p.note !== null
57
+ ? { kind: row.kind, ts: row.ts, note: p.note }
58
+ : null;
59
+ case 'hotctx.note.dropped':
60
+ return typeof p.note_id === 'string' && p.note_id.length > 0
61
+ ? { kind: row.kind, ts: row.ts, note_id: p.note_id }
62
+ : null;
63
+ case 'notebook.entry.promoted':
64
+ case 'notebook.entry.revised':
65
+ return typeof p.entry === 'object' && p.entry !== null
66
+ ? { kind: row.kind, ts: row.ts, entry: p.entry }
67
+ : null;
68
+ case 'notebook.entry.dropped':
69
+ return typeof p.entry_id === 'string' && p.entry_id.length > 0
70
+ ? { kind: row.kind, ts: row.ts, entry_id: p.entry_id }
71
+ : null;
72
+ default:
73
+ return null;
74
+ }
75
+ }
76
+ /** 事件 → 落账载荷(与 `trip.logged` 的 `{ trip }` 同形:变更后全量文档随事件走) */
77
+ function eventPayload(ev) {
78
+ switch (ev.kind) {
79
+ case 'hotctx.note.captured':
80
+ case 'hotctx.note.revised':
81
+ return { subjectId: ev.note.note_id, payload: { note: ev.note } };
82
+ case 'hotctx.note.dropped':
83
+ return { subjectId: ev.note_id, payload: { note_id: ev.note_id } };
84
+ case 'notebook.entry.promoted':
85
+ case 'notebook.entry.revised':
86
+ return { subjectId: ev.entry.entry_id, payload: { entry: ev.entry } };
87
+ case 'notebook.entry.dropped':
88
+ return { subjectId: ev.entry_id, payload: { entry_id: ev.entry_id } };
89
+ }
90
+ }
91
+ /**
92
+ * 读分区日志:六个 kind 各自 `readEvents` → 按 seq 升序归并 → P4-1 确定性 fold。
93
+ * 任一 kind 触到 limit 即 truncated(写路径据此 fail-closed:截断日志的 rev 不可信)。
94
+ */
95
+ export function readZoneLog(ledger, opts = {}) {
96
+ const limit = opts.limit ?? ZONE_LOG_READ_LIMIT;
97
+ const rows = [];
98
+ let truncated = false;
99
+ for (const kind of ZONE_EVENT_KINDS) {
100
+ const batch = ledger.readEvents(kind, limit);
101
+ if (batch.length >= limit)
102
+ truncated = true;
103
+ for (const r of batch)
104
+ rows.push({ seq: r.seq, ts: r.ts, kind: r.kind, payload: r.payload });
105
+ }
106
+ rows.sort((a, b) => a.seq - b.seq);
107
+ const events = [];
108
+ let malformed = 0;
109
+ for (const row of rows) {
110
+ const ev = rowToZoneEvent(row);
111
+ if (ev === null)
112
+ malformed++;
113
+ else
114
+ events.push(ev);
115
+ }
116
+ return { state: foldZoneEvents(events), events, truncated, malformed };
117
+ }
118
+ /** 单请求过 P4-1 写门(账本层不重写任何算术/闸语义,只转发) */
119
+ function gateRequest(state, req) {
120
+ switch (req.op) {
121
+ case 'capture':
122
+ return captureHotNote(state, req.input);
123
+ case 'capture_or_touch': {
124
+ // 先走 capture 的完整闸链(闭集/形状/有界/负面清单/时钟);只有「同 id 已存在」
125
+ // 这一种拒收(stale_rev)才转成续命写入——其余拒收原样上报,闸语义零放宽。
126
+ const birth = captureHotNote(state, req.input);
127
+ if (birth.ok || birth.code !== 'stale_rev')
128
+ return birth;
129
+ const noteId = makeHotNoteId({
130
+ session_ref: req.input.session_ref,
131
+ tier: req.input.tier,
132
+ kind: req.input.kind,
133
+ payload: req.input.payload,
134
+ });
135
+ const cur = noteId === null ? undefined : state.hot[noteId];
136
+ if (!cur)
137
+ return birth;
138
+ // 同一写入时刻的同形重放:TTL 已自该时刻起算,再续一次只是空转 rev → 幂等 no-op。
139
+ // (id 由会话+层+kind+规范载荷派生,所以撞到这里时载荷与 kind 必然相同,只有 ts 可能不同。)
140
+ if (cur.last_touched_at === req.input.ts) {
141
+ return {
142
+ ok: true,
143
+ appended: false,
144
+ idemKey: zoneIdemKey({ kind: 'hotctx.note.revised', ts: req.input.ts, note: cur }),
145
+ detail: '同写入时刻同形重放:幂等 no-op(TTL 已自该时刻起算)',
146
+ };
147
+ }
148
+ // 同语义片段再次出现 = 续命写入(TTL 自本次写入起算;过期笔记据此复活)
149
+ return reviseHotNote(state, {
150
+ note_id: cur.note_id,
151
+ expected_rev: cur.rev,
152
+ payload: req.input.payload,
153
+ kind: req.input.kind,
154
+ ts: req.input.ts,
155
+ });
156
+ }
157
+ case 'revise':
158
+ return reviseHotNote(state, req.input);
159
+ case 'drop':
160
+ return dropHotNote(state, req.note_id, req.ts);
161
+ case 'promote':
162
+ return promoteHotNote(state, req.input);
163
+ case 'notebook_revise':
164
+ return reviseNotebookEntry(state, req.input);
165
+ case 'notebook_drop':
166
+ return dropNotebookEntry(state, req.entry_id, req.ts);
167
+ }
168
+ }
169
+ /** 批量写被拒时的回滚载体(事务内抛出 → better-sqlite3 回滚 → 外层取回结果) */
170
+ class ZoneBatchRejected extends Error {
171
+ results;
172
+ constructor(results) {
173
+ super('zone batch rejected');
174
+ this.results = results;
175
+ }
176
+ }
177
+ /**
178
+ * 批量追加(单事务,全有或全无):
179
+ * - 事务内先 fold 读一次当前状态,逐请求过闸并把已接受的事件并入在算状态
180
+ * (批内自洽:同一事务里 capture→revise 的 rev CAS 成立);
181
+ * - 任一请求被拒 → 抛出 → 整个事务回滚 → 账本零新行,返回逐项结果;
182
+ * - 日志截断 → 第一项即 `log_truncated`,零写入(不在不全的日志上猜 rev)。
183
+ */
184
+ export function appendZoneWrites(ledger, requests, opts = {}) {
185
+ if (requests.length === 0)
186
+ return [];
187
+ const actor = opts.actor ?? ZONE_DEFAULT_ACTOR;
188
+ const run = ledger.db.transaction(() => {
189
+ const read = readZoneLog(ledger, { limit: opts.limit });
190
+ if (read.truncated) {
191
+ throw new ZoneBatchRejected([
192
+ { ok: false, code: 'log_truncated', detail: `分区事件日志触到读上界(${opts.limit ?? ZONE_LOG_READ_LIMIT});截断日志的 rev 不可信,写路径 fail-closed` },
193
+ ]);
194
+ }
195
+ const events = [...read.events];
196
+ let state = read.state;
197
+ const results = [];
198
+ for (const req of requests) {
199
+ const gate = gateRequest(state, req);
200
+ if (!gate.ok) {
201
+ results.push({ ok: false, code: gate.code, detail: gate.detail });
202
+ throw new ZoneBatchRejected(results);
203
+ }
204
+ if (!gate.appended) {
205
+ results.push({ ok: true, appended: false, idemKey: gate.idemKey, detail: gate.detail });
206
+ continue;
207
+ }
208
+ const ev = gate.event;
209
+ const { subjectId, payload } = eventPayload(ev);
210
+ const seq = ledger.insertEvent({ actor, kind: ev.kind, subjectId, payload, idemKey: gate.idemKey, ts: ev.ts });
211
+ if (seq === null) {
212
+ // 守门闸刚说该追加(fold 里没有这条),却撞上既有幂等键 → 生代碰撞:
213
+ // 同一毫秒内 capture→drop→重捕获会复用 `<id>:<created_at>:1`。这不是幂等重放,
214
+ // 而是**这次写入没发生**。静默报 appended:false 会骗调用方,所以显式失败、整批回滚。
215
+ results.push({
216
+ ok: false,
217
+ code: 'idem_collision',
218
+ detail: `幂等键已存在但 fold 中无此主体(生代碰撞:${gate.idemKey});本次写入未发生,请用不同的写入时刻重试`,
219
+ });
220
+ throw new ZoneBatchRejected(results);
221
+ }
222
+ events.push(ev);
223
+ state = foldZoneEvents(events);
224
+ results.push({ ok: true, appended: true, idemKey: gate.idemKey, seq, event: ev });
225
+ }
226
+ opts.beforeCommit?.();
227
+ return results;
228
+ });
229
+ try {
230
+ return run();
231
+ }
232
+ catch (e) {
233
+ if (e instanceof ZoneBatchRejected)
234
+ return e.results;
235
+ throw e;
236
+ }
237
+ }
238
+ /** 单请求追加(批量的 1 元特例) */
239
+ export function appendZoneWrite(ledger, request, opts = {}) {
240
+ return appendZoneWrites(ledger, [request], opts)[0];
241
+ }
242
+ /** 主体级遗忘:一次 forgetSubject 调用 → 物理删除全部相关事件 + 恰一行审计 */
243
+ export function forgetZoneSubjects(ledger, subjects, actor = 'system:state-cli') {
244
+ if (subjects.length === 0)
245
+ return { deleted: 0, subjects: 0 };
246
+ const specs = subjects.map(s => ({
247
+ kinds: [...(s.zone === 'hot_context' ? HOT_ZONE_EVENT_KINDS : NOTEBOOK_ZONE_EVENT_KINDS)],
248
+ subjectId: s.id,
249
+ }));
250
+ const r = ledger.forgetSubject(specs, actor);
251
+ return { deleted: r.deleted, subjects: specs.length };
252
+ }
253
+ /**
254
+ * 会话级遗忘:该会话的全部工作区笔记 + 血缘指向该会话的全部笔记本条目,
255
+ * 放进**一次** forgetSubject 调用 → 仍只留一行审计(「删除本身留一行」)。
256
+ *
257
+ * 截断即拒(fail-closed):主体集合是从 fold 推出来的,而 `readEvents` 触到读上界时
258
+ * 丢的是**最老**的事件——在截断日志上做遗忘会静默漏删主体,红线 6「可删除」就
259
+ * fail-open 了。宁可报错让调用方提高上界/先归档,也不假装删干净了。
260
+ */
261
+ export function forgetZoneSession(ledger, sessionRef, actor = 'system:state-cli', opts = {}) {
262
+ const { state, truncated } = readZoneLog(ledger, opts);
263
+ if (truncated) {
264
+ return {
265
+ ok: false,
266
+ code: 'log_truncated',
267
+ detail: `分区事件日志触到读上界(${opts.limit ?? ZONE_LOG_READ_LIMIT}):截断日志会漏掉最老的主体,遗忘拒绝在不完整视图上执行`,
268
+ };
269
+ }
270
+ const subjects = [];
271
+ for (const note of Object.values(state.hot)) {
272
+ if (note.session_ref === sessionRef)
273
+ subjects.push({ zone: 'hot_context', id: note.note_id });
274
+ }
275
+ for (const entry of Object.values(state.notebook)) {
276
+ if (entry.origin?.session_ref === sessionRef)
277
+ subjects.push({ zone: 'trip_notebook', id: entry.entry_id });
278
+ }
279
+ const r = forgetZoneSubjects(ledger, subjects, actor);
280
+ return { ok: true, deleted: r.deleted, subjects: r.subjects };
281
+ }
282
+ // ---- 导出视图(从 fold 派生;单向 DB→文件,永不是写路径) --------------------------
283
+ export const ZONE_EXPORT_HOT_FILE = 'hot-context.jsonl';
284
+ export const ZONE_EXPORT_NOTEBOOK_FILE = 'notebook.json';
285
+ /**
286
+ * 两个派生视图:直接取 fold 结果渲染,确定性排序。
287
+ * hot 视图导出的是 fold 里**未被 drop** 的全部笔记(每条自带 ttl_expires_at,
288
+ * 过期判定留给读者/同一纯函数)——导出不引入第二个过期口径。
289
+ */
290
+ export function renderZoneExportViews(state) {
291
+ const notes = Object.values(state.hot).sort((a, b) => (a.note_id < b.note_id ? -1 : 1));
292
+ return {
293
+ hotContextJsonl: notes.length ? notes.map(n => JSON.stringify(n)).join('\n') + '\n' : '',
294
+ notebookJson: JSON.stringify(readNotebook(state), null, 2),
295
+ };
296
+ }
297
+ /**
298
+ * 账本 → 导出视图(导出面唯一入口;截断即拒 fail-closed)。
299
+ * 「可见可导出」同样不能 fail-open:截断日志导出的是**残缺**视图,而读者会把
300
+ * 它当全量。纯渲染器 `renderZoneExportViews` 留给已确认完整的 state 使用。
301
+ */
302
+ export function readZoneExportViews(ledger, opts = {}) {
303
+ const { state, truncated } = readZoneLog(ledger, opts);
304
+ if (truncated) {
305
+ return {
306
+ ok: false,
307
+ code: 'log_truncated',
308
+ detail: `分区事件日志触到读上界(${opts.limit ?? ZONE_LOG_READ_LIMIT}):导出拒绝输出残缺视图(读者会把它当全量)`,
309
+ };
310
+ }
311
+ return { ok: true, views: renderZoneExportViews(state) };
312
+ }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * 会话双区记忆的观测面与可否证指标(P4-4,docs/design/session-dual-zone-memory-design.md §4/§5)。
3
+ *
4
+ * 立场:**价值要么可否证,要么不算**。「记得更多」过不了这里任何一关:
5
+ * ① 晋升信号质量 = owner 确认率(提议里被确认的比例);
6
+ * ② 工作区收益 = intent TTL 内回访的免重问率(配对口径,借 #20);
7
+ * ③ 失效命中率 = 被 owner 更正的分区供述 / 分区供述总数(上界,越低越好)。
8
+ * 三个阈值在**见到任何数据之前**冻结在本文件的 metric contract 里(#20 阈值冻结纪律),
9
+ * 输入不得改阈值:夹具声明与冻结值不一致即 `contract_invalid` fail-closed。
10
+ *
11
+ * 诚实边界(设计 §4 + #20 证据分级):
12
+ * - 夹具只证明**契约与算法**:`synthetic_fixture` 的 `exit_evidence_eligible`
13
+ * 恒 false,`value_claimed` 恒 false;
14
+ * - 真实私有 cohort(`observed_private`)同样只到 **candidate**:本模块永不生成
15
+ * reviewer/attestation 字段,自证不算证据(与 #228 收集器同纪律);
16
+ * - 计数**只有形状**:每层的捕获/修订/弃用计数、晋升提议 vs 确认 vs 否决、
17
+ * 过期年龄分桶、工作区读命中/未命中——**零 id、零载荷、零时间戳**;
18
+ * - 观测路径零定时器、零网络、零驻留服务;计数接收器须显式 opt-in
19
+ * (`GOTRY_SESSION_ZONE_OBSERVE=1`),默认不记任何东西;
20
+ * - 导出引用全部 HMAC 假名化(复用 #228 收集器的 `pseudonymousRef`)。
21
+ *
22
+ * 本模块不碰 #20 scorer 里冻结的 `p4` 闸:那道闸按它自己的证据规则走。
23
+ */
24
+ import { MemoryLifecycleError, canonicalJson, pseudonymousRef, sha256Hex, } from './memory-lifecycle.js';
25
+ import { HOT_TIERS } from './session-zones.js';
26
+ // ---- 冻结的指标契约(见数据之前钉死;输入不得改) ----------------------------------
27
+ export const SESSION_ZONE_METRIC_CONTRACT_SCHEMA = 'session_zone_metric_contract.v1';
28
+ export const SESSION_ZONE_FIXTURE_SCHEMA = 'session_zone_metric_fixture.v1';
29
+ export const SESSION_ZONE_REPORT_SCHEMA = 'session_zone_metric_report.v1';
30
+ export const SESSION_ZONE_COUNTERS_SCHEMA = 'session_zone_counters.v1';
31
+ export const SESSION_ZONE_EXPORT_SCHEMA = 'session_zone_observation_export.v1';
32
+ /**
33
+ * 阈值冻结(2026-10-04,P4-4 PR 时,先于任何真实数据):
34
+ * - `minimum_sample_count = 5`:沿用 #20 已冻结的 `minimum_pair_count_for_exit = 5`。
35
+ * 不另发明第二条样本线;低于它比值只是噪声,判定一律 insufficient_sample。
36
+ * - `min_confirm_rate = 0.5`:多数提议被 owner 否决的提议器按定义就是调错了。
37
+ * 0.5 = 「对的比错的多」,是最弱可辩护下界,刻意不设成期望值。
38
+ * - `min_reask_avoidance_ratio = 0.5`:与 #20 的 `target_median_reduction_ratio = 0.5`
39
+ * 同一个数。工作区的收益线就用 M4 Exit 已经冻结的那条线——另发明一条会让 P4
40
+ * 过一条 M4 过不了的线。
41
+ * - `max_stale_hit_rate = 0.1`:被 owner 更正的供述是用户**看得见**的记忆失败。
42
+ * 十分之一是 TTL 分层被否证的上界(被否证的是分层,不是功能);0.1 是常规误差
43
+ * 上界,是天花板不是目标。
44
+ */
45
+ export const SESSION_ZONE_METRIC_CONTRACT = {
46
+ schema: SESSION_ZONE_METRIC_CONTRACT_SCHEMA,
47
+ frozen_at: '2026-10-04',
48
+ frozen_before_any_data: true,
49
+ minimum_sample_count: 5,
50
+ min_confirm_rate: 0.5,
51
+ min_reask_avoidance_ratio: 0.5,
52
+ max_stale_hit_rate: 0.1,
53
+ };
54
+ export const SESSION_ZONE_METRIC_IDS = [
55
+ 'promotion_signal_quality',
56
+ 'working_zone_payoff',
57
+ 'stale_hit_rate',
58
+ ];
59
+ export const SESSION_ZONE_VERDICTS = ['pass', 'below_threshold', 'insufficient_sample'];
60
+ function err(code, detail) {
61
+ return { ok: false, code, detail };
62
+ }
63
+ // ---- 形状计数(从账本事件确定性投影;零 id、零载荷、零时间戳) ----------------------
64
+ export const ZONE_EXPIRY_BUCKETS = ['lt_1h', 'lt_24h', 'lt_7d', 'gte_7d'];
65
+ function zeroByTier() {
66
+ return { resource: 0, intent: 0 };
67
+ }
68
+ function bucketOf(ageMs) {
69
+ if (ageMs < 3_600_000)
70
+ return 'lt_1h';
71
+ if (ageMs < 86_400_000)
72
+ return 'lt_24h';
73
+ if (ageMs < 7 * 86_400_000)
74
+ return 'lt_7d';
75
+ return 'gte_7d';
76
+ }
77
+ /**
78
+ * 形状计数投影(纯函数,只读):
79
+ * - drop 事件只带 note_id,tier 由同一日志里先前的 capture 事件解析(确定性);
80
+ * - 过期分桶按注入时钟对 fold 后仍在的笔记判定(过期是读时视图,不是事件);
81
+ * - 输出里没有任何 id / 载荷 / 时间戳——只有整数计数。
82
+ */
83
+ export function projectZoneCounters(events, now) {
84
+ const counters = {
85
+ schema: SESSION_ZONE_COUNTERS_SCHEMA,
86
+ captures: zeroByTier(),
87
+ revisions: zeroByTier(),
88
+ drops: zeroByTier(),
89
+ notebook: { promoted: 0, revised: 0, dropped: 0 },
90
+ expired: { lt_1h: 0, lt_24h: 0, lt_7d: 0, gte_7d: 0 },
91
+ live: zeroByTier(),
92
+ };
93
+ const tierOf = new Map();
94
+ const alive = new Map();
95
+ for (const ev of events) {
96
+ switch (ev.kind) {
97
+ case 'hotctx.note.captured':
98
+ tierOf.set(ev.note.note_id, ev.note.tier);
99
+ counters.captures[ev.note.tier]++;
100
+ alive.set(ev.note.note_id, { tier: ev.note.tier, expiresAt: ev.note.ttl_expires_at });
101
+ break;
102
+ case 'hotctx.note.revised':
103
+ tierOf.set(ev.note.note_id, ev.note.tier);
104
+ counters.revisions[ev.note.tier]++;
105
+ alive.set(ev.note.note_id, { tier: ev.note.tier, expiresAt: ev.note.ttl_expires_at });
106
+ break;
107
+ case 'hotctx.note.dropped': {
108
+ const tier = tierOf.get(ev.note_id);
109
+ if (tier)
110
+ counters.drops[tier]++;
111
+ alive.delete(ev.note_id);
112
+ break;
113
+ }
114
+ case 'notebook.entry.promoted':
115
+ counters.notebook.promoted++;
116
+ break;
117
+ case 'notebook.entry.revised':
118
+ counters.notebook.revised++;
119
+ break;
120
+ case 'notebook.entry.dropped':
121
+ counters.notebook.dropped++;
122
+ break;
123
+ }
124
+ }
125
+ const nowMs = Date.parse(now);
126
+ for (const { tier, expiresAt } of alive.values()) {
127
+ const expMs = Date.parse(expiresAt);
128
+ if (Number.isNaN(nowMs) || Number.isNaN(expMs))
129
+ continue; // 坏时钟不猜:既不算活也不算过期
130
+ if (nowMs < expMs)
131
+ counters.live[tier]++;
132
+ else
133
+ counters.expired[bucketOf(nowMs - expMs)]++;
134
+ }
135
+ return counters;
136
+ }
137
+ // ---- 非账本信号的 opt-in 计数接收器(进程内、零持久化、零定时器) -------------------
138
+ export const ZONE_SIGNALS = ['proposal', 'confirm', 'deny', 'read_hit', 'read_miss'];
139
+ export const ZONE_OBSERVE_ENV = 'GOTRY_SESSION_ZONE_OBSERVE';
140
+ export function zeroSignalCounts() {
141
+ return { proposal: 0, confirm: 0, deny: 0, read_hit: 0, read_miss: 0 };
142
+ }
143
+ /** 显式 opt-in:只有环境里明示 '1' 才记;默认永不记(「无驻留服务、无自动遥测」) */
144
+ export function isZoneObservationOptIn(env = process.env) {
145
+ return env[ZONE_OBSERVE_ENV] === '1';
146
+ }
147
+ /**
148
+ * 计数接收器(纯内存):opt-in 关闭时 note() 恒为 no-op(snapshot 全 0)。
149
+ * 没有定时器、没有 IO、没有网络——它只是五个整数。
150
+ */
151
+ export function createZoneCounterSink(optIn) {
152
+ let counts = zeroSignalCounts();
153
+ return {
154
+ note(signal) {
155
+ if (!optIn)
156
+ return;
157
+ if (!ZONE_SIGNALS.includes(signal))
158
+ return;
159
+ counts[signal]++;
160
+ },
161
+ snapshot: () => ({ ...counts }),
162
+ reset: () => { counts = zeroSignalCounts(); },
163
+ };
164
+ }
165
+ /**
166
+ * 进程默认接收器(接线面调用 `noteZoneSignal`):构造时读一次 opt-in 环境,
167
+ * 默认关 = 五个 0。`configureZoneObservation` 显式重建(测试与显式 CLI opt-in 用)。
168
+ */
169
+ let defaultSink = createZoneCounterSink(isZoneObservationOptIn());
170
+ export function configureZoneObservation(optIn) {
171
+ defaultSink = createZoneCounterSink(optIn);
172
+ }
173
+ export function noteZoneSignal(signal) {
174
+ defaultSink.note(signal);
175
+ }
176
+ export function zoneSignalSnapshot() {
177
+ return defaultSink.snapshot();
178
+ }
179
+ function verdictOf(sample, value, threshold, direction) {
180
+ if (sample < SESSION_ZONE_METRIC_CONTRACT.minimum_sample_count || value === null)
181
+ return 'insufficient_sample';
182
+ if (direction === 'higher_is_better')
183
+ return value >= threshold ? 'pass' : 'below_threshold';
184
+ return value <= threshold ? 'pass' : 'below_threshold';
185
+ }
186
+ function nonNegInt(v) {
187
+ return typeof v === 'number' && Number.isInteger(v) && v >= 0;
188
+ }
189
+ /**
190
+ * 只读指标投影:
191
+ * - schema/evidence_kind 闭集;
192
+ * - 夹具声明的 metric_contract 必须逐字段等于冻结契约(输入不得改阈值 → contract_invalid);
193
+ * - 样本形状非法即 bad_sample(不猜、不截断);
194
+ * - 样本不足即 insufficient_sample(永不因为「只有一两条」就宣布通过)。
195
+ */
196
+ export function projectZoneMetrics(fixture) {
197
+ if (typeof fixture !== 'object' || fixture === null || Array.isArray(fixture)) {
198
+ return err('bad_schema', '夹具必须是对象');
199
+ }
200
+ const f = fixture;
201
+ if (f.schema !== SESSION_ZONE_FIXTURE_SCHEMA) {
202
+ return err('bad_schema', `未知 schema:${String(f.schema)}(闭集 ${SESSION_ZONE_FIXTURE_SCHEMA})`);
203
+ }
204
+ if (f.evidence_kind !== 'synthetic_fixture' && f.evidence_kind !== 'observed_private') {
205
+ return err('bad_schema', `未知 evidence_kind:${String(f.evidence_kind)}(闭集 synthetic_fixture|observed_private)`);
206
+ }
207
+ const declared = f.metric_contract;
208
+ if (typeof declared !== 'object' || declared === null)
209
+ return err('contract_invalid', '夹具必须显式声明 metric_contract(阈值冻结纪律)');
210
+ for (const [key, frozen] of Object.entries(SESSION_ZONE_METRIC_CONTRACT)) {
211
+ if (declared[key] !== frozen) {
212
+ return err('contract_invalid', `阈值冻结:${key} 声明值 ${JSON.stringify(declared[key])} ≠ 冻结值 ${JSON.stringify(frozen)}(输入不得改阈值)`);
213
+ }
214
+ }
215
+ if (!Array.isArray(f.promotion_decisions) || !Array.isArray(f.reask_pairs) || !Array.isArray(f.zone_served_facts)) {
216
+ return err('bad_sample', 'promotion_decisions / reask_pairs / zone_served_facts 必须都是数组');
217
+ }
218
+ // ① 晋升信号质量:确认 / (确认 + 否决)
219
+ let confirmed = 0;
220
+ let denied = 0;
221
+ for (const d of f.promotion_decisions) {
222
+ if (d?.decision === 'confirmed')
223
+ confirmed++;
224
+ else if (d?.decision === 'denied')
225
+ denied++;
226
+ else
227
+ return err('bad_sample', `promotion_decisions.decision 闭集 confirmed|denied,实测 ${JSON.stringify(d?.decision)}`);
228
+ }
229
+ const decisions = confirmed + denied;
230
+ const confirmRate = decisions > 0 ? confirmed / decisions : null;
231
+ // ② 工作区收益:intent TTL 内回访的免重问率
232
+ let askedTotal = 0;
233
+ let reaskedTotal = 0;
234
+ let pairs = 0;
235
+ for (const p of f.reask_pairs) {
236
+ if (typeof p?.within_intent_ttl !== 'boolean')
237
+ return err('bad_sample', 'reask_pairs.within_intent_ttl 必须是布尔');
238
+ if (!nonNegInt(p.first_visit_asked_fields) || !nonNegInt(p.returning_reasked_fields)) {
239
+ return err('bad_sample', 'reask_pairs 的字段计数必须是非负整数');
240
+ }
241
+ if (p.returning_reasked_fields > p.first_visit_asked_fields) {
242
+ return err('bad_sample', '回访重问字段数不得大于首访询问字段数(配对口径,不猜)');
243
+ }
244
+ if (!p.within_intent_ttl)
245
+ continue; // TTL 外的回访不是工作区该负责的窗口
246
+ pairs++;
247
+ askedTotal += p.first_visit_asked_fields;
248
+ reaskedTotal += p.returning_reasked_fields;
249
+ }
250
+ const avoidance = askedTotal > 0 ? (askedTotal - reaskedTotal) / askedTotal : null;
251
+ // ③ 失效命中率:被更正的供述 / 供述总数
252
+ let corrected = 0;
253
+ for (const s of f.zone_served_facts) {
254
+ if (typeof s?.corrected !== 'boolean')
255
+ return err('bad_sample', 'zone_served_facts.corrected 必须是布尔');
256
+ if (s.corrected)
257
+ corrected++;
258
+ }
259
+ const served = f.zone_served_facts.length;
260
+ const staleRate = served > 0 ? corrected / served : null;
261
+ const metrics = [
262
+ {
263
+ id: 'promotion_signal_quality',
264
+ direction: 'higher_is_better',
265
+ threshold: SESSION_ZONE_METRIC_CONTRACT.min_confirm_rate,
266
+ sample_count: decisions,
267
+ value: confirmRate,
268
+ verdict: verdictOf(decisions, confirmRate, SESSION_ZONE_METRIC_CONTRACT.min_confirm_rate, 'higher_is_better'),
269
+ },
270
+ {
271
+ id: 'working_zone_payoff',
272
+ direction: 'higher_is_better',
273
+ threshold: SESSION_ZONE_METRIC_CONTRACT.min_reask_avoidance_ratio,
274
+ sample_count: pairs,
275
+ value: avoidance,
276
+ verdict: verdictOf(pairs, avoidance, SESSION_ZONE_METRIC_CONTRACT.min_reask_avoidance_ratio, 'higher_is_better'),
277
+ },
278
+ {
279
+ id: 'stale_hit_rate',
280
+ direction: 'lower_is_better',
281
+ threshold: SESSION_ZONE_METRIC_CONTRACT.max_stale_hit_rate,
282
+ sample_count: served,
283
+ value: staleRate,
284
+ verdict: verdictOf(served, staleRate, SESSION_ZONE_METRIC_CONTRACT.max_stale_hit_rate, 'lower_is_better'),
285
+ },
286
+ ];
287
+ return {
288
+ ok: true,
289
+ report: {
290
+ schema: SESSION_ZONE_REPORT_SCHEMA,
291
+ evidence_kind: f.evidence_kind,
292
+ metric_contract: SESSION_ZONE_METRIC_CONTRACT,
293
+ exit_evidence_eligible: false,
294
+ value_claimed: false,
295
+ source_review: {
296
+ state: f.evidence_kind === 'synthetic_fixture' ? 'not_required_for_synthetic' : 'candidate',
297
+ reviewer_ref: null,
298
+ attestation_ref: null,
299
+ },
300
+ metrics,
301
+ },
302
+ };
303
+ }
304
+ /**
305
+ * 候选级导出:只带**计数与假名引用**,不带任何内容字段
306
+ * (无 id、无载荷、无原话、无 session_ref 原文、无时间戳——`generated_at` 除外)。
307
+ * `source_review` 永远停在 candidate:自证不算证据。
308
+ */
309
+ export function buildZoneObservationExport(input) {
310
+ if (typeof input.consent !== 'string' || input.consent.trim().length === 0) {
311
+ return err('missing_consent', '导出需要显式同意声明(opt-in 收集纪律)');
312
+ }
313
+ let datasetRef;
314
+ let consentRef;
315
+ try {
316
+ datasetRef = pseudonymousRef(input.hmacKey ?? '', 'session_zone_dataset', [input.dataset]);
317
+ consentRef = pseudonymousRef(input.hmacKey ?? '', 'session_zone_consent', [input.consent.trim()]);
318
+ }
319
+ catch (e) {
320
+ const code = e instanceof MemoryLifecycleError && e.code === 'missing_hmac_key' ? 'missing_hmac_key' : 'invalid_hmac_key';
321
+ return err(code, `HMAC 假名化失败(${code}):引用必须假名化后才可导出`);
322
+ }
323
+ const body = {
324
+ schema: SESSION_ZONE_EXPORT_SCHEMA,
325
+ evidence_kind: input.evidenceKind,
326
+ generated_at: input.generatedAt,
327
+ dataset_ref: datasetRef,
328
+ consent_ref: consentRef,
329
+ counters: input.counters,
330
+ signals: input.signals,
331
+ observation_opt_in: input.observationOptIn,
332
+ report: input.report,
333
+ };
334
+ return {
335
+ ok: true,
336
+ export: { ...body, payload_digest_sha256: sha256Hex(canonicalJson(body)) },
337
+ };
338
+ }
339
+ /** 导出里允许出现的键闭集(测试据此断言「只有计数与引用,零内容字段」) */
340
+ export const ZONE_EXPORT_ALLOWED_KEYS = [
341
+ 'schema', 'evidence_kind', 'generated_at', 'dataset_ref', 'consent_ref',
342
+ 'counters', 'signals', 'observation_opt_in', 'report', 'payload_digest_sha256',
343
+ // counters
344
+ 'captures', 'revisions', 'drops', 'notebook', 'expired', 'live',
345
+ ...HOT_TIERS, 'promoted', 'revised', 'dropped', ...ZONE_EXPIRY_BUCKETS,
346
+ // signals
347
+ ...ZONE_SIGNALS,
348
+ // report
349
+ 'metric_contract', 'exit_evidence_eligible', 'value_claimed', 'source_review', 'metrics',
350
+ 'frozen_at', 'frozen_before_any_data', 'minimum_sample_count', 'min_confirm_rate',
351
+ 'min_reask_avoidance_ratio', 'max_stale_hit_rate',
352
+ 'state', 'reviewer_ref', 'attestation_ref',
353
+ 'id', 'direction', 'threshold', 'sample_count', 'value', 'verdict',
354
+ ];