Skip to content

feat(digest-dedup): replayable per-story input log (opt-in, no behaviour change)#3330

Merged
koala73 merged 4 commits into
mainfrom
feat/digest-dedup-replay-log
Apr 23, 2026
Merged

feat(digest-dedup): replayable per-story input log (opt-in, no behaviour change)#3330
koala73 merged 4 commits into
mainfrom
feat/digest-dedup-replay-log

Conversation

@koala73

@koala73 koala73 commented Apr 23, 2026

Copy link
Copy Markdown
Owner

Why this PR?

The current brief-dedup path embeds titles only, so wire headlines that share a real event but drop the geographic anchor can slip past the 0.60 cosine threshold. Concrete miss from the 2026-04-23 08:02 brief:

# Source Headline
1 Premium Times Alleged Coup: One defendant arrives in court for arraignment, five others still expected
2 France 24 Trial opens after Nigeria charges six over 2025 coup plot

Both are the same Nigerian coup-plot trial. They should have been one rep with mentionCount=2.

To tune recall without regressing precision we need a replayable per-tick dataset — one record per story with every field any downstream candidate (title+slug, LLM-canonicalise, text-embedding-3-large, cross-encoder re-rank, etc.) would need to re-score the full pair matrix offline. A baseline-band pair log alone is not enough: options that shift the score distribution surface misses that are currently below 0.45 or already above 0.60, and a band-scoped log is blind to those.

This PR ships only the measurement layer. No recall-lift behaviour change, no model change, no threshold change. One env flip away from collecting data, a second flip away to turn off.

What this ships

  • scripts/lib/brief-dedup-replay-log.mjs — new pure module:
    • replayLogEnabled(env) — gate, reads DIGEST_DEDUP_REPLAY_LOG at call time.
    • buildReplayLogKey(ruleId, tsMs) — keyer, digest:replay-log:v1:{ruleId}:{YYYY-MM-DD}.
    • buildReplayRecords(...) — pure record builder; clusterId derives from rep.mergedHashes (already set by materializeCluster) so the orchestrator stays untouched.
    • writeReplayLog(...) — best-effort Upstash write. All failures caught + warned; never throws.
  • scripts/seed-digest-notifications.mjs — one call site after deduplicateStories. Uses void because the writer never rejects; node drains outstanding promises before natural exit.
  • tests/brief-dedup-replay-log.test.mjs — 21 tests.

Record shape (one per input story)

{
  "v": 1,
  "briefTickId": "full:en:high:1713859320000",
  "ruleId": "full:en:high",
  "tsMs": 1713859320000,
  "storyHash": "h1",
  "originalIndex": 0,
  "isRep": true,
  "clusterId": 0,
  "title": "Alleged Coup: One defendant arrives in court...",
  "normalizedTitle": "alleged coup: one defendant arrives in court...",
  "link": "https://premiumtimesng.com/...",
  "severity": "high",
  "currentScore": 12,
  "mentionCount": 1,
  "phase": "emerging",
  "sources": ["Premium Times"],
  "embeddingCacheKey": "brief:emb:v1:text-3-small-512:sha256...",
  "hasEmbedding": true,
  "tickConfig": {
    "mode": "embed",
    "clustering": "single",
    "cosineThreshold": 0.6,
    "topicThreshold": 0.45,
    "entityVetoEnabled": true
  }
}

Zero-behaviour-change guarantees

  • DIGEST_DEDUP_REPLAY_LOG defaults OFF; requires literal "1" to enable (same fail-closed-on-typo pattern as DIGEST_DEDUP_MODE).
  • Writer catches every failure (pipeline null, pipeline throw, malformed input, missing creds) and emits a warn line. No throw path can reach the digest cron.
  • No change to the dedup orchestrator (brief-dedup.mjs).
  • No change to the Jaccard fallback.
  • No change to the story:track:v1 Redis schema.
  • Storage: new Upstash list at digest:replay-log:v1:* with 30-day EXPIRE. Date suffix caps per-key growth.

Activation path (post-merge)

  1. Set DIGEST_DEDUP_REPLAY_LOG=1 on the seed-digest-notifications Railway service. Takes effect on next cron tick (no redeploy needed, same orchestrator pattern as other DIGEST_DEDUP_* flags).
  2. Watch one tick for the RPUSH + EXPIRE pair in Upstash (or a single warn line if creds/upstream flake).
  3. Leave running for ≥1 week to accumulate calibration data before evaluating any recall-lift candidates.

Rollback

DIGEST_DEDUP_REPLAY_LOG=0 or unset. Next cron tick stops writing.

Tests

  • node --test tests/brief-dedup-replay-log.test.mjs21/21 pass
  • node --test tests/brief-dedup-embedding.test.mjs tests/brief-dedup-jaccard.test.mjs77/77 pass (pre-existing, unchanged)
  • npm run typecheck → clean
  • npx biome lint on changed files → clean

Test coverage

  • Env parsing (OFF default, OFF on typos/lenient values, ON only on literal '1')
  • Key shape: prefix + sanitised ruleId + YYYY-MM-DD
  • Sanitiser allows [A-Za-z0-9:_-], collapses others to _, falls back to "unknown" if the result is all-underscore
  • Record fields: isRep, clusterId from mergedHashes, embeddingCacheKey, hasEmbedding, tickConfig snapshot, originalIndex preservation
  • Happy path: RPUSH with N records + EXPIRE 30d
  • Failure modes: pipeline returns null → warn + wrote=0, pipeline throws → caught + warn + wrote=0, malformed args → no throw
  • Rep without mergedHashes falls back to rep.hash alone (defensive)

Related

Driven by internal brainstorm memo (gitignored under docs/brainstorms/) that laid out the recall-gap diagnosis + 19 options. The doc's §5 Phase 1 specifies this log's record shape; its §7 pins this as the first ship, prerequisite for testing any of the recall-lift candidates (B URL-slug, H rare-token overlap, J verb-class gate, P adaptive threshold).

Test plan

  • node --test tests/brief-dedup-replay-log.test.mjs
  • node --test tests/brief-dedup-embedding.test.mjs tests/brief-dedup-jaccard.test.mjs
  • npm run typecheck
  • npx biome lint on changed files
  • Post-merge: flip DIGEST_DEDUP_REPLAY_LOG=1 on Railway, observe one tick in Upstash
  • Post-merge: leave on for ≥7 days, export records for labelling pipeline (silver-label N-way clustering — separate PR)

…our change)

Ship the measurement layer before picking any recall-lift strategy.

Why: the current dedup path embeds titles only, so brief-wire headlines
that share a real event but drop the geographic anchor (e.g. "Alleged
Coup: defendant arrives in court" vs "Trial opens after Nigeria charges
six over 2025 coup plot") can slip past the 0.60 cosine threshold. To
tune recall without regressing precision we need a replayable per-tick
dataset — one record per story with the exact fields any downstream
candidate (title+slug, LLM-canonicalise, text-embedding-3-large, cross-
encoder re-rank, etc.) would need to score.

This PR ships ONLY the log. Zero behaviour change:
  - Opt-in via DIGEST_DEDUP_REPLAY_LOG=1 (default OFF).
  - Writer is best-effort: all errors swallowed + warned, never affects
    digest delivery. No throw path.
  - Records include hash, originalIndex, isRep, clusterId, raw +
    normalised title, link, severity/score/mentions/phase/sources,
    embeddingCacheKey, hasEmbedding sidecar flag, and the tick's config
    snapshot (mode, clustering, cosineThreshold, topicThreshold, veto).
  - clusterId derives from rep.mergedHashes (already set by
    materializeCluster) so the orchestrator is untouched.
  - Storage: Upstash list keyed by {variant}:{lang}:{sensitivity}:{date}
    with 30-day EXPIRE. Date suffix caps per-key growth; retention
    covers the labelling cadence + cross-candidate comparison window.
  - Env flag is '1'-only (fail-closed on typos, same pattern as
    DIGEST_DEDUP_MODE).

Activation path (post-merge): flip DIGEST_DEDUP_REPLAY_LOG=1 on the
seed-digest-notifications Railway service. Watch one cron tick for the
RPUSH + EXPIRE pair (or a single warn line if creds/upstream flake),
then leave running for at least one week to accumulate calibration data.

Tests: 21 unit tests covering flag parsing, key shape + sanitisation,
record field correctness (isRep, clusterId, embeddingCacheKey,
hasEmbedding, tickConfig), pipeline null/throw handling, malformed
input. Existing 77 dedup tests unchanged and still green.
@vercel

vercel Bot commented Apr 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Preview Apr 23, 2026 7:42am

Request Review

@greptile-apps

greptile-apps Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an opt-in, append-only per-story replay log (DIGEST_DEDUP_REPLAY_LOG=1) to capture every dedup input tick in Upstash for offline calibration of recall-lift candidates. All three changes are purely additive: a new pure module, one void-discarded call after dedup, and 21 new unit tests — no behaviour change to the dedup path on merge.

Confidence Score: 5/5

Safe to merge; all findings are minor style suggestions with no runtime impact.

All three P2 findings are non-blocking style points: a shared object reference only relevant to in-memory consumers, a post-dedup timestamp acceptable for calibration, and a cosmetic Redis key edge case. No logic bugs, data-loss risk, or security concerns. The failure-isolation contract (best-effort, never throws, env-gated) is correctly implemented and well-tested.

No files require special attention.

Important Files Changed

Filename Overview
scripts/lib/brief-dedup-replay-log.mjs New pure module implementing opt-in replay log. Well-structured, defensive error handling, clear contracts. Minor: tickConfig shared reference and safeRuleId edge case with colon-only inputs.
scripts/seed-digest-notifications.mjs Single call site addition: void writeReplayLog(...) after dedup. tsMs is captured post-dedup rather than pre-dedup, creating a slight timestamp drift in briefTickId; no functional impact on calibration.
tests/brief-dedup-replay-log.test.mjs 21 tests covering env gate, key shape, record fields, cluster membership, pipeline null/throw failure modes, and malformed args. Comprehensive coverage of the new module's contract.

Sequence Diagram

sequenceDiagram
    participant Cron as seed-digest-notifications
    participant Dedup as deduplicateStories
    participant Log as writeReplayLog
    participant Redis as Upstash (replay-log key)

    Cron->>Dedup: stories[]
    Dedup-->>Cron: { reps, embeddingByHash, logSummary }
    Cron->>Log: void writeReplayLog({ stories, reps, embeddingByHash, cfg, tickContext })
    Note over Cron,Log: fire-and-forget — void discards the promise

    alt DIGEST_DEDUP_REPLAY_LOG != '1'
        Log-->>Cron: { wrote:0, skipped:'disabled' }
    else stories empty
        Log-->>Cron: { wrote:0, skipped:'empty' }
    else happy path
        Log->>Log: buildReplayRecords() → N JSON records
        Log->>Log: buildReplayLogKey(ruleId, tsMs) → digest:replay-log:v1:{rule}:{date}
        Log->>Redis: RPUSH key record0 … recordN
        Log->>Redis: EXPIRE key 2592000
        Redis-->>Log: [rpush_result, 1]
        Log-->>Cron: { wrote:N, key, skipped:null }
    else pipeline null / throws
        Log->>Log: warn(...)
        Log-->>Cron: { wrote:0, key, skipped:null }
    end
Loading

Reviews (1): Last reviewed commit: "feat(digest-dedup): replayable per-story..." | Re-trigger Greptile

Comment thread scripts/seed-digest-notifications.mjs Outdated
// purpose: dedup input is shared across users of the same (variant,
// lang, sensitivity), and we don't want user identity in log keys.
// See docs/brainstorms/2026-04-23-001-brief-dedup-recall-gap.md §5 Phase 1.
const tsMs = Date.now();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 tsMs captured post-dedup, not at tick start

const tsMs = Date.now() is sampled after await deduplicateStories(stories) returns, so it reflects the moment dedup finished rather than when the tick began processing. For a calibration log whose primary key is a date suffix this is harmless, but the briefTickId (${ruleKey}:${tsMs}) is documented as a "full tick id" and a downstream reader might expect it to anchor to tick start. Consider capturing tsMs before the deduplicateStories call, or naming the field loggedAtMs to set accurate expectations.

Suggested change
const tsMs = Date.now();
const tsMs = Date.now();
const cfg = readOrchestratorConfig(process.env);
const { reps: dedupedAll, embeddingByHash, logSummary } =
await deduplicateStories(stories);
// Replay log (opt-in via DIGEST_DEDUP_REPLAY_LOG=1). Best-effort — any
// failure is swallowed by writeReplayLog. Runs AFTER dedup so the log
// captures the real rep + cluster assignments. RuleId omits userId on
// purpose: dedup input is shared across users of the same (variant,
// lang, sensitivity), and we don't want user identity in log keys.
// See docs/brainstorms/2026-04-23-001-brief-dedup-recall-gap.md §5 Phase 1.

Comment thread scripts/lib/brief-dedup-replay-log.mjs Outdated
Comment on lines +56 to +59
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 safeRuleId fallback check ignores colons and hyphens

The emptiness check strips only underscores: raw.replace(/_/g, '') === ''. A ruleId consisting entirely of colons (e.g., ':::') sanitizes to ':::', passes the check, and produces a key like digest:replay-log:v1::::2026-04-23 with four consecutive colons. That's a valid Redis key but will confuse redis-cli KEYS/SCAN queries that use : as a namespace separator. Extending the strip to also remove : and - makes the guard tighter.

Suggested change
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
const safeRuleId = raw.replace(/[_:\-]/g, '') === '' ? 'unknown' : raw;

Comment on lines +100 to +110
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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 tickConfig object shared across all record entries

tickConfig is built once outside the forEach and the same object reference is pushed into every record. JSON.stringify in writeReplayLog serializes independently so there's no storage bug, but any in-memory consumer of the returned records array (future tests, replay harness) could mutate one record's tickConfig and silently affect all others. A shallow spread on push — tickConfig: { ...tickConfig } — costs nothing and removes the footgun.

Review catch (PR #3330): the tickConfig snapshot omitted
topicGroupingEnabled even though readOrchestratorConfig returns it and
the digest's post-dedup topic ordering gates on it. A tick run with
DIGEST_DEDUP_TOPIC_GROUPING=0 serialised identically to a default
tick, making those runs non-replayable for the calibration work this
log is meant to enable.

Add topicGroupingEnabled to the recorded tickConfig. One-line schema
fix + regression test asserting topic-grouping-off ticks serialise
distinctly from default.

22/22 tests pass.
@koala73

koala73 commented Apr 23, 2026

Copy link
Copy Markdown
Owner Author

Fixed in be8449a.

You're right — topicGroupingEnabled was missing from the tickConfig snapshot even though readOrchestratorConfig returns it and the post-dedup topic ordering in seed-digest-notifications gates on it. A tick run with DIGEST_DEDUP_TOPIC_GROUPING=0 would serialise identically to a default tick, so replays couldn't reconstruct output behaviour for those runs. That's a real data-loss bug in the log schema, exactly as called out.

The fix is a one-field addition to the tickConfig literal in buildReplayRecords, plus a regression test that asserts topic-grouping-off ticks serialise distinctly from the default. Existing 21 tests still pass + 1 new → 22/22.

koala73 added 2 commits April 23, 2026 11:33
….exit

Review catch (PR #3330): the fire-and-forget `void writeReplayLog(...)`
call could be dropped on the explicit-exit paths — the brief-compose
failure gate at line 1539 and main().catch at line 1545 both call
process.exit(1). Unlike natural exit, process.exit does not drain
in-flight promises, so the last N ticks' replay records could be
silently lost on runs where measurement fidelity matters most.

Fix: await the writeReplayLog call. Safe because:
  - writeReplayLog returns synchronously when the flag is off
    (replayLogEnabled check is the first thing it does)
  - It has a top-level try/catch that always returns a result object
  - The Upstash pipeline call has a 10s timeout ceiling
  - buildDigest already awaits many Upstash calls (dedup, compose,
    render) so one more is not a hot-path concern

Comment block added above the call explains why the await is
deliberate — so a future refactor doesn't revert it to void thinking
it's a leftover.

No test change: existing writeReplayLog unit tests already cover the
disabled / empty / success / error paths. The fix is a single-keyword
change in a caller that was already guaranteed-safe by the callee's
contract.
… log

Three non-blocking polish items from the automated review, bundled
because they all touch the same new module and none change behaviour.

1. tsMs captured BEFORE deduplicateStories (seed-digest-notifications.mjs).
   Previously sampled after dedup returned, so briefTickId reflected
   dedup-completion time rather than tick-start. For downstream readers
   the natural reading of "briefTickId" is when the tick began
   processing; moved the Date.now() call to match that expectation.
   Drift is maybe 100ms-2s on cold-cache embed calls — small, but
   moving it is free.

2. buildReplayLogKey emptiness check now strips ':' and '-' in addition
   to '_'. A pathological ruleId of ':::' previously passed through
   verbatim, producing keys like `digest:replay-log:v1::::2026-04-23`
   that confuse redis-cli's namespace tooling (SCAN / KEYS / tab
   completion). The new guard falls back to "unknown" on any input
   that's all separators. Added a regression test covering the
   ':::' / '---' / '___' / mixed cases.

3. tickConfig is now a per-record shallow copy instead of a shared
   reference. Storage is unaffected (writeReplayLog serialises each
   record via JSON.stringify independently) but an in-memory consumer
   that mutated one record's tickConfig for experimentation would have
   silently affected all other records in the same batch. Added a
   regression test asserting mutation doesn't leak across records.

Tests: 24/24 pass (22 prior + 2 new regression). Typecheck + lint clean.
@koala73

koala73 commented Apr 23, 2026

Copy link
Copy Markdown
Owner Author

Addressed all three P2 comments in commit 2afaa05:

  1. tsMs captured post-dedup — moved const tsMs = Date.now() to before the await deduplicateStories(...) call so briefTickId anchors to tick-start, not dedup-completion.
  2. safeRuleId ignores colons/hyphens — emptiness check now strips [:_-] before deciding to fall back to unknown. Inputs like ::: / --- / ___ / mixed separators all collapse to unknown. Added regression test.
  3. tickConfig shared reference — per-record shallow copy now (tickConfig: { ...tickConfig }). Added regression test asserting mutation of one record's tickConfig doesn't leak into other records.

Tests: 24/24 pass (22 prior + 2 new regression). Typecheck + lint clean.

@koala73

koala73 commented Apr 23, 2026

Copy link
Copy Markdown
Owner Author

Fixed in 0dac05a.

You're right — void writeReplayLog(...) would be dropped on the explicit process.exit(1) paths (brief-compose failure gate at L1539, main().catch at L1545). Unlike natural exit, process.exit does not drain in-flight promises.

Changed to await writeReplayLog(...). Safe because the callee is bounded + non-throwing:

  • Returns synchronously when the flag is off (first line is the replayLogEnabled(env) check → zero overhead on the disabled path).
  • Top-level try/catch always returns a result object, never rejects.
  • Upstash pipeline has a 10s timeout ceiling via defaultRedisPipeline.
  • buildDigest already awaits many Upstash calls (dedup, compose, render); one more isn't a hot-path concern.

Added a comment block above the call explaining why the await is deliberate so a future refactor doesn't revert it thinking it's leftover.

@koala73
koala73 merged commit 8ea4c8f into main Apr 23, 2026
10 checks passed
@koala73
koala73 deleted the feat/digest-dedup-replay-log branch April 23, 2026 07:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant