@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,492 @@
1
+ /**
2
+ * 会话双区记忆观测面与指标契约单测(P4-4,design/session-dual-zone-memory-design.md §4/§5):
3
+ * - 形状计数:每层捕获/修订/弃用、笔记本三计数、过期年龄分桶、活笔记计数;
4
+ * 输出里零 id、零载荷、零时间戳(逐键断言);坏时钟不猜;
5
+ * - opt-in 计数接收器:默认关 = 全 0(no-op);显式 opt-in 才计数;
6
+ * - 三个可否证指标:阈值**冻结**(输入声明不一致即 contract_invalid)、
7
+ * 样本不足即 insufficient_sample、方向性阈值(确认率/免重问率下界,失效率上界)、
8
+ * 样本形状非法即 bad_sample;
9
+ * - 夹具只证契约:`synthetic_fixture` 的 exit_evidence_eligible/value_claimed 恒 false,
10
+ * observed_private 也只到 candidate 且永不生成 reviewer/attestation;
11
+ * - 候选导出:只带计数与 HMAC 假名引用,键集闭集断言(零内容字段)、摘要绑定、
12
+ * 缺/弱 HMAC key 与缺同意声明 fail-closed;
13
+ * - 观测路径零定时器、零网络(真实 spy 断言);
14
+ * - #20 scorer 的冻结 `p4` 闸不被本面改动(#228 manifest 仍 closed)。
15
+ *
16
+ * 全离线:纯函数 + mkdtemp 隔离 stateRoot + 有界子进程;零网络、零真实 LLM。
17
+ * 运行:cd ts && npx tsx scripts/session-zone-observation-tests.ts
18
+ */
19
+ import assert from 'node:assert/strict';
20
+ import { spawnSync } from 'node:child_process';
21
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import { ensureLedger } from '../src/state-ledger.js';
25
+ import { appendZoneWrite, readZoneLog } from '../src/session-zone-ledger.js';
26
+ import { SESSION_ZONE_EXPORT_SCHEMA, SESSION_ZONE_FIXTURE_SCHEMA, SESSION_ZONE_METRIC_CONTRACT, SESSION_ZONE_REPORT_SCHEMA, ZONE_EXPORT_ALLOWED_KEYS, ZONE_OBSERVE_ENV, buildZoneObservationExport, configureZoneObservation, createZoneCounterSink, isZoneObservationOptIn, noteZoneSignal, projectZoneCounters, projectZoneMetrics, zeroSignalCounts, zoneSignalSnapshot, } from '../src/session-zone-observation.js';
27
+ import { applyZoneNote, promoteWithRouting, renderSessionZoneBrief, resetZoneSessionBindingForTests, } from '../src/session-zone-wiring.js';
28
+ import { initMemoryLifecycleDataset, readStoreForTests } from '../src/memory-lifecycle.js';
29
+ let n = 0;
30
+ function pass(name, body) {
31
+ body();
32
+ console.log(` ${++n}. ${name} OK`);
33
+ }
34
+ const roots = [];
35
+ function freshRoot(tag) {
36
+ const root = mkdtempSync(join(tmpdir(), `gotry-zone-obs-${tag}-`));
37
+ roots.push(root);
38
+ return root;
39
+ }
40
+ const T0 = '2026-10-04T08:00:00.000Z';
41
+ const MIN = 60_000;
42
+ const at = (ms) => new Date(Date.parse(T0) + ms).toISOString();
43
+ const SESS = 'sess-observe-001';
44
+ const QUOTE = '对,这条你长期记着';
45
+ const HMAC_KEY = 'x'.repeat(48);
46
+ const FIXTURE_PATH = join('data', 'session-zone-metric-fixture.json');
47
+ function captureReq(over = {}) {
48
+ return {
49
+ op: 'capture',
50
+ input: {
51
+ session_ref: SESS,
52
+ tier: over.tier ?? 'intent',
53
+ kind: over.kind ?? 'destination',
54
+ payload: over.payload ?? { city: '大理' },
55
+ evidence_ref: { session_ref: SESS, turn: 0 },
56
+ ts: over.ts ?? T0,
57
+ },
58
+ };
59
+ }
60
+ function loadFixture() {
61
+ return JSON.parse(readFileSync(FIXTURE_PATH, 'utf-8'));
62
+ }
63
+ /** 递归收集对象里出现过的全部键名(导出键集闭集断言用) */
64
+ function allKeys(value, acc = new Set()) {
65
+ if (Array.isArray(value)) {
66
+ for (const item of value)
67
+ allKeys(item, acc);
68
+ return acc;
69
+ }
70
+ if (typeof value === 'object' && value !== null) {
71
+ for (const [k, v] of Object.entries(value)) {
72
+ acc.add(k);
73
+ allKeys(v, acc);
74
+ }
75
+ }
76
+ return acc;
77
+ }
78
+ try {
79
+ // ---- 形状计数 ----
80
+ pass('形状计数:分层捕获/修订/弃用 + 笔记本三计数;输出零 id/零载荷/零时间戳', () => {
81
+ const root = freshRoot('counters');
82
+ const ledger = ensureLedger(root);
83
+ const first = appendZoneWrite(ledger, captureReq());
84
+ assert.ok(first.ok);
85
+ const noteId = Object.keys(readZoneLog(ledger).state.hot)[0];
86
+ appendZoneWrite(ledger, { op: 'revise', input: { note_id: noteId, expected_rev: 1, payload: { city: '丽江' }, ts: at(MIN) } });
87
+ appendZoneWrite(ledger, captureReq({ tier: 'resource', kind: 'availability', payload: { tool: 'gotry_flyai_search', verdict: 'hit', option_count: 2 }, ts: at(2 * MIN) }));
88
+ const resourceId = Object.keys(readZoneLog(ledger).state.hot).find(id => id.includes('resource'));
89
+ appendZoneWrite(ledger, { op: 'drop', note_id: resourceId, ts: at(3 * MIN) });
90
+ appendZoneWrite(ledger, { op: 'promote', input: { note_id: noteId, kind: 'preference', owner_confirm: { surface: 'user_reply', quote: QUOTE }, ts: at(4 * MIN) } });
91
+ const events = readZoneLog(ledger).events;
92
+ const counters = projectZoneCounters(events, at(5 * MIN));
93
+ assert.deepEqual(counters.captures, { resource: 1, intent: 1 });
94
+ assert.deepEqual(counters.revisions, { resource: 0, intent: 1 });
95
+ assert.deepEqual(counters.drops, { resource: 1, intent: 0 }, 'drop 的 tier 由同一日志里的 capture 解析');
96
+ assert.deepEqual(counters.notebook, { promoted: 1, revised: 0, dropped: 0 });
97
+ assert.deepEqual(counters.live, { resource: 0, intent: 1 });
98
+ // 零内容:计数里不得出现任何 id/载荷/原话/时间戳
99
+ const serialized = JSON.stringify(counters);
100
+ for (const forbidden of [noteId, resourceId, '大理', '丽江', QUOTE, T0, SESS, 'gotry_flyai_search']) {
101
+ assert.equal(serialized.includes(forbidden), false, `计数不得携带:${forbidden}`);
102
+ }
103
+ assert.ok(/^[{}":,a-z_0-9.]+$/.test(serialized.replace(/session_zone_counters\.v1/, '')), `计数应只有键名与整数:${serialized}`);
104
+ });
105
+ pass('形状计数:过期年龄分桶(读时视图);两次投影一致;坏时钟不猜', () => {
106
+ const events = [];
107
+ const mk = (tier, ts, city) => ({
108
+ kind: 'hotctx.note.captured',
109
+ ts,
110
+ note: {
111
+ schema: 'session_zone_hot_note.v1',
112
+ note_id: `hn|${SESS}|${tier}|destination|${city}`,
113
+ zone: 'hot_context',
114
+ tier,
115
+ kind: 'destination',
116
+ payload: { city },
117
+ evidence_ref: { session_ref: SESS, turn: 0 },
118
+ session_ref: SESS,
119
+ created_at: ts,
120
+ last_touched_at: ts,
121
+ ttl_expires_at: new Date(Date.parse(ts) + (tier === 'resource' ? 30 * MIN : 24 * 60 * MIN)).toISOString(),
122
+ rev: 1,
123
+ },
124
+ });
125
+ events.push(mk('resource', T0, 'a')); // 到期 T0+30min
126
+ events.push(mk('intent', T0, 'b')); // 到期 T0+24h
127
+ const soon = projectZoneCounters(events, at(31 * MIN));
128
+ assert.deepEqual(soon.expired, { lt_1h: 1, lt_24h: 0, lt_7d: 0, gte_7d: 0 });
129
+ assert.deepEqual(soon.live, { resource: 0, intent: 1 });
130
+ const late = projectZoneCounters(events, at(10 * 24 * 60 * MIN));
131
+ assert.deepEqual(late.expired, { lt_1h: 0, lt_24h: 0, lt_7d: 0, gte_7d: 2 });
132
+ assert.deepEqual(projectZoneCounters(events, at(31 * MIN)), soon, '同输入两次投影一致(确定性)');
133
+ const bad = projectZoneCounters(events, 'not-a-timestamp');
134
+ assert.deepEqual(bad.expired, { lt_1h: 0, lt_24h: 0, lt_7d: 0, gte_7d: 0 }, '坏时钟既不算活也不算过期');
135
+ assert.deepEqual(bad.live, { resource: 0, intent: 0 });
136
+ });
137
+ // ---- opt-in 接收器 ----
138
+ pass('计数接收器:默认关 = 全 0 no-op;显式 opt-in 才计数;未知信号不记', () => {
139
+ const off = createZoneCounterSink(false);
140
+ off.note('proposal');
141
+ off.note('confirm');
142
+ assert.deepEqual(off.snapshot(), zeroSignalCounts(), '未 opt-in 恒不记(无驻留服务、无自动遥测)');
143
+ const on = createZoneCounterSink(true);
144
+ on.note('proposal');
145
+ on.note('proposal');
146
+ on.note('deny');
147
+ on.note('read_hit');
148
+ on.note('nope');
149
+ assert.deepEqual(on.snapshot(), { proposal: 2, confirm: 0, deny: 1, read_hit: 1, read_miss: 0 });
150
+ on.reset();
151
+ assert.deepEqual(on.snapshot(), zeroSignalCounts());
152
+ assert.equal(isZoneObservationOptIn({}), false);
153
+ assert.equal(isZoneObservationOptIn({ [ZONE_OBSERVE_ENV]: '0' }), false);
154
+ assert.equal(isZoneObservationOptIn({ [ZONE_OBSERVE_ENV]: 'true' }), false, '只认明示 1');
155
+ assert.equal(isZoneObservationOptIn({ [ZONE_OBSERVE_ENV]: '1' }), true);
156
+ // 进程默认接收器:关 → no-op;显式配置 → 计数
157
+ configureZoneObservation(false);
158
+ noteZoneSignal('confirm');
159
+ assert.deepEqual(zoneSignalSnapshot(), zeroSignalCounts());
160
+ configureZoneObservation(true);
161
+ noteZoneSignal('confirm');
162
+ assert.deepEqual(zoneSignalSnapshot(), { ...zeroSignalCounts(), confirm: 1 });
163
+ configureZoneObservation(false);
164
+ });
165
+ // ---- 指标契约 ----
166
+ pass('阈值冻结:夹具声明必须逐字段等于冻结契约,改一个数即 contract_invalid', () => {
167
+ const base = loadFixture();
168
+ const good = projectZoneMetrics(base);
169
+ assert.ok(good.ok, JSON.stringify(good));
170
+ assert.deepEqual(good.report.metric_contract, SESSION_ZONE_METRIC_CONTRACT);
171
+ for (const key of ['minimum_sample_count', 'min_confirm_rate', 'min_reask_avoidance_ratio', 'max_stale_hit_rate', 'frozen_at', 'frozen_before_any_data']) {
172
+ const tampered = { ...base, metric_contract: { ...base.metric_contract, [key]: key === 'frozen_at' ? '2027-01-01' : key === 'frozen_before_any_data' ? false : 0.01 } };
173
+ const r = projectZoneMetrics(tampered);
174
+ assert.equal(r.ok, false, `篡改 ${key} 应被拒`);
175
+ assert.equal(r.code, 'contract_invalid', `篡改 ${key} 应 contract_invalid`);
176
+ }
177
+ const noContract = { ...base, metric_contract: undefined };
178
+ assert.equal(projectZoneMetrics(noContract).code, 'contract_invalid');
179
+ });
180
+ pass('三指标口径:确认率/免重问率下界、失效率上界;TTL 外回访不计入工作区收益', () => {
181
+ const r = projectZoneMetrics(loadFixture());
182
+ assert.ok(r.ok);
183
+ const byId = new Map(r.report.metrics.map(m => [m.id, m]));
184
+ const promotion = byId.get('promotion_signal_quality');
185
+ assert.equal(promotion.sample_count, 6);
186
+ assert.equal(promotion.value, 4 / 6);
187
+ assert.equal(promotion.direction, 'higher_is_better');
188
+ assert.equal(promotion.verdict, 'pass');
189
+ const payoff = byId.get('working_zone_payoff');
190
+ assert.equal(payoff.sample_count, 5, 'TTL 外的回访配对不计入(不是工作区该负责的窗口)');
191
+ assert.equal(payoff.value, 14 / 22);
192
+ assert.equal(payoff.verdict, 'pass');
193
+ const stale = byId.get('stale_hit_rate');
194
+ assert.equal(stale.sample_count, 12);
195
+ assert.equal(stale.value, 1 / 12);
196
+ assert.equal(stale.direction, 'lower_is_better');
197
+ assert.equal(stale.verdict, 'pass', '夹具刻意不踩在上界上(1/12 ≈ 0.083 < 0.1)');
198
+ // 上界语义单独验:恰好 0.1 过(≤),刚过 0.1 即不过
199
+ const onBound = projectZoneMetrics({
200
+ ...loadFixture(),
201
+ zone_served_facts: Array.from({ length: 10 }, (_, i) => ({ served_ref: `ref-${i}`, corrected: i === 0 })),
202
+ });
203
+ assert.ok(onBound.ok);
204
+ assert.equal(onBound.report.metrics.find(m => m.id === 'stale_hit_rate').verdict, 'pass', '恰好 0.1 仍过(上界是 ≤)');
205
+ const overBound = projectZoneMetrics({
206
+ ...loadFixture(),
207
+ zone_served_facts: Array.from({ length: 9 }, (_, i) => ({ served_ref: `ref-${i}`, corrected: i === 0 })),
208
+ });
209
+ assert.ok(overBound.ok);
210
+ assert.equal(overBound.report.metrics.find(m => m.id === 'stale_hit_rate').verdict, 'below_threshold', '1/9 > 0.1 即不过');
211
+ });
212
+ pass('样本不足即 insufficient_sample;越界即 below_threshold(永不因少量样本宣布通过)', () => {
213
+ const base = loadFixture();
214
+ const thin = projectZoneMetrics({
215
+ ...base,
216
+ promotion_decisions: base.promotion_decisions.slice(0, 4),
217
+ reask_pairs: base.reask_pairs.slice(0, 2),
218
+ zone_served_facts: base.zone_served_facts.slice(0, 1),
219
+ });
220
+ assert.ok(thin.ok);
221
+ for (const m of thin.report.metrics) {
222
+ assert.equal(m.verdict, 'insufficient_sample', `${m.id} 样本不足必须 insufficient_sample`);
223
+ }
224
+ const emptyAll = projectZoneMetrics({ ...base, promotion_decisions: [], reask_pairs: [], zone_served_facts: [] });
225
+ assert.ok(emptyAll.ok);
226
+ for (const m of emptyAll.report.metrics) {
227
+ assert.equal(m.value, null, '零样本不得编造 0 或 1');
228
+ assert.equal(m.verdict, 'insufficient_sample');
229
+ }
230
+ // 多数被否决 → below_threshold
231
+ const denied = projectZoneMetrics({
232
+ ...base,
233
+ promotion_decisions: base.promotion_decisions.map((d, i) => ({ ...d, decision: i === 0 ? 'confirmed' : 'denied' })),
234
+ });
235
+ assert.ok(denied.ok);
236
+ assert.equal(denied.report.metrics.find(m => m.id === 'promotion_signal_quality').verdict, 'below_threshold');
237
+ // 失效率超上界 → below_threshold
238
+ const stale = projectZoneMetrics({
239
+ ...base,
240
+ zone_served_facts: base.zone_served_facts.map((s, i) => ({ ...s, corrected: i < 2 })),
241
+ });
242
+ assert.ok(stale.ok);
243
+ assert.equal(stale.report.metrics.find(m => m.id === 'stale_hit_rate').verdict, 'below_threshold');
244
+ });
245
+ pass('样本形状 fail-closed:未知裁决/负数/重问多于首问/非布尔一律 bad_sample,不猜不截断', () => {
246
+ const base = loadFixture();
247
+ const cases = [
248
+ { promotion_decisions: [{ proposal_ref: 'r', decision: 'maybe' }] },
249
+ { reask_pairs: [{ pair_ref: 'p', first_visit_asked_fields: -1, returning_reasked_fields: 0, within_intent_ttl: true }] },
250
+ { reask_pairs: [{ pair_ref: 'p', first_visit_asked_fields: 1.5, returning_reasked_fields: 0, within_intent_ttl: true }] },
251
+ { reask_pairs: [{ pair_ref: 'p', first_visit_asked_fields: 2, returning_reasked_fields: 3, within_intent_ttl: true }] },
252
+ { reask_pairs: [{ pair_ref: 'p', first_visit_asked_fields: 2, returning_reasked_fields: 1, within_intent_ttl: 'yes' }] },
253
+ { zone_served_facts: [{ served_ref: 's', corrected: 'no' }] },
254
+ { promotion_decisions: 'nope' },
255
+ ];
256
+ for (const patch of cases) {
257
+ const r = projectZoneMetrics({ ...base, ...patch });
258
+ assert.equal(r.ok, false, `应被拒:${JSON.stringify(patch).slice(0, 120)}`);
259
+ assert.equal(r.code, 'bad_sample', `应 bad_sample:${JSON.stringify(r)}`);
260
+ }
261
+ assert.equal(projectZoneMetrics({ ...base, schema: 'other.v1' }).code, 'bad_schema');
262
+ assert.equal(projectZoneMetrics({ ...base, evidence_kind: 'exit_evidence' }).code, 'bad_schema');
263
+ assert.equal(projectZoneMetrics(null).code, 'bad_schema');
264
+ });
265
+ pass('夹具只证契约:synthetic 恒不可作 Exit 证据、恒不声称价值;observed_private 只到 candidate', () => {
266
+ const base = loadFixture();
267
+ const synthetic = projectZoneMetrics(base);
268
+ assert.ok(synthetic.ok);
269
+ assert.equal(synthetic.report.schema, SESSION_ZONE_REPORT_SCHEMA);
270
+ assert.equal(base.schema, SESSION_ZONE_FIXTURE_SCHEMA);
271
+ assert.equal(synthetic.report.exit_evidence_eligible, false);
272
+ assert.equal(synthetic.report.value_claimed, false);
273
+ assert.equal(synthetic.report.source_review.state, 'not_required_for_synthetic');
274
+ const observed = projectZoneMetrics({ ...base, evidence_kind: 'observed_private' });
275
+ assert.ok(observed.ok);
276
+ assert.equal(observed.report.exit_evidence_eligible, false, 'observed_private 同样不可自证 Exit');
277
+ assert.equal(observed.report.value_claimed, false);
278
+ assert.equal(observed.report.source_review.state, 'candidate');
279
+ assert.equal(observed.report.source_review.reviewer_ref, null, '本面永不生成 reviewer 字段');
280
+ assert.equal(observed.report.source_review.attestation_ref, null, '本面永不生成 attestation 字段');
281
+ });
282
+ // ---- 候选导出 ----
283
+ pass('候选导出:只带计数与 HMAC 假名引用(键集闭集);摘要绑定载荷;零内容字段', () => {
284
+ const report = projectZoneMetrics(loadFixture());
285
+ assert.ok(report.ok);
286
+ const root = freshRoot('export');
287
+ const ledger = ensureLedger(root);
288
+ appendZoneWrite(ledger, captureReq());
289
+ const noteId = Object.keys(readZoneLog(ledger).state.hot)[0];
290
+ appendZoneWrite(ledger, { op: 'promote', input: { note_id: noteId, kind: 'preference', owner_confirm: { surface: 'user_reply', quote: QUOTE }, ts: at(MIN) } });
291
+ const counters = projectZoneCounters(readZoneLog(ledger).events, at(2 * MIN));
292
+ const built = buildZoneObservationExport({
293
+ evidenceKind: 'synthetic_fixture',
294
+ dataset: 'p4-contract-fixture',
295
+ consent: '我同意导出形状计数用于 #255 指标契约验证',
296
+ hmacKey: HMAC_KEY,
297
+ counters,
298
+ signals: { proposal: 3, confirm: 2, deny: 1, read_hit: 4, read_miss: 2 },
299
+ observationOptIn: true,
300
+ report: report.report,
301
+ generatedAt: at(3 * MIN),
302
+ });
303
+ assert.ok(built.ok, JSON.stringify(built));
304
+ const exported = built.export;
305
+ assert.equal(exported.schema, SESSION_ZONE_EXPORT_SCHEMA);
306
+ assert.match(exported.dataset_ref, /^hmac-sha256:[0-9a-f]{64}$/);
307
+ assert.match(exported.consent_ref, /^hmac-sha256:[0-9a-f]{64}$/);
308
+ assert.notEqual(exported.dataset_ref, exported.consent_ref);
309
+ // 键集闭集:导出里不得出现任何闭集外键名(内容字段一律进不来)
310
+ for (const key of allKeys(exported)) {
311
+ assert.ok(ZONE_EXPORT_ALLOWED_KEYS.includes(key), `导出出现闭集外键:${key}`);
312
+ }
313
+ // 零内容:原话/城市/会话/笔记 id 一个都不得出现
314
+ const serialized = JSON.stringify(exported);
315
+ for (const forbidden of [QUOTE, '大理', SESS, noteId, 'p4-contract-fixture', '我同意导出']) {
316
+ assert.equal(serialized.includes(forbidden), false, `导出不得携带:${forbidden}`);
317
+ }
318
+ // 摘要绑定:改一个计数,摘要就变
319
+ const tampered = buildZoneObservationExport({
320
+ evidenceKind: 'synthetic_fixture',
321
+ dataset: 'p4-contract-fixture',
322
+ consent: '我同意导出形状计数用于 #255 指标契约验证',
323
+ hmacKey: HMAC_KEY,
324
+ counters: { ...counters, notebook: { ...counters.notebook, promoted: 99 } },
325
+ signals: { proposal: 3, confirm: 2, deny: 1, read_hit: 4, read_miss: 2 },
326
+ observationOptIn: true,
327
+ report: report.report,
328
+ generatedAt: at(3 * MIN),
329
+ });
330
+ assert.ok(tampered.ok);
331
+ assert.notEqual(tampered.export.payload_digest_sha256, exported.payload_digest_sha256);
332
+ });
333
+ pass('导出 fail-closed:缺同意声明 / 缺 HMAC key / 弱 key 一律拒绝(引用必须假名化)', () => {
334
+ const report = projectZoneMetrics(loadFixture());
335
+ assert.ok(report.ok);
336
+ const common = {
337
+ evidenceKind: 'synthetic_fixture',
338
+ dataset: 'p4',
339
+ counters: projectZoneCounters([], T0),
340
+ signals: zeroSignalCounts(),
341
+ observationOptIn: false,
342
+ report: report.report,
343
+ generatedAt: T0,
344
+ };
345
+ assert.equal(buildZoneObservationExport({ ...common, consent: '', hmacKey: HMAC_KEY }).code, 'missing_consent');
346
+ assert.equal(buildZoneObservationExport({ ...common, consent: ' ', hmacKey: HMAC_KEY }).code, 'missing_consent');
347
+ assert.equal(buildZoneObservationExport({ ...common, consent: '同意', hmacKey: undefined }).code, 'missing_hmac_key');
348
+ assert.equal(buildZoneObservationExport({ ...common, consent: '同意', hmacKey: 'short' }).code, 'invalid_hmac_key');
349
+ });
350
+ // ---- 零定时器/零网络 ----
351
+ pass('观测路径零定时器、零网络(真实 spy:fetch/setTimeout/setInterval 全程零调用)', () => {
352
+ const g = globalThis;
353
+ const realFetch = g.fetch;
354
+ const realTimeout = g.setTimeout;
355
+ const realInterval = g.setInterval;
356
+ const realImmediate = g.setImmediate;
357
+ let calls = 0;
358
+ g.fetch = () => { calls++; throw new Error('观测路径不得联网'); };
359
+ g.setTimeout = () => { calls++; throw new Error('观测路径不得挂定时器'); };
360
+ g.setInterval = () => { calls++; throw new Error('观测路径不得挂定时器'); };
361
+ g.setImmediate = () => { calls++; throw new Error('观测路径不得挂定时器'); };
362
+ try {
363
+ const root = freshRoot('spy');
364
+ const ledger = ensureLedger(root);
365
+ appendZoneWrite(ledger, captureReq());
366
+ const counters = projectZoneCounters(readZoneLog(ledger).events, at(MIN));
367
+ const report = projectZoneMetrics(loadFixture());
368
+ assert.ok(report.ok);
369
+ const built = buildZoneObservationExport({
370
+ evidenceKind: 'synthetic_fixture',
371
+ dataset: 'spy',
372
+ consent: '同意',
373
+ hmacKey: HMAC_KEY,
374
+ counters,
375
+ signals: zeroSignalCounts(),
376
+ observationOptIn: false,
377
+ report: report.report,
378
+ generatedAt: T0,
379
+ });
380
+ assert.ok(built.ok);
381
+ const sink = createZoneCounterSink(true);
382
+ sink.note('read_hit');
383
+ assert.equal(calls, 0, '观测路径零 fetch / 零定时器');
384
+ }
385
+ finally {
386
+ g.fetch = realFetch;
387
+ g.setTimeout = realTimeout;
388
+ g.setInterval = realInterval;
389
+ g.setImmediate = realImmediate;
390
+ }
391
+ });
392
+ // ---- CLI 面 ----
393
+ pass('CLI:report 输出报告;counters 无账本即全 0 且不建库;export --out 不覆盖;坏夹具 exit 2', () => {
394
+ const emptyRoot = freshRoot('cli');
395
+ const report = spawnSync('npx', ['tsx', 'scripts/session-zone-observe.ts', 'report', '--fixture', FIXTURE_PATH], { encoding: 'utf-8', timeout: 120_000 });
396
+ assert.equal(report.status, 0, `report 应 exit 0:${report.stderr?.slice(0, 300)}`);
397
+ const parsed = JSON.parse(report.stdout);
398
+ assert.equal(parsed.exit_evidence_eligible, false);
399
+ assert.equal(parsed.value_claimed, false);
400
+ assert.equal(parsed.metrics.length, 3);
401
+ const counters = spawnSync('npx', ['tsx', 'scripts/session-zone-observe.ts', 'counters', '--state-root', emptyRoot], { encoding: 'utf-8', timeout: 120_000 });
402
+ assert.equal(counters.status, 0, `counters 应 exit 0:${counters.stderr?.slice(0, 300)}`);
403
+ const countersOut = JSON.parse(counters.stdout);
404
+ assert.deepEqual(countersOut.counters.captures, { resource: 0, intent: 0 });
405
+ assert.equal(countersOut.observation_opt_in, false, '默认不 opt-in');
406
+ assert.equal(existsSync(join(emptyRoot, 'gotry-state', 'gotry-state.db')), false, '只读 CLI 不得建库');
407
+ const out = join(emptyRoot, 'zone-export.json');
408
+ const first = spawnSync('npx', ['tsx', 'scripts/session-zone-observe.ts', 'export', '--fixture', FIXTURE_PATH, '--consent', '同意导出形状计数', '--hmac-key', HMAC_KEY, '--dataset', 'p4-cli', '--state-root', emptyRoot, '--out', out], { encoding: 'utf-8', timeout: 120_000 });
409
+ assert.equal(first.status, 0, `export 应 exit 0:${first.stderr?.slice(0, 400)}`);
410
+ const exported = JSON.parse(readFileSync(out, 'utf-8'));
411
+ for (const key of allKeys(exported))
412
+ assert.ok(ZONE_EXPORT_ALLOWED_KEYS.includes(key), `CLI 导出出现闭集外键:${key}`);
413
+ assert.equal(JSON.stringify(exported).includes('p4-cli'), false, 'dataset 名不得明文出现');
414
+ const again = spawnSync('npx', ['tsx', 'scripts/session-zone-observe.ts', 'export', '--fixture', FIXTURE_PATH, '--consent', '同意导出形状计数', '--hmac-key', HMAC_KEY, '--dataset', 'p4-cli', '--state-root', emptyRoot, '--out', out], { encoding: 'utf-8', timeout: 120_000 });
415
+ assert.equal(again.status, 2, '拒绝覆盖既有导出');
416
+ assert.match(again.stderr, /output_exists/);
417
+ const badFixture = join(emptyRoot, 'bad.json');
418
+ const badRun = spawnSync('npx', ['tsx', 'scripts/session-zone-observe.ts', 'report', '--fixture', badFixture], { encoding: 'utf-8', timeout: 120_000 });
419
+ assert.notEqual(badRun.status, 0, '夹具不可读必须非 0 退出');
420
+ });
421
+ // ---- #20 冻结闸不被触碰 ----
422
+ pass('#20 scorer 的冻结 p4 闸不被本面改动:#228 manifest 仍 closed、触发器仍全 false', () => {
423
+ const root = freshRoot('p4gate');
424
+ initMemoryLifecycleDataset({
425
+ stateRoot: root,
426
+ consent: '我同意用于 #255 指标契约自测',
427
+ hmacKey: HMAC_KEY,
428
+ dataset: 'p4-gate-check',
429
+ sourceKind: 'synthetic_fixture',
430
+ waitCodes: ['vendor_wait'],
431
+ });
432
+ const store = readStoreForTests(root);
433
+ assert.deepEqual(store.manifest.p4, { state: 'closed', triggers: { real_usage: false, multi_user: false } });
434
+ assert.deepEqual(store.manifest.measurement_policy.minimum_pair_count_for_exit, 5);
435
+ assert.equal(store.manifest.measurement_policy.target_median_reduction_ratio, 0.5);
436
+ // 冻结契约与 #20 同源的两个数:样本线与收益线不另发明
437
+ assert.equal(SESSION_ZONE_METRIC_CONTRACT.minimum_sample_count, store.manifest.measurement_policy.minimum_pair_count_for_exit);
438
+ assert.equal(SESSION_ZONE_METRIC_CONTRACT.min_reask_avoidance_ratio, store.manifest.measurement_policy.target_median_reduction_ratio);
439
+ });
440
+ // ---- 接线面真的在喂计数(否则确认率恒为 1 = 假指标) ----
441
+ pass('接线面喂计数:propose/deny/confirm 与读回命中各自入账;opt-in 关时同样调用链零计数', () => {
442
+ const root = freshRoot('signals');
443
+ const ledger = ensureLedger(root);
444
+ const ts = new Date().toISOString();
445
+ // 源笔记用 availability + 无权威形状键的载荷:lesson 晋升本就该留在笔记本
446
+ const mk = (payload) => {
447
+ const before = new Set(Object.keys(readZoneLog(ledger).state.hot));
448
+ assert.ok(appendZoneWrite(ledger, { op: 'capture', input: { session_ref: SESS, tier: 'resource', kind: 'availability', payload, evidence_ref: { session_ref: SESS, turn: 0 }, ts } }).ok);
449
+ const born = Object.keys(readZoneLog(ledger).state.hot).find(id => !before.has(id));
450
+ assert.ok(born, `捕获应产生新主体:${JSON.stringify(payload)}`);
451
+ return born;
452
+ };
453
+ const noteA = mk({ lesson: 'too_tight' });
454
+ const noteB = mk({ lesson: 'too_long' });
455
+ // opt-in 关:同一条调用链零计数
456
+ configureZoneObservation(false);
457
+ applyZoneNote(ledger, { action: 'propose', noteId: noteA, promoteKind: 'lesson', session_ref: SESS, ts });
458
+ applyZoneNote(ledger, { action: 'deny', noteId: noteA, session_ref: SESS, ts });
459
+ renderSessionZoneBrief({ ledger, now: ts, sessionRef: SESS, zoneSwitch: 'on' });
460
+ assert.deepEqual(zoneSignalSnapshot(), zeroSignalCounts(), 'opt-in 关闭时整条链零计数');
461
+ // opt-in 开:计数口径与指标 ① 的 proposal_ref 去重口径一致
462
+ configureZoneObservation(true);
463
+ resetZoneSessionBindingForTests(); // 清提议去重集,从干净口径开始
464
+ assert.ok(applyZoneNote(ledger, { action: 'propose', noteId: noteA, promoteKind: 'lesson', session_ref: SESS, ts }).ok);
465
+ assert.ok(applyZoneNote(ledger, { action: 'propose', noteId: noteA, promoteKind: 'lesson', session_ref: SESS, ts }).ok);
466
+ assert.deepEqual(zoneSignalSnapshot().proposal, 1, '同一提议重复两次仍是一个提议(与 proposal_ref 去重同口径)');
467
+ assert.ok(applyZoneNote(ledger, { action: 'propose', noteId: noteB, promoteKind: 'lesson', session_ref: SESS, ts }).ok);
468
+ assert.equal(zoneSignalSnapshot().proposal, 2, '不同提议各记一次');
469
+ assert.ok(applyZoneNote(ledger, { action: 'deny', noteId: noteA, session_ref: SESS, ts }).ok);
470
+ assert.ok(applyZoneNote(ledger, { action: 'deny', noteId: noteA, session_ref: SESS, ts }).ok);
471
+ assert.equal(zoneSignalSnapshot().deny, 1, '同一提议被否两次仍是一次否决');
472
+ const promoted = promoteWithRouting(ledger, { noteId: noteB, kind: 'lesson', ownerQuote: QUOTE, surface: 'user_reply', ts });
473
+ assert.ok(promoted.ok, JSON.stringify(promoted));
474
+ const replay = promoteWithRouting(ledger, { noteId: noteB, kind: 'lesson', ownerQuote: QUOTE, surface: 'user_reply', ts });
475
+ assert.ok(replay.ok && replay.appended === false, '幂等重放不是第二次确认');
476
+ assert.equal(zoneSignalSnapshot().confirm, 1, '确认只在真的落了条目时记一次');
477
+ const hit = renderSessionZoneBrief({ ledger, now: ts, sessionRef: SESS, zoneSwitch: 'on' });
478
+ assert.notEqual(hit, '', '有活笔记 = 读命中');
479
+ const emptyRoot = freshRoot('signals-empty');
480
+ renderSessionZoneBrief({ ledger: ensureLedger(emptyRoot), now: ts, sessionRef: SESS, zoneSwitch: 'on' });
481
+ assert.deepEqual(zoneSignalSnapshot(), { proposal: 2, confirm: 1, deny: 1, read_hit: 1, read_miss: 1 });
482
+ // deny 零落账:否决不动账本
483
+ assert.equal(Object.keys(readZoneLog(ledger).state.hot).length, 2);
484
+ configureZoneObservation(false);
485
+ });
486
+ console.log(`\nSESSION ZONE OBSERVATION TESTS: ${n}/14 OK(P4-4 观测与指标:形状计数零内容/opt-in 默认关/阈值冻结 contract_invalid/样本不足 insufficient_sample/夹具只证契约不证价值/候选导出键集闭集与假名引用/零定时器零网络 spy/#20 p4 闸未被触碰;隔离 stateRoot,全离线)`);
487
+ }
488
+ finally {
489
+ configureZoneObservation(false);
490
+ for (const root of roots)
491
+ rmSync(root, { recursive: true, force: true });
492
+ }
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * 会话双区记忆观测 CLI(P4-4,issue #255;#228 收集器同族的显式 opt-in 面)。
4
+ *
5
+ * report --fixture <file> 只读指标报告(夹具只证契约,不证价值)
6
+ * counters [--state-root <root>] 只读形状计数(零 id/零载荷/零时间戳)
7
+ * export --fixture <file> --consent <text> --hmac-key <key> --dataset <name>
8
+ * [--state-root <root>] [--out <file>]
9
+ * 候选级导出(HMAC 假名引用 + 计数,
10
+ * 永不自证;--out 不覆盖既有文件)
11
+ *
12
+ * 全程**只读**:不建库、不写账本、零网络、零定时器;--out 是唯一写动作且拒绝覆盖。
13
+ * 运行:cd ts && npx tsx scripts/session-zone-observe.ts report --fixture data/session-zone-metric-fixture.json
14
+ */
15
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { openLedgerIfExists } from '../src/state-ledger.js';
17
+ import { readZoneLog } from '../src/session-zone-ledger.js';
18
+ import { buildZoneObservationExport, projectZoneCounters, projectZoneMetrics, zeroSignalCounts, zoneSignalSnapshot, isZoneObservationOptIn, } from '../src/session-zone-observation.js';
19
+ import { ZONE_EVENT_KINDS } from '../src/session-zones.js';
20
+ const HELP = `用法: npx tsx scripts/session-zone-observe.ts <report|counters|export> [options]
21
+ report --fixture <file>
22
+ counters [--state-root <root>]
23
+ export --fixture <file> --consent <text> --hmac-key <key> --dataset <name> [--state-root <root>] [--out <file>]
24
+ 纪律:全程只读(不建库/不写账本/零网络/零定时器);夹具只证契约不证价值;
25
+ 导出只带计数与 HMAC 假名引用,source_review 永远停在 candidate(自证不算证据)。`;
26
+ function usage(message) {
27
+ console.error(message);
28
+ console.error(HELP);
29
+ process.exit(1);
30
+ }
31
+ const argv = process.argv.slice(2);
32
+ const cmd = argv[0];
33
+ const flags = new Map();
34
+ for (let i = 1; i < argv.length; i += 1) {
35
+ const token = argv[i];
36
+ if (!token.startsWith('--'))
37
+ usage(`未知位置参数:${token}`);
38
+ const value = argv[i + 1];
39
+ if (value === undefined || value.startsWith('--'))
40
+ usage(`${token} 缺少值`);
41
+ if (flags.has(token))
42
+ usage(`重复选项 ${token}`);
43
+ flags.set(token, value);
44
+ i += 1;
45
+ }
46
+ function readFixture() {
47
+ const path = flags.get('--fixture');
48
+ if (!path)
49
+ usage('需要 --fixture <file>');
50
+ try {
51
+ return JSON.parse(readFileSync(path, 'utf-8'));
52
+ }
53
+ catch (e) {
54
+ usage(`夹具不可读或非法 JSON:${e instanceof Error ? e.message : String(e)}`);
55
+ }
56
+ }
57
+ /**
58
+ * 只读计数:无账本 = 全 0(不建库)。
59
+ * 日志触读上界即拒:`readEvents` 丢的是最老事件,残缺日志算出的计数会被当成全量
60
+ * (与 forget/export 同一条截断纪律)。
61
+ */
62
+ function countersOf(now) {
63
+ const root = flags.get('--state-root') ?? '.';
64
+ const ledger = openLedgerIfExists(root);
65
+ if (!ledger)
66
+ return projectZoneCounters([], now);
67
+ const read = readZoneLog(ledger);
68
+ if (read.truncated) {
69
+ console.error('log_truncated: 分区事件日志触到读上界,计数拒绝在残缺日志上输出(会被当成全量)');
70
+ process.exit(2);
71
+ }
72
+ return projectZoneCounters(read.events, now);
73
+ }
74
+ const now = new Date().toISOString();
75
+ switch (cmd) {
76
+ case 'report': {
77
+ const r = projectZoneMetrics(readFixture());
78
+ if (!r.ok) {
79
+ console.error(`${r.code}: ${r.detail}`);
80
+ process.exit(2);
81
+ }
82
+ console.log(JSON.stringify(r.report, null, 2));
83
+ break;
84
+ }
85
+ case 'counters': {
86
+ console.log(JSON.stringify({ counters: countersOf(now), event_kinds: ZONE_EVENT_KINDS, observation_opt_in: isZoneObservationOptIn() }, null, 2));
87
+ break;
88
+ }
89
+ case 'export': {
90
+ const consent = flags.get('--consent');
91
+ const dataset = flags.get('--dataset');
92
+ const hmacKey = flags.get('--hmac-key');
93
+ if (!consent)
94
+ usage('需要 --consent <text>(显式同意声明)');
95
+ if (!dataset)
96
+ usage('需要 --dataset <name>');
97
+ const report = projectZoneMetrics(readFixture());
98
+ if (!report.ok) {
99
+ console.error(`${report.code}: ${report.detail}`);
100
+ process.exit(2);
101
+ }
102
+ const optIn = isZoneObservationOptIn();
103
+ const built = buildZoneObservationExport({
104
+ evidenceKind: report.report.evidence_kind,
105
+ dataset,
106
+ consent,
107
+ hmacKey,
108
+ counters: countersOf(now),
109
+ signals: optIn ? zoneSignalSnapshot() : zeroSignalCounts(),
110
+ observationOptIn: optIn,
111
+ report: report.report,
112
+ generatedAt: now,
113
+ });
114
+ if (!built.ok) {
115
+ console.error(`${built.code}: ${built.detail}`);
116
+ process.exit(2);
117
+ }
118
+ const text = JSON.stringify(built.export, null, 2);
119
+ const out = flags.get('--out');
120
+ if (out) {
121
+ if (existsSync(out)) {
122
+ console.error(`output_exists: ${out}(拒绝覆盖既有导出)`);
123
+ process.exit(2);
124
+ }
125
+ writeFileSync(out, text + '\n', 'utf-8');
126
+ console.log(`候选级导出已写出(只带计数与假名引用,未自证):${out}`);
127
+ }
128
+ else {
129
+ console.log(text);
130
+ }
131
+ break;
132
+ }
133
+ default:
134
+ usage(cmd ? `未知命令 ${cmd}` : '缺少命令');
135
+ }