Skip to content

fix(brief): post-LLM hallucination validator with shadow-mode rollout#3836

Merged
koala73 merged 1 commit into
mainfrom
fix/brief-hallucination-validator
May 19, 2026
Merged

fix(brief): post-LLM hallucination validator with shadow-mode rollout#3836
koala73 merged 1 commit into
mainfrom
fix/brief-hallucination-validator

Conversation

@koala73

@koala73 koala73 commented May 19, 2026

Copy link
Copy Markdown
Owner

Symptom

The 2026-05-19 0801 Pro brief shipped "Lebanese President Michel Aoun pledged..." against a source headline that contained no name ("Lebanese president vows to 'do the impossible' to end war with Israel..."). Current Lebanese president is Joseph Aoun; Michel Aoun left office in 2022. The LLM invented "Michel Aoun" despite the prompt explicitly forbidding invention. One of 5 distinct content-quality defects on the same brief; this PR addresses defect #1.

Root cause

scripts/_insights-brief.mjs:30-39 briefSystemPrompt instructs the LLM "Do not invent proper nouns (people, organizations, countries) that are not in the headline." The instruction was correct. The LLM violated it anyway. Prompts that say "do not invent" aren't mechanically enforceable on every output.

Fix

Add validateNoHallucinatedProperNouns(summary, headline) to shared/brief-llm-core.js. Extract proper-noun sequences from both texts, normalize via acronym + demonym tables, and verify each summary sequence has a matching contiguous subsequence in the headline. When a summary sequence has no headline match, the summary is rejected.

Extractor rules pinned (per the plan, after /ce-doc-review flagged these as load-bearing — see origin plan U2 "Approach (H2 fix)"):

  • TITLE_PREFIX_STOP — "former President Trump" extracts as ['trump'] (consume the title-prefix, don't register it).
  • PROPER_NOUN_JOINER — "Democratic Republic of Congo" stays one sequence, bridged by lowercase joiners (of, the, and, for, …).
  • All-caps acronym rule — 2–6-char ALL CAPS tokens (WHO, UN, NATO, IDF, …) register as proper nouns even at sentence start.
  • SENTENCE_START_AMBIGUOUS — explicit stop list of common sentence-starters ("The", "It", "Breaking", "Meanwhile", …) that LOOK proper-noun-like but aren't. Real proper nouns at sentence start ("Trump said...", "Israel announced...") pass through.
  • Possessive 's stripped ("Beirut's" → "beirut").
  • ACRONYM_NORMALIZE — 24 acronym↔expansion groups, bidirectional (WHO ↔ World Health Organization, US ↔ United States, EU ↔ European Union, …).
  • DEMONYM_NORMALIZE — 50+ demonym↔nation mappings (Israeli ↔ Israel, Iranian ↔ Iran, Cuban ↔ Cuba, …).

Shadow-mode rollout (env-flag controlled):

  • BRIEF_VALIDATOR_MODE=shadow (DEFAULT): violations logged to console as [brief_hallucination SHADOW] but the LLM output ships unchanged. Lets us measure false-positive rate in production without breaking anything.
  • BRIEF_VALIDATOR_MODE=enforce: violations replace the LLM summary with the source headline (R1 of the plan: "falls back to a safe summary (headline-grounded template)"). Flip via Railway env after the 7-day shadow window confirms <5% false-positive rate.

Defensive against malformed inputs (undefined, null, empty string, non-string, HTML-encoded, 10KB long): all return { ok: true } without throwing. The validator can never crash brief generation.

Test plan

  • 17 new test cases covering:
    • REGRESSION (captured May 19 hallucination): "Michel Aoun" not in headline → flagged ✓
    • CLASS (3 synthesized hallucination variants): LLM non-determinism guard ✓
    • Happy path: every summary proper noun grounded in headline ✓
    • Hallucination by addition ("US" introduced from nowhere) → flagged ✓
    • Title-prefix stop list: "former President Trump" passes ✓
    • Demonym rules (Israeli↔Israel, Iranian↔Iran) ✓
    • Acronym↔expansion (both directions): WHO ↔ World Health Organization, US ↔ United States
    • Multi-word with joiner: "Democratic Republic of Congo" preserved as one sequence ✓
    • Sentence-start The not registered ✓
    • Out-of-scope: headline already contains wrong name → validator does NOT fact-check (documented limit) ✓
    • Defensive: malformed inputs return ok without throwing ✓
    • Defensive: 10x-longer summaries don't crash ✓
  • All 41 tests pass (npx tsx --test tests/brief-llm-core.test.mjs)
  • Mirror parity test passes (tests/scripts-shared-mirror.test.mjs) — shared/brief-llm-core.js and scripts/shared/brief-llm-core.js are byte-for-byte equal
  • npm run typecheck clean
  • biome lint clean on changed files
  • After deploy: monitor the [brief_hallucination SHADOW] log frequency for 7 days. False-positive rate ≥5% → expand acronym/demonym lists before flipping enforce. ≤5% → flip BRIEF_VALIDATOR_MODE=enforce.

Origin

PR-2 of the 4-PR Phase 1 wave from docs/plans/2026-05-19-001-fix-brief-content-quality-regressions-plan.md.

Out of scope (documented in plan Scope Boundaries)

  • Source-level fact-checking. Validator catches LLM invention only. If a wire-service typo ships the wrong name in the headline, validator OKs the summary using that name. Brief pipeline isn't fact-checking source data; that's separate.
  • Full NER. Heuristic rules cover the surface PR-2 needs. Deeper NER is deferred unless shadow-mode data surfaces a class the heuristics miss.
  • LLM provider/model change. Validator is provider-agnostic — operates on output text, not metadata. Any provider that produces the same hallucination class triggers the same fallback.

@vercel

vercel Bot commented May 19, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment May 19, 2026 11:06am

Request Review

@greptile-apps

greptile-apps Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a heuristic post-LLM hallucination validator (validateNoHallucinatedProperNouns) that extracts proper-noun sequences from the LLM summary and verifies each has a contiguous match in the source headline, with acronym/demonym normalization. A shadow-mode rollout (BRIEF_VALIDATOR_MODE=shadow, default) logs violations without affecting output; enforce mode replaces hallucinated summaries with the source headline. This directly addresses the May 19 regression where the LLM invented "Michel Aoun" from a headline that contained no name.

  • Core validator (shared/brief-llm-core.js): ~400 lines of heuristic NLP — token extraction, title-prefix stop list, joiner bridging, acronym/demonym normalization tables, and contiguous-subsequence matching.
  • Integration (scripts/seed-insights.mjs): wires the validator into the brief generation path with a two-mode env-flag rollout; 17 new tests cover the regression fixture, happy paths, normalization round-trips, and defensive inputs.

Confidence Score: 4/5

Safe to merge as-is; shadow mode is the default so no user-visible output changes can result from the heuristic edge cases identified.

The validator logic is sound for the target regression and all 41 tests pass. Three heuristic issues affect only monitoring noise or future false-positive rates once enforce mode is enabled: specifically, 'Defense' and 'Foreign' in TITLE_PREFIX_STOP can silently drop the first word of institution names like 'Defense Department', producing a sequence that won't normalize to the expected acronym. In shadow mode this causes only spurious console.warn lines, but it would cause unnecessary headline-fallbacks once enforce is flipped on.

shared/brief-llm-core.js — the TITLE_PREFIX_STOP list and the dead multi-word DEMONYM_NORMALIZE entries are worth revisiting before flipping to enforce mode.

Important Files Changed

Filename Overview
shared/brief-llm-core.js Core validator implementation: adds ~400 lines implementing extractProperNounSequences, normalizeSequence, and validateNoHallucinatedProperNouns. Has three findings: 'Defense'/'Foreign'/'Justice' in TITLE_PREFIX_STOP cause false positives for institution names; multi-word DEMONYM_NORMALIZE keys are dead code; redundant single-token subsequence check.
scripts/seed-insights.mjs Integration of the validator with shadow/enforce rollout logic; code is correct but the inline comment mistakenly says 'log violations to Sentry' while the implementation uses console.warn.
tests/brief-llm-core.test.mjs 17 new test cases covering the regression fixture, class variants, happy paths, demonym/acronym normalization, and defensive inputs; no test covers the 'Defense Department' false-positive pattern.
scripts/shared/brief-llm-core.js Mirror of shared/brief-llm-core.js; byte-for-byte identical per the parity test, no independent changes needed.
shared/brief-llm-core.d.ts Type declarations for the two new exports; accurately reflects the implementation signatures.
scripts/shared/brief-llm-core.d.ts Mirror of shared/brief-llm-core.d.ts; identical content, no issues.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[callLLM returns llmResult] --> B[validateNoHallucinatedProperNouns\nsummary, headline]
    B --> C{malformed input?}
    C -->|yes| D[return ok:true\nship LLM output]
    C -->|no| E[extractProperNounSequences\nfor summary + headline]
    E --> F[normalizeSequence\nacronym + demonym tables]
    F --> G{all summarySeqs\nfound in headline?}
    G -->|yes| H[return ok:true]
    G -->|no| I[return ok:false\nhallucinated: seq]
    H --> J[worldBrief = llmResult.text\nnormal path]
    I --> K{BRIEF_VALIDATOR_MODE}
    K -->|enforce| L[worldBrief = topHeadline\nbrief_hallucination ENFORCE]
    K -->|shadow default| M[worldBrief = llmResult.text\nbriefhallucination SHADOW log]
Loading

Reviews (1): Last reviewed commit: "fix(brief): post-LLM hallucination valid..." | Re-trigger Greptile

Comment thread scripts/seed-insights.mjs
Comment on lines +10 to +14
// Hallucination validator rollout mode (PR-2 of brief-content-quality
// regressions). `shadow` = log violations to Sentry but ship the LLM
// output unchanged (default, safe). `enforce` = on violation, replace
// the LLM summary with the source headline. Flip via Railway env after
// the 7-day shadow window confirms <5% violation rate.

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 Stale "Sentry" reference in shadow-mode comment

The inline comment says shadow = log violations to Sentry, but the implementation uses console.warn and the PR description explicitly states violations are "logged to console as [brief_hallucination SHADOW]." The discrepancy could mislead a future engineer who reads this comment and assumes Sentry events exist for querying during the 7-day monitoring window — only to find nothing in the Sentry dashboard.

Comment thread shared/brief-llm-core.js
'Dr', 'Dr.', 'Mr', 'Mr.', 'Ms', 'Ms.', 'Mrs', 'Mrs.',
'Acting', 'Interim', 'Former', 'Ex',
'Chairman', 'Chairwoman', 'Chair', 'Speaker',
'CEO', 'Secretary', 'Defense', 'Foreign',

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 'Defense' / 'Justice' / 'Foreign' in TITLE_PREFIX_STOP produce false positives for government-department names

When "Defense Department", "Justice Department", or "Foreign Ministry" appears at the start of a proper-noun sequence (e.g. after a sentence-start ambiguous word is filtered), the first word is consumed by the title-prefix rule (current.length === 0), leaving only ['department'] / ['ministry'] as the extracted sequence. Those tokens don't normalize to 'dod', 'doj', etc., so if the headline uses the acronym form the validator flags the summary as hallucinated. Removing them from the stop list keeps person-title suppression working (since 'Secretary' is also in the list and handles "Defense/Foreign/Justice Secretary") while letting the institution-name path stay intact.

Suggested change
'CEO', 'Secretary', 'Defense', 'Foreign',
'CEO', 'Secretary',

Comment thread shared/brief-llm-core.js
Comment on lines +242 to +248
// every output. This validator catches inventions mechanically: it
// extracts proper-noun sequences from the LLM summary and verifies
// each appears in the source headline (after acronym + demonym
// normalization). When a sequence in the summary has no matching
// contiguous subsequence in the headline, the summary is rejected.
//
// The caller decides what to do with a rejection. In `seed-insights.mjs`

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 Multi-word keys in DEMONYM_NORMALIZE are unreachable dead code

DEMONYM_TO_NATION contains multi-word keys 'South Korean', 'South Koreans', 'North Korean', 'North Koreans'. These are stored in DEMONYM_NORMALIZE as 'south korean''south korea' etc. However, normalizeToken performs only single-token Map lookups, and normalizeSequence's multi-word greedy loop queries only ACRONYM_NORMALIZE, never DEMONYM_NORMALIZE. The multi-word entries are therefore never retrieved. The single-word entries 'korean''korea' handle the intent correctly, so there is no functional regression — but the dead entries add noise and could confuse a future contributor.

Comment thread shared/brief-llm-core.js
Comment on lines +455 to +461
for (const token of tokens) {
// Strip trailing punctuation and possessive 's / ’s so
// "Beirut's" → "beirut" and "U.S." → "U.S" (handled below).
let stripped = token.replace(/[.,;:'’]+$/g, '');
stripped = stripped.replace(/['’]s$/i, '');
const tokenForLookup = stripped || token;
const isTitlePrefix = TITLE_PREFIX_STOP.has(stripped);

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 Redundant single-token includes check

containsSubsequence(headlineSeq, summarySeq) already handles the single-element-needle case: it iterates headlineSeq checking headlineSeq[i] === summarySeq[0], which is logically identical to headlineSeq.includes(summarySeq[0]). The secondary branch if (summarySeq.length === 1 && headlineSeq.includes(...)) can therefore never produce a different result from the containsSubsequence call above. The comment describes it as "the reverse" check but it is not — it is the same check.

@koala73
koala73 force-pushed the fix/brief-hallucination-validator branch from d845f15 to 4e0f063 Compare May 19, 2026 10:13
@koala73

koala73 commented May 19, 2026

Copy link
Copy Markdown
Owner Author

P1 confirmed and fixed in 4e0f06341.

You're right — /[^\p{L}\p{N}'’-]+/u splits on dots, so U.S. tokenized to ['U', 'S']. Both are single-char tokens; my all-caps acronym rule requires length 2-6 so neither registered. Worse, even if they had, the canonical lookup target is 'us' (single token) — not 'u s' (two tokens). A summary using common dotted style ("the U.S. delegation...") against an expanded headline ("United States...") would always false-flag.

Fix: added a normalizeDottedAcronyms() preprocessing pass that collapses [A-Z]\.(?:[A-Z]\.?)+ runs to bare form BEFORE tokenization:

Before After
the U.S. announced the US announced
U.S.A. delegation USA delegation
FBI raided J.D. Vance's office FBI raided JD Vance's office
i.e., these things i.e., these things (lowercase — no match)
end of sentence. end of sentence. (single dot — no run)
I met with J. I met with J. (single letter-dot pair — no run)

The "requires ≥2 letter-dot pairs to commit" prevents over-collapsing — single sentence-final initials don't match.

Adjacent fix surfaced during testing: single-char capitalized tokens (J. in the trailing-initial case) were registering as proper noun sequences ['j']. Tightened the rule from /^[A-Z]/.test(token) to token.length >= 2 && /^[A-Z]/.test(token) — single capital letters are sentence-final initials, middle initials in names, or 'I' (pronoun, already in SENTENCE_START_AMBIGUOUS). All-caps acronyms already require length 2-6, so they're unaffected.

New tests (5):

  • U.S. summary vs US headline → ok
  • U.S. summary vs United States headline → ok
  • U.S.A. summary vs United States headline → ok
  • extractProperNounSequences("The U.S. announced sanctions.") extracts ['us']
  • i.e. does NOT collapse to ie
  • Sentence-final J. does NOT register as a proper noun

47/47 tests pass (was 41; +6). Mirror parity test (scripts/shared/brief-llm-core.js) still green. Typecheck + biome clean.

The 2026-05-19 Pro brief shipped "Lebanese President Michel Aoun
pledged..." against a source headline that contained NO name — just
"Lebanese president vows to 'do the impossible'..." Current Lebanese
president is Joseph Aoun; Michel Aoun left office in 2022. The LLM
invented "Michel Aoun" despite the prompt explicitly forbidding
invention.

Prompts that say "do not invent proper nouns" are not enforceable on
every output. This validator catches inventions mechanically: extracts
proper-noun sequences from the LLM summary, normalizes via acronym
and demonym tables (WHO ↔ World Health Organization, Israeli ↔ Israel,
US ↔ United States, etc.), and verifies each summary sequence has a
matching contiguous subsequence in the source headline. When a summary
sequence has no headline match, the summary is rejected.

Extractor rules pinned per the plan (docs/plans/2026-05-19-001 U2):
- TITLE_PREFIX_STOP — "former President Trump" extracts as ['trump'],
  consuming the title-prefix without registering it.
- PROPER_NOUN_JOINER — "Democratic Republic of Congo" stays one
  sequence, bridged by lowercase joiners ('of', 'the', 'and', 'for', …).
- All-caps acronym rule — 2–6-char ALL CAPS tokens (WHO, UN, NATO,
  IDF, …) register as proper nouns even at sentence start.
- SENTENCE_START_AMBIGUOUS — explicit stop list of common
  sentence-starters ('The', 'It', 'Breaking', 'Meanwhile', …) that
  LOOK proper-noun-like but aren't. Real proper nouns at sentence
  start ('Trump said...', 'Israel announced...') pass through.
- Possessive 's stripped ("Beirut's" → "beirut").
- ACRONYM_NORMALIZE — 24 acronym↔expansion groups, bidirectional.
- DEMONYM_NORMALIZE — 50+ demonym↔nation mappings.

Shadow-mode rollout: BRIEF_VALIDATOR_MODE=shadow (default) logs
violations to console but ships the LLM output unchanged. After 7 days
of shadow data, the false-positive rate is reviewed; if <5% the env
flips to BRIEF_VALIDATOR_MODE=enforce and violations replace the LLM
summary with the source headline (R1 of the plan: "falls back to a
safe summary").

Defensive against malformed inputs (undefined, null, empty string,
non-string, HTML-encoded, 10KB long) — all return { ok: true } without
throwing, so the validator can never crash brief generation.

41 unit tests cover: the captured May 19 hallucination + 3 synthesized
variants (LLM non-determinism guard), title-prefix consume,
acronym↔expansion both directions, demonym normalization, multi-word
joiners, sentence-start handling, possessive-s, malformed inputs, and
the explicit out-of-scope case (headline-level errors not fact-checked).

This is PR-2 of the 4-PR Phase 1 wave from
docs/plans/2026-05-19-001-fix-brief-content-quality-regressions-plan.md.
@koala73
koala73 force-pushed the fix/brief-hallucination-validator branch from 4e0f063 to cb066b7 Compare May 19, 2026 11:03
@koala73

koala73 commented May 19, 2026

Copy link
Copy Markdown
Owner Author

P1 confirmed and fixed in cb066b7bb.

You're right — this is the same root-dir escape class as PR #3818's hotfix to the simulation/forecast workers. Even though scripts/seed-insights.mjs already imports ./shared/geo-extract.mjs (the local pattern), I broke that pattern on the new line. For Railway services that build with rootDirectory=scripts, ../shared/brief-llm-core.js resolves to /shared/brief-llm-core.js at runtime, which isn't in the container, and the cron crashes on startup with ERR_MODULE_NOT_FOUND.

Fixes shipped:

  1. scripts/seed-insights.mjs:8 — changed import to ./shared/brief-llm-core.js. The scripts mirror was already in place from this PR (scripts/shared/brief-llm-core.js) and the mirror-parity test was green. The bug was solely the import-path choice. Added a header comment at the import explaining WHY ./shared/ over ../shared/ so a future contributor doesn't reach for the wrong path.

  2. tests/scripts-railway-nixpacks-no-escape-import.test.mts — added scripts/seed-insights.mjs to ENTRY_POINTS. The pre-existing test only covered the three forecast/simulation services from PR fix(simulation): move queue-constants shim into scripts/ so Railway workers can resolve it (#3811 hotfix) #3818; that's why it didn't catch this regression. Now any new scripts/*.mjs Railway entry point gets added here, the BFS walks transitive imports, and the failure message names the escape with a clear remediation.

Verified:

  • npx tsx --test tests/scripts-railway-nixpacks-no-escape-import.test.mts — 4 entry points, all pass (was 3).
  • All 47 hallucination-validator tests still pass.
  • Mirror-parity test still green (scripts/shared/brief-llm-core.js byte-for-byte equal to shared/brief-llm-core.js).
  • npm run typecheck clean.
  • biome lint clean on changed files.

Recipe match: skill railway-deploy-gotchas/reference/nixpacks-root-dir-scripts-cross-dir-import-escape — exact pattern. I had this in memory and still got caught by it because the new line went in a file that already had a correct ./shared/ import; visual review missed the inconsistency. The test-coverage extension prevents the next miss.

@koala73
koala73 merged commit 1db9237 into main May 19, 2026
11 checks passed
@koala73
koala73 deleted the fix/brief-hallucination-validator branch May 19, 2026 13:00
koala73 added a commit that referenced this pull request May 23, 2026
…identity (#3872)

* ci(digest): add digest-image build job + scheduled feed-validation workflow

PR CI gains a docker-build smoke for Dockerfile.digest-notifications that
catches cross-directory-import + native-dep breakage the static BFS test
in tests/dockerfile-digest-notifications-imports.test.mjs cannot see.
Gated on path changes under scripts/, shared/, server/_shared/, api/,
the Dockerfile itself, or the root package manifest.

Feed-registry validation moves OFF pull_request CI (SSRF surface — a
hostile PR could rewrite src/config/feeds.ts to make runners hit arbitrary
URLs) and INTO a new feed-validation.yml that runs on push-to-main,
every-6h schedule, and workflow_dispatch. The new test:feeds:ci script
passes --ci to scripts/validate-rss-feeds.mjs which enforces https-only,
allowlist-membership (via api/_rss-allowed-domain-match.js, the new
extracted predicate the Edge rss-proxy.js also consumes), and per-hop
redirect re-checks.

Plan: docs/plans/2026-05-23-001-fix-digest-pipeline-review-actions-plan.md

* test(railway): registry as single source of truth for service entrypoints

Replaces the hand-maintained ENTRY_POINTS array in
tests/scripts-railway-nixpacks-no-escape-import.test.mts (4 entries,
already known to drift per PR #3836 retrospective) with a derived list
from scripts/railway-services.json. The Dockerfile.digest-notifications
BFS test reads the same registry filtered to deployMode === "dockerfile".

A new coverage test (tests/railway-services-registry-coverage.test.mts)
audits every Dockerfile.* CMD line and every runbook "Start command:"
entry against the registry — if a deployment artifact references a
script that isn't registered, the test fails naming the file. Self-
fixtures pin both regex shapes so a future simplification can't
silently stop matching.

The registry surfaces 17 nixpacks services (up from 4) and 4 Dockerfile-
deployed services. The runbook header now points new-service authors at
the registry as the first stop.

Plan: docs/plans/2026-05-23-001-fix-digest-pipeline-review-actions-plan.md

* fix(rss): exclude missing-date items from freshness via effectivePubDateMs

The legacy parseFeedDateOrNow silently substituted Date.now() for any
missing/invalid feed pubDate, which produced false freshness across every
ranking and recency gate. The fix replaces it with parseFeedDate (returns
{ date, missing }) and a new effectivePubDateMs helper that returns 0 for
items flagged pubDateMissing — so they sort last in newest-first
comparators and fail any positive-duration recency gate
(Date.now() - 0 = huge), while keeping the synthesized pubDate in place
for display consumers.

Routed through the helper:
  - rss.ts AI-candidate top-N selection + min-heap insert (lines 296, 352-369)
  - daily-market-brief.ts headline-pool sort (line 217)
  - breaking-news-alerts.ts isRecent recency gate + best-alert ranking (lines 145, 260)
  - data-loader.ts staleItems sort, hero/digest sorts, time-range filter (6 sites)
  - panel-layout.ts time-range filter (caught by the new guardrail audit)
  - velocity.ts trend-midpoint partition (lines 58-59)
  - analysis-core.ts cluster sort + WINDOW_MS recency gate (lines 292, 440)

Cache prefix bumped feed: → feed:v2: at rss.ts:23, 44-65 because pre-v2
serialized items lack pubDateMissing; old entries are left to TTL out.

Two new tests:
  - tests/feed-date.test.mts — parser shapes + helper ranking behavior
  - tests/feed-date-ranking-uses-effective.test.mts — static audit that
    walks src/services/ + src/app/ and fails if any .pubDate.getTime()
    inside a ranking/recency call site bypasses the helper, with a
    documented allow-list for metadata/identity uses and self-fixtures
    pinning both sort-comparator and recency-gate regex shapes

Plan: docs/plans/2026-05-23-001-fix-digest-pipeline-review-actions-plan.md

* fix(rss): use link-keyed identity in AI classify queue dedupe

Previously canQueueAiClassification(title) collapsed every article sharing
a wire headline into one dedupe slot, so only the first got an AI threat
upgrade and the rest stayed at keyword confidence for the 30-minute
window. The new signature canQueueAiClassification({ link, title }) keys
on the normalized link when present (lowercased host, fragments dropped,
utm_*/fbclid/gclid stripped) and falls back to title when link is empty
or malformed.

Behavioral surface is narrower than it might look: the caller in rss.ts
only invokes the queue for items where threat.source === 'keyword' —
items already keyword-classified that are eligible for an AI confidence
upgrade. A dropped enqueue does NOT drop the article from the feed; it
just leaves its threat label at keyword confidence.

Also adds __resetAiClassifyQueueForTests() following the project's
__…ForTests convention (insights-loader.ts:104) so the new test file
can drive the module-scope dedupe map across scenarios deterministically.

Import switched from '@/config' barrel to '@/config/variant' leaf to
avoid pulling @/utils/proxy.ts (and its import.meta.env.DEV usage) into
the node:test runner.

Plan: docs/plans/2026-05-23-001-fix-digest-pipeline-review-actions-plan.md

* fix(rss): close pubDate-getTime guardrail bypass + harden effectivePubDateMs

Code-review caught a real regex bypass: src/app/country-intel.ts:297 ran
`new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime()` over
NewsItem[] from ctx.allNews — a freshness-ranking comparator that the
U3 guardrail regex /\.pubDate\s*\.getTime\(\)/ did not match. The
country-brief panel's news ordering would have surfaced missing-date
items at the top despite the U3 fix.

Three changes:
1. src/app/country-intel.ts:297 now routes through effectivePubDateMs.
2. PUBDATE_GETTIME_RE broadened to also match `item.pubDate?.getTime()`
   (optional chain, idiomatic in TS 4+) and `new Date(item.pubDate).
   getTime()` (the bypass the country-intel violation used). Self-fixtures
   pin all three shapes plus a negative-control fixture confirming the
   regex does NOT catch `new Date(item.endDate).getTime()`. Paren-wrapped,
   aliased, and destructured reads remain residual risk — documented in
   the regex docblock.
3. effectivePubDateMs now guards NaN/Infinity on every input branch
   (Date with Invalid value, NaN/Infinity number, unparseable string).
   Sort comparators on NaN have unspecified behavior per the JS spec;
   the previous numeric branch returned the value verbatim. Tests cover
   NaN, ±Infinity, and Invalid Date.

Also minor cleanups from review:
- Inverted JSDoc on isRecent clarified (`0 >= (large positive) = false`).
- Misleading migration-fallback comment in rss.ts (claimed "older builds
  stored feeds as feed:v2:") corrected to describe the actual scope:
  language-scope migration carried forward from the pre-v2 era.

Plan: docs/plans/2026-05-23-001-fix-digest-pipeline-review-actions-plan.md

* fix(rss): apply 9 P2/observation findings from code review (LFG batch)

F2 — Cache prefix bump for happy-all-items:
  data-loader.ts:3200 HAPPY_ITEMS_CACHE_KEY bumped 'happy-all-items' →
  'happy-all-items:v2' to match the feed: → feed:v2: invalidation. Pre-v2
  entries serialize NewsItem without pubDateMissing; under the new helper
  they would fraudulently claim freshness inside the 24h gate window.
  Bumping forces refetch; pre-v2 entries are left to TTL out naturally.

F3 — Velocity excludes missing-date items from trend math:
  velocity.ts pre-filters items.filter(i => !i.pubDateMissing) before the
  midpoint partition. Without this, clustering.ts:116 (allow-listed raw
  aggregation) would compute firstSeen/lastUpdated from synthesized
  stamps, then velocity would treat every missing-date item as 'older'
  via effectivePubDateMs=0, flipping cluster trend to 'falling' for any
  cluster with several missing-date items.

F4/F5 — CI workflow timeouts:
  feed-validation.yml job gains timeout-minutes: 15 (cap on hung npm ci
  or stalled feed registry). digest-image job in test.yml gains
  timeout-minutes: 20 (cap on docker build infra flakes).

F6 — Per-hop fetch timeout in --ci validator:
  validate-rss-feeds.mjs --ci redirect handler now creates a fresh
  AbortController + setTimeout per hop instead of one budget across all
  hops. "Timeout (15s)" in the report now means a single 15s network
  call, not 17s+ chained.

F7 — MAX_HOPS off-by-one in --ci validator:
  Loop bound changed from `hop <= MAX_HOPS` to `hop < MAX_HOPS` so the
  documented "max 3 hops" intent matches the actual iteration count.
  Final-iteration redirect now throws 'Too many redirects' consistently.

F8 — Dublin Core <dc:date> fallback in RSS parser:
  rss.ts:267-274 adds dc:date as a final fallback for the RSS branch
  (after pubDate). ArXiv RSS feeds — "ArXiv AI" and "ArXiv ML" in the
  feed registry — ship dc:date with no <pubDate>; without this fallback
  every ArXiv item would be marked pubDateMissing=true and demoted in
  every freshness ranking. Prior precedent: PR #3417 dc:date strict-drop
  incident.

Observation 1 — Asymmetric tie-break in breaking-news-alerts.ts:
  Captures a bestEffectiveMs local so the tie-break comparison is
  symmetric (helper on both sides). Today isRecent excludes missing-
  date items upstream so the comparison is safe; the local makes the
  guarantee defense-in-depth against a future refactor that removes
  the upstream gate.

Observation 2 — Document NaN/Infinity exclusion in time-range filters:
  data-loader.ts filterItemsByTimeRange and panel-layout.ts time-range
  filter previously wrapped raw pubDate.getTime() with Number.isFinite
  and fell through to `true` for non-finite, INCLUDING corrupt-stamp
  items in narrow windows. The helper now sanitizes NaN/Infinity to 0
  uniformly with pubDateMissing — corrupt-stamp items are excluded.
  This is intentional (untrustworthy timestamps shouldn't claim
  freshness) but undocumented in U3; comments at both sites now call
  out the behavior shift.

Plan: docs/plans/2026-05-23-001-fix-digest-pipeline-review-actions-plan.md
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