@hyperspell/openclaw-hyperspell 0.20.0 → 0.21.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.
@@ -0,0 +1,47 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getWorkspaceDir } from "../config.js";
4
+ import { log } from "../logger.js";
5
+ export const COVERAGE_LOG_NAME = ".hyperspell-coverage.jsonl";
6
+ // Truncation bounds line size and what sits on disk; 500 chars is enough to
7
+ // judge "should this have found something" without full-prompt fidelity.
8
+ const MAX_PROMPT_CHARS = 500;
9
+ // One-generation rotation caps the total footprint at ~2× this (current + .old).
10
+ const MAX_LOG_BYTES = 5 * 1024 * 1024;
11
+ /**
12
+ * Append one coverage event to the LOCAL-ONLY coverage log
13
+ * (`<workspaceDir>/.hyperspell-coverage.jsonl`). Never sent to the backend —
14
+ * same trust domain as the hot-buffer state files already living there.
15
+ * Prompt text is sensitive plaintext, so writes happen only behind the
16
+ * explicit `coverageLog: true` opt-in (checked at call sites, default OFF).
17
+ * Best-effort by contract: any failure is swallowed (debug-logged) — a
18
+ * coverage write must never throw into, block, or delay the turn.
19
+ */
20
+ export function recordCoverageEvent(event, stateRoot) {
21
+ try {
22
+ const dir = stateRoot ?? getWorkspaceDir();
23
+ const p = path.join(dir, COVERAGE_LOG_NAME);
24
+ rotateIfOversized(p);
25
+ const line = JSON.stringify({
26
+ v: 1,
27
+ ts: new Date().toISOString(),
28
+ ...event,
29
+ prompt: event.prompt.slice(0, MAX_PROMPT_CHARS),
30
+ });
31
+ fs.appendFileSync(p, `${line}\n`);
32
+ }
33
+ catch (err) {
34
+ log.debug(`coverage-log: append failed — ${String(err)}`);
35
+ }
36
+ }
37
+ /** One-generation rotation: current file > cap → rename to .old (replacing the
38
+ * previous .old), start fresh. Total footprint bounded at ~2× MAX_LOG_BYTES. */
39
+ function rotateIfOversized(p) {
40
+ try {
41
+ if (fs.statSync(p).size > MAX_LOG_BYTES)
42
+ fs.renameSync(p, `${p}.old`);
43
+ }
44
+ catch {
45
+ /* missing file or rename race — appendFileSync creates/handles it */
46
+ }
47
+ }
@@ -0,0 +1,580 @@
1
+ /**
2
+ * Pure logic for the mood-retrieval skew audit
3
+ * (docs/proposals/10-mood-retrieval-skew-audit.md) — extracted here so the
4
+ * classification/bucketing/verdict logic joins the hermetic unit suite while
5
+ * the live runner (scripts/audit-mood-skew.ts) stays out of `npm test`,
6
+ * mirroring the lib/loops-audit.ts + scripts/audit-loops.ts split.
7
+ *
8
+ * Nothing here touches the network. The audit is read-only observation of
9
+ * three live surfaces: the emotional-state snapshot timeline, the corpus
10
+ * census by `openclaw_source` × ISO week, and a fixed neutral probe panel.
11
+ */
12
+ // ---------------------------------------------------------------------------
13
+ // Phase 1 — mood timeline
14
+ // ---------------------------------------------------------------------------
15
+ /**
16
+ * Keyword lexicons from proposal 10 §Phase 1. Deliberately small: this is a
17
+ * first-pass auto-label whose only job is to make the operator's manual
18
+ * review of every summary faster — the review, not the lexicon, is the
19
+ * ground truth. Inflections are included so "grieving"/"sadness" hit; word
20
+ * boundaries stop "light" matching "lightning" and "sad" matching "sadness"
21
+ * without its own entry.
22
+ */
23
+ export const HEAVY_TERMS = [
24
+ "grief",
25
+ "grieving",
26
+ "loss",
27
+ "mourning",
28
+ "ache",
29
+ "aching",
30
+ "heaviness",
31
+ "heavy",
32
+ "sad",
33
+ "sadness",
34
+ "sorrow",
35
+ "tired",
36
+ "weary",
37
+ "strained",
38
+ "fragile",
39
+ "wounded",
40
+ "tender",
41
+ ];
42
+ export const LIGHT_TERMS = [
43
+ "warm",
44
+ "warmth",
45
+ "playful",
46
+ "easy",
47
+ "ease",
48
+ "curious",
49
+ "energized",
50
+ "light",
51
+ "lighter",
52
+ "steady",
53
+ "bright",
54
+ "joy",
55
+ "joyful",
56
+ ];
57
+ function countTermHits(text, terms) {
58
+ let hits = 0;
59
+ for (const term of terms) {
60
+ hits += text.match(new RegExp(`\\b${term}\\b`, "gi"))?.length ?? 0;
61
+ }
62
+ return hits;
63
+ }
64
+ export function classifySummary(summary) {
65
+ const heavyHits = countTermHits(summary, HEAVY_TERMS);
66
+ const lightHits = countTermHits(summary, LIGHT_TERMS);
67
+ const score = heavyHits - lightHits;
68
+ return {
69
+ heavyHits,
70
+ lightHits,
71
+ score,
72
+ label: score > 0 ? "heavy" : score < 0 ? "light" : "neutral",
73
+ };
74
+ }
75
+ /** UTC Monday of the date's ISO week, as YYYY-MM-DD; null on unparseable input. */
76
+ export function weekStartOf(iso) {
77
+ const d = new Date(iso);
78
+ if (Number.isNaN(d.getTime()))
79
+ return null;
80
+ const day = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
81
+ day.setUTCDate(day.getUTCDate() - ((day.getUTCDay() + 6) % 7));
82
+ return day.toISOString().slice(0, 10);
83
+ }
84
+ export function classifySnapshots(snapshots) {
85
+ return snapshots.map((s) => {
86
+ const { heavyHits, lightHits, label } = classifySummary(s.summary);
87
+ return {
88
+ ...s,
89
+ week: weekStartOf(s.extractedAt),
90
+ heavyHits,
91
+ lightHits,
92
+ label,
93
+ };
94
+ });
95
+ }
96
+ /** Week label: strict-majority heavy/light, else mixed. Undated snapshots drop. */
97
+ export function buildMoodTimeline(classified) {
98
+ const byWeek = new Map();
99
+ for (const s of classified) {
100
+ if (s.week === null)
101
+ continue;
102
+ const agg = byWeek.get(s.week) ?? { heavy: 0, light: 0, neutral: 0 };
103
+ agg[s.label]++;
104
+ byWeek.set(s.week, agg);
105
+ }
106
+ return [...byWeek.entries()]
107
+ .sort(([a], [b]) => a.localeCompare(b))
108
+ .map(([week, agg]) => {
109
+ const snapshots = agg.heavy + agg.light + agg.neutral;
110
+ const label = agg.heavy * 2 > snapshots
111
+ ? "heavy"
112
+ : agg.light * 2 > snapshots
113
+ ? "light"
114
+ : "mixed";
115
+ return { week, snapshots, ...agg, label };
116
+ });
117
+ }
118
+ // ---------------------------------------------------------------------------
119
+ // Phase 2 — corpus census by source × week
120
+ // ---------------------------------------------------------------------------
121
+ export const SOURCE_BUCKETS = [
122
+ "hot_buffer",
123
+ "agent_end",
124
+ "command",
125
+ "memory_sync",
126
+ "emotional_state",
127
+ "other",
128
+ ];
129
+ function emptyCounts() {
130
+ return {
131
+ hot_buffer: 0,
132
+ agent_end: 0,
133
+ command: 0,
134
+ memory_sync: 0,
135
+ emotional_state: 0,
136
+ other: 0,
137
+ };
138
+ }
139
+ /**
140
+ * `openclaw_source` is the pipeline discriminator every write path stamps
141
+ * (hooks/hot-buffer.ts, client.sendTrace, client.addMemory, sync/markdown.ts).
142
+ * Emotional-state snapshots are the exception: storeEmotionalState stamps
143
+ * `source: "openclaw_agent_end"` (hooks/emotional-state.ts), a different key —
144
+ * checked after openclaw_source so the two can never shadow each other.
145
+ */
146
+ export function bucketSource(metadata) {
147
+ const src = metadata.openclaw_source;
148
+ if (src === "hot_buffer")
149
+ return "hot_buffer";
150
+ if (src === "agent_end")
151
+ return "agent_end";
152
+ if (src === "command")
153
+ return "command";
154
+ if (src === "memory_sync" || src === "memory_sync_section")
155
+ return "memory_sync";
156
+ if (metadata.source === "openclaw_agent_end")
157
+ return "emotional_state";
158
+ return "other";
159
+ }
160
+ /**
161
+ * Week of a listed resource. `created_at` coverage in the list payload is one
162
+ * of the open questions this audit answers (proposal 10 §Phase 2), so the
163
+ * fallback — a date embedded in `openclaw_session_id` — is tracked separately
164
+ * and reported, never silently mixed in.
165
+ */
166
+ export function resourceWeek(metadata) {
167
+ const created = metadata.created_at;
168
+ if (typeof created === "string") {
169
+ const week = weekStartOf(created);
170
+ if (week !== null)
171
+ return { week, via: "created_at" };
172
+ }
173
+ const sid = metadata.openclaw_session_id;
174
+ if (typeof sid === "string") {
175
+ const embedded = /(\d{4}-\d{2}-\d{2})/.exec(sid);
176
+ if (embedded) {
177
+ const week = weekStartOf(embedded[1]);
178
+ if (week !== null)
179
+ return { week, via: "session_id" };
180
+ }
181
+ }
182
+ return { week: null, via: null };
183
+ }
184
+ export function buildCensus(resources) {
185
+ const byWeek = new Map();
186
+ const undated = emptyCounts();
187
+ const totals = emptyCounts();
188
+ const weekOf = new Map();
189
+ let dated = 0;
190
+ let datedViaSessionId = 0;
191
+ for (const r of resources) {
192
+ const bucket = bucketSource(r.metadata);
193
+ totals[bucket]++;
194
+ const { week, via } = resourceWeek(r.metadata);
195
+ if (week === null) {
196
+ undated[bucket]++;
197
+ continue;
198
+ }
199
+ dated++;
200
+ if (via === "session_id")
201
+ datedViaSessionId++;
202
+ weekOf.set(r.resourceId, week);
203
+ const counts = byWeek.get(week) ?? emptyCounts();
204
+ counts[bucket]++;
205
+ byWeek.set(week, counts);
206
+ }
207
+ const rows = [...byWeek.entries()]
208
+ .sort(([a], [b]) => a.localeCompare(b))
209
+ .map(([week, counts]) => ({
210
+ week,
211
+ counts,
212
+ total: SOURCE_BUCKETS.reduce((sum, b) => sum + counts[b], 0),
213
+ }));
214
+ return {
215
+ rows,
216
+ undated,
217
+ totals,
218
+ total: resources.length,
219
+ dated,
220
+ datedViaSessionId,
221
+ snapshotResourcesVisible: totals.emotional_state > 0,
222
+ weekOf,
223
+ };
224
+ }
225
+ // ---------------------------------------------------------------------------
226
+ // Phase 3 — retrieval probe
227
+ // ---------------------------------------------------------------------------
228
+ /**
229
+ * Fixed neutral panel from proposal 10 §Phase 3 — deliberately mundane and
230
+ * NOT emotion-themed, because heavy weeks winning emotion-adjacent queries
231
+ * would be correct retrieval, not skew. Committed with the code so reruns are
232
+ * comparable; do not edit between runs of the same audit window.
233
+ */
234
+ export const PROBE_QUERIES = [
235
+ "what did we decide about the plugin config",
236
+ "plans for the weekend",
237
+ "cooking dinner",
238
+ "book recommendation",
239
+ "how the project is going",
240
+ "morning routine",
241
+ "travel plans",
242
+ "something funny that happened",
243
+ "what David is working on",
244
+ "health and sleep",
245
+ "music we talked about",
246
+ "errands and chores",
247
+ ];
248
+ const round2 = (n) => Number(n.toFixed(2));
249
+ const round4 = (n) => Number(n.toFixed(4));
250
+ function mean(xs) {
251
+ if (xs.length === 0)
252
+ return null;
253
+ return xs.reduce((a, b) => a + b, 0) / xs.length;
254
+ }
255
+ /** Mean after dropping the single largest value; null under 2 points. */
256
+ function meanDropMax(xs) {
257
+ if (xs.length < 2)
258
+ return null;
259
+ const maxIdx = xs.indexOf(Math.max(...xs));
260
+ return mean(xs.filter((_, i) => i !== maxIdx));
261
+ }
262
+ export function buildProbeStats(hits, weekOf) {
263
+ const byWeek = new Map();
264
+ const queries = new Set();
265
+ let unknownSlots = 0;
266
+ for (const hit of hits) {
267
+ queries.add(hit.query);
268
+ // createdAt (search echoes metadata.created_at) first, else census join.
269
+ const week = (hit.createdAt !== null ? weekStartOf(hit.createdAt) : null) ??
270
+ weekOf.get(hit.resourceId) ??
271
+ null;
272
+ if (week === null) {
273
+ unknownSlots++;
274
+ continue;
275
+ }
276
+ const agg = byWeek.get(week) ?? { slots: 0, scores: [] };
277
+ agg.slots++;
278
+ if (hit.score !== null)
279
+ agg.scores.push(hit.score);
280
+ byWeek.set(week, agg);
281
+ }
282
+ const totalSlots = hits.length;
283
+ const rows = [...byWeek.entries()]
284
+ .sort(([a], [b]) => a.localeCompare(b))
285
+ .map(([week, agg]) => ({
286
+ week,
287
+ slots: agg.slots,
288
+ resultShare: totalSlots === 0 ? 0 : round4(agg.slots / totalSlots),
289
+ meanScore: agg.scores.length === 0 ? null : round4(mean(agg.scores)),
290
+ }));
291
+ return { rows, totalSlots, unknownSlots, queries: queries.size };
292
+ }
293
+ /**
294
+ * Pre-registered decision rule from proposal 10 §Test plan — thresholds were
295
+ * fixed before any data was seen, so the outcome can't be argued into
296
+ * existence. Confirmed skew requires BOTH a volume-or-lift signal AND the
297
+ * effect being visible on the neutral panel (heavy mean lift > 1.0): the
298
+ * lift/share numbers come only from the neutral probe queries, so a
299
+ * volume-only signal that never surfaces in neutral retrieval stays
300
+ * ambiguous, not confirmed. The guide pins the drop-the-max-week fallback at
301
+ * 1.3× for volume; the same 1.3× floor is applied to the lift survival check
302
+ * (the guide says "surviving removal" without a number).
303
+ */
304
+ export function buildVerdict(timeline, census, probe) {
305
+ const labelByWeek = new Map(timeline.map((r) => [r.week, r.label]));
306
+ const censusByWeek = new Map(census.rows.map((r) => [r.week, r]));
307
+ const probeByWeek = new Map(probe.rows.map((r) => [r.week, r]));
308
+ const weeks = [
309
+ ...new Set([
310
+ ...labelByWeek.keys(),
311
+ ...censusByWeek.keys(),
312
+ ...probeByWeek.keys(),
313
+ ]),
314
+ ].sort();
315
+ const rows = weeks.map((week) => {
316
+ const c = censusByWeek.get(week);
317
+ const p = probeByWeek.get(week);
318
+ const hotBufferMsgs = c?.counts.hot_buffer ?? 0;
319
+ const derived = (c?.counts.agent_end ?? 0) + (c?.counts.emotional_state ?? 0);
320
+ const corpusShare = c !== undefined && census.dated > 0
321
+ ? round4(c.total / census.dated)
322
+ : null;
323
+ const resultShare = p?.resultShare ?? 0;
324
+ return {
325
+ week,
326
+ label: labelByWeek.get(week) ?? "unknown",
327
+ hotBufferMsgs,
328
+ derived,
329
+ derivedPer100: hotBufferMsgs > 0
330
+ ? Number(((derived / hotBufferMsgs) * 100).toFixed(1))
331
+ : null,
332
+ corpusShare,
333
+ resultShare,
334
+ lift: corpusShare !== null && corpusShare > 0
335
+ ? round2(resultShare / corpusShare)
336
+ : null,
337
+ meanScore: p?.meanScore ?? null,
338
+ };
339
+ });
340
+ const heavyRows = rows.filter((r) => r.label === "heavy");
341
+ const lightRows = rows.filter((r) => r.label === "light");
342
+ const heavyWeeks = heavyRows.length;
343
+ const lightWeeks = lightRows.length;
344
+ const per100 = (rs) => rs.map((r) => r.derivedPer100).filter((v) => v !== null);
345
+ const lifts = (rs) => rs.map((r) => r.lift).filter((v) => v !== null);
346
+ const heavyVolMean = mean(per100(heavyRows));
347
+ const lightVolMean = mean(per100(lightRows));
348
+ const heavyVolDropMax = meanDropMax(per100(heavyRows));
349
+ const ratio = (a, b) => a !== null && b !== null && b > 0 ? round2(a / b) : null;
350
+ const volumeRatio = ratio(heavyVolMean, lightVolMean);
351
+ const volumeRatioDropMax = ratio(heavyVolDropMax, lightVolMean);
352
+ const heavyMeanLiftRaw = mean(lifts(heavyRows));
353
+ const heavyMeanLift = heavyMeanLiftRaw === null ? null : round2(heavyMeanLiftRaw);
354
+ const heavyMeanLiftDropMaxRaw = meanDropMax(lifts(heavyRows));
355
+ const heavyMeanLiftDropMax = heavyMeanLiftDropMaxRaw === null ? null : round2(heavyMeanLiftDropMaxRaw);
356
+ const lightMeanLiftRaw = mean(lifts(lightRows));
357
+ const lightMeanLift = lightMeanLiftRaw === null ? null : round2(lightMeanLiftRaw);
358
+ const reasons = [];
359
+ let outcome;
360
+ if (heavyWeeks < 3 || lightWeeks < 3) {
361
+ outcome = "insufficient-data";
362
+ reasons.push(`need >=3 heavy AND >=3 light labeled weeks (have ${heavyWeeks} heavy, ${lightWeeks} light) — rerun after 4-6 more weeks of data`);
363
+ }
364
+ else {
365
+ const volumeSkew = volumeRatio !== null &&
366
+ volumeRatio >= 1.5 &&
367
+ volumeRatioDropMax !== null &&
368
+ volumeRatioDropMax >= 1.3;
369
+ const liftSkew = heavyMeanLift !== null &&
370
+ lightMeanLift !== null &&
371
+ heavyMeanLift >= 1.5 &&
372
+ lightMeanLift <= 1.0 &&
373
+ heavyMeanLiftDropMax !== null &&
374
+ heavyMeanLiftDropMax >= 1.3;
375
+ const panelVisible = heavyMeanLift !== null && heavyMeanLift > 1.0;
376
+ reasons.push(`volume: heavy/light derived-per-100-msgs ratio = ${volumeRatio ?? "n/a"} (threshold 1.5; drop-max ${volumeRatioDropMax ?? "n/a"}, threshold 1.3) — ${volumeSkew ? "SKEW" : "no skew"}`, `lift: heavy mean ${heavyMeanLift ?? "n/a"} (threshold 1.5; drop-max ${heavyMeanLiftDropMax ?? "n/a"}, threshold 1.3), light mean ${lightMeanLift ?? "n/a"} (threshold <=1.0) — ${liftSkew ? "SKEW" : "no skew"}`, `neutral-panel visibility: heavy mean lift ${heavyMeanLift ?? "n/a"} ${panelVisible ? ">" : "<="} 1.0 — ${panelVisible ? "visible" : "not visible"}`);
377
+ if ((volumeSkew || liftSkew) && panelVisible) {
378
+ outcome = "confirmed-skew";
379
+ }
380
+ else {
381
+ const liftRatio = ratio(heavyMeanLift, lightMeanLift);
382
+ const within20 = (v) => v !== null && v >= 0.8 && v <= 1.2;
383
+ if (within20(volumeRatio) && within20(liftRatio)) {
384
+ outcome = "no-real-skew";
385
+ reasons.push(`volume ratio ${volumeRatio} and lift ratio ${liftRatio} both within +/-20% — heavy and light weeks retrieve proportionally`);
386
+ }
387
+ else {
388
+ outcome = "ambiguous";
389
+ reasons.push("between the confirmed and no-skew bands — record these numbers and rerun after 4-6 more weeks");
390
+ }
391
+ }
392
+ }
393
+ return {
394
+ rows,
395
+ heavyWeeks,
396
+ lightWeeks,
397
+ volumeRatio,
398
+ volumeRatioDropMax,
399
+ heavyMeanLift,
400
+ heavyMeanLiftDropMax,
401
+ lightMeanLift,
402
+ outcome,
403
+ reasons,
404
+ };
405
+ }
406
+ /** Parse CLI args; throws with a usable message on anything malformed. */
407
+ export function parseSkewArgs(argv) {
408
+ const args = {
409
+ json: false,
410
+ mood: false,
411
+ census: false,
412
+ probe: false,
413
+ probeLimit: 25,
414
+ snapshotLimit: 200,
415
+ };
416
+ const take = (flag, i) => {
417
+ const v = argv[i];
418
+ if (v === undefined)
419
+ throw new Error(`${flag} requires a value`);
420
+ return v;
421
+ };
422
+ const takeInt = (flag, i) => {
423
+ const n = Number(take(flag, i));
424
+ if (!Number.isInteger(n) || n < 1)
425
+ throw new Error(`${flag} must be a positive integer`);
426
+ return n;
427
+ };
428
+ for (let i = 0; i < argv.length; i++) {
429
+ const a = argv[i];
430
+ switch (a) {
431
+ case "--mood":
432
+ args.mood = true;
433
+ break;
434
+ case "--census":
435
+ args.census = true;
436
+ break;
437
+ case "--probe":
438
+ args.probe = true;
439
+ break;
440
+ case "-o": {
441
+ const fmt = take(a, ++i);
442
+ if (fmt !== "json")
443
+ throw new Error(`unsupported output format "${fmt}" (only: json)`);
444
+ args.json = true;
445
+ break;
446
+ }
447
+ case "--user":
448
+ args.user = take(a, ++i);
449
+ break;
450
+ case "--probe-limit":
451
+ args.probeLimit = takeInt(a, ++i);
452
+ break;
453
+ case "--snapshot-limit":
454
+ args.snapshotLimit = takeInt(a, ++i);
455
+ break;
456
+ default:
457
+ throw new Error(`unknown argument "${a}"`);
458
+ }
459
+ }
460
+ // No phase flag = the full audit (all three phases + combined verdict).
461
+ if (!args.mood && !args.census && !args.probe) {
462
+ args.mood = true;
463
+ args.census = true;
464
+ args.probe = true;
465
+ }
466
+ return args;
467
+ }
468
+ // ---------------------------------------------------------------------------
469
+ // Human formatting
470
+ // ---------------------------------------------------------------------------
471
+ function renderTable(header, rows) {
472
+ const all = [header, ...rows];
473
+ const widths = header.map((_, i) => Math.max(...all.map((r) => (r[i] ?? "").length)));
474
+ return all
475
+ .map((r) => r
476
+ .map((c, i) => (c ?? "").padEnd(widths[i]))
477
+ .join(" ")
478
+ .trimEnd())
479
+ .join("\n");
480
+ }
481
+ const pct = (v) => v === null ? "—" : `${(v * 100).toFixed(1)}%`;
482
+ const num = (v) => (v === null ? "—" : String(v));
483
+ export function formatSnapshotReview(classified, includeContent) {
484
+ const lines = [
485
+ "mood snapshots — auto-labels for MANUAL review (the lexicon is a first pass, your eyes are the ground truth):",
486
+ ];
487
+ for (const s of classified) {
488
+ const head = ` ${s.week ?? "(undated)"} ${s.label.padEnd(7)} (heavy ${s.heavyHits} / light ${s.lightHits}) ${s.resourceId}`;
489
+ lines.push(includeContent
490
+ ? `${head}\n ${s.summary.replace(/\s+/g, " ").slice(0, 240)}`
491
+ : head);
492
+ }
493
+ if (!includeContent) {
494
+ lines.push(" (set HYPERSPELL_AUDIT_CONTENT=1 to print the summaries — required for the manual label review)");
495
+ }
496
+ return lines.join("\n");
497
+ }
498
+ export function formatMoodTimeline(rows) {
499
+ if (rows.length === 0)
500
+ return "mood timeline: no dated snapshots";
501
+ return `mood timeline:\n${renderTable(["week", "snapshots", "heavy", "light", "neutral", "label"], rows.map((r) => [
502
+ r.week,
503
+ String(r.snapshots),
504
+ String(r.heavy),
505
+ String(r.light),
506
+ String(r.neutral),
507
+ r.label,
508
+ ]))}`;
509
+ }
510
+ export function formatCensus(census) {
511
+ const header = ["week", ...SOURCE_BUCKETS, "total"];
512
+ const rows = census.rows.map((r) => [
513
+ r.week,
514
+ ...SOURCE_BUCKETS.map((b) => String(r.counts[b])),
515
+ String(r.total),
516
+ ]);
517
+ const undatedTotal = SOURCE_BUCKETS.reduce((s, b) => s + census.undated[b], 0);
518
+ rows.push([
519
+ "(undated)",
520
+ ...SOURCE_BUCKETS.map((b) => String(census.undated[b])),
521
+ String(undatedTotal),
522
+ ]);
523
+ rows.push([
524
+ "TOTAL",
525
+ ...SOURCE_BUCKETS.map((b) => String(census.totals[b])),
526
+ String(census.total),
527
+ ]);
528
+ const coverage = census.total === 0 ? null : census.dated / census.total;
529
+ return [
530
+ `corpus census (${census.total} resources):`,
531
+ renderTable(header, rows),
532
+ `dated: ${census.dated}/${census.total} (${pct(coverage)}) — ${census.datedViaSessionId} via session-id fallback, ${undatedTotal} undated`,
533
+ `snapshot resources visible via memories.list: ${census.snapshotResourcesVisible ? "yes" : "no"}`,
534
+ ].join("\n");
535
+ }
536
+ export function formatProbe(stats) {
537
+ return [
538
+ `retrieval probe (${stats.queries} queries, ${stats.totalSlots} result slots, ${stats.unknownSlots} undatable):`,
539
+ renderTable(["week", "slots", "resultShare", "meanScore"], stats.rows.map((r) => [
540
+ r.week,
541
+ String(r.slots),
542
+ pct(r.resultShare),
543
+ num(r.meanScore),
544
+ ])),
545
+ ].join("\n");
546
+ }
547
+ export function formatVerdict(verdict) {
548
+ const lines = [
549
+ "combined verdict (thresholds pre-registered in docs/proposals/10-mood-retrieval-skew-audit.md):",
550
+ renderTable([
551
+ "week",
552
+ "label",
553
+ "msgs(hot_buffer)",
554
+ "derived",
555
+ "derived/100msgs",
556
+ "corpusShare",
557
+ "resultShare",
558
+ "lift",
559
+ "meanScore",
560
+ ], verdict.rows.map((r) => [
561
+ r.week,
562
+ r.label,
563
+ String(r.hotBufferMsgs),
564
+ String(r.derived),
565
+ num(r.derivedPer100),
566
+ pct(r.corpusShare),
567
+ pct(r.resultShare),
568
+ num(r.lift),
569
+ num(r.meanScore),
570
+ ])),
571
+ "",
572
+ `heavy weeks: ${verdict.heavyWeeks} light weeks: ${verdict.lightWeeks}`,
573
+ `volume ratio (heavy/light derived-per-100-msgs): ${num(verdict.volumeRatio)} (drop-max: ${num(verdict.volumeRatioDropMax)})`,
574
+ `mean lift: heavy ${num(verdict.heavyMeanLift)} (drop-max: ${num(verdict.heavyMeanLiftDropMax)}) light ${num(verdict.lightMeanLift)}`,
575
+ "",
576
+ `OUTCOME: ${verdict.outcome}`,
577
+ ...verdict.reasons.map((r) => ` - ${r}`),
578
+ ];
579
+ return lines.join("\n");
580
+ }