File size: 16,802 Bytes
ee888e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
/**
 * Replayable per-story input log for brief-dedup calibration.
 *
 * Problem this solves: we can't validate recall-lift options that shift
 * the embedding score distribution (title+slug, LLM-canonicalise, 3-large
 * model upgrade, etc.) from a baseline-band pair log alone. We need the
 * per-story inputs for every tick so offline replays can re-embed with
 * alternative configs and re-score the full pair matrix.
 *
 * See docs/brainstorms/2026-04-23-001-brief-dedup-recall-gap.md Β§5 Phase 1.
 *
 * Contract:
 *   - Opt-in via DIGEST_DEDUP_REPLAY_LOG=1 (default OFF β€” zero behaviour
 *     change on merge).
 *   - Best-effort: ALL failures are swallowed + warned. Replay-log write
 *     errors MUST NEVER affect digest delivery.
 *   - Append-only list in Upstash: one JSON record per story, keyed by
 *     rule + date so operators can range-query a day's traffic.
 *   - 30-day TTL (see Β§5 Phase 1 retention rationale: covers labelling
 *     cadence + cross-candidate comparison window; cache TTL is not the
 *     right anchor β€” replays that change embed config pay a fresh embed
 *     regardless of cache).
 */

import { cacheKeyFor, normalizeForEmbedding } from './brief-embedding.mjs';
import { defaultRedisPipeline } from './_upstash-pipeline.mjs';

const KEY_PREFIX = 'digest:replay-log:v1';
const TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days

/**
 * Per-day list cap. Each record is ~1.0-1.7KB JSON; Upstash enforces a
 * 500MB max-record-size on the Fixed plan. Without a cap, busy days
 * (~420K entries observed in production on 2026-05-07) hit the limit
 * and back-pressure adjacent Redis writes β€” see WM 2026-05-10 incident
 * where seed-forecasts publish timed out coincident with Max Record Size
 * alerts.
 *
 * 100,000 entries Γ— 1.5KB β‰ˆ 150MB β†’ ~3Γ— safety margin under 500MB.
 * For the calibration use-case (replay/sweep tooling consumes the
 * NEWEST entries to evaluate dedup quality), tail-keep semantics are
 * correct: LTRIM `-N..-1` keeps the most recent N records.
 *
 * Tradeoff: very busy days lose the OLDEST entries beyond 100K. The
 * U6 14-day replay harness aggregates ACROSS days and uses repHash
 * stability for cluster identity, so within-day eviction of older
 * entries is acceptable β€” operators get a representative sample of
 * each day's traffic, not exhaustive coverage.
 */
export const REPLAY_LOG_MAX_ENTRIES_PER_DAY = 100_000;

/**
 * Env-read at call time so Railway can flip the flag without a redeploy.
 * Anything other than literal '1' (including unset, '0', 'yes', 'true',
 * mis-cased 'True') is treated as OFF β€” fail-closed so a typo can't
 * silently turn the log on in prod. '1' is the single intentional value.
 *
 * @param {Record<string,string|undefined>} [env]
 */
export function replayLogEnabled(env = process.env) {
  return env.DIGEST_DEDUP_REPLAY_LOG === '1';
}

/**
 * Build the Upstash list key for a given tick.
 *
 * Format: digest:replay-log:v1:{ruleId}:{YYYY-MM-DD}
 *
 * Scoped per-rule so operators can range-query a single rule's day
 * without scanning traffic from other digest variants. Date suffix
 * (UTC) caps list length to one day's cron ticks β€” prevents unbounded
 * growth of a single key over the 30-day retention window.
 *
 * Safe-characters gate on ruleId: strip anything not alnum/underscore/
 * hyphen so an exotic rule id can't escape the key namespace.
 */
export function buildReplayLogKey(ruleId, tsMs) {
  // Allow ':' so `variant:lang:sensitivity` composite ruleIds stay
  // readable as Redis key segments. Strip anything else to '_'; then
  // if the whole string collapsed to nothing meaningful β€” all '_',
  // ':', '-', or empty β€” use 'unknown' so the key namespace stays
  // consistent. Stripping ':' / '-' in the emptiness check prevents
  // pathological inputs like ':::' producing keys like
  // `digest:replay-log:v1::::2026-04-23` that confuse Redis namespace
  // tooling (SCAN / KEYS / redis-cli tab completion).
  const raw = String(ruleId ?? '').replace(/[^A-Za-z0-9:_-]/g, '_');
  const safeRuleId = raw.replace(/[_:-]/g, '') === '' ? 'unknown' : raw;
  const iso = new Date(tsMs).toISOString();
  const dateKey = iso.slice(0, 10); // YYYY-MM-DD
  return `${KEY_PREFIX}:${safeRuleId}:${dateKey}`;
}

/**
 * Build one JSON record per story in the dedup input.
 *
 * `clusterId` is derived from `reps[].mergedHashes` β€” the authoritative
 * cluster-membership contract that materializeCluster already provides
 * (brief-dedup-jaccard.mjs:75-85). No change to the orchestrator needed.
 *
 * `embeddingCacheKey` is computed from normalizeForEmbedding(title). It
 * only helps replays that keep the SAME embedding config (model, dims,
 * input transform) β€” replays that change any of those pay fresh embed
 * calls regardless. Still worth recording: it's ~60 bytes and makes
 * same-config replays cheap.
 *
 * @param {Array<object>} stories β€” the input passed to deduplicateStories
 * @param {Array<object>} reps β€” the reps returned by deduplicateStories
 * @param {Map<string, number[]>} embeddingByHash β€” sidecar from the embed path
 * @param {object} cfg β€” the full config object from readOrchestratorConfig
 * @param {object} tickContext
 * @param {string} tickContext.briefTickId
 * @param {string} tickContext.ruleId
 * @param {number} tickContext.tsMs
 * @returns {Array<object>}
 */
export function buildReplayRecords(stories, reps, embeddingByHash, cfg, tickContext) {
  // Derive hash β†’ clusterId from rep membership. A rep's mergedHashes
  // lists every hash in its cluster including the rep's own; iterate
  // reps in output order and use the index as clusterId.
  const clusterByHash = new Map();
  if (Array.isArray(reps)) {
    reps.forEach((rep, clusterId) => {
      const hashes = Array.isArray(rep?.mergedHashes) ? rep.mergedHashes : [rep?.hash];
      for (const h of hashes) {
        if (typeof h === 'string' && !clusterByHash.has(h)) {
          clusterByHash.set(h, clusterId);
        }
      }
    });
  }

  // `repHashes` is a Set of the winning story's hash per cluster. A
  // story is the rep iff its hash === the rep.hash at its clusterId.
  const repHashes = new Set();
  if (Array.isArray(reps)) {
    for (const rep of reps) {
      if (typeof rep?.hash === 'string') repHashes.add(rep.hash);
    }
  }

  // Codex PR #3617 P1 β€” Sprint 1 / U6 cluster identity contract.
  //
  // Map storyHash β†’ rep.hash so every record can carry the canonical
  // stable cluster identity (the rep's own hash, which equals
  // mergedHashes[0] by U3's contract from Sprint 1). The pre-fix
  // writer only emitted a per-tick numeric clusterId and the rep's
  // mergedHashes was unreachable from non-rep records; U6's harness
  // had to guess at cluster identity by re-deriving from individual
  // storyHashes, splitting clusters whenever a non-rep story got
  // sampled.
  //
  // Now: every record carries `repHash` (stable across ticks). U6
  // collapses by repHash to get one timeline per (ruleId, cluster)
  // regardless of which member story happened to be in the dedup
  // input that tick.
  //
  // We also retain a separate Map of rep.hash β†’ mergedHashes so the
  // record builder can stamp mergedHashes ONLY onto rep records (the
  // mergedHashes set lives on the rep object, not on individual input
  // stories β€” readers asking "which storyHashes are in this cluster?"
  // need to consult the rep, not the member).
  const repHashByStoryHash = new Map();
  const mergedHashesByRepHash = new Map();
  // Codex PR #3617 round-3 P1 β€” sources live on REP objects (post
  // pre-hydration in seed-digest-notifications), NOT on the original
  // pre-dedup `stories` array. materializeCluster() in brief-dedup-jaccard
  // copies the rep into a new object, so mutations to dedupedAll[i].sources
  // never reach the input `stories[i]` references the writer iterates
  // below. Build a sourcesByRepHash Map here so EVERY record (rep AND
  // non-rep cluster member) gets the rep's hydrated source set β€”
  // non-reps share the rep's source identity by definition (the rep
  // is the cluster's canonical view).
  const sourcesByRepHash = new Map();
  if (Array.isArray(reps)) {
    for (const rep of reps) {
      const hashes = Array.isArray(rep?.mergedHashes) ? rep.mergedHashes : [rep?.hash];
      for (const h of hashes) {
        if (typeof h === 'string' && typeof rep?.hash === 'string' && !repHashByStoryHash.has(h)) {
          repHashByStoryHash.set(h, rep.hash);
        }
      }
      if (typeof rep?.hash === 'string' && Array.isArray(rep?.mergedHashes)) {
        mergedHashesByRepHash.set(rep.hash, rep.mergedHashes);
      }
      if (typeof rep?.hash === 'string' && Array.isArray(rep?.sources)) {
        sourcesByRepHash.set(rep.hash, rep.sources);
      }
    }
  }

  const tickConfig = {
    mode: cfg?.mode ?? null,
    clustering: cfg?.clustering ?? null,
    cosineThreshold: cfg?.cosineThreshold ?? null,
    // topicGroupingEnabled gates the post-dedup topic ordering pass in
    // seed-digest-notifications. Omitting it makes topic-grouping-off
    // ticks indistinguishable from default ticks at replay time, so
    // downstream replays can't reconstruct output behaviour for runs
    // with DIGEST_DEDUP_TOPIC_GROUPING=0. Serialise explicitly.
    topicGroupingEnabled: cfg?.topicGroupingEnabled ?? null,
    topicThreshold: cfg?.topicThreshold ?? null,
    entityVetoEnabled: cfg?.entityVetoEnabled ?? null,
  };

  const records = [];
  stories.forEach((story, originalIndex) => {
    const rawTitle = typeof story?.title === 'string' ? story.title : '';
    const normalizedTitle = normalizeForEmbedding(rawTitle);
    const cacheKey = rawTitle ? cacheKeyFor(normalizedTitle) : null;
    // hasEmbedding is a diagnostic: if the embed path produced a vector
    // for this rep, the sidecar has it. Useful in replay to tell apart
    // "embed path completed" from "embed path fell back to Jaccard".
    const hasEmbedding =
      embeddingByHash instanceof Map && embeddingByHash.has(story?.hash);
    // Codex PR #3617 P1 β€” Sprint 1 / U6 fields. headline + sourceUrl
    // are the canonical names U5's classifier expects (matches the
    // BriefStory schema and the digest-cooldown-decision input shape).
    // We keep `title` and `link` as legacy aliases for any older
    // consumer that pinned to the v1 shape.
    const link = typeof story?.link === 'string' ? story.link : null;
    const sourceUrl = link;
    const isRep = repHashes.has(story?.hash);
    // Codex PR #3617 round-3 P1 β€” read sources from the rep's hydrated
    // set (sourcesByRepHash) keyed by repHash, NOT from the input
    // story's `sources` field. The latter is empty at writeReplayLog
    // call time because materializeCluster returned copied rep objects
    // and pre-hydration mutates dedupedAll, not the input `stories`.
    const repHashForStory = repHashByStoryHash.has(story?.hash)
      ? repHashByStoryHash.get(story?.hash)
      : null;
    const repSources = repHashForStory && sourcesByRepHash.has(repHashForStory)
      ? sourcesByRepHash.get(repHashForStory)
      : null;
    records.push({
      v: 2, // Codex PR #3617 P1 β€” bump to v2 for repHash + headline + sourceUrl additions
      briefTickId: tickContext.briefTickId,
      ruleId: tickContext.ruleId,
      tsMs: tickContext.tsMs,
      storyHash: story?.hash ?? null,
      originalIndex,
      isRep,
      clusterId: clusterByHash.has(story?.hash)
        ? clusterByHash.get(story?.hash)
        : null,
      // Codex PR #3617 P1 β€” stable cluster identity (rep's own hash)
      // for every record, including non-rep cluster members. U6
      // collapses timelines by this field.
      repHash: repHashForStory,
      // Only reps carry the full mergedHashes set. Non-reps get null
      // (their cluster membership is preserved via repHash). The set
      // lives on the rep object (looked up via mergedHashesByRepHash);
      // input stories don't carry mergedHashes themselves.
      mergedHashes: isRep && typeof story?.hash === 'string' && mergedHashesByRepHash.has(story.hash)
        ? mergedHashesByRepHash.get(story.hash)
        : null,
      title: rawTitle,
      headline: rawTitle, // U5/U6 prefer this name; matches BriefStory.headline
      normalizedTitle,
      link,
      sourceUrl, // U5/U6 prefer this name; matches BriefStory.sourceUrl
      severity: story?.severity ?? null,
      currentScore: Number(story?.currentScore ?? 0),
      mentionCount: Number(story?.mentionCount ?? 1),
      phase: story?.phase ?? null,
      // Codex PR #3617 round-3 P1 β€” sources from the rep's hydrated
      // set, not the input story's (empty by construction at this point).
      // Non-rep records inherit the rep's set so cluster source-count
      // identity is uniform across all member records. Falls back to
      // the input story's sources when the rep map has no entry (e.g.
      // a synthetic test fixture passing pre-hydrated input stories
      // and bypass-rep-build paths) so existing tests don't break.
      sources: Array.isArray(repSources)
        ? repSources
        : (Array.isArray(story?.sources) ? story.sources : []),
      embeddingCacheKey: cacheKey,
      hasEmbedding,
      // Per-record shallow copy so an in-memory consumer (future
      // replay harness, test) that mutates one record's tickConfig
      // can't silently affect every other record via shared reference.
      // Serialisation goes through JSON.stringify in writeReplayLog so
      // storage is unaffected either way; this is purely an in-memory
      // footgun fix.
      tickConfig: { ...tickConfig },
    });
  });
  return records;
}

/**
 * Write the replay log for one dedup tick. Best-effort: every error is
 * caught and warned; the function NEVER throws.
 *
 * @param {object} args
 * @param {Array<object>} args.stories β€” input to deduplicateStories
 * @param {Array<object>} args.reps β€” output from deduplicateStories
 * @param {Map<string, number[]>} args.embeddingByHash β€” sidecar from deduplicateStories
 * @param {object} args.cfg β€” readOrchestratorConfig result
 * @param {object} args.tickContext
 * @param {string} args.tickContext.briefTickId
 * @param {string} args.tickContext.ruleId
 * @param {number} args.tickContext.tsMs
 * @param {object} [args.deps]
 * @param {Record<string,string|undefined>} [args.deps.env]
 * @param {typeof defaultRedisPipeline} [args.deps.redisPipeline]
 * @param {(line: string) => void} [args.deps.warn]
 * @returns {Promise<{ wrote: number, key: string | null, skipped: 'disabled' | 'empty' | null }>}
 */
export async function writeReplayLog(args) {
  const {
    stories,
    reps,
    embeddingByHash,
    cfg,
    tickContext,
    deps = {},
  } = args ?? {};
  const env = deps.env ?? process.env;
  const warn = deps.warn ?? ((line) => console.warn(line));

  if (!replayLogEnabled(env)) {
    return { wrote: 0, key: null, skipped: 'disabled' };
  }
  if (!Array.isArray(stories) || stories.length === 0) {
    return { wrote: 0, key: null, skipped: 'empty' };
  }

  try {
    const pipelineImpl = deps.redisPipeline ?? defaultRedisPipeline;
    const records = buildReplayRecords(
      stories,
      reps ?? [],
      embeddingByHash instanceof Map ? embeddingByHash : new Map(),
      cfg ?? {},
      tickContext ?? { briefTickId: 'unknown', ruleId: 'unknown', tsMs: Date.now() },
    );
    if (records.length === 0) {
      return { wrote: 0, key: null, skipped: 'empty' };
    }
    const key = buildReplayLogKey(tickContext?.ruleId, tickContext?.tsMs ?? Date.now());
    // RPUSH + LTRIM + EXPIRE in one pipeline. LTRIM `-N..-1` keeps the
    // last N entries (most recent), evicting the oldest beyond the cap.
    // This bounds each per-day key under ~150MB at observed entry sizes,
    // well under Upstash's 500MB max-record-size that production hit on
    // 2026-05-10 (busy days reached 420K entries β‰ˆ 630MB without a cap).
    // Stringify each record individually so downstream readers can
    // consume with LRANGE + JSON.parse.
    const rpushCmd = ['RPUSH', key, ...records.map((r) => JSON.stringify(r))];
    const ltrimCmd = ['LTRIM', key, `-${REPLAY_LOG_MAX_ENTRIES_PER_DAY}`, '-1'];
    const expireCmd = ['EXPIRE', key, String(TTL_SECONDS)];
    const result = await pipelineImpl([rpushCmd, ltrimCmd, expireCmd]);
    if (result == null) {
      warn(`[digest] replay-log: pipeline returned null (creds missing or upstream down) key=${key}`);
      return { wrote: 0, key, skipped: null };
    }
    return { wrote: records.length, key, skipped: null };
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    warn(`[digest] replay-log: write failed β€” ${msg}`);
    return { wrote: 0, key: null, skipped: null };
  }
}