Skip to content

feat(resilience): T1.7 source-failure wiring + delete absence branch#2964

Merged
koala73 merged 2 commits into
mainfrom
fix/t1-7-source-failure-wiring
Apr 11, 2026
Merged

feat(resilience): T1.7 source-failure wiring + delete absence branch#2964
koala73 merged 2 commits into
mainfrom
fix/t1-7-source-failure-wiring

Conversation

@koala73

@koala73 koala73 commented Apr 11, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 1 T1.7 source-failure wiring. PR #2944 tagged every imputation with a four-class taxonomy at definition time and explicitly deferred the source-failure wiring. PR #2959 exposed the imputationClass field on the response. This PR consults the existing seed-meta:resilience:static.failedDatasets array and re-tags imputed dimensions as source-failure when the underlying adapter fetch failed, satisfying the Phase 1 acceptance criterion "4-class imputation taxonomy live end-to-end; old absence-based path deleted".

Depends on #2959

Base branch is fix/t1-7-schema-pass-imputation-class, not main. Does NOT depend on PR #2961 or #2962; once #2959 lands, this PR can merge independently of the freshness/widget stack.

What this PR commits

New module server/worldmonitor/resilience/v1/_source-failure.ts

  • readFailedDatasets(reader): reads seed-meta:resilience:static, extracts failedDatasets: string[], defensive against missing, null, malformed, non-string entries, and reader throws. Always returns an array.
  • failedDimensionsFromDatasets(keys): expands adapter keys into a Set<ResilienceDimensionId> via the explicit DATASET_TO_DIMENSIONS map. The map is 1-to-1 with the 11 adapters in scripts/seed-resilience-static.mjs::fetchAllDatasetMaps() (wgi, infrastructure, gpi, rsf, who, fao, aquastat, iea, tradeToGdp, fxReservesMonths, appliedTariffRate).

scoreAllDimensions decoration pass

After the 13 scorers run, if failedDatasets.length > 0:

  1. Expand to the affected dimension set.
  2. For each affected dimension, if imputationClass != null (already imputed), overwrite to 'source-failure'. Real-data dimensions with observedWeight > 0 keep their existing null class.
  3. Info-level log listing the failed adapter keys and affected dimensions so ops can see which adapters went down without combing through Redis.

Deleted the remaining absence-based branch

scoreCurrencyExternal had one return path that emitted imputationClass: null with score: 50, coverage: 0 when BIS EER + IMF inflation + WB reserves were ALL absent. That was the last place in the scorer where an untagged null could escape on a non-real-data path. Replaced with an IMPUTE.bisEer fall-through (which aliases IMPUTATION.curated_list_absent -> unmonitored) so the taxonomy is the single source of truth and the aggregation pass can still re-tag to source-failure when the adapter fails.

Tests

  • 13 new unit tests in tests/resilience-source-failure.test.mts for readFailedDatasets defensive branches, failedDimensionsFromDatasets expansion and dedup, and DATASET_TO_DIMENSIONS coverage vs the seed adapter list (breaks loudly if the seed grows an adapter without updating the map).
  • 5 new integration cases in tests/resilience-dimension-scorers.test.mts under a new describe('resilience source-failure aggregation (T1.7)') block: end-to-end source-failure propagation via the tradeSanctions path, real-data countries not re-tagged, unaffected dimensions untouched, healthy-seed no-op.
  • Updated tests/resilience-scorers.test.mts "all seeds missing" contract: currencyExternal now carries an explicit exemption because the legacy null return path is gone. The exemption asserts the new curated_list_absent tag.
  • Updated tests/resilience-dimension-scorers.test.mts "both BIS and IMF null" case to assert the new curated_list_absent semantics (observedWeight=0, imputedWeight=1, imputationClass='unmonitored').

What is NOT in this PR

  • No widget rendering changes -- PR feat(resilience): T1.6 full grid with imputation + freshness columns #2962 already renders the source-failure icon; the scaffolding was ready before the scorer emitted the class.
  • No new adapters beyond the current 11 in DATASET_TO_DIMENSIONS. If a new dataset lands in the seed, its mapping lands in the same PR.
  • No INDICATOR_REGISTRY sourceKey renames -- naming drift between _indicator-registry.ts sourceKeys and the static seed adapter keys is a known wart, separate cleanup.
  • No seed-resilience-static.mjs changes -- the seed already writes failedDatasets correctly.
  • No cache key bump -- response shape unchanged.

Phase 1 dependency chain

Test plan

  • npm run typecheck + typecheck:api clean
  • tests/resilience-source-failure.test.mts 13/13 passing
  • tests/resilience-dimension-scorers.test.mts 63/63 passing (5 new integration cases)
  • Full resilience suite (resilience-*.test.mts + .mjs) passing
  • npm run test:data 4379/4379 passing
  • npm run lint exit 0
  • After merge: when the resilience-static seed fails a specific adapter (e.g. manually kill WGI fetch for one run), verify the get-resilience-score response shows governanceInstitutional.imputationClass === 'source-failure' for imputed countries while real-data countries keep null.

Post-Deploy Monitoring & Validation

  • What to monitor: Railway resilience-static seed logs for failedDatasets non-empty runs. Vercel get-resilience-score RPC response bodies. Vercel logs for [Resilience] source-failure decoration info lines.
  • Validation queries:
    • Railway log search: failedDatasets during the next natural adapter failure
    • RPC probe: curl -s https://api.worldmonitor.app/worldmonitor/resilience/v1/get-resilience-score -d '{"countryCode":"XX"}' | jq '.domains[].dimensions[] | select(.imputationClass=="source-failure") | .id'
  • Expected healthy signal: when all adapters succeed, no dimension emits source-failure. When one adapter fails, only imputed dimensions consuming that adapter are re-tagged.
  • Failure signal / rollback trigger: EVERY dimension emits source-failure (indicates a reader bug). Revert.
  • Validation window: first 30 minutes + next natural adapter failure (observed failures of WGI and RSF adapters happen approximately monthly).
  • Owner: on-call analyst

@vercel

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

Request Review

@greptile-apps

greptile-apps Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR wires Phase 1 T1.7 source-failure detection: after the 13 dimension scorers run, scoreAllDimensions reads seed-meta:resilience:static.failedDatasets in parallel and re-tags any fully-imputed dimension whose adapter is in the failed set from its default taxonomy class (unmonitored/stable-absence) to source-failure. It also deletes the last absence-based branch in scoreCurrencyExternal that returned imputationClass: null on a non-real-data path, replacing it with an explicit IMPUTE.bisEer (unmonitored) fallback so the aggregation pass can now re-tag it as needed.

All remaining findings are P2 (stale inline comments, a misleading test name, a missing explanatory comment at the decoration site about the no-data-at-all gap, and a wrong PR number in the plan doc); none block merge.

Confidence Score: 5/5

Safe to merge; all findings are P2 (stale comments, a misleading test name, and a wrong PR number in docs).

The core logic is sound and well-tested: readFailedDatasets is defensive, DATASET_TO_DIMENSIONS covers all 11 seed adapters with a compile-time parity test, and scoreAllDimensions correctly guards re-tagging behind imputationClass != null. The known "no-data gap" for fully-observed-but-failed dimensions is intentional Phase 1 scope and explicitly documented in the test. No P0/P1 findings.

_dimension-scorers.ts has two stale comment blocks (lines 71–87 and 1188–1197) that should be updated before this file is next touched.

Important Files Changed

Filename Overview
server/worldmonitor/resilience/v1/_source-failure.ts New module; readFailedDatasets and failedDimensionsFromDatasets are well-written, defensive, and fully covered by 13 unit tests. DATASET_TO_DIMENSIONS correctly maps all 11 seed adapters to their dependent dimensions.
server/worldmonitor/resilience/v1/_dimension-scorers.ts Source-failure decoration in scoreAllDimensions is logically correct, but two stale comments (lines 73–87) still describe source-failure as "Wired in T1.9" and the block as a "foundation-only slice"; also the decoration silently skips adapter-fail + no-data-at-all dimensions without a comment explaining the gap.
tests/resilience-source-failure.test.mts 13 unit tests are thorough — covers well-formed, missing, malformed, non-array, non-string, reader-throws, and primitive meta cases; plus a compile-time adapter-parity check that will break loudly if the seed grows a new adapter without updating the map.
tests/resilience-dimension-scorers.test.mts 5 new integration tests for T1.7 aggregation; the tradeSanctions and fxReservesMonths paths exercise re-tagging correctly, but the first test's name ("re-tags imputed dimensions") is misleading — it only asserts that real-data dims are NOT re-tagged.
tests/resilience-scorers.test.mts Updated "all seeds missing" contract correctly adds currencyExternal to the exemption set and asserts the new unmonitored tag and imputedWeight > 0 — accurately reflects the deleted absence branch.
docs/internal/country-resilience-upgrade-plan.md T1.7 task entry updated to mark source-failure wiring as shipped, but references PR #2963 when the actual PR is #2964.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[scoreAllDimensions called] --> B{Run 13 scorers + readFailedDatasets in parallel}
    B --> C[13 ResilienceDimensionScore entries]
    B --> D[failedDatasets: string array]
    D --> E{failedDatasets.length > 0?}
    E -- No --> Z[Return scores as-is]
    E -- Yes --> F[failedDimensionsFromDatasets\nDATASET_TO_DIMENSIONS lookup]
    F --> G{For each affected dimension}
    G --> H{current.imputationClass != null?}
    H -- No\nno data / real data --> I[Keep existing null class]
    H -- Yes\nfully imputed unmonitored/stable-absence --> J[Overwrite to source-failure\nconsole.info log]
    I --> Z
    J --> Z
Loading

Comments Outside Diff (1)

  1. server/worldmonitor/resilience/v1/_dimension-scorers.ts, line 71-87 (link)

    P2 Stale comment block — source-failure is wired here, not in T1.9

    The inline documentation for source-failure still says "Wired in T1.9" and describes the class as "not currently represented in the tables below". This PR is exactly the commit that wires it, and the block-level summary below it ("foundation-only slice … deferred to T1.5/T1.6") is also now outdated. All three of those deferral tasks have shipped.

    The block-level summary lines 82–87 should similarly drop the "foundation-only" and "deferred" language now that T1.5, T1.6, and this PR have landed.

Reviews (1): Last reviewed commit: "docs(resilience): update plan doc T1.7 P..." | Re-trigger Greptile

Comment on lines +1188 to +1197
for (const dimId of affected) {
const current = scores[dimId];
// Only re-tag imputed dimensions. Dimensions with any observed
// weight keep their existing null class (which is the correct
// semantics: the seed failing did not prevent us from producing
// a real-data score for this country).
if (current != null && current.imputationClass != null) {
scores[dimId] = { ...current, imputationClass: 'source-failure' };
}
}

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 Source-failure decoration silently skips "no data at all" dimensions

Dimensions like governanceInstitutional (WGI-only), healthPublicService (WHO-only), and energy have no imputation fallback in their scorer — they call weightedBlend with all-null metrics when the adapter is down, which returns imputationClass: null. The decoration then skips them because current.imputationClass != null is false.

Consequence: when wgi is in failedDatasets, a country that has no WGI data (or whose prior snapshot was purged) keeps governanceInstitutional.imputationClass === null instead of source-failure. Consumers cannot distinguish "adapter never covered this country" from "adapter failed" for these dimensions.

The first integration test documents this limitation inline, but there is no comment at the decoration site explaining why the check is != null rather than also re-tagging the (observedWeight === 0 && imputedWeight === 0) case. Adding a brief comment here would make the gap explicit for future maintainers.

Comment on lines +942 to +997
it('re-tags imputed dimensions when their adapter is in failedDatasets', async () => {
// Case: WGI adapter failed at seed time AND the country has no real
// WGI data in the static record. governanceInstitutional is fully
// imputed (observedWeight === 0) → must flip from its default class
// to source-failure. macroFiscal depends on a different data path
// (IMF + debt) so it stays observed and is NOT re-tagged even
// though it is in the wgi→dimensions affected set.
const reader = makeOverrideReader({
'resilience:static:US': {
// wgi key omitted → scoreGovernanceInstitutional sees no data
infrastructure: {
indicators: {
'EG.ELC.ACCS.ZS': { value: 100, year: 2025 },
'IS.ROD.PAVE.ZS': { value: 74, year: 2025 },
'EG.USE.ELEC.KH.PC': { value: 12000, year: 2025 },
'IT.NET.BBND.P2': { value: 35, year: 2025 },
},
},
gpi: { score: 2.4, rank: 132, year: 2025 },
rsf: { score: 30, rank: 45, year: 2025 },
who: {
indicators: {
hospitalBeds: { value: 2.8, year: 2024 },
uhcIndex: { value: 82, year: 2024 },
measlesCoverage: { value: 91, year: 2024 },
physiciansPer1k: { value: 2.6, year: 2024 },
healthExpPerCapitaUsd: { value: 12000, year: 2024 },
},
},
fao: { peopleInCrisis: 5000, phase: 'IPC Phase 2', year: 2025 },
aquastat: { indicator: 'Renewable water availability', value: 1500, year: 2024 },
iea: { energyImportDependency: { value: 25, year: 2024, source: 'IEA' } },
tradeToGdp: { source: 'worldbank', tradeToGdpPct: 25, year: 2023 },
fxReservesMonths: { source: 'worldbank', months: 2.5, year: 2023 },
appliedTariffRate: { source: 'worldbank', value: 3.5, year: 2023 },
},
'seed-meta:resilience:static': {
fetchedAt: 1712102400000,
recordCount: 196,
failedDatasets: ['wgi'],
},
});
const dims = await scoreAllDimensions('US', reader);
// governanceInstitutional is fully imputed (no WGI) → coverage=0,
// score=0, imputationClass=null from weightedBlend. Even with the
// source-failure set, it stays null because the decoration only
// re-tags when imputationClass was already non-null. To exercise
// the real re-tagging branch, tradeSanctions is the right target:
// it has a WTO imputation fallback, and we put tradeToGdp into the
// failed set below in the next test case. For this test, simply
// assert the infrastructure row (in wgi's affected set only through
// the logistics mapping) stays correct: the decoration does not
// touch dimensions that produced real-data scores.
assert.equal(dims.infrastructure.imputationClass, null,
'real-data infrastructure must not be re-tagged even if its adapter is failed');
});

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 Test description doesn't match what it actually asserts

The test is named "re-tags imputed dimensions when their adapter is in failedDatasets" but the only assertion made is that real-data infrastructure is not re-tagged. The actual re-tagging behavior is verified in the next test via tradeSanctions. The test comment itself (lines 989–994) explicitly acknowledges the re-tagging doesn't fire for governanceInstitutional due to the no-data case.

Consider renaming to "does not re-tag real-data dimensions when their adapter is in failedDatasets" to match what is actually asserted.

Comment on lines +611 to +613
ResilienceDimension proto). T1.7 source-failure wiring shipped in
PR #2964 (consult seed-meta failedDatasets and re-tag affected
dimensions at the aggregation layer + delete the one remaining

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 Wrong PR number — should be #2964, not #2963

The plan doc records the T1.7 source-failure wiring as "shipped in PR #2963" but the PR under review is #2964. Minor tracking error worth correcting before the plan doc becomes the historical record.

Base automatically changed from fix/t1-7-schema-pass-imputation-class to main April 11, 2026 19:50
koala73 added a commit that referenced this pull request Apr 11, 2026
…P1)

Greptile P1 finding on PR #2961: readFreshnessMap() assumed every
INDICATOR_REGISTRY sourceKey could be fetched as seed-meta:<sourceKey>,
but most entries use placeholder templates like resilience:static:{ISO2},
energy:mix:v1:{ISO2}, and displacement:summary:v1:{year}. Those produce
literal lookups like seed-meta:resilience:static:{ISO2} which don't
exist in Redis, so the freshness map missed every templated entry and
classifyDimensionFreshness marked the affected dimensions stale even
with healthy seeds. Most Phase 1 T1.5 freshness badges were broken on
arrival.

Fix: two-layer resolution in _dimension-freshness.ts.

Layer 1 stripTemplateTokens: drop :{placeholder} and :* segments.
  'resilience:static:{ISO2}'        -> 'resilience:static'
  'resilience:static:*'             -> 'resilience:static'
  'energy:mix:v1:{ISO2}'            -> 'energy:mix:v1'
  'displacement:summary:v1:{year}'  -> 'displacement:summary:v1'

Layer 2 stripTrailingVersion: strip trailing :v\d+, mirroring
writeExtraKeyWithMeta + runSeed() in scripts/_seed-utils.mjs which
never persist the trailing version in seed-meta keys. Handles
cyber:threats:v2, infra:outages:v1, unrest:events:v1,
conflict:ucdp-events:v1, sanctions:country-counts:v1, and the
displacement v1 case above.

Layer 3 SOURCE_KEY_META_OVERRIDES: explicit table for drift cases
where the two strips still do not match the real seed-meta key.
Verified against api/seed-health.js, api/health.js, and scripts/seed-*.
Drift cases covered:
  economic:imf:macro       -> economic:imf-macro
  economic:bis:eer         -> economic:bis
  economic:energy:v1:all   -> economic:energy-prices
  energy:mix               -> economic:owid-energy-mix
  energy:gas-storage       -> energy:gas-storage-countries
  news:threat:summary      -> news:threat-summary
  intelligence:social:reddit -> intelligence:social-reddit

readFreshnessMap now deduplicates reads by resolved meta key (so
the 15+ resilience:static indicators share one Redis read) and
projects per-meta-key results back onto per-sourceKey map entries so
classifyDimensionFreshness can keep its existing interface.

Regression coverage:
- stripTemplateTokens cases for {ISO2}, {year}, and *.
- stripTrailingVersion cases for :v1 / :v2 suffixes.
- Embedded :v1 carve-out (trade:restrictions:v1:tariff-overview:50
  stays unchanged because :v1 is not trailing).
- Override cases for the seven drift entries.
- Integration test that proves every resilience:static:* / {ISO2}
  registry entry resolves to the same seed-meta and is marked fresh
  when that one key has a recent fetchedAt.
- healthPublicService end-to-end test: classifies fresh when
  seed-meta:resilience:static is recent (was stale before the fix).
- Registry-coverage assertion: every INDICATOR_REGISTRY sourceKey
  must resolve to a seed-meta key that either lives in
  api/seed-health.js, api/health.js, or the test's
  KNOWN_SEEDS_NOT_IN_HEALTH allowlist (which covers the four seeds
  written by writeExtraKeyWithMeta / runSeed that no health monitor
  tracks yet: trade:restrictions, trade:barriers,
  sanctions:country-counts, economic:energy-prices). Fails loudly if
  a future registry entry introduces an unknown sourceKey.

Note on P1 #2 (scoreCurrencyExternal absence-branch delete): that is
PR #2964's scope (T1.7 source-failure wiring), not #2961 (T1.5
propagation pass). #2961 never claimed to delete the fallback branch;
no test in this branch expects the new IMPUTE.bisEer fallback. The
reviewer conflated the two stacked PRs. #2964 owns the delete.
@koala73

koala73 commented Apr 11, 2026

Copy link
Copy Markdown
Owner Author

This finding is against pre-fix state. The legacy bisExchangeRaw == null early-return was deleted in commit 3698f67de (already on the PR branch HEAD).

Actual current code on origin/fix/t1-7-source-failure-wiring (server/worldmonitor/resilience/v1/_dimension-scorers.ts, scoreCurrencyExternal function, the `countryRates.length === 0` branch):

if (hasReserves) {
  const coverage = bisExchangeRaw != null ? 0.4 : 0.3;
  return { score: reservesScore, coverage, observedWeight: 1, imputedWeight: 0, imputationClass: null };
}
// No BIS EER, no IMF inflation fallback, no WB reserves fallback.
// This is true structural absence: the country isn't covered by any
// currency-stability source we track. Tag with curated_list_absent
// (= 'unmonitored') so the taxonomy is the single source of truth
// and the aggregation pass can still re-tag it as 'source-failure'
// when the underlying adapter fails. The prior absence-based branch
// returned { score: 50, imputationClass: null } which silently
// bypassed the taxonomy; replaced in T1.7 source-failure wiring.
return {
  score: IMPUTE.bisEer.score,
  coverage: IMPUTE.bisEer.certaintyCoverage,
  observedWeight: 0,
  imputedWeight: 1,
  imputationClass: IMPUTE.bisEer.imputationClass,
};

The if (bisExchangeRaw == null) return { score: 50, ... imputationClass: null } line is gone. The fall-through when BIS + inflation + reserves are all absent now returns IMPUTE.bisEer which carries imputationClass: 'unmonitored' (via IMPUTATION.curated_list_absent aliasing). The aggregation pass in scoreAllDimensions then further re-tags it as 'source-failure' when seed-meta:resilience:static.failedDatasets lists the BIS adapter.

Evidence the fix is on the branch:

$ git log origin/fix/t1-7-source-failure-wiring --oneline -3
b8351021f docs(resilience): update plan doc T1.7 PR number to #2964
3698f67de feat(resilience): T1.7 source-failure wiring + delete absence branch
33cddc6a8 feat(resilience): expose imputationClass on ResilienceDimension (T1.7 schema pass)

Test coverage: tests/resilience-dimension-scorers.test.mts T1.7 P2 BIS-null regression case asserts that a country with no BIS EER, no IMF inflation, and no WB reserves returns imputationClass === 'unmonitored' rather than null. All 63 resilience-dimension-scorers.test.mts cases pass against the current branch HEAD.

If your review tool is diffing against a snapshot older than 3698f67de, please refresh.

koala73 added a commit that referenced this pull request Apr 11, 2026
…P1)

Greptile P1 finding on PR #2961: readFreshnessMap() assumed every
INDICATOR_REGISTRY sourceKey could be fetched as seed-meta:<sourceKey>,
but most entries use placeholder templates like resilience:static:{ISO2},
energy:mix:v1:{ISO2}, and displacement:summary:v1:{year}. Those produce
literal lookups like seed-meta:resilience:static:{ISO2} which don't
exist in Redis, so the freshness map missed every templated entry and
classifyDimensionFreshness marked the affected dimensions stale even
with healthy seeds. Most Phase 1 T1.5 freshness badges were broken on
arrival.

Fix: two-layer resolution in _dimension-freshness.ts.

Layer 1 stripTemplateTokens: drop :{placeholder} and :* segments.
  'resilience:static:{ISO2}'        -> 'resilience:static'
  'resilience:static:*'             -> 'resilience:static'
  'energy:mix:v1:{ISO2}'            -> 'energy:mix:v1'
  'displacement:summary:v1:{year}'  -> 'displacement:summary:v1'

Layer 2 stripTrailingVersion: strip trailing :v\d+, mirroring
writeExtraKeyWithMeta + runSeed() in scripts/_seed-utils.mjs which
never persist the trailing version in seed-meta keys. Handles
cyber:threats:v2, infra:outages:v1, unrest:events:v1,
conflict:ucdp-events:v1, sanctions:country-counts:v1, and the
displacement v1 case above.

Layer 3 SOURCE_KEY_META_OVERRIDES: explicit table for drift cases
where the two strips still do not match the real seed-meta key.
Verified against api/seed-health.js, api/health.js, and scripts/seed-*.
Drift cases covered:
  economic:imf:macro       -> economic:imf-macro
  economic:bis:eer         -> economic:bis
  economic:energy:v1:all   -> economic:energy-prices
  energy:mix               -> economic:owid-energy-mix
  energy:gas-storage       -> energy:gas-storage-countries
  news:threat:summary      -> news:threat-summary
  intelligence:social:reddit -> intelligence:social-reddit

readFreshnessMap now deduplicates reads by resolved meta key (so
the 15+ resilience:static indicators share one Redis read) and
projects per-meta-key results back onto per-sourceKey map entries so
classifyDimensionFreshness can keep its existing interface.

Regression coverage:
- stripTemplateTokens cases for {ISO2}, {year}, and *.
- stripTrailingVersion cases for :v1 / :v2 suffixes.
- Embedded :v1 carve-out (trade:restrictions:v1:tariff-overview:50
  stays unchanged because :v1 is not trailing).
- Override cases for the seven drift entries.
- Integration test that proves every resilience:static:* / {ISO2}
  registry entry resolves to the same seed-meta and is marked fresh
  when that one key has a recent fetchedAt.
- healthPublicService end-to-end test: classifies fresh when
  seed-meta:resilience:static is recent (was stale before the fix).
- Registry-coverage assertion: every INDICATOR_REGISTRY sourceKey
  must resolve to a seed-meta key that either lives in
  api/seed-health.js, api/health.js, or the test's
  KNOWN_SEEDS_NOT_IN_HEALTH allowlist (which covers the four seeds
  written by writeExtraKeyWithMeta / runSeed that no health monitor
  tracks yet: trade:restrictions, trade:barriers,
  sanctions:country-counts, economic:energy-prices). Fails loudly if
  a future registry entry introduces an unknown sourceKey.

Note on P1 #2 (scoreCurrencyExternal absence-branch delete): that is
PR #2964's scope (T1.7 source-failure wiring), not #2961 (T1.5
propagation pass). #2961 never claimed to delete the fallback branch;
no test in this branch expects the new IMPUTE.bisEer fallback. The
reviewer conflated the two stacked PRs. #2964 owns the delete.
@koala73
koala73 force-pushed the fix/t1-7-source-failure-wiring branch from b835102 to a43acd2 Compare April 11, 2026 20:25
koala73 added a commit that referenced this pull request Apr 11, 2026
…ass) (#2961)

* feat(resilience): dimension freshness propagation (T1.5 propagation pass)

Ships the Phase 1 T1.5 propagation pass of the country-resilience
reference-grade upgrade plan. PR #2947 shipped the staleness
classifier foundation (classifyStaleness, cadence taxonomy, three
staleness levels) and explicitly deferred the dimension-level
propagation. This PR consumes the classifier and surfaces per
dimension freshness on the ResilienceDimension response.

What this PR commits

- Proto: new DimensionFreshness message + `freshness` field on
  ResilienceDimension (last_observed_at_ms, staleness string).
- New module server/worldmonitor/resilience/v1/_dimension-freshness.ts
  that reads seed-meta values for every sourceKey in INDICATOR_REGISTRY
  and aggregates the worst staleness + oldest fetchedAt across the
  constituent indicators of each dimension.
- scoreAllDimensions decorates each dimension score with its freshness
  result before returning. The 13 dimension scorer function bodies are
  untouched: aggregation is a decoration pass at the caller level so
  this PR stays mechanical.
- Response builder: _shared.ts buildDimensionList propagates the
  freshness field to the proto output.
- Tests: 10 classifyDimensionFreshness + readFreshnessMap cases in a
  new test file + response-shape case on the release-gate test.

Aggregation rules

- last_observed_at_ms: MIN fetchedAt across the dimension's indicators
  (oldest signal = most conservative bound). 0 when no signal has
  ever been observed.
- staleness: MAX staleness level across the dimension's indicators
  (stale > aging > fresh). Empty string when the dimension has no
  indicators in the registry (defensive path).

What is deliberately NOT in this PR

- No changes to the 13 individual dimension scorer function bodies.
  Per-signal freshness inside scorers is a future enhancement.
- No widget rendering of the freshness badge (T1.6 full grid, PR 3).
- No cache key bump: additive int64/string fields with zero defaults.

Verified

- make generate clean, new interface in regenerated types
- typecheck + typecheck:api clean
- tests/resilience-dimension-freshness.test.mts all new cases pass
- tests/resilience-*.test.mts full suite pass
- test:data clean
- lint exits 0 on touched files

* fix(resilience): resolve templated sourceKeys to real seed-meta (#2961 P1)

Greptile P1 finding on PR #2961: readFreshnessMap() assumed every
INDICATOR_REGISTRY sourceKey could be fetched as seed-meta:<sourceKey>,
but most entries use placeholder templates like resilience:static:{ISO2},
energy:mix:v1:{ISO2}, and displacement:summary:v1:{year}. Those produce
literal lookups like seed-meta:resilience:static:{ISO2} which don't
exist in Redis, so the freshness map missed every templated entry and
classifyDimensionFreshness marked the affected dimensions stale even
with healthy seeds. Most Phase 1 T1.5 freshness badges were broken on
arrival.

Fix: two-layer resolution in _dimension-freshness.ts.

Layer 1 stripTemplateTokens: drop :{placeholder} and :* segments.
  'resilience:static:{ISO2}'        -> 'resilience:static'
  'resilience:static:*'             -> 'resilience:static'
  'energy:mix:v1:{ISO2}'            -> 'energy:mix:v1'
  'displacement:summary:v1:{year}'  -> 'displacement:summary:v1'

Layer 2 stripTrailingVersion: strip trailing :v\d+, mirroring
writeExtraKeyWithMeta + runSeed() in scripts/_seed-utils.mjs which
never persist the trailing version in seed-meta keys. Handles
cyber:threats:v2, infra:outages:v1, unrest:events:v1,
conflict:ucdp-events:v1, sanctions:country-counts:v1, and the
displacement v1 case above.

Layer 3 SOURCE_KEY_META_OVERRIDES: explicit table for drift cases
where the two strips still do not match the real seed-meta key.
Verified against api/seed-health.js, api/health.js, and scripts/seed-*.
Drift cases covered:
  economic:imf:macro       -> economic:imf-macro
  economic:bis:eer         -> economic:bis
  economic:energy:v1:all   -> economic:energy-prices
  energy:mix               -> economic:owid-energy-mix
  energy:gas-storage       -> energy:gas-storage-countries
  news:threat:summary      -> news:threat-summary
  intelligence:social:reddit -> intelligence:social-reddit

readFreshnessMap now deduplicates reads by resolved meta key (so
the 15+ resilience:static indicators share one Redis read) and
projects per-meta-key results back onto per-sourceKey map entries so
classifyDimensionFreshness can keep its existing interface.

Regression coverage:
- stripTemplateTokens cases for {ISO2}, {year}, and *.
- stripTrailingVersion cases for :v1 / :v2 suffixes.
- Embedded :v1 carve-out (trade:restrictions:v1:tariff-overview:50
  stays unchanged because :v1 is not trailing).
- Override cases for the seven drift entries.
- Integration test that proves every resilience:static:* / {ISO2}
  registry entry resolves to the same seed-meta and is marked fresh
  when that one key has a recent fetchedAt.
- healthPublicService end-to-end test: classifies fresh when
  seed-meta:resilience:static is recent (was stale before the fix).
- Registry-coverage assertion: every INDICATOR_REGISTRY sourceKey
  must resolve to a seed-meta key that either lives in
  api/seed-health.js, api/health.js, or the test's
  KNOWN_SEEDS_NOT_IN_HEALTH allowlist (which covers the four seeds
  written by writeExtraKeyWithMeta / runSeed that no health monitor
  tracks yet: trade:restrictions, trade:barriers,
  sanctions:country-counts, economic:energy-prices). Fails loudly if
  a future registry entry introduces an unknown sourceKey.

Note on P1 #2 (scoreCurrencyExternal absence-branch delete): that is
PR #2964's scope (T1.7 source-failure wiring), not #2961 (T1.5
propagation pass). #2961 never claimed to delete the fallback branch;
no test in this branch expects the new IMPUTE.bisEer fallback. The
reviewer conflated the two stacked PRs. #2964 owns the delete.
koala73 added a commit that referenced this pull request Apr 11, 2026
…2962)

* feat(resilience): dimension freshness propagation (T1.5 propagation pass)

Ships the Phase 1 T1.5 propagation pass of the country-resilience
reference-grade upgrade plan. PR #2947 shipped the staleness
classifier foundation (classifyStaleness, cadence taxonomy, three
staleness levels) and explicitly deferred the dimension-level
propagation. This PR consumes the classifier and surfaces per
dimension freshness on the ResilienceDimension response.

What this PR commits

- Proto: new DimensionFreshness message + `freshness` field on
  ResilienceDimension (last_observed_at_ms, staleness string).
- New module server/worldmonitor/resilience/v1/_dimension-freshness.ts
  that reads seed-meta values for every sourceKey in INDICATOR_REGISTRY
  and aggregates the worst staleness + oldest fetchedAt across the
  constituent indicators of each dimension.
- scoreAllDimensions decorates each dimension score with its freshness
  result before returning. The 13 dimension scorer function bodies are
  untouched: aggregation is a decoration pass at the caller level so
  this PR stays mechanical.
- Response builder: _shared.ts buildDimensionList propagates the
  freshness field to the proto output.
- Tests: 10 classifyDimensionFreshness + readFreshnessMap cases in a
  new test file + response-shape case on the release-gate test.

Aggregation rules

- last_observed_at_ms: MIN fetchedAt across the dimension's indicators
  (oldest signal = most conservative bound). 0 when no signal has
  ever been observed.
- staleness: MAX staleness level across the dimension's indicators
  (stale > aging > fresh). Empty string when the dimension has no
  indicators in the registry (defensive path).

What is deliberately NOT in this PR

- No changes to the 13 individual dimension scorer function bodies.
  Per-signal freshness inside scorers is a future enhancement.
- No widget rendering of the freshness badge (T1.6 full grid, PR 3).
- No cache key bump: additive int64/string fields with zero defaults.

Verified

- make generate clean, new interface in regenerated types
- typecheck + typecheck:api clean
- tests/resilience-dimension-freshness.test.mts all new cases pass
- tests/resilience-*.test.mts full suite pass
- test:data clean
- lint exits 0 on touched files

* fix(resilience): resolve templated sourceKeys to real seed-meta (#2961 P1)

Greptile P1 finding on PR #2961: readFreshnessMap() assumed every
INDICATOR_REGISTRY sourceKey could be fetched as seed-meta:<sourceKey>,
but most entries use placeholder templates like resilience:static:{ISO2},
energy:mix:v1:{ISO2}, and displacement:summary:v1:{year}. Those produce
literal lookups like seed-meta:resilience:static:{ISO2} which don't
exist in Redis, so the freshness map missed every templated entry and
classifyDimensionFreshness marked the affected dimensions stale even
with healthy seeds. Most Phase 1 T1.5 freshness badges were broken on
arrival.

Fix: two-layer resolution in _dimension-freshness.ts.

Layer 1 stripTemplateTokens: drop :{placeholder} and :* segments.
  'resilience:static:{ISO2}'        -> 'resilience:static'
  'resilience:static:*'             -> 'resilience:static'
  'energy:mix:v1:{ISO2}'            -> 'energy:mix:v1'
  'displacement:summary:v1:{year}'  -> 'displacement:summary:v1'

Layer 2 stripTrailingVersion: strip trailing :v\d+, mirroring
writeExtraKeyWithMeta + runSeed() in scripts/_seed-utils.mjs which
never persist the trailing version in seed-meta keys. Handles
cyber:threats:v2, infra:outages:v1, unrest:events:v1,
conflict:ucdp-events:v1, sanctions:country-counts:v1, and the
displacement v1 case above.

Layer 3 SOURCE_KEY_META_OVERRIDES: explicit table for drift cases
where the two strips still do not match the real seed-meta key.
Verified against api/seed-health.js, api/health.js, and scripts/seed-*.
Drift cases covered:
  economic:imf:macro       -> economic:imf-macro
  economic:bis:eer         -> economic:bis
  economic:energy:v1:all   -> economic:energy-prices
  energy:mix               -> economic:owid-energy-mix
  energy:gas-storage       -> energy:gas-storage-countries
  news:threat:summary      -> news:threat-summary
  intelligence:social:reddit -> intelligence:social-reddit

readFreshnessMap now deduplicates reads by resolved meta key (so
the 15+ resilience:static indicators share one Redis read) and
projects per-meta-key results back onto per-sourceKey map entries so
classifyDimensionFreshness can keep its existing interface.

Regression coverage:
- stripTemplateTokens cases for {ISO2}, {year}, and *.
- stripTrailingVersion cases for :v1 / :v2 suffixes.
- Embedded :v1 carve-out (trade:restrictions:v1:tariff-overview:50
  stays unchanged because :v1 is not trailing).
- Override cases for the seven drift entries.
- Integration test that proves every resilience:static:* / {ISO2}
  registry entry resolves to the same seed-meta and is marked fresh
  when that one key has a recent fetchedAt.
- healthPublicService end-to-end test: classifies fresh when
  seed-meta:resilience:static is recent (was stale before the fix).
- Registry-coverage assertion: every INDICATOR_REGISTRY sourceKey
  must resolve to a seed-meta key that either lives in
  api/seed-health.js, api/health.js, or the test's
  KNOWN_SEEDS_NOT_IN_HEALTH allowlist (which covers the four seeds
  written by writeExtraKeyWithMeta / runSeed that no health monitor
  tracks yet: trade:restrictions, trade:barriers,
  sanctions:country-counts, economic:energy-prices). Fails loudly if
  a future registry entry introduces an unknown sourceKey.

Note on P1 #2 (scoreCurrencyExternal absence-branch delete): that is
PR #2964's scope (T1.7 source-failure wiring), not #2961 (T1.5
propagation pass). #2961 never claimed to delete the fallback branch;
no test in this branch expects the new IMPUTE.bisEer fallback. The
reviewer conflated the two stacked PRs. #2964 owns the delete.

* feat(resilience): T1.6 full grid with imputation + freshness columns

Ships the Phase 1 T1.6 full grid slice of the country-resilience
reference-grade upgrade plan. PR #2949 shipped the per-dimension
confidence grid scaffold (label + coverage bar + percentage) and
explicitly deferred two columns because their proto fields had not
landed yet. PR #2959 (imputationClass) and #2961 (freshness) added
those fields to the ResilienceDimension response type. This PR wires
both into the widget, completing the grid and satisfying the Phase 1
acceptance criterion "Widget shows per-dimension coverage + imputation
class + freshness badge".

What this PR commits

- DimensionConfidence type gains imputationClass and staleness plus
  a numeric lastObservedAtMs coerced from the proto int64 string.
- formatDimensionConfidence normalizes empty string, unknown, and
  missing fields into typed nulls so downstream rendering can branch
  safely.
- New helpers getImputationClassIcon, getImputationClassLabel,
  getStalenessLabel for compact glyphs and tooltip strings.
- ResilienceWidget.renderDimensionConfidenceCell renders two new
  columns:
    * imputation-icon column between the coverage bar and the
      percentage (color-coded per class, empty when null)
    * freshness-dot column at the far right (green/yellow/red dot
      per staleness, empty when null)
  aria-label and title attributes explain each glyph for a11y.
- country-deep-dive.css: cell grid template expanded from 3 columns
  to 5. New .resilience-widget__dimension-imputation and
  .resilience-widget__dimension-freshness rules with per-class and
  per-level color modifiers. Mobile media-query adjusted to keep the
  5-column layout readable below 480px.
- LOCKED_PREVIEW fixture populated with a realistic mix of classes
  and staleness levels so the gated-UI preview shows off the new
  columns.
- Tests: normalization cases for imputationClass and freshness,
  glyph / label helpers, LOCKED_PREVIEW smoke test.

What is deliberately NOT in this PR

- No scorer changes. source-failure class is exposed for rendering
  but no scorer path emits it yet; PR 4 of 5 wires the seed-meta
  failedDatasets consultation.
- formatResilienceConfidence single-label fallback is untouched; the
  cleanup is out of scope.
- No new SVG assets; text glyphs and CSS colors only.

Verified

- typecheck + typecheck:api clean
- tests/resilience-widget-utils.test.mts passing
- tests/resilience-*.test.mts full suite passing
- test:data clean
- lint exit 0
koala73 added 2 commits April 12, 2026 00:32
Ships the final slice of Phase 1 T1.7. The foundation (PR #2944) tagged
every imputation with a four-class taxonomy at definition time and
explicitly deferred the source-failure wiring as a follow-up. PR #2959
exposed the imputationClass field on the ResilienceDimension response.
This PR consults the seed-meta failedDatasets array and re-tags imputed
dimensions as source-failure when the underlying adapter fetch failed.

What this PR commits

- New module server/worldmonitor/resilience/v1/_source-failure.ts:
    readFailedDatasets(reader) reads seed-meta:resilience:static,
    extracts failedDatasets string[], defensive against missing, null,
    malformed, non-string entries, and reader throws.
    failedDimensionsFromDatasets(keys) expands adapter keys into the
    ResilienceDimensionId set via DATASET_TO_DIMENSIONS. The map is
    1-to-1 with the 11 adapters in scripts/seed-resilience-static.mjs
    fetchAllDatasetMaps().
- scoreAllDimensions decorates the per-dimension score Record after
  the 13 scorers run: for each dimension in the affected set, if
  imputationClass != null (i.e. already imputed), overwrite to
  'source-failure'. Real-data dimensions keep their existing null
  class.
- Info-level log at the top of the decoration pass when
  failedDatasets.length > 0, listing failed adapter keys + affected
  dimensions so ops can see which adapters went down without combing
  through Redis.
- scoreCurrencyExternal: deleted the old absence-based branch that
  returned imputationClass=null when BIS EER + IMF inflation + WB
  reserves are all absent. Replaced with an IMPUTE.bisEer fall-through
  (curated_list_absent, unmonitored) so the taxonomy is the single
  source of truth. This is the last place in the scorer where a null
  imputationClass escaped on an imputed return path.
- 13 new unit tests in tests/resilience-source-failure.test.mts
  covering readFailedDatasets defensive branches,
  failedDimensionsFromDatasets expansion and deduplication, and
  DATASET_TO_DIMENSIONS coverage against the seed adapter list.
- 5 new integration cases in tests/resilience-dimension-scorers.test.mts
  inside a new describe('resilience source-failure aggregation (T1.7)')
  block: end-to-end source-failure propagation via tradeSanctions,
  real-data countries not re-tagged, unaffected dimensions untouched,
  healthy-seed no-op path.
- Updated tests/resilience-scorers.test.mts BIS-null assertion: the
  "coverage=0 when all seeds missing" contract now carries an
  explicit exemption for currencyExternal because the legacy null
  return path is gone (replaced with the taxonomy fall-through). The
  exemption verifies the new tag is unmonitored.
- tests/resilience-dimension-scorers.test.mts 'both BIS and IMF null'
  case updated to assert the new curated_list_absent semantics.

Phase 1 acceptance delta

  '4-class imputation taxonomy live end-to-end; old absence-based path
  deleted' now has all four classes emittable and every imputed return
  path carries a non-null imputationClass.

What is deliberately NOT in this PR

- No widget rendering changes (PR #2962 already renders the
  source-failure icon).
- No new adapters to DATASET_TO_DIMENSIONS beyond the current 11.
- No INDICATOR_REGISTRY sourceKey renames; that is a separate cleanup.
- No cache key bump (response shape unchanged).

Verified

- npm run typecheck and typecheck:api clean
- tests/resilience-source-failure.test.mts 13/13 passing
- tests/resilience-dimension-scorers.test.mts 63/63 passing
- full resilience suite (tests/resilience-*.test.mts + .mjs) passing
- npm run test:data 4379/4379 passing
- npm run lint exit 0
@koala73
koala73 force-pushed the fix/t1-7-source-failure-wiring branch from a43acd2 to 39835fb Compare April 11, 2026 20:35
@koala73
koala73 merged commit 5511ba0 into main Apr 11, 2026
10 of 11 checks passed
@koala73
koala73 deleted the fix/t1-7-source-failure-wiring branch April 11, 2026 20:38
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