Skip to content

fix(resilience): symmetric headline gate + cache-key constant import in tests (PR #3472 follow-up)#3477

Merged
koala73 merged 2 commits into
mainfrom
fix/resilience-gate-symmetric-and-cache-key-import
Apr 28, 2026
Merged

fix(resilience): symmetric headline gate + cache-key constant import in tests (PR #3472 follow-up)#3477
koala73 merged 2 commits into
mainfrom
fix/resilience-gate-symmetric-and-cache-key-import

Conversation

@koala73

@koala73 koala73 commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Summary

Follow-up to #3472 — Greptile review on the now-merged PR surfaced two P2s that warrant a separate fix:

P2 — Asymmetric gate application

The cache-hit gate in get-resilience-ranking.ts demoted ineligible items from items[] → greyedOut[], but never re-evaluated items already sitting in cached greyedOut[]. An item flagged headlineEligible: true in greyedOut[] would remain demoted on every cache-hit read until a full 6h-TTL recompute. The PR's stated contract — "gate is the single source of truth across both code paths" — was only half-delivered.

Fix: split greyedOut[] symmetrically. Eligible items in either array land in items[]; the rest land in greyedOut[]. New regression test "promotes greyedOut items with headlineEligible:true to items[] (symmetric gate)". Mutation-verified: removing promotedFromGreyed from items[] makes the test fail.

P2 — Hardcoded cache key in tests

Two redis.set('resilience:ranking:v17', ...) literals in tests/resilience-headline-eligible-gate.test.mts. A future v18 bump would silently land at a different Redis key, the handler finds nothing, falls through to recompute, and tests fail for an opaque reason unrelated to gate logic. Replaced with imported RESILIENCE_RANKING_CACHE_KEY.

Test fixture cleanup

The pre-existing "all-greyed-out cached payload" test had headlineEligible: true on items in greyedOut[] — exactly the anomalous state the new symmetric gate correctly handles by promotion. Updated the fixture to false to match what a legitimate post-PR-6 writer stamps on low-coverage countries.

Test plan

  • npm run typecheck clean
  • npm run lint clean (exit 0)
  • 676 resilience tests pass (1 new + 675 existing)
  • Mutation-verified: removing the symmetric promotion makes the new test fail

References

…in tests (PR #3472 review round 2)

Two Greptile P2s on PR #3472 (now merged):

(P2) Asymmetric gate application. The cache-hit gate demoted ineligible
items from items[] → greyedOut[], but never promoted items already in
greyedOut[] that carry headlineEligible:true. Result: an item that now
passes the gate could remain demoted across all cache-hit reads until
the next 6h-TTL recompute. The PR's stated contract is "gate is the
single source of truth across both code paths," which the asymmetric
implementation only half-delivered.

Fix: split greyedOut[] symmetrically. Items with `headlineEligible: true`
in either array land in items[]; everything else lands in greyedOut[].
The gate is now the only thing that decides bucket placement on the
cache-hit path. Added a dedicated regression test
("promotes greyedOut items with headlineEligible:true to items[]")
and mutation-verified: removing `promotedFromGreyed` from items[] makes
the test fail.

(P2) Hardcoded cache key in tests. Two `redis.set('resilience:ranking:v17')`
literals in tests/resilience-headline-eligible-gate.test.mts. A future
v18 bump would silently land at a different Redis key, the handler
finds nothing, falls through to recompute, tests fail for an opaque
reason unrelated to gate logic. Replaced with imported
`RESILIENCE_RANKING_CACHE_KEY`.

(Test fixture update) The pre-existing "all-greyed-out cached payload"
test had `headlineEligible: true` on greyedOut items — anomalous state
the new symmetric gate now correctly handles by promoting those items
to items[]. Updated the fixture to `false`, matching what a legitimate
post-PR-6 writer stamps on low-coverage countries.

676/676 resilience tests pass; typecheck + lint clean.
@vercel

vercel Bot commented Apr 27, 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 27, 2026 8:26pm

Request Review

@greptile-apps

greptile-apps Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR completes the symmetric headline gate on the cache-hit path — items in greyedOut[] that are flagged headlineEligible: true are now promoted back to items[] rather than remaining demoted until a full TTL recompute — and migrates two hardcoded cache-key literals in resilience-headline-eligible-gate.test.mts to the RESILIENCE_RANKING_CACHE_KEY constant. Two P2 observations: the combined items[] after promotion is not re-sorted (the fresh-compute path applies sortRankingItems; the cache-hit path does not), and resilience-ranking.test.mts still carries ~10 hardcoded 'resilience:ranking:v17' literals that are subject to the same version-drift risk the PR explicitly set out to fix, including at line 125 which sits in the block directly modified by this PR.

Confidence Score: 4/5

Safe to merge; both findings are P2 and only affect anomalous cache states or future maintenance friction.

Both inline findings are P2: the sort-order issue only materialises in an anomalous scenario explicitly documented as rare, and the incomplete cache-key migration causes future test confusion rather than current runtime breakage. No P1 or P0 issues found.

tests/resilience-ranking.test.mts — hardcoded cache-key literals remain throughout; server/worldmonitor/resilience/v1/get-resilience-ranking.ts — items[] not re-sorted after symmetric promotion.

Important Files Changed

Filename Overview
server/worldmonitor/resilience/v1/get-resilience-ranking.ts Implements symmetric headline gate: items promoted from greyedOut[] are correct, but the combined items[] is not re-sorted after promotion, which can produce out-of-order results in the anomalous-promotion scenario.
tests/resilience-headline-eligible-gate.test.mts Migrates two hardcoded cache-key literals to the imported constant and adds a well-formed regression test for the symmetric gate; mutation-verification claim is credible.
tests/resilience-ranking.test.mts Fixture updated correctly (headlineEligible: false on greyed-out items), but the hardcoded 'resilience:ranking:v17' cache key — the same pattern fixed in the gate test file — remains in ~10 places, including the block directly touched by this PR.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Cache hit] --> B[Universe filter both arrays]
    B --> C[backfillEligibilityConservative: missing to false]
    C --> D{Split each array by headlineEligible}
    D -->|items eligible| E[eligibleItems]
    D -->|items ineligible| F[ineligibleFromItems]
    D -->|greyedOut eligible| G[promotedFromGreyed]
    D -->|greyedOut ineligible| H[stillGreyed]
    E --> I[items: eligibleItems + promotedFromGreyed - not re-sorted]
    G --> I
    H --> J[greyedOut: stillGreyed + ineligibleFromItems]
    F --> J
    I --> K[Return to caller]
    J --> K
Loading

Reviews (1): Last reviewed commit: "fix(resilience): symmetric headline gate..." | Re-trigger Greptile

Comment thread tests/resilience-ranking.test.mts Outdated
{ countryCode: 'ER', overallScore: 10, level: 'critical', lowConfidence: true, overallCoverage: 0.12, headlineEligible: false },
],
};
redis.set('resilience:ranking:v17', JSON.stringify({ ...cachedPublic, _formula: 'd6' }));

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 Hardcoded cache key not migrated here

This PR migrated two hardcoded 'resilience:ranking:v17' literals in resilience-headline-eligible-gate.test.mts to RESILIENCE_RANKING_CACHE_KEY, but resilience-ranking.test.mts retains the same pattern across ~10 callsites (lines 64, 90, 125, 203, 250, 303, 350, 399, 474, 566, 620, 710). Line 125 is in the exact block this PR modified (changing headlineEligible values), making the omission particularly visible. A v18 bump will silently route cache-seeding writes to the wrong key, causing tests that should cover the cache-hit path to fall through to the recompute path instead — failing for reasons unrelated to the logic under test.

The same replacement applies to the remaining hardcoded occurrences throughout the file, and RESILIENCE_RANKING_CACHE_KEY will need to be added to the import from _shared.ts at line 5.

Suggested change
redis.set('resilience:ranking:v17', JSON.stringify({ ...cachedPublic, _formula: 'd6' }));
redis.set(RESILIENCE_RANKING_CACHE_KEY, JSON.stringify({ ...cachedPublic, _formula: 'd6' }));

Comment on lines +161 to +162
items: [...eligibleItems, ...promotedFromGreyed],
greyedOut: [...stillGreyed, ...ineligibleFromItems],

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 Promoted items not re-sorted — items[] order may break

When promotedFromGreyed is non-empty (the anomalous-but-now-handled case), items[] becomes [...eligibleItems, ...promotedFromGreyed]. eligibleItems preserves the cached sort order, but promotedFromGreyed items are appended without ranking order, so a high-score promoted item (e.g., NO: 82) could trail a lower-score eligible item (e.g., US: 61) already present in items[]. The fresh-compute path applies sortRankingItems explicitly; the cache-hit path should do the same after combining the two sets.

Suggested change
items: [...eligibleItems, ...promotedFromGreyed],
greyedOut: [...stillGreyed, ...ineligibleFromItems],
items: sortRankingItems([...eligibleItems, ...promotedFromGreyed]),
greyedOut: [...stillGreyed, ...ineligibleFromItems],

…ache key in ranking tests (PR #3477 review round 1)

Two Greptile P2s on PR #3477 (still open):

(P2) Promoted greyedOut entries appended to items[] without re-sort.
The recompute path uses sortRankingItems before publishing, but the
cache-hit path's symmetric promotion just did
`[...eligibleItems, ...promotedFromGreyed]` — a high-scoring item
promoted from greyedOut would land at the END of items[] instead of
its correct rank position. Cache-hit responses visibly differed from
fresh recomputes for the same data, breaking the front-of-house
ranking sort.

Fix: wrap with sortRankingItems on the cache-hit return. Added a
dedicated regression test ("re-sorts items[] after promoting from
greyedOut") that seeds items=[US@61], greyedOut=[NO@82, both
eligible:true] and asserts the response order is [NO, US]. Mutation-
verified: removing the sort wrapper makes the test fail.

(P2) Hardcoded cache-key literals in tests/resilience-ranking.test.mts.
The previous fix only covered tests/resilience-headline-eligible-gate
.test.mts; the sibling file still had 12 `'resilience:ranking:v17'`
literals. Same fragility: a v18 bump would silently break tests at
the wrong key. Replaced all 12 with the imported
`RESILIENCE_RANKING_CACHE_KEY` constant.

677/677 resilience tests pass; typecheck + lint clean.
@koala73
koala73 merged commit a4f86b5 into main Apr 28, 2026
11 checks passed
@koala73
koala73 deleted the fix/resilience-gate-symmetric-and-cache-key-import branch April 28, 2026 04:53
koala73 added a commit that referenced this pull request Apr 28, 2026
… doc TTL/dim drift, P2 stale-count tightening)

Three review findings + a Greptile P2 perf note. All addressed locally.

P1 — cache invalidation missing for the §U8.1 scorer-formula change.
liquidReserveAdequacy now adjusts reserveMonths by re-export share for
AE/PA, but the prior commit kept score/ranking at v17 and history at
v12. The `_formula` tag is binary 'd6'|'pc' and does NOT detect intra-
'd6' scorer changes — without this bump, cached v17 AE/PA scores
(gross-imports-denominated) would continue to serve until TTL expiry,
defeating the construct fix this PR delivers. Bumped:
  - RESILIENCE_SCORE_CACHE_PREFIX     v17 → v18  (_shared.ts:164)
  - RESILIENCE_RANKING_CACHE_KEY      v17 → v18  (_shared.ts:235)
  - RESILIENCE_HISTORY_KEY_PREFIX     v12 → v13  (_shared.ts:217)
  - script mirrors:                   seed-resilience-scores.mjs,
                                      backtest, validate, benchmark
  - api/health.js:                    resilienceRanking key
History bumps in lockstep so the rolling 30-day window doesn't mix
pre/post-fix points and manufacture a false "improving" trend on day
one. Same pattern as PR 3A's v11→v12 lockstep for the SWF-side fix.

P2 — Redis keys table TTL + write-gate prose drift. Doc claimed:
  resilience:ranking:v17 | 6 hours | "only when all countries are scored"
Actual code: 12h TTL (RESILIENCE_RANKING_CACHE_TTL_SECONDS) and
publishes when coverage >= RANKING_CACHE_MIN_COVERAGE = 0.75. Updated
the table to reflect both. Same correction caught by the parity test
that flagged the v11/v17 drift in the prior commit.

P2 — stale "19 dimensions" claim still in the doc. The v2.0 scorecard
Data row at line 750 still said "19 dimensions across 6 domains"
because the parity test only required ONE correct mention. Tightened
the test to fail on any "<N> dimensions" mention in the
plausible-current-total band [15, 25] that doesn't equal activeCount
or totalCount — historical sub-counts (e.g. v1.0's "13 dimensions",
recovery pillar's "6 dimensions") are outside the band and stay
untouched. Updated the stale line to "20 active dimensions across 6
domains (plus 2 structurally-retired dimensions)".

Bonus — addressed a load of test-side cache-key literal duplications
(plan 002 §U8 follow-up):
  - tests/resilience-ranking.test.mts: 27 hardcoded
    'resilience:score:v17:XX' / 'resilience:history:v12:XX' literals
    swapped to ${RESILIENCE_SCORE_CACHE_PREFIX}/${RESILIENCE_HISTORY_KEY_PREFIX}
    template-literal references.
  - tests/resilience-handlers.test.mts: same.
  - tests/resilience-pillar-aggregation.test.mts: pinned-to-version
    assertion replaced with structural-shape regex.
  - tests/resilience-scores-seed.test.mjs: same.
This was caught by PR #3477 review for the ranking key; the score and
history prefixes had the same drift trap that resurfaced now. The
proper fix per the project skill (proto-required-field-add-needs-cache-
backfill Trap 5b) is to import the constants — never hardcode literals.

Greptile P2 (per-country Redis read for reexport-share map) — verified
benign: createMemoizedSeedReader caches by key, and _shared.ts:992's
`sharedReader` is shared across all 196 countries in the warm path
(getOrComputeResilienceScores), so reexport-share is fetched ONCE per
ranking compute, not 196 times. Single-country handler creates a fresh
memoized reader per request → 1 read per request. No fix needed.

Verification: 214/214 resilience tests pass, 7606/7606 npm test:data
pass, typecheck clean, lint:md clean.
koala73 added a commit that referenced this pull request Apr 28, 2026
…+ net-imports parity (v17.1) (#3482)

* feat(resilience): plan 002 §U8 — methodology rewrite, doc-parity test, widget polish + net-imports parity for liquidReserveAdequacy (v17.1)

Closes the plan 2026-04-26-002 sequence. Five things land together:

1. **§U8.1 — net-imports denominator parity for liquidReserveAdequacy.**
   PR #3380 shipped `computeNetImports(grossImports, reexportShareOfImports)`
   for `sovereignFiscalBuffer` via the SWF seeder, sourced from
   `resilience:recovery:reexport-share:v1` (Comtrade-backed via PR #3385).
   The same correction was structurally needed on the sibling
   `liquidReserveAdequacy` dimension — re-export hubs (AE 35.5%, PA
   similar) consume WB `FI.RES.TOTL.MO` which is computed at WB source
   against gross imports, double-counting goods that flow through
   without settling as domestic consumption. v17.1 multiplies WB's
   pre-computed months by `1/(1−reexportShare)` for hub countries
   (algebraic inverse of dividing the denominator) — yields the same
   adjusted-months a custom `reserves/(net-imports/12)` calc would,
   without re-fetching raw `FI.RES.TOTL.CD` + `BM.GSR.GNFS.CD` series.
   Non-hub countries unchanged (status quo). Expected impact at next
   ranking refresh: AE liquidReserveAdequacy 38 → ~64 (a +26-point dim
   swing); PA similar magnitude.

2. **§U8 — methodology doc comprehensive v17 changelog.** Single
   coherent narrative covering the universe + coverage rebuild:
   source-comprehensiveness flag, coverage penalty multiplier,
   per-capita normalization with 0.5M tiny-state floor, headline-
   eligible gate, symmetric cache-hit gate filtering. Anchored to the
   live `resilience:ranking:v17` cohort numbers captured 2026-04-28
   post-#3477 merge: median(Nordics)=78.52 vs median(GCC)=70.53
   (+7.98pt gap), min(G7)=64.31 vs max(LIC)=53.73 (+10.58pt gap), 1
   microstate (MO at #4) in top-20. Documents 3 known open construct
   gaps honestly: economic-complexity / industrial-base indicator,
   importConcentration coverage gap on AE, cyberDigital transient
   zeros.

3. **§U8 — new doc-parity test (`tests/resilience-doc-parity.test.mts`).**
   Asserts the doc's prose claims match `_shared.ts` (cache prefix
   constants), `RESILIENCE_DOMAIN_ORDER` (6 domains), and
   `RESILIENCE_DIMENSION_ORDER` − `RESILIENCE_RETIRED_DIMENSIONS` (20
   active dimensions). Caught real drift: Redis keys table named v11
   prefixes despite live v17, and the headline "19 dimensions" claim
   was off-by-one after PR 2 §3.4 added liquidReserveAdequacy +
   sovereignFiscalBuffer. Fixed both as part of this commit.

4. **§U8 — widget polish.** `formatResilienceConfidence` now renders
   "Outside headline ranking" when `headlineEligible: false`, distinct
   from "Low confidence — sparse data" when `lowConfidence: true`. The
   two reasons are different and analysts should see them as different.
   Order matters: lowConfidence is more specific so it wins when both
   flags fire. New test pins the precedence.

5. **MDX-unsafe `<digit/letter` sweep + `lint:md`.** Both clean.

89 dimension-scorer tests + 36 widget tests + 5 doc-parity tests + 41
ranking + headline-gate tests all green (171 total, 0 fail).

* fix(resilience): demote v17.1 changelog H4 to bold-paragraph (methodology-lint compat)

CI methodology-lint test (T1.8) requires every H4 in
docs/methodology/country-resilience-index.mdx to map to a known
scorer dimension via HEADING_TO_DIMENSION — that's how the doc-vs-
registry parity gate works. The v17.1 changelog sub-section I added
(`#### v17.1 — Net-imports denominator parity for liquidReserveAdequacy
(U8.1)`) is a non-dimension subsection and tripped the linter.

Demoted to a bold lead-in paragraph (`**v17.1 — …**`). Same content,
same prose, just no H4. The lint comment explicitly anticipates this
case ("if a future edit adds a legitimate non-dimension H4, either
upgrade it to H3 or add an explicit allowlist") — bold-paragraph is
the cleanest of the three options and matches how Mintlify renders
sub-changelog notes elsewhere in the doc.

Local re-run: methodology-lint + doc-parity 10/10 pass; full test:data
suite 7606/7606 pass, 0 fail.

* fix(resilience): plan 002 §U8 review fixes (P1 cache bump v17→v18, P2 doc TTL/dim drift, P2 stale-count tightening)

Three review findings + a Greptile P2 perf note. All addressed locally.

P1 — cache invalidation missing for the §U8.1 scorer-formula change.
liquidReserveAdequacy now adjusts reserveMonths by re-export share for
AE/PA, but the prior commit kept score/ranking at v17 and history at
v12. The `_formula` tag is binary 'd6'|'pc' and does NOT detect intra-
'd6' scorer changes — without this bump, cached v17 AE/PA scores
(gross-imports-denominated) would continue to serve until TTL expiry,
defeating the construct fix this PR delivers. Bumped:
  - RESILIENCE_SCORE_CACHE_PREFIX     v17 → v18  (_shared.ts:164)
  - RESILIENCE_RANKING_CACHE_KEY      v17 → v18  (_shared.ts:235)
  - RESILIENCE_HISTORY_KEY_PREFIX     v12 → v13  (_shared.ts:217)
  - script mirrors:                   seed-resilience-scores.mjs,
                                      backtest, validate, benchmark
  - api/health.js:                    resilienceRanking key
History bumps in lockstep so the rolling 30-day window doesn't mix
pre/post-fix points and manufacture a false "improving" trend on day
one. Same pattern as PR 3A's v11→v12 lockstep for the SWF-side fix.

P2 — Redis keys table TTL + write-gate prose drift. Doc claimed:
  resilience:ranking:v17 | 6 hours | "only when all countries are scored"
Actual code: 12h TTL (RESILIENCE_RANKING_CACHE_TTL_SECONDS) and
publishes when coverage >= RANKING_CACHE_MIN_COVERAGE = 0.75. Updated
the table to reflect both. Same correction caught by the parity test
that flagged the v11/v17 drift in the prior commit.

P2 — stale "19 dimensions" claim still in the doc. The v2.0 scorecard
Data row at line 750 still said "19 dimensions across 6 domains"
because the parity test only required ONE correct mention. Tightened
the test to fail on any "<N> dimensions" mention in the
plausible-current-total band [15, 25] that doesn't equal activeCount
or totalCount — historical sub-counts (e.g. v1.0's "13 dimensions",
recovery pillar's "6 dimensions") are outside the band and stay
untouched. Updated the stale line to "20 active dimensions across 6
domains (plus 2 structurally-retired dimensions)".

Bonus — addressed a load of test-side cache-key literal duplications
(plan 002 §U8 follow-up):
  - tests/resilience-ranking.test.mts: 27 hardcoded
    'resilience:score:v17:XX' / 'resilience:history:v12:XX' literals
    swapped to ${RESILIENCE_SCORE_CACHE_PREFIX}/${RESILIENCE_HISTORY_KEY_PREFIX}
    template-literal references.
  - tests/resilience-handlers.test.mts: same.
  - tests/resilience-pillar-aggregation.test.mts: pinned-to-version
    assertion replaced with structural-shape regex.
  - tests/resilience-scores-seed.test.mjs: same.
This was caught by PR #3477 review for the ranking key; the score and
history prefixes had the same drift trap that resurfaced now. The
proper fix per the project skill (proto-required-field-add-needs-cache-
backfill Trap 5b) is to import the constants — never hardcode literals.

Greptile P2 (per-country Redis read for reexport-share map) — verified
benign: createMemoizedSeedReader caches by key, and _shared.ts:992's
`sharedReader` is shared across all 196 countries in the warm path
(getOrComputeResilienceScores), so reexport-share is fetched ONCE per
ranking compute, not 196 times. Single-country handler creates a fresh
memoized reader per request → 1 read per request. No fix needed.

Verification: 214/214 resilience tests pass, 7606/7606 npm test:data
pass, typecheck clean, lint:md clean.
fuleinist pushed a commit to fuleinist/worldmonitor that referenced this pull request May 9, 2026
…in tests (PR koala73#3472 follow-up) (koala73#3477)

* fix(resilience): symmetric headline gate + cache-key constant import in tests (PR koala73#3472 review round 2)

Two Greptile P2s on PR koala73#3472 (now merged):

(P2) Asymmetric gate application. The cache-hit gate demoted ineligible
items from items[] → greyedOut[], but never promoted items already in
greyedOut[] that carry headlineEligible:true. Result: an item that now
passes the gate could remain demoted across all cache-hit reads until
the next 6h-TTL recompute. The PR's stated contract is "gate is the
single source of truth across both code paths," which the asymmetric
implementation only half-delivered.

Fix: split greyedOut[] symmetrically. Items with `headlineEligible: true`
in either array land in items[]; everything else lands in greyedOut[].
The gate is now the only thing that decides bucket placement on the
cache-hit path. Added a dedicated regression test
("promotes greyedOut items with headlineEligible:true to items[]")
and mutation-verified: removing `promotedFromGreyed` from items[] makes
the test fail.

(P2) Hardcoded cache key in tests. Two `redis.set('resilience:ranking:v17')`
literals in tests/resilience-headline-eligible-gate.test.mts. A future
v18 bump would silently land at a different Redis key, the handler
finds nothing, falls through to recompute, tests fail for an opaque
reason unrelated to gate logic. Replaced with imported
`RESILIENCE_RANKING_CACHE_KEY`.

(Test fixture update) The pre-existing "all-greyed-out cached payload"
test had `headlineEligible: true` on greyedOut items — anomalous state
the new symmetric gate now correctly handles by promoting those items
to items[]. Updated the fixture to `false`, matching what a legitimate
post-PR-6 writer stamps on low-coverage countries.

676/676 resilience tests pass; typecheck + lint clean.

* fix(resilience): re-sort items[] after symmetric promotion + import cache key in ranking tests (PR koala73#3477 review round 1)

Two Greptile P2s on PR koala73#3477 (still open):

(P2) Promoted greyedOut entries appended to items[] without re-sort.
The recompute path uses sortRankingItems before publishing, but the
cache-hit path's symmetric promotion just did
`[...eligibleItems, ...promotedFromGreyed]` — a high-scoring item
promoted from greyedOut would land at the END of items[] instead of
its correct rank position. Cache-hit responses visibly differed from
fresh recomputes for the same data, breaking the front-of-house
ranking sort.

Fix: wrap with sortRankingItems on the cache-hit return. Added a
dedicated regression test ("re-sorts items[] after promoting from
greyedOut") that seeds items=[US@61], greyedOut=[NO@82, both
eligible:true] and asserts the response order is [NO, US]. Mutation-
verified: removing the sort wrapper makes the test fail.

(P2) Hardcoded cache-key literals in tests/resilience-ranking.test.mts.
The previous fix only covered tests/resilience-headline-eligible-gate
.test.mts; the sibling file still had 12 `'resilience:ranking:v17'`
literals. Same fragility: a v18 bump would silently break tests at
the wrong key. Replaced all 12 with the imported
`RESILIENCE_RANKING_CACHE_KEY` constant.

677/677 resilience tests pass; typecheck + lint clean.
fuleinist pushed a commit to fuleinist/worldmonitor that referenced this pull request May 9, 2026
…+ net-imports parity (v17.1) (koala73#3482)

* feat(resilience): plan 002 §U8 — methodology rewrite, doc-parity test, widget polish + net-imports parity for liquidReserveAdequacy (v17.1)

Closes the plan 2026-04-26-002 sequence. Five things land together:

1. **§U8.1 — net-imports denominator parity for liquidReserveAdequacy.**
   PR koala73#3380 shipped `computeNetImports(grossImports, reexportShareOfImports)`
   for `sovereignFiscalBuffer` via the SWF seeder, sourced from
   `resilience:recovery:reexport-share:v1` (Comtrade-backed via PR koala73#3385).
   The same correction was structurally needed on the sibling
   `liquidReserveAdequacy` dimension — re-export hubs (AE 35.5%, PA
   similar) consume WB `FI.RES.TOTL.MO` which is computed at WB source
   against gross imports, double-counting goods that flow through
   without settling as domestic consumption. v17.1 multiplies WB's
   pre-computed months by `1/(1−reexportShare)` for hub countries
   (algebraic inverse of dividing the denominator) — yields the same
   adjusted-months a custom `reserves/(net-imports/12)` calc would,
   without re-fetching raw `FI.RES.TOTL.CD` + `BM.GSR.GNFS.CD` series.
   Non-hub countries unchanged (status quo). Expected impact at next
   ranking refresh: AE liquidReserveAdequacy 38 → ~64 (a +26-point dim
   swing); PA similar magnitude.

2. **§U8 — methodology doc comprehensive v17 changelog.** Single
   coherent narrative covering the universe + coverage rebuild:
   source-comprehensiveness flag, coverage penalty multiplier,
   per-capita normalization with 0.5M tiny-state floor, headline-
   eligible gate, symmetric cache-hit gate filtering. Anchored to the
   live `resilience:ranking:v17` cohort numbers captured 2026-04-28
   post-koala73#3477 merge: median(Nordics)=78.52 vs median(GCC)=70.53
   (+7.98pt gap), min(G7)=64.31 vs max(LIC)=53.73 (+10.58pt gap), 1
   microstate (MO at #4) in top-20. Documents 3 known open construct
   gaps honestly: economic-complexity / industrial-base indicator,
   importConcentration coverage gap on AE, cyberDigital transient
   zeros.

3. **§U8 — new doc-parity test (`tests/resilience-doc-parity.test.mts`).**
   Asserts the doc's prose claims match `_shared.ts` (cache prefix
   constants), `RESILIENCE_DOMAIN_ORDER` (6 domains), and
   `RESILIENCE_DIMENSION_ORDER` − `RESILIENCE_RETIRED_DIMENSIONS` (20
   active dimensions). Caught real drift: Redis keys table named v11
   prefixes despite live v17, and the headline "19 dimensions" claim
   was off-by-one after PR 2 §3.4 added liquidReserveAdequacy +
   sovereignFiscalBuffer. Fixed both as part of this commit.

4. **§U8 — widget polish.** `formatResilienceConfidence` now renders
   "Outside headline ranking" when `headlineEligible: false`, distinct
   from "Low confidence — sparse data" when `lowConfidence: true`. The
   two reasons are different and analysts should see them as different.
   Order matters: lowConfidence is more specific so it wins when both
   flags fire. New test pins the precedence.

5. **MDX-unsafe `<digit/letter` sweep + `lint:md`.** Both clean.

89 dimension-scorer tests + 36 widget tests + 5 doc-parity tests + 41
ranking + headline-gate tests all green (171 total, 0 fail).

* fix(resilience): demote v17.1 changelog H4 to bold-paragraph (methodology-lint compat)

CI methodology-lint test (T1.8) requires every H4 in
docs/methodology/country-resilience-index.mdx to map to a known
scorer dimension via HEADING_TO_DIMENSION — that's how the doc-vs-
registry parity gate works. The v17.1 changelog sub-section I added
(`#### v17.1 — Net-imports denominator parity for liquidReserveAdequacy
(U8.1)`) is a non-dimension subsection and tripped the linter.

Demoted to a bold lead-in paragraph (`**v17.1 — …**`). Same content,
same prose, just no H4. The lint comment explicitly anticipates this
case ("if a future edit adds a legitimate non-dimension H4, either
upgrade it to H3 or add an explicit allowlist") — bold-paragraph is
the cleanest of the three options and matches how Mintlify renders
sub-changelog notes elsewhere in the doc.

Local re-run: methodology-lint + doc-parity 10/10 pass; full test:data
suite 7606/7606 pass, 0 fail.

* fix(resilience): plan 002 §U8 review fixes (P1 cache bump v17→v18, P2 doc TTL/dim drift, P2 stale-count tightening)

Three review findings + a Greptile P2 perf note. All addressed locally.

P1 — cache invalidation missing for the §U8.1 scorer-formula change.
liquidReserveAdequacy now adjusts reserveMonths by re-export share for
AE/PA, but the prior commit kept score/ranking at v17 and history at
v12. The `_formula` tag is binary 'd6'|'pc' and does NOT detect intra-
'd6' scorer changes — without this bump, cached v17 AE/PA scores
(gross-imports-denominated) would continue to serve until TTL expiry,
defeating the construct fix this PR delivers. Bumped:
  - RESILIENCE_SCORE_CACHE_PREFIX     v17 → v18  (_shared.ts:164)
  - RESILIENCE_RANKING_CACHE_KEY      v17 → v18  (_shared.ts:235)
  - RESILIENCE_HISTORY_KEY_PREFIX     v12 → v13  (_shared.ts:217)
  - script mirrors:                   seed-resilience-scores.mjs,
                                      backtest, validate, benchmark
  - api/health.js:                    resilienceRanking key
History bumps in lockstep so the rolling 30-day window doesn't mix
pre/post-fix points and manufacture a false "improving" trend on day
one. Same pattern as PR 3A's v11→v12 lockstep for the SWF-side fix.

P2 — Redis keys table TTL + write-gate prose drift. Doc claimed:
  resilience:ranking:v17 | 6 hours | "only when all countries are scored"
Actual code: 12h TTL (RESILIENCE_RANKING_CACHE_TTL_SECONDS) and
publishes when coverage >= RANKING_CACHE_MIN_COVERAGE = 0.75. Updated
the table to reflect both. Same correction caught by the parity test
that flagged the v11/v17 drift in the prior commit.

P2 — stale "19 dimensions" claim still in the doc. The v2.0 scorecard
Data row at line 750 still said "19 dimensions across 6 domains"
because the parity test only required ONE correct mention. Tightened
the test to fail on any "<N> dimensions" mention in the
plausible-current-total band [15, 25] that doesn't equal activeCount
or totalCount — historical sub-counts (e.g. v1.0's "13 dimensions",
recovery pillar's "6 dimensions") are outside the band and stay
untouched. Updated the stale line to "20 active dimensions across 6
domains (plus 2 structurally-retired dimensions)".

Bonus — addressed a load of test-side cache-key literal duplications
(plan 002 §U8 follow-up):
  - tests/resilience-ranking.test.mts: 27 hardcoded
    'resilience:score:v17:XX' / 'resilience:history:v12:XX' literals
    swapped to ${RESILIENCE_SCORE_CACHE_PREFIX}/${RESILIENCE_HISTORY_KEY_PREFIX}
    template-literal references.
  - tests/resilience-handlers.test.mts: same.
  - tests/resilience-pillar-aggregation.test.mts: pinned-to-version
    assertion replaced with structural-shape regex.
  - tests/resilience-scores-seed.test.mjs: same.
This was caught by PR koala73#3477 review for the ranking key; the score and
history prefixes had the same drift trap that resurfaced now. The
proper fix per the project skill (proto-required-field-add-needs-cache-
backfill Trap 5b) is to import the constants — never hardcode literals.

Greptile P2 (per-country Redis read for reexport-share map) — verified
benign: createMemoizedSeedReader caches by key, and _shared.ts:992's
`sharedReader` is shared across all 196 countries in the warm path
(getOrComputeResilienceScores), so reexport-share is fetched ONCE per
ranking compute, not 196 times. Single-country handler creates a fresh
memoized reader per request → 1 read per request. No fix needed.

Verification: 214/214 resilience tests pass, 7606/7606 npm test:data
pass, typecheck clean, lint:md clean.
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