@hyperspell/openclaw-hyperspell 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +260 -10
- package/dist/client.js +14 -0
- package/dist/commands/setup.js +16 -6
- package/dist/commands/slash.js +40 -0
- package/dist/config.js +89 -6
- package/dist/graph/ops.js +36 -3
- package/dist/hooks/auto-context.js +126 -15
- package/dist/hooks/emotional-state.js +19 -8
- package/dist/hooks/memory-sync.js +23 -7
- package/dist/hooks/mood-weather.js +45 -0
- package/dist/hooks/startup-orientation.js +10 -1
- package/dist/index.js +24 -1
- package/dist/lib/coverage-log.js +47 -0
- package/dist/lib/filters.js +39 -11
- package/dist/lib/loops-audit.js +438 -0
- package/dist/lib/mood-skew-audit.js +580 -0
- package/dist/lib/ranking.js +188 -11
- package/dist/logger.js +16 -0
- package/dist/sync/markdown.js +57 -2
- package/openclaw.plugin.json +35 -2
- package/package.json +2 -2
|
@@ -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
|
+
}
|