@dzhechkov/harness-core 0.3.142 → 0.3.144

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.
@@ -106,6 +106,47 @@ export interface UsageEvent {
106
106
  readonly queryTruncated?: boolean;
107
107
  }
108
108
 
109
+ export interface ReplayInstance {
110
+ /** Stable per-PROMPT key — one prompt is one instance, however many lessons it injected. */
111
+ readonly id: string;
112
+ readonly query: string;
113
+ /** Exactly the lesson texts the apply leg injected for this prompt — the WARM arm's only delta. */
114
+ readonly lessons: readonly string[];
115
+ /**
116
+ * Pre-registered slice label (e.g. `task` / `conversational`). `null` until a human assigns it —
117
+ * nothing here invents a classification, because a class assigned AFTER outcomes are known is
118
+ * not a pre-registration.
119
+ */
120
+ readonly class: string | null;
121
+ }
122
+
123
+ /**
124
+ * The ONE definition of "a replayable pair": the readiness gate below COUNTS these and
125
+ * `dz epoch-replay --emit` EMITS these. A second copy would let readiness say 12 while the runner
126
+ * emits 9, silently — the drift class this repo keeps catching.
127
+ *
128
+ * Rules: a prompt with no query cannot be replayed; a TRUNCATED query is a prefix, not the prompt;
129
+ * one prompt = one instance (the hook writes one row per injected hit, up to 3 per prompt).
130
+ */
131
+ export function replayableInstances(
132
+ usage: readonly UsageEvent[],
133
+ lessonText: ReadonlyMap<string, string> = new Map(),
134
+ ): ReplayInstance[] {
135
+ const byKey = new Map<string, { query: string; lessons: string[] }>();
136
+ for (const u of usage) {
137
+ if (typeof u.query !== 'string' || u.query.trim() === '') continue;
138
+ if (u.queryTruncated === true) continue; // a prefix is not the prompt
139
+ const key = u.eventId ?? `${u.runId ?? ''}|${u.ts}|${u.query}`;
140
+ const entry = byKey.get(key) ?? { query: u.query, lessons: [] };
141
+ const text = lessonText.get(u.dzId);
142
+ if (typeof text === 'string' && text.trim() !== '' && !entry.lessons.includes(text)) {
143
+ entry.lessons.push(text);
144
+ }
145
+ byKey.set(key, entry);
146
+ }
147
+ return [...byKey.entries()].map(([id, e]) => ({ id, query: e.query, lessons: e.lessons, class: null }));
148
+ }
149
+
109
150
  export interface GuardEvent {
110
151
  readonly ts: string;
111
152
  readonly verdict: string;
@@ -238,14 +279,10 @@ export function assembleCompoundingReport(facts: CompoundingFacts): CompoundingR
238
279
 
239
280
  // 3. Replay readiness — cold-vs-warm needs (query -> injected lesson) pairs. They were never
240
281
  // recorded before 2026-07-28, so this gate REPORTS accrual instead of faking a verdict.
241
- const replayKeys = new Set<string>();
242
- for (const u of usage) {
243
- if (typeof u.query !== 'string' || u.query.trim() === '') continue;
244
- if (u.queryTruncated === true) continue; // a prefix is not the prompt
245
- // one prompt = one pair, however many hits it injected
246
- replayKeys.add(u.eventId ?? `${u.runId ?? ''}|${u.ts}|${u.query}`);
247
- }
248
- const replayablePairs = replayKeys.size;
282
+ // The rule ("no query / truncated query / one prompt = one pair") has exactly ONE definition,
283
+ // in epoch-replay.ts, and the RUNNER emits precisely what this gate counts — a second copy
284
+ // would let readiness say 12 while the runner emits 9, silently.
285
+ const replayablePairs = replayableInstances(usage).length;
249
286
  const replay: ReplayReadiness = {
250
287
  replayablePairs,
251
288
  minNeeded: MIN_SAMPLES_PER_ARM,