| /** | |
| * 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 }; | |
| } | |
| } | |