Skip to content

feat(resilience): expose imputationClass on ResilienceDimension (T1.7 schema pass)#2959

Merged
koala73 merged 3 commits into
mainfrom
fix/t1-7-schema-pass-imputation-class
Apr 11, 2026
Merged

feat(resilience): expose imputationClass on ResilienceDimension (T1.7 schema pass)#2959
koala73 merged 3 commits into
mainfrom
fix/t1-7-schema-pass-imputation-class

Conversation

@koala73

@koala73 koala73 commented Apr 11, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 1 T1.7 schema pass from the country-resilience reference-grade upgrade plan. PR #2944 shipped the classifier table foundation and explicitly deferred the schema propagation; this PR lands it so downstream consumers can distinguish the four semantic imputation classes on a per-dimension basis.

What this PR commits

  • Proto: new imputation_class string field on ResilienceDimension (empty string when the dimension has any observed data; otherwise one of stable-absence, unmonitored, source-failure, not-applicable).
  • Generated TS types: regenerated service_server.ts and service_client.ts via make generate.
  • Scorer: ResilienceDimensionScore carries ImputationClass | null. WeightedMetric carries an optional imputationClass that imputation paths populate. weightedBlend aggregates the dominant class by weight when the dimension is fully imputed, returns null otherwise.
  • IMPUTE. early-return paths*: IMPUTE.bisEer, IMPUTE.wtoData, IMPUTE.ipcFood, IMPUTE.unhcrDisplacement all propagate their tagged class.
  • Response builder: _shared.ts buildDimensionList passes the class through to the proto field.
  • Tests: weightedBlend aggregation semantics (5 cases), dimension-level propagation from IMPUTE tables, serialized response shape.

What is NOT in this PR

  • No widget icon rendering, that's T1.6 full grid (PR 3 of 5), which consumes this field.
  • No source-failure seed-meta consultation, the class is reserved but no scorer path emits it yet. Lands in PR 4 of 5.
  • No not-applicable emission, reserved for future structural N/A cases (e.g. landlocked maritime exposure).
  • No freshness field, T1.5 propagation is PR 2 of 5.
  • No cache key bump, the new field is optional (empty-string default), so existing cached responses in resilience:score:v7 deserialize cleanly.

Phase 1 dependency chain

  • PR 1 (this): T1.7 schema pass, imputationClass on the proto
  • PR 2: T1.5 propagation pass, freshness on the proto
  • PR 3: T1.6 full grid, widget icon column (consumes PR 1) + freshness badge column (consumes PR 2)
  • PR 4: T1.7 source-failure wiring, scorer consults seed-meta.failedDatasets
  • PR 5: T1.9 + Phase 1 scorecard close-out

Test plan

  • make generate clean, new field visible in regenerated types
  • npm run typecheck + typecheck:api clean
  • tests/resilience-dimension-scorers.test.mts passes existing + new cases
  • tests/resilience-*.test.mts full suite passes
  • npm run test:data full suite passes (4361 tests)
  • npm run lint exits 0
  • After merge, deploy + verify get-resilience-score RPC returns imputationClass on dimensions for a known fully-imputed case (e.g. a small-population Pacific island where wtoData / ipcFood are absent)

Post-Deploy Monitoring & Validation

No additional operational monitoring required: this PR only adds an optional string field to the response schema and propagates an existing in-memory class tag. Existing cached responses in resilience:score:v7 keep deserializing cleanly because the field defaults to empty string.

… schema pass)

Ships the Phase 1 T1.7 schema pass of the country-resilience reference
grade upgrade plan. PR #2944 shipped the classifier table foundation
(ImputationClass type, ImputationEntry interface, IMPUTATION/IMPUTE
tagged with four semantic classes) and explicitly deferred the schema
propagation. This PR lands that propagation so downstream consumers can
distinguish "country is stable" from "country is unmonitored" from
"upstream is down" from "structurally not applicable" on a per-dimension
basis.

What this PR commits

- Proto: new imputation_class string field on ResilienceDimension
  (empty string = dimension has any observed data; otherwise one of
  stable-absence, unmonitored, source-failure, not-applicable).
- Generated TS types: regenerated service_server.ts and service_client.ts
  via make generate.
- Scorer: ResilienceDimensionScore carries ImputationClass | null.
  WeightedMetric carries an optional imputationClass that imputation
  paths populate. weightedBlend aggregates the dominant class by
  weight when the dimension is fully imputed, returns null otherwise.
- All IMPUTE.* early-return paths propagate the class from the table
  (IMPUTE.bisEer, IMPUTE.wtoData, IMPUTE.ipcFood, IMPUTE.unhcrDisplacement).
- Response builder: _shared.ts buildDimensionList passes the class
  through to the ResilienceDimension proto field.
- Tests: weightedBlend aggregation semantics (5 cases), dimension-level
  propagation from IMPUTE tables, serialized response includes the field.

What is deliberately NOT in this PR

- No widget icon rendering (T1.6 full grid, PR 3 of 5)
- No source-failure seed-meta consultation (PR 4 of 5)
- No freshness field (T1.5 propagation, PR 2 of 5)
- No cache key bump: the new field is empty-string default, existing
  cached responses continue to deserialize cleanly

Verified

- make generate clean
- npm run typecheck + typecheck:api clean
- tests/resilience-dimension-scorers.test.mts all passing (existing + new)
- tests/resilience-*.test.mts + test:data suite passing (4361 tests)
- npm run lint exits 0
@mintlify

mintlify Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
WorldMonitor 🟢 Ready View Preview Apr 11, 2026, 4:47 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@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 7:50pm

Request Review

@greptile-apps

greptile-apps Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR lands the T1.7 schema pass: imputationClass is added to ResilienceDimension in the proto, propagated through weightedBlend's new dominant-class aggregation logic, and threaded out to four imputed-path scorers (bisEer, wtoData, ipcFood, unhcrDisplacement). The change is backward-compatible (Proto3 empty-string default, no cache key bump required), well-tested with a dedicated taxonomy suite and release-gate pins, and the LOCKED_PREVIEW fixture is correctly updated.

Confidence Score: 5/5

Safe to merge — all findings are P2 documentation/comment inaccuracies with no impact on runtime behavior.

The core implementation is correct: weightedBlend aggregation, tie-break ordering, four scorer propagation paths, and the null→'' mapping in buildDimensionList all behave as intended and are fully covered by the new test suites. The only issues are an incomplete empty-string semantics description and a stale comment, neither of which affects correctness of current code or any live consumer.

server/worldmonitor/resilience/v1/_shared.ts — imputationClass comment should note the zero-coverage case.

Important Files Changed

Filename Overview
server/worldmonitor/resilience/v1/_dimension-scorers.ts Adds ImputationClass type, IMPUTATION/IMPUTE tables, IMPUTATION_CLASS_TIE_BREAK, and weightedBlend aggregation logic; four scorer functions correctly propagate imputationClass through their imputed metric paths.
server/worldmonitor/resilience/v1/_shared.ts buildDimensionList maps null → '' correctly, but the comment incompletely states that empty string also applies to the zero-data/no-coverage case.
proto/worldmonitor/resilience/v1/resilience.proto Adds imputation_class string field (field 6) to ResilienceDimension; backward-compatible Proto3 addition.
src/components/resilience-widget-utils.ts LOCKED_PREVIEW fixture correctly updated with imputationClass: '' on all 13 dimensions; stale comment says schema changes land in later PRs.
tests/resilience-dimension-scorers.test.mts Comprehensive T1.7 test suite covering taxonomy table entries, weightedBlend aggregation semantics, and all four imputation paths.
tests/resilience-release-gate.test.mts Adds two T1.7 gate tests pinning the serialized shape and the full scorer→buildDimensionList pipeline.
src/generated/server/worldmonitor/resilience/v1/service_server.ts Regenerated — imputationClass: string added to ResilienceDimension interface.
src/generated/client/worldmonitor/resilience/v1/service_client.ts Regenerated — imputationClass: string present on client-side ResilienceDimension.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["Scorer"] --> B{Source loaded?}
    B -- No --> C["score: null (no tag)"]
    B -- Yes --> D{Country in source?}
    D -- Yes --> E["Real metric\nimputed: false"]
    D -- No --> F["Imputed metric\nimputed: true\nimputationClass: IMPUTE.X"]
    C --> G["weightedBlend"]
    E --> G
    F --> G
    G --> H{observedWeight > 0?}
    H -- Yes --> I["imputationClass: null"]
    H -- No --> J{imputedWeight > 0?}
    J -- No --> K["imputationClass: null\n(no data at all)"]
    J -- Yes --> L["Dominant class by weight\ntie-break: stable-absence\n> unmonitored > source-failure\n> not-applicable"]
    L --> M["imputationClass: 'stable-absence' etc."]
    I --> N["buildDimensionList: null ?? ''"]
    K --> N
    M --> N
    N --> O["ResilienceDimension.imputationClass"]
Loading

Comments Outside Diff (1)

  1. src/components/resilience-widget-utils.ts, line 162-167 (link)

    P2 Stale comment — schema change now present

    These lines say "no proto or schema changes are needed" and refer to imputationClass landing "in later PRs once the schema exposes those fields." But this PR (T1.7) already adds imputationClass to the proto and generated types. Consider updating the comment to reflect that the schema propagation is complete and only the widget icon rendering (T1.6) remains deferred.

Reviews (1): Last reviewed commit: "feat(resilience): expose imputationClass..." | Re-trigger Greptile

Comment on lines +105 to +106
// T1.7 schema pass: empty string = dimension has any observed data.
imputationClass: scores[dimensionId].imputationClass ?? '',

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 Incomplete comment — empty string also applies to the zero-data case

The comment // T1.7 schema pass: empty string = dimension has any observed data. is only half the story. imputationClass is null (→ '') in three distinct situations from weightedBlend:

  1. observedWeight > 0 – at least one real data point (the described case)
  2. availableWeight === 0 – no metrics have non-null scores at all (completely absent dimension)
  3. observedWeight === 0 && classWeights.size === 0 – imputed but no class recorded (edge case)

A downstream consumer reading imputationClass === '' cannot conclude "has observed data" — they also need coverage > 0 to rule out the absent case. The same issue appears verbatim in the proto comment and OpenAPI description.

Suggested change
// T1.7 schema pass: empty string = dimension has any observed data.
imputationClass: scores[dimensionId].imputationClass ?? '',
// T1.7 schema pass: empty string when the dimension has any observed data
// OR when coverage=0 (no data at all — every metric returned null). A
// non-empty value (e.g. "stable-absence") means the dimension is fully
// imputed with no observed data. Consumers must check coverage > 0 to
// distinguish "observed" from "absent".
imputationClass: scores[dimensionId].imputationClass ?? '',

koala73 added a commit that referenced this pull request Apr 11, 2026
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 a commit that referenced this pull request Apr 11, 2026
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
Greptile P2 finding on PR #2959: cachedFetchJson and
getCachedResilienceScores return pre-change payloads verbatim, so a
resilience:score:v7 entry written before this PR lands lacks the
imputationClass field. Downstream consumers that read
dim.imputationClass get undefined for up to 6 hours until the cache
TTL expires.

Fix: add normalizeResilienceScoreResponse helper that defaults
missing optional fields in place and apply it at both read sites.
Defaults imputationClass to empty string, matching the proto3 default
for the new imputation_class field.

- ensureResilienceScoreCached applies the normalizer after
  cachedFetchJson returns.
- getCachedResilienceScores applies it after each successful
  JSON.parse on the pipeline result.
- Two new test cases: stale payload without imputationClass gets
  defaulted, present values are preserved.
- Not bumping the cache key: stale-read defaults are safe, the key
  bump would invalidate every cached score for a 6-hour cold-start
  cycle. The normalizer is extensible when PR #2961 adds freshness
  to the same payload.

P3 finding (broken docs reference) verified invalid: the proto
comment points to docs/methodology/country-resilience-index.mdx,
which IS the current file. The .md predecessor was renamed in
PR #2945 (T1.3 methodology doc promotion to CII parity). No change
needed to the comment.
@koala73

koala73 commented Apr 11, 2026

Copy link
Copy Markdown
Owner Author

Addressed P2 in commit 40ea220: added normalizeResilienceScoreResponse helper that defaults missing optional fields on cached payloads, applied at both read sites (ensureResilienceScoreCached and getCachedResilienceScores). imputationClass now defaults to empty string when a stale resilience:score:v7 entry lacks the field, matching the proto3 default. Tests cover both the stale-payload and present-value cases. Chose normalize-on-read over a cache-key bump to avoid the 6-hour cold-start cycle and because the helper extends cleanly for the freshness field #2961 adds to the same payload.

P3 (broken docs reference) is invalid: docs/methodology/country-resilience-index.mdx exists and is the current methodology file. The .md predecessor was renamed in #2945 (T1.3 methodology doc promotion to CII parity). No change needed.

…P2)

Second fixup for the Greptile P2 finding on #2959. The previous fixup
(40ea220) added normalizeResilienceScoreResponse to default missing
imputationClass fields on cached payloads to empty string. The
reviewer correctly pushed back: defaulting to empty string is the
proto3 default for "dimension has observed data", which silently
misreports pre-rollout imputed dimensions as observed until the 6h
TTL expires.

Correct fix: bump RESILIENCE_SCORE_CACHE_PREFIX from resilience:score:v7:
to resilience:score:v8:. Invalidates every pre-change cache entry, so
the next request per country repopulates with the correct
imputationClass written by the scorer. Cost: a 6h warmup cycle where
first-request-per-country recomputes the score, ~100ms per country
across hundreds of requests.

Also deletes the normalizeResilienceScoreResponse helper and its two
call sites. It was misleading defense-in-depth that can hide future
schema drift bugs. Future additive field additions should bump the
key, not silently default fields.

- server/worldmonitor/resilience/v1/_shared.ts: prefix v7 to v8,
  delete normalizer function and both call sites.
- scripts/seed-resilience-scores.mjs, validate-resilience-correlation.mjs,
  validate-resilience-backtest.mjs: mirror constants bumped.
- tests/resilience-scores-seed.test.mjs: pin literal v7 to v8.
- tests/resilience-ranking.test.mts: 7 hardcoded cache keys bumped.
- tests/resilience-handlers.test.mts: stray v7 cache key bumped.
- tests/resilience-release-gate.test.mts: the two normalizer test
  cases from 40ea220 deleted along with the helper.
- docs/methodology/country-resilience-index.mdx: Redis keys table
  updated from v7 to v8 to match the canonical constant.

P3 (broken docs reference) confirmed invalid a second time.
docs/methodology/country-resilience-index.mdx exists on origin/main
AND on the PR branch with the same blob hash
d2ab1eb. docs/methodology/resilience-index.md
does not exist on either. No proto comment change.
@koala73

koala73 commented Apr 11, 2026

Copy link
Copy Markdown
Owner Author

Replaced the normalize-on-read approach with a proper cache-key bump in commit 5b29040.

What changed from 40ea220:

  • RESILIENCE_SCORE_CACHE_PREFIX bumped from resilience:score:v7: to resilience:score:v8: in the canonical file, the two validate scripts, the seed script, the test fixtures, tests/resilience-handlers.test.mts, and the Redis-keys table in docs/methodology/country-resilience-index.mdx.
  • normalizeResilienceScoreResponse helper + its two call sites removed. It was misleading: defaulting missing fields to the proto3 empty string silently misreports pre-rollout imputed dimensions as observed. The cache-key bump invalidates those entries so the next request repopulates with the correct imputationClass written by the scorer.
  • The two test cases I added in 40ea220 for the normalizer are deleted along with the helper.

Cost of the bump: 6h warmup cycle where first-request-per-country recomputes the score, ~100ms per country across hundreds of requests. Acceptable.

P3 (broken docs reference): confirmed invalid a second time. docs/methodology/country-resilience-index.mdx exists on origin/main AND on the PR branch with the same blob hash d2ab1ebad393473ceff333984db05562612429b9. docs/methodology/resilience-index.md does not exist on either branch. The reviewer appears to be diffing against a stale tree.

Verification: typecheck / typecheck:api / full resilience suite (214/214) / test:data (4361/4361) all clean. Pre-push hook passed.

@koala73
koala73 merged commit dca2e1c into main Apr 11, 2026
11 checks passed
@koala73
koala73 deleted the fix/t1-7-schema-pass-imputation-class branch April 11, 2026 19:50
koala73 added a commit that referenced this pull request Apr 11, 2026
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 added a commit that referenced this pull request Apr 11, 2026
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 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 a commit that referenced this pull request Apr 11, 2026
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 added a commit that referenced this pull request Apr 11, 2026
…2964)

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

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

* docs(resilience): update plan doc T1.7 PR number to #2964
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