Skip to content

fix(classifier): two-layer historical-retrospective downgrade (L1 + L3)#3429

Merged
koala73 merged 4 commits into
mainfrom
fix/classifier-historical-marker-downgrade
Apr 26, 2026
Merged

fix(classifier): two-layer historical-retrospective downgrade (L1 + L3)#3429
koala73 merged 4 commits into
mainfrom
fix/classifier-historical-marker-downgrade

Conversation

@koala73

@koala73 koala73 commented Apr 26, 2026

Copy link
Copy Markdown
Owner

Why

Brief 2026-04-26-1302 surfaced this 40-year Chernobyl anniversary article from Live Science as if it were current news:

Science history: Chernobyl nuclear power plant melts down, bringing the world to the brink of disaster — April 26, 1986

Investigation showed two distinct failure modes that cooperate to ship retrospectives as critical news:

L1: enrichWithAiCache locked out the LLM cache for keyword=critical items

for (const item of relatedItems) {
  if (0.9 <= item.confidence) continue;  // ← skipped LLM cache for keyword=critical
  ...
}

Original intent: "trust keyword when confident." But for retrospective titles tripping a CRITICAL keyword (e.g. meltdown), the LLM is the only thing that could disambiguate context — and the gate locked it out. The U4 +2-tier cap already protects against UPWARD over-promotion; symmetric DOWNWARD demotion is unconditionally safer than keeping a suspect keyword classification (capLlmUpgrade is Math.min so downgrades pass freely). Removed.

L3: Two-layer historical-marker downgrade

L3a (classifier-side): classifyByKeyword now downgrades CRITICAL/HIGH keyword matches to info when the title contains a retrospective marker. Cheap regex patterns:

  • Prefix: ^(Science history|On this day|Today in|This day in|Throwback|Flashback)
  • Phrase: \b(N years/decades/months ago|after|later|anniversary|in memoriam|remembering|commemorat|retrospective)\b
  • Full date: April 26, 1986 or ISO 1986-04-26

Standalone 4-digit year is intentionally NOT a marker (current-event headlines like "Russia warns of 2026 strike" would falsely trigger).

L3b (LLM-cache-side defense-in-depth): The Chernobyl headline above doesn't actually match any CRITICAL keyword in the keyword classifier — "meltdown" substring isn't present in "melts down" (there's a space). So L3a wouldn't catch it. But the LLM cache had promoted this title at some prior point. The new L3b guard re-runs hasHistoricalMarker AFTER capLlmUpgrade — if the LLM-promoted level is CRITICAL/HIGH AND the title has a marker, force info. Logs on every fire so operators can audit.

Tests

File Cases Covers
tests/news-classifier-historical-downgrade.test.mts 23 hasHistoricalMarker predicate matrix + classifyByKeyword integration. Regression guards: current-year mentions don't trigger, "history" alone doesn't trigger (only the prefix forms), LOW/MEDIUM not downgraded, current-event critical headlines unchanged
tests/news-classifier-llm-historical-guard.test.mts 9 LLM-cache guard predicate behavior + behavioral semantics doc. Includes the EXACT brief 2026-04-26-1302 title as a test fixture

Behavioral matrix:

keyword LLM cache marker? final source
CRITICAL match n/a yes info keyword-historical-downgrade
no match (info) CRITICAL yes info (forced) llm
CRITICAL match n/a no critical keyword
no match (info) CRITICAL no critical llm
MEDIUM match n/a yes medium keyword (unchanged — only CRITICAL/HIGH downgrade)

106/106 tests pass; importance-score-parity preserved.

Type tightening

  • ClassificationResult.source: 'keyword' | 'keyword-historical-downgrade'
  • ParsedItem.classSource: 'keyword' | 'keyword-historical-downgrade' | 'llm'

Operator runbook

After merge, two new log lines surface guard fires:

  • [classify] LLM upgrade capped: (existing — U4 +2 cap)
  • [classify] LLM-promoted level forced to info by historical marker: (new — L3b)

Audit the second log over a 24h window post-deploy. If it fires on legitimate current-event titles (false positives), tighten the markers in a follow-up. Current marker set is intentionally conservative.

Test plan

  • CI green
  • After deploy: brief contains zero items where track.title matches a historical-marker pattern + track.severity ∈ {critical, high}
  • Random spot-check on 5 user briefs over 24h: no anniversary/retrospective journalism in critical/high slots

Related

Brief 2026-04-26-1302 surfaced a 40-year Chernobyl anniversary article from
Live Science:

  "Science history: Chernobyl nuclear power plant melts down, bringing the
   world to the brink of disaster — April 26, 1986 - Live Science"

Investigation showed two distinct failure modes that cooperate to ship
retrospective/anniversary content as if it were a current crisis:

L1 — enrichWithAiCache skipped the LLM cache result for keyword=critical
    items (`if (0.9 <= item.confidence) continue;`). Original intent was
    "trust keyword when confident", but for retrospective titles that
    trip a CRITICAL keyword (e.g. "meltdown"), the LLM is the only thing
    that could disambiguate context — and the gate locked it out. Removed.
    The U4 +2-tier cap already protects against UPWARD over-promotion;
    symmetric DOWNWARD demotion is unconditionally safer than keeping a
    suspect keyword classification (capLlmUpgrade is a Math.min so
    downgrades pass freely). Cache application also now covers the new
    'keyword-historical-downgrade' classSource so LLM can confirm/override
    the heuristic.

L3 — Two-layer historical-marker downgrade:

  L3a (classifier-side): classifyByKeyword now downgrades CRITICAL/HIGH
      keyword matches to info when the title contains a retrospective
      marker. Markers (cheap regex):
        - Prefix: ^(Science history|On this day|Today in|This day in|
                    Throwback|Flashback)
        - Phrase: \b(N years/decades/months ago|after|later|anniversary|
                    in memoriam|remembering|commemorat|retrospective)\b
        - Full date: "April 26, 1986" or ISO 1986-04-26
      Standalone 4-digit year is intentionally NOT a marker (current-event
      headlines like "Russia warns of 2026 strike" would falsely trigger).
      Downgrade applies only to CRITICAL/HIGH; LOW/MEDIUM are left alone
      (don't clear brief thresholds anyway, over-aggression cost outweighs
      signal). Distinct source tag 'keyword-historical-downgrade' for
      telemetry.

  L3b (LLM-cache-side defense-in-depth): The Chernobyl headline ABOVE
      doesn't match any CRITICAL keyword in the keyword classifier
      ("meltdown" substring isn't present in "melts down" — there's a
      space). So L3a wouldn't catch it. But the LLM cache had promoted
      this title (or one with the same hash) to CRITICAL at some prior
      point, and enrichWithAiCache applied that cache hit. The new
      L3b guard re-runs hasHistoricalMarker AFTER capLlmUpgrade — if the
      LLM-promoted level is CRITICAL/HIGH AND the title has a marker,
      force info. Logs on every fire so operators can audit.

Tests:
  - tests/news-classifier-historical-downgrade.test.mts — 23 cases for
    hasHistoricalMarker predicate matrix + classifyByKeyword integration.
    Includes regression guards: current-year mentions don't trigger,
    "history" alone doesn't trigger (only the prefix forms), LOW/MEDIUM
    not downgraded, current-event critical headlines unchanged.
  - tests/news-classifier-llm-historical-guard.test.mts — 9 cases
    covering the LLM-cache guard predicate behavior + behavioral
    semantics doc (CRITICAL+marker → info; HIGH+marker → info;
    MEDIUM+marker → unchanged; CRITICAL-no-marker → unchanged). Includes
    the EXACT brief 2026-04-26-1302 title as a test fixture.

Type tightening:
  - ClassificationResult.source: 'keyword' | 'keyword-historical-downgrade'
  - ParsedItem.classSource: 'keyword' | 'keyword-historical-downgrade' | 'llm'

106/106 tests pass across the touched + parity-coupled surface;
importance-score-parity preserved.
@vercel

vercel Bot commented Apr 26, 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 Apr 26, 2026 10:21am

Request Review

@greptile-apps

greptile-apps Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a two-layer defense against historical/retrospective articles shipping as critical news: classifyByKeyword (L3a) downgrades CRITICAL/HIGH keyword matches to info when a retrospective headline pattern is detected, and enrichWithAiCache (L3b) re-applies the same guard after LLM-cache promotion. It also removes the high-confidence skip gate that previously prevented the LLM cache from ever demoting critical keyword matches.

  • P1 — The confidence-gate removal is unconditional: capLlmUpgrade only caps upward promotions, so a stale or wrong LLM cache entry can now silently demote any genuine critical-keyword event to info. L3b only guards titles containing historical-retrospective markers; there is no protection against an incorrect cache entry for a current breaking event.
  • P2ISO_DATE_RE and FULL_DATE_RE both match current-year full dates, potentially triggering false-positive downgrades on headlines that embed today's date. The test suite has no negative test case for this edge.

Confidence Score: 3/5

Not safe to merge without addressing the unconditional confidence-gate removal, which creates a new suppression vector for genuine critical events via stale LLM cache entries.

One P1 finding (stale-LLM demotion of genuine critical events, introduced by blanket gate removal) pulls the score below the P1 ceiling of 4. The targeted fix is small, but the risk is on the highest-severity classification path.

server/worldmonitor/news/v1/list-feed-digest.ts — specifically the removed confidence skip and the interaction between capLlmUpgrade and downgrades for non-retrospective items.

Important Files Changed

Filename Overview
server/worldmonitor/news/v1/list-feed-digest.ts Removes the high-confidence LLM-skip gate and adds the L3b historical-marker guard after capLlmUpgrade. The gate removal is unconditional, so stale LLM cache entries can now silently demote genuine critical keyword matches to info — the cap only protects against upward promotions.
server/worldmonitor/news/v1/_classifier.ts Adds hasHistoricalMarker predicate (4 regexes) and 'keyword-historical-downgrade' source tag; downgrades CRITICAL/HIGH keyword matches to info when a retrospective pattern is present. Regexes match current-year full dates, creating a potential false-positive downgrade path for recent headlines.
tests/news-classifier-historical-downgrade.test.mts 23 well-structured tests covering prefix/phrase/date predicates and classifyByKeyword integration; missing a negative test for current-year full dates which would reveal the FULL_DATE_RE false-positive risk.
tests/news-classifier-llm-historical-guard.test.mts 9 tests validating the hasHistoricalMarker predicate and documenting the L3b behavioral contract; correctly notes that enrichWithAiCache integration tests require a Redis double and scope is limited to the predicate layer.

Comments Outside Diff (1)

  1. server/worldmonitor/news/v1/list-feed-digest.ts, line 573-576 (link)

    P2 L3b-forced-info leaves no programmatic audit trail — classSource stays 'llm'

    When the L3b guard fires and overrides cappedLevel to info, the item's classSource is still set to 'llm', making it indistinguishable in data from any organically LLM-classified-info item. The keyword-side deliberately added 'keyword-historical-downgrade' precisely for this observability purpose. Without a corresponding 'llm-historical-downgrade' value, post-hoc analysis of brief contents can't differentiate L3b-forced items without log scraping — which is ephemeral and doesn't survive log rotation.

    Consider adding 'llm-historical-downgrade' to the classSource union and setting it here when L3b fires, symmetric with the keyword-side treatment.

Reviews (1): Last reviewed commit: "fix(classifier): two-layer historical-re..." | Re-trigger Greptile

Comment on lines +529 to +546
for (const item of relatedItems) {
if (0.9 <= item.confidence) continue;
// L1 (PR #3424): the prior `if (0.9 <= item.confidence) continue` skip
// here meant the LLM cache result was IGNORED for keyword=critical
// matches. That made the cache an upgrade-only path: keyword=info →
// LLM=high could promote, but keyword=critical → LLM=info (e.g.
// retrospective/anniversary content tripping a critical keyword)
// could NEVER demote. Removed unconditionally — the U4 +2 tier cap
// below already protects against poisoned UPWARD promotions, and
// symmetric DOWNGRADES are unconditionally safer than keeping a
// suspect keyword classification.
//
// Cap the LLM upgrade at +2 tiers above the keyword classification
// so a poisoned cache entry (e.g., "About Section 508" → high) can't
// promote an info-keyword item past medium (info+2=medium). Legitimate
// medium→critical upgrades (medium+2=critical) remain reachable; the
// bounded loss is keyword=low → LLM=critical, which caps at high
// (low+2=high) and is logged below. See LEVEL_RANK doc + R4 for the
// full per-keyword cap table.
const cappedLevel = capLlmUpgrade(item.level, hit.level);
// medium→critical upgrades (medium+2=critical) remain reachable.
// capLlmUpgrade is a Math.min so downgrades pass through freely.
// See LEVEL_RANK doc + R4 for the full per-keyword cap table.
let cappedLevel = capLlmUpgrade(item.level, hit.level);

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.

P1 Unconditional gate removal opens genuine critical events to stale-LLM silent demotion

The prior confidence >= 0.9 skip specifically protected high-confidence keyword matches from being demoted by the LLM cache. Its removal is unconditional here: capLlmUpgrade only caps upward promotions (Math.min semantics); downgrades pass through freely. The L3b guard added below only saves items whose title contains a historical marker, so a stale or incorrectly-populated cache entry for a genuine current breaking event will demote it to info with no remaining safeguard.

The targeted fix needed to address retrospectives is narrower: bypass the gate only when an item was already classified as keyword-historical-downgrade, since those are the items that need LLM confirmation. Items classified as a plain critical keyword match would retain the skip, preventing stale demotions of genuine events. Removing the gate unconditionally is riskier than the original bug it addresses.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in commit 6024206 — restored if (0.9 <= item.confidence) continue; after the L3 marker check. The retrospective case PR #3424 originally targeted is handled upstream by classSource='keyword-historical-downgrade' (confidence 0.85, level=info), which still flows through enrichWithAiCache and is caught by the L3 marker guard. Items reaching with confidence 0.9 are by construction keyword=critical with no marker — those keep their keyword verdict. Brief 2026-04-26-1302 "Science history: melts down…" still works because keyword=info no-match (confidence 0.3) bypasses the gate and L3 forces info.

const FULL_DATE_RE =
/\b(?:january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{1,2},?\s+(?:19|20)\d{2}\b/i;

const ISO_DATE_RE = /\b(?:19|20)\d{2}-\d{1,2}-\d{1,2}\b/;

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 ISO_DATE_RE (and FULL_DATE_RE) match current-year dates, risking false-positive downgrades

ISO_DATE_RE uses (?:19|20)\d{2} which covers 2000–2099, including the current year. A current-event headline containing today's date in ISO format would match and incorrectly downgrade a CRITICAL/HIGH keyword match to info. The same applies to FULL_DATE_RE. The test suite validates that a year alone is safe but has no negative test for a current-year full date.

One mitigation: restrict the year range in both regexes to a cutoff well before the current year, accepting that very recent anniversaries might be missed — these are rare, while false positives on current-event headlines are an ongoing suppression risk.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Already addressed in round 2 (commit 96d3c12). Both FULL_DATE_RE and ISO_DATE_RE matches are gated through isPastRetrospectiveYear(year, nowMs) (_classifier.ts:270), which requires year < currentYear - 1. So a current-year ISO date in a headline (e.g. 2026-04-26) does NOT trigger the marker. There's also a regression test for the FULL_DATE current-year case in tests/news-classifier-llm-historical-guard.test.mts; I can add a parallel ISO-format negative test if you want belt-and-suspenders.

Comment on lines +248 to +255
/**
* Returns true if the title looks like a historical retrospective.
* Used by classifyByKeyword to downgrade CRITICAL/HIGH keyword matches
* (e.g. "meltdown") that appear in a backward-looking headline.
*
* Exported for test coverage — DO NOT call from production code paths
* other than classifyByKeyword.
*/

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 JSDoc contradicts actual usage — function is called from two production paths

The JSDoc says "DO NOT call from production code paths other than classifyByKeyword", but hasHistoricalMarker is now also directly imported and called from enrichWithAiCache in list-feed-digest.ts. This comment will mislead future readers about the intended call sites and should be updated to reflect both approved callers.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The JSDoc on commit 6024206 reads DO NOT call from production code paths other than classifyByKeyword and enrichWithAiCacheenrichWithAiCache is already named. Looks like a stale diff context on Greptile's side; the JSDoc was updated when the second caller was added.

Reviewer found two false-positive vectors that suppressed real critical
alerts:

  - "Today in Ukraine: Russian missile strikes Kyiv" → was downgraded
    to info because of bare "Today in" prefix. This is a current-event
    headline pattern, not a historical one.
  - "Missile launch reported on April 26, 2026" → was downgraded
    because any full-date pattern matched, regardless of year.

Both vectors would have suppressed real missile/attack/invasion alerts
before they reached the brief.

Fixes:

1. Prefix regex narrowed:
   REMOVED: bare "Today in" / "This day in" (too broad — legitimate
            current-event uses).
   KEPT:    "Science history:", "Throwback", "Flashback" (always
            retrospective).
   ADDED:   "On this day in YYYY" (year required — prevents bare
            "On this day, Iran fires missile" from triggering).
   ADDED:   "This day in history" (specific phrasing — distinct from
            bare "This day in").

2. Full-date matching now extracts the year and only treats dates as
   retrospective when year < currentYear - 1 (i.e. 2+ years old). In
   2026:
     - 2024 and earlier dates → retrospective marker fires.
     - 2025 (last year) → NOT a marker (could be current context, e.g.
       "court ruling on April 15, 2025 takes effect").
     - 2026 (current) → NOT a marker.
     - 2027+ (future) → NOT a marker (clock skew or scheduled events).
   Same logic applies to ISO date format.

3. hasHistoricalMarker(title, nowMs?) now takes optional nowMs for
   unit testability. Defaults to Date.now(); production callers omit.

Tests:
  - 4 explicit reviewer-fix safety cases:
    * "Today in Ukraine: Russian missile strikes Kyiv" → high (preserved)
    * "Missile launch reported on April 26, 2026" → high (preserved)
    * "Today in tech: Apple unveils iPhone" → no marker
    * "On this day, Iran invasion begins" (no year) → no marker
  - 4 full-date boundary cases (1986, 2024, 2025, 2026, 2027) verify
    the year-based gating.
  - All test calls pass NOW = 2026-04-15 UTC to pin "current year"
    deterministically.

120/120 tests pass; importance-score-parity preserved.
… PR #3429 round 3)

Previously the L3 defense-in-depth marker check ran on cappedLevel and
only fired for critical/high. For keyword=info + hit=critical, capLlm-
Upgrade demotes to medium (info+2=medium) — the post-cap check missed
it and the brief 2026-04-26-1302 Chernobyl case shipped at MEDIUM,
which still ships in 'all'-sensitivity briefs.

Move the check to run on the RAW hit before the cap and force info
unconditionally (not just critical/high). Retrospective markers now
suppress the LLM verdict at every non-info level.
…1 PR #3429 round 4)

Greptile flagged that removing the confidence>=0.9 skip unconditionally
opens genuine current keyword=critical events to silent demotion: the LLM
cache hit feeds capLlmUpgrade which is Math.min, so a stale/wrong cache
entry saying 'info' demotes critical -> info with no safeguard.

The retrospective case PR #3424 wanted to handle is already handled
UPSTREAM in classifyByKeyword via classSource='keyword-historical-
downgrade' (confidence 0.85, level=info), which still flows through this
function and is caught by the L3 marker check. Items reaching
enrichWithAiCache at confidence 0.9 are by construction keyword=critical
matches where the keyword classifier saw NO marker - trust the keyword
verdict for those.

The L3 marker check still runs BEFORE this skip, so the keyword=info
(no-match, confidence 0.3) + marker case - the brief 2026-04-26-1302
'Science history: melts down...' shape - still gets forced to info.
@koala73
koala73 merged commit f37216d into main Apr 26, 2026
10 checks passed
@koala73
koala73 deleted the fix/classifier-historical-marker-downgrade branch April 26, 2026 10:22
koala73 added a commit that referenced this pull request Apr 28, 2026
…ix (#3480)

* fix(classifier): match flashback/throwback after publisher brand prefix

Brief 2026-04-28-0801 surfaced "CBS News Radio flashback: D-Day,
Invasion of Normandy in 1944" as severity=critical in slot #3, two
days after PR #3429 shipped the historical-retrospective downgrade.

Root cause: HISTORICAL_PREFIX_RE was anchored with `^` —
`/^(?:science history|throwback|flashback)\s*:?/i` — so flashback /
throwback only matched at title position 0. Publishers prepend their
brand ("CBS News Radio", "BBC", "NPR") before the marker token, which
defeated the anchor. The keyword path then matched `invasion`
unimpeded → critical.

Fix: word-boundary match for flashback / throwback (unambiguous
markers regardless of position); science history stays anchored
since it's an editorial-slot prefix that's only retrospective at
the start of a headline.

  /(?:^science history\s*:?|\b(?:throwback|flashback)\b)/i

Regression test added under the existing
news-classifier-historical-downgrade suite covering CBS News Radio,
BBC, and NPR brand-prefix forms. All 56 cases in that file plus the
56 cases across the historical-downgrade + LLM-cache-guard tests
still pass.

* fix(classifier): tighten flashback/throwback to editorial-slot syntax

Reviewer P2 on PR #3480: widening flashback/throwback to bare
word-boundary match also broadens the L3b LLM-cache guard at
list-feed-digest.ts:547, which force-demotes ANY cached LLM hit
to info when hasHistoricalMarker is true. A current-event headline
that incidentally uses "flashback" / "throwback" as a comparison
word ("Markets see flashback to 2008 crisis as bonds tumble") would
be wrongly suppressed even though the marker isn't acting as a
retrospective framing token.

Replace the broad word-boundary form with two explicit branches:

  1. ANCHORED: marker at title position 0 (original behavior —
     "Throwback Thursday:", "Flashback:", "Science history:").

  2. BRAND-PREFIX: 1-4 Title-Case prefix words + marker + optional
     qualifier word + colon. Catches the brand-prefixed forms
     ("CBS News Radio flashback:", "BBC Throwback Thursday:",
     "NPR Flashback Friday:") while rejecting sentence-form uses.

The brand-prefix branch deliberately requires (a) Title-Case
prefix words (rejects "markets see flashback…"), AND (b) an
editorial-slot colon after the marker (rejects "Markets See
Flashback To 2008 Crisis As Bonds Tumble"). The Title-Case gate
uses a non-i regex so [A-Z] enforces actual capitalization rather
than getting case-folded.

Adds 5 negative tests for the realistic false-positive cases the
reviewer was worried about — "Markets see flashback to 2008
crisis", "Stocks suffer flashback to March 2020 crash", "Tesla
stock throwback after split", "AI flashback to 2023 boom: Nvidia
earnings beat", and a lowercase-leading sentence form. All
existing positive tests still pass (61/61 in the historical-
downgrade + LLM-cache-guard suite).

* chore(classifier): bump CLASSIFY_CACHE_PREFIX v4 → v5

Evicts cache entries that landed under the pre-publisher-prefix-fix
classifier — brand-prefixed retrospective titles like "CBS News Radio
flashback: D-Day, Invasion of Normandy in 1944" had been promoted to
severity=critical via the `invasion` keyword and persisted in the
classify cache. The new brand-prefix branch in _classifier.ts (PR
#3480 commits 5ec9892 + f61fae5) re-rules those rows on next
touch; v5 forces immediate eviction rather than waiting for natural
TTL on every poisoned entry.

Three lockstep sites updated atomically per the cache-prefix-bump-
propagation-scope learning:
  - server/worldmonitor/intelligence/v1/_shared.ts:20 (canonical writer)
  - server/worldmonitor/news/v1/list-feed-digest.ts:511 (doc-comment)
  - scripts/ais-relay.cjs:3172 (relay inline helper)

The news-classify-cache-prefix-audit static-analysis test enforces
lockstep — passes after this change.
fuleinist pushed a commit to fuleinist/worldmonitor that referenced this pull request May 9, 2026
…ix (koala73#3480)

* fix(classifier): match flashback/throwback after publisher brand prefix

Brief 2026-04-28-0801 surfaced "CBS News Radio flashback: D-Day,
Invasion of Normandy in 1944" as severity=critical in slot #3, two
days after PR koala73#3429 shipped the historical-retrospective downgrade.

Root cause: HISTORICAL_PREFIX_RE was anchored with `^` —
`/^(?:science history|throwback|flashback)\s*:?/i` — so flashback /
throwback only matched at title position 0. Publishers prepend their
brand ("CBS News Radio", "BBC", "NPR") before the marker token, which
defeated the anchor. The keyword path then matched `invasion`
unimpeded → critical.

Fix: word-boundary match for flashback / throwback (unambiguous
markers regardless of position); science history stays anchored
since it's an editorial-slot prefix that's only retrospective at
the start of a headline.

  /(?:^science history\s*:?|\b(?:throwback|flashback)\b)/i

Regression test added under the existing
news-classifier-historical-downgrade suite covering CBS News Radio,
BBC, and NPR brand-prefix forms. All 56 cases in that file plus the
56 cases across the historical-downgrade + LLM-cache-guard tests
still pass.

* fix(classifier): tighten flashback/throwback to editorial-slot syntax

Reviewer P2 on PR koala73#3480: widening flashback/throwback to bare
word-boundary match also broadens the L3b LLM-cache guard at
list-feed-digest.ts:547, which force-demotes ANY cached LLM hit
to info when hasHistoricalMarker is true. A current-event headline
that incidentally uses "flashback" / "throwback" as a comparison
word ("Markets see flashback to 2008 crisis as bonds tumble") would
be wrongly suppressed even though the marker isn't acting as a
retrospective framing token.

Replace the broad word-boundary form with two explicit branches:

  1. ANCHORED: marker at title position 0 (original behavior —
     "Throwback Thursday:", "Flashback:", "Science history:").

  2. BRAND-PREFIX: 1-4 Title-Case prefix words + marker + optional
     qualifier word + colon. Catches the brand-prefixed forms
     ("CBS News Radio flashback:", "BBC Throwback Thursday:",
     "NPR Flashback Friday:") while rejecting sentence-form uses.

The brand-prefix branch deliberately requires (a) Title-Case
prefix words (rejects "markets see flashback…"), AND (b) an
editorial-slot colon after the marker (rejects "Markets See
Flashback To 2008 Crisis As Bonds Tumble"). The Title-Case gate
uses a non-i regex so [A-Z] enforces actual capitalization rather
than getting case-folded.

Adds 5 negative tests for the realistic false-positive cases the
reviewer was worried about — "Markets see flashback to 2008
crisis", "Stocks suffer flashback to March 2020 crash", "Tesla
stock throwback after split", "AI flashback to 2023 boom: Nvidia
earnings beat", and a lowercase-leading sentence form. All
existing positive tests still pass (61/61 in the historical-
downgrade + LLM-cache-guard suite).

* chore(classifier): bump CLASSIFY_CACHE_PREFIX v4 → v5

Evicts cache entries that landed under the pre-publisher-prefix-fix
classifier — brand-prefixed retrospective titles like "CBS News Radio
flashback: D-Day, Invasion of Normandy in 1944" had been promoted to
severity=critical via the `invasion` keyword and persisted in the
classify cache. The new brand-prefix branch in _classifier.ts (PR
koala73#3480 commits 5ec9892 + f61fae5) re-rules those rows on next
touch; v5 forces immediate eviction rather than waiting for natural
TTL on every poisoned entry.

Three lockstep sites updated atomically per the cache-prefix-bump-
propagation-scope learning:
  - server/worldmonitor/intelligence/v1/_shared.ts:20 (canonical writer)
  - server/worldmonitor/news/v1/list-feed-digest.ts:511 (doc-comment)
  - scripts/ais-relay.cjs:3172 (relay inline helper)

The news-classify-cache-prefix-audit static-analysis test enforces
lockstep — passes after this change.
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