Skip to content

feat(resilience): 4-class imputation taxonomy foundation (T1.7)#2944

Merged
koala73 merged 2 commits into
mainfrom
feat/resilience-imputation-taxonomy
Apr 11, 2026
Merged

feat(resilience): 4-class imputation taxonomy foundation (T1.7)#2944
koala73 merged 2 commits into
mainfrom
feat/resilience-imputation-taxonomy

Conversation

@koala73

@koala73 koala73 commented Apr 11, 2026

Copy link
Copy Markdown
Owner

Why this PR?

Ships the foundation slice of Phase 1 T1.7 of the country-resilience reference-grade upgrade plan (PR #2938): tag every absence-based imputation in the resilience scorer with one of four semantic classes so downstream consumers can distinguish "nothing is happening" from "we do not know" from "upstream is down" from "the dimension does not apply."

The current scorer already handles absence as a typed signal via the IMPUTATION / IMPUTE tables, but the tables only carry score and certaintyCoverage. Downstream code (widget confidence label, benchmark gates, methodology changelog) cannot ask why a dimension is imputed. This PR adds that why.

What this PR commits

  • New exported type ImputationClass:
    • stable-absence: source publishes globally; absence means the tracked phenomenon is not happening (IPC, UCDP, UNHCR). Strong positive signal.
    • unmonitored: source is a curated list that may not cover every country. Absence is ambiguous; penalized conservatively.
    • source-failure: reserved for runtime path that consults seed-meta failedDatasets. Not yet wired; documented as landing with T1.9.
    • not-applicable: reserved for structural N/A (landlocked maritime exposure, etc.). No current scorer branches on it.
  • New exported interface ImputationEntry so IMPUTATION and IMPUTE share a single shape: { score, certaintyCoverage, imputationClass }.
  • Every table entry tagged:
    • crisis_monitoring_absent (IPC/UCDP/UNHCR) => stable-absence
    • curated_list_absent (BIS/WTO) => unmonitored
    • ipcFood => stable-absence
    • wtoData => unmonitored
    • unhcrDisplacement => stable-absence
    • bisEer, bisCredit inherit via shared reference (same object, same class)
  • Uses as const satisfies Record<string, ImputationEntry> so literal types stay narrow for existing call sites while the compiler enforces the shape on every entry.
  • Exports IMPUTATION, IMPUTE, and ImputationClass for downstream consumers.
  • 5 new scorer tests: every entry has a valid class, crisis_monitoring_absent / curated_list_absent constants unchanged, per-metric overrides carry the right class, shared-reference aliases preserve semantics, semantic sanity check that stable-absence score and certainty both exceed unmonitored.

What is deliberately NOT in this PR

  • No response-schema changes. The imputationBreakdown field on ResilienceDimension lands with T1.6 (widget dimension confidence bar with imputation icon). Keeping the proto clean here avoids an as never cascade across the generated types.
  • No scorer-aggregation changes. The taxonomy is tagged at definition time, not yet propagated through each of the 13 dimension scorers. Propagation lands with T1.5 so the two schema additions ship together.
  • No seed-time storage refactor. The plan's T1.7 description mentions writing imputationClass to per-signal Redis keys, but the current storage model is global source keys (UCDP, UNHCR, etc.) fetched once per request and keyed by ISO2 inside. A per-signal refactor is out of scope; classification at read time via the IMPUTATION table achieves the same downstream effect at zero storage cost.
  • No source-failure wiring. The seed-meta failedDatasets array is already written by the seeder; wiring the scorer to consult it and re-tag imputations is tracked for T1.9.

Prerequisite PRs (verified merged)

Testing

  • npx tsx --test tests/resilience-dimension-scorers.test.mts: 51/51 passing (46 existing + 5 new)
  • npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs: 176/176 passing
  • npm run typecheck: clean
  • Pre-push hook passes (typecheck + build:full + version:check)

Post-Deploy Monitoring & Validation

No additional operational monitoring required: this PR only adds a classification field to existing in-memory constants and tests. No runtime behavior change, no schema change, no new Redis keys, no new API surface. The scorer returns identical ResilienceDimensionScore values byte-for-byte with or without this PR.


Compound Engineering v2.49.0

Generated with Claude Opus 4.6 (1M context) via Claude Code

Why this PR?

Ships the foundation slice of Phase 1 T1.7 of the country-resilience
reference-grade upgrade plan: tag every absence-based imputation with
one of four semantic classes so downstream consumers can distinguish
"nothing is happening" from "we do not know" from "upstream is down"
from "the dimension does not apply."

Scope of this PR (foundation-only):

- Adds an `ImputationClass` type union with four values:
  - `stable-absence`: the source publishes globally and the country is
    not listed, which means the tracked phenomenon is not happening
    (e.g., no IPC Phase 3+, no UCDP event). Strong positive signal.
  - `unmonitored`: the source is a curated list that may not cover
    every country. Absence is ambiguous; penalized conservatively.
  - `source-failure`: reserved for the runtime path that consults
    seed-meta failedDatasets. Not yet wired; comment explains this
    will land in T1.9.
  - `not-applicable`: reserved for structural N/A (e.g., landlocked
    country has no maritime exposure). No current scorer branches on
    it; reserved for future dimensions.
- Introduces an `ImputationEntry` interface so both the generic
  `IMPUTATION` table and the per-metric `IMPUTE` overrides share a
  single shape with `score`, `certaintyCoverage`, and `imputationClass`.
- Tags every existing table entry with its class:
  - `crisis_monitoring_absent` (IPC, UCDP, UNHCR global feeds) ->
    stable-absence
  - `curated_list_absent` (BIS, WTO curated lists) -> unmonitored
  - `ipcFood` -> stable-absence (food-specific override)
  - `wtoData` -> unmonitored (trade-specific override)
  - `unhcrDisplacement` -> stable-absence (displacement-specific)
  - `bisEer`, `bisCredit` inherit via shared reference (same object)
- Uses `as const satisfies Record<string, ImputationEntry>` so the
  literal types stay narrow (required for the existing call sites
  that destructure specific fields) while the compiler enforces the
  shape on every entry.
- Exports `IMPUTATION`, `IMPUTE`, and `ImputationClass` so tests and
  (later) downstream consumers in T1.5 / T1.6 / T1.9 can import them.

What is deliberately NOT in this PR:

- No changes to the response schema (GetResilienceScoreResponse,
  ResilienceDimension). Exposing the class breakdown on the response
  is T1.6 (widget dimension confidence bar with imputation icon).
- No changes to the scorer aggregation logic (coverage, certainty,
  score composition). The taxonomy is tagged at definition time, not
  propagated through the 13 dimension scorers yet. Propagation lands
  with T1.5 source-recency so the two schema additions ship together.
- No seed-time tagging. The plan's T1.7 description mentions writing
  `imputationClass` to `resilience:static:signal:<id>:<cc>` keys, but
  the current storage model uses global source keys (UCDP, UNHCR,
  etc.) fetched once per request and keyed by ISO2 inside. A per-
  signal storage refactor is out of scope; classification at read
  time via the IMPUTATION table achieves the same downstream effect
  at zero storage cost.
- No source-failure detection. The seed-meta failedDatasets array is
  already written by the seeder; wiring the scorer to read it and
  re-tag imputations as source-failure lands with T1.9.

New tests (tests/resilience-dimension-scorers.test.mts):

- Every IMPUTATION entry has a valid imputationClass.
- Every IMPUTE entry has a valid imputationClass.
- crisis_monitoring_absent is stable-absence with the expected score
  and certaintyCoverage constants.
- curated_list_absent is unmonitored with the expected constants.
- Per-metric overrides (ipcFood, wtoData, unhcrDisplacement) carry
  the right class; bisEer / bisCredit preserve shared-reference
  semantics and inherit the class from the parent entry.
- Semantic sanity: stable-absence score and certaintyCoverage are
  both higher than unmonitored (fails loudly if the taxonomy ever
  drifts meaning).

Test results:
- npx tsx --test tests/resilience-dimension-scorers.test.mts: 51/51 pass
  (46 existing + 5 new)
- npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs:
  176/176 pass
- npm run typecheck: clean

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@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 10:39am

Request Review

@greptile-apps

greptile-apps Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR ships the foundation layer of the T1.7 imputation taxonomy by adding the ImputationClass union type, the ImputationEntry interface, and tagging every entry in the IMPUTATION and IMPUTE tables with one of the four semantic classes. There is no runtime behavior change; the scorer returns identical ResilienceDimensionScore values, with propagation to the 13 dimension scorers and the response schema deliberately deferred to T1.5/T1.6.

Confidence Score: 5/5

Safe to merge — purely additive type and constant changes with no runtime behavior difference.

No P0 or P1 findings. The only comment is a P2 suggestion to widen the semantic-sanity test to cover all entries in both tables rather than just the base-table pair. The as const satisfies pattern is correctly applied, shared references are correctly tested for identity equality, and the two reserved classes (source-failure, not-applicable) are clearly documented as placeholders.

No files require special attention.

Important Files Changed

Filename Overview
server/worldmonitor/resilience/v1/_dimension-scorers.ts Adds ImputationClass union, ImputationEntry interface, and tags all IMPUTATION/IMPUTE entries; clean use of as const satisfies; no behavioral change to scoring logic.
tests/resilience-dimension-scorers.test.mts Adds 5 new tests covering taxonomy completeness, per-entry class correctness, shared-reference aliasing, and a score/certainty semantic-sanity comparison.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Absence-based imputation] --> B{Source type?}
    B -->|Global publisher\nIPC / UCDP / UNHCR| C["stable-absence\n(score ≥ 85, certainty ≥ 0.6)"]
    B -->|Curated list\nBIS / WTO| D["unmonitored\n(score 50-60, certainty 0.3-0.4)"]
    B -->|Upstream API down\nat seed time| E["source-failure\n(reserved — T1.9)"]
    B -->|Structural N/A\ne.g. landlocked maritime| F["not-applicable\n(reserved — future dimensions)"]
    C --> G[IMPUTATION.crisis_monitoring_absent\nIMPUTE.ipcFood\nIMPUTE.unhcrDisplacement]
    D --> H[IMPUTATION.curated_list_absent\nIMPUTE.wtoData\nIMPUTE.bisEer\nIMPUTE.bisCredit]
    E --> I[Not wired to any table entry yet]
    F --> J[Not wired to any table entry yet]
Loading

Reviews (1): Last reviewed commit: "feat(resilience): 4-class imputation tax..." | Re-trigger Greptile

Comment on lines +755 to +772
}
});

it('stable-absence entries score higher than unmonitored (semantic sanity)', () => {
// stable-absence = strong positive signal (feed is comprehensive,
// nothing happened). unmonitored = we do not know, penalized.
// If this assertion ever fails, the semantic meaning of the classes
// has drifted and the taxonomy needs to be re-argued.
assert.ok(
IMPUTATION.crisis_monitoring_absent.score > IMPUTATION.curated_list_absent.score,
`stable-absence score (${IMPUTATION.crisis_monitoring_absent.score}) should be higher than unmonitored (${IMPUTATION.curated_list_absent.score})`,
);
assert.ok(
IMPUTATION.crisis_monitoring_absent.certaintyCoverage > IMPUTATION.curated_list_absent.certaintyCoverage,
`stable-absence certainty (${IMPUTATION.crisis_monitoring_absent.certaintyCoverage}) should be higher than unmonitored (${IMPUTATION.curated_list_absent.certaintyCoverage})`,
);
});
});

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 Semantic sanity check covers only base table entries

The invariant is verified only for IMPUTATION.crisis_monitoring_absent vs IMPUTATION.curated_list_absent, so a future IMPUTE entry that violates the ordering (e.g. a stable-absence override with a score below a unmonitored one) would not be caught here. Extending the loop to group all stable-absence and unmonitored entries from both tables and assert the min stable-absence > max unmonitored would make the guard exhaustive.

it('stable-absence entries score higher than unmonitored (semantic sanity)', () => {
  const allEntries = [
    ...Object.values(IMPUTATION),
    ...Object.values(IMPUTE),
  ];
  const stableScores = allEntries
    .filter(e => e.imputationClass === 'stable-absence')
    .map(e => e.score);
  const unmonitoredScores = allEntries
    .filter(e => e.imputationClass === 'unmonitored')
    .map(e => e.score);
  assert.ok(
    Math.min(...stableScores) > Math.max(...unmonitoredScores),
    `All stable-absence scores should exceed all unmonitored scores`,
  );
});

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Applied in 0f3d05a. Rewrote the semantic-sanity test to collect every entry from both IMPUTATION and IMPUTE into a single labeled list, partition by imputationClass, and assert min(stable-absence) > max(unmonitored) on both score and certaintyCoverage. The failure message reports which specific entry broke the invariant and from which table, so a future reviewer who hits a regression sees the offender immediately.

Under current values the invariant holds with a 25-point gap: min stable-absence score is 85 (crisis_monitoring_absent, unhcrDisplacement), max unmonitored score is 60 (wtoData). Certainty check holds too.

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

Why this PR?

Ships Phase 1 T1.8 of the country-resilience reference-grade upgrade
plan: add a test that fails loudly if the published methodology
document drifts from the scorer's RESILIENCE_DIMENSION_ORDER. This is
the discipline that keeps the methodology page trustworthy over time,
a forever risk on composite indices per the OECD/JRC handbook.

The linter runs on every test pass and checks four things:

1. Every dimension in RESILIENCE_DIMENSION_ORDER has an H4 subsection
   in the methodology document.
2. Every H4 subsection in the methodology maps to a real scorer
   dimension (no stale docs).
3. Every H4 subsection is either a mapped dimension or explicitly
   allowlisted (prevents typos and unwired new sections).
4. HEADING_TO_DIMENSION in the test file maps exactly onto
   RESILIENCE_DIMENSION_ORDER with no extras and no gaps. This makes
   the test file itself the single source of truth for how the doc
   labels map to scorer IDs.

Location-agnostic: the linter looks for the methodology file at a
short list of candidate paths and prefers the newer
country-resilience-index.mdx once T1.3 lands on main. On the current
origin/main it finds the older resilience-index.md and lints that.
This keeps T1.8 independent of T1.3's merge order so the PRs can land
in either sequence.

What this PR commits:
- New test file tests/resilience-methodology-lint.test.mts with 5
  scenarios covering the four checks above plus a smoke test that
  the file locator works.
- Hardcoded HEADING_TO_DIMENSION map (13 entries, one per scorer
  dimension) as the source of truth for the heading-to-ID mapping.
  Any future dimension add must update this map in lockstep with the
  scorer and the methodology doc, which is exactly the drift
  prevention we want.

What is NOT in this PR:
- No changes to the methodology document or the scorer.
- No automated HTML comment markers in the mdx. The hardcoded map in
  the test file is simpler and produces the same drift-detection
  signal.
- No integration with lint-staged or CI-specific gating. The linter
  runs as part of the standard test:data suite so it fires on every
  pre-push hook run.

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Related (not prerequisite) in-flight Phase 1 PRs this session:
- #2941 T1.1 regression test
- #2943 T1.4 dataVersion widget wire
- #2944 T1.7 imputation taxonomy foundation
- #2945 T1.3 methodology mdx promotion

Testing:
- npx tsx --test tests/resilience-methodology-lint.test.mts: 5/5 pass
- npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs:
  176/176 pass
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>
koala73 added a commit that referenced this pull request Apr 11, 2026
Why this PR?

Ships the foundation-only slice of Phase 1 T1.5 of the country-
resilience reference-grade upgrade plan: a pure staleness classifier
that maps a `lastObservedAt` timestamp and a source cadence to one of
three staleness levels (fresh, aging, stale). This is the primitive
that T1.6 (widget dimension confidence bar with freshness badge) and
the later T1.5 scorer propagation pass both consume.

Same pattern as the T1.7 foundation PR (#2944): define the type and
the primitive in isolation with comprehensive tests, then land the
consumer wiring in separate PRs so each unit is bounded and
reviewable.

What this PR commits:

- New module `server/_shared/resilience-freshness.ts` (110 lines)
  exporting:
    - `ResilienceCadence` type union covering the 5 cadences the
      methodology document lists (realtime, daily, weekly, monthly,
      annual).
    - `StalenessLevel` type union: fresh / aging / stale.
    - `cadenceUnitMs(cadence)` helper returning a canonical duration
      per cadence: realtime = 1 hour, daily = 1 day, weekly = 7 days,
      monthly = 30 days, annual = 365 days.
    - `FRESH_MULTIPLIER` = 1.5 and `AGING_MULTIPLIER` = 3. A signal is
      fresh when age is strictly less than 1.5x its cadence unit,
      aging when strictly less than 3x, stale otherwise.
    - `classifyStaleness({ lastObservedAtMs, cadence, nowMs })` pure
      function returning `{ staleness, ageMs, ageInCadenceUnits }`.
      Null / undefined / NaN / future timestamps return stale with
      positive-infinity age. `nowMs` is accepted as a deterministic
      override for unit testing.
- New test file `tests/resilience-freshness.test.mts` (170 lines, 10
  tests covering cadence ordering, fresh/aging/stale classification
  across all 5 cadences, defensive handling of null/NaN/future
  timestamps, exact threshold boundaries, internal consistency, and
  classifier purity).

What is deliberately NOT in this PR:

- No changes to the 13 dimension scorers. Propagating `lastObservedAt`
  through each scorer and aggregating max age per dimension is the
  next slice of T1.5 and will consume this classifier as a pure
  import.
- No schema changes (proto, OpenAPI, `ResilienceDimension` response
  type). The schema field `freshness: { lastObservedAt, staleness }`
  lands alongside the widget rendering in T1.6.
- No widget rendering. T1.6 owns the per-dimension freshness badge UI
  and will call `classifyStaleness` at render time.

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Related in-flight Phase 1 PRs this session:
- #2941 T1.1 regression test
- #2943 T1.4 dataVersion widget wire
- #2944 T1.7 imputation taxonomy foundation
- #2945 T1.3 methodology mdx promotion
- #2946 T1.8 methodology doc linter

Testing:
- npx tsx --test tests/resilience-freshness.test.mts: 10/10 pass
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>
… check)

Addresses the P2 comment from Greptile on PR #2944:

> "The invariant is verified only for IMPUTATION.crisis_monitoring_absent
>  vs IMPUTATION.curated_list_absent, so a future IMPUTE entry that
>  violates the ordering (e.g. a stable-absence override with a score
>  below an unmonitored one) would not be caught here. Extending the
>  loop to group all stable-absence and unmonitored entries from both
>  tables and assert the min stable-absence > max unmonitored would
>  make the guard exhaustive."

Fair. The earlier test only pinned the two base entries, so a
regression in a per-metric IMPUTE override (the exact layer most
likely to drift over time) would have slipped through CI. This
commit rewrites the semantic-sanity test to:

1. Collect every entry from both IMPUTATION and IMPUTE into a
   single labeled list.
2. Partition by imputationClass.
3. Assert min stable-absence score > max unmonitored score.
4. Assert min stable-absence certaintyCoverage > max unmonitored
   certaintyCoverage.
5. Report the offending entries in the failure message so a
   reviewer who hits the failure immediately sees which entry broke
   the invariant and from which table.

Under the current values the invariant holds:
- stable-absence scores: IMPUTATION.crisis_monitoring_absent=85,
  IMPUTE.ipcFood=88, IMPUTE.unhcrDisplacement=85.
- unmonitored scores: IMPUTATION.curated_list_absent=50,
  IMPUTE.wtoData=60, IMPUTE.bisEer=50, IMPUTE.bisCredit=50.
- min stable-absence = 85 > max unmonitored = 60, so the check
  passes with a 25-point gap.

Testing:
- npx tsx --test tests/resilience-dimension-scorers.test.mts: 51/51
  pass (46 existing + 5 taxonomy tests, including the rewritten
  semantic-sanity check)
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>
koala73 added a commit that referenced this pull request Apr 11, 2026
Why this PR?

Ships Phase 1 T1.6 of the country-resilience reference-grade upgrade
plan: a compact per-dimension coverage grid below the 5-domain rows
in the resilience widget so analysts can see per-dimension data
provenance without opening the deep-dive panel.

This is a scope-narrowed slice of T1.6. The plan's full description
adds an imputation class icon and a freshness badge per dimension,
but both of those require proto schema additions that have not
landed yet (T1.7 foundation and T1.5 foundation introduced the types
and classifier, but neither exposes the fields through the response
schema). This PR ships the coverage column immediately using the
existing `coverage`, `observedWeight`, `imputedWeight` fields that
are already on every ResilienceDimension, and leaves two follow-up
columns (imputation class icon, freshness badge) to later PRs once
the schema lands.

What this PR commits:

- New utils in `src/components/resilience-widget-utils.ts`:
    - `DIMENSION_LABELS` map with short display labels for each of
      the 13 scorer dimensions (`Macro`, `Currency`, `Trade`, `Cyber`,
      `Logistics`, `Infra`, `Energy`, `Gov`, `Social`, `Border`,
      `Info`, `Health`, `Food`).
    - `getResilienceDimensionLabel(dimensionId)` helper, matching
      the existing `getResilienceDomainLabel` pattern.
    - `DimensionConfidenceInput`, `DimensionCoverageStatus`, and
      `DimensionConfidence` types for the confidence classifier.
    - `formatDimensionConfidence(input)` pure function: returns
      `{ id, label, coveragePct, status, absent }` where status is
      one of `observed`, `partial`, `imputed`, `absent`. The 80%
      observed-share threshold for `observed` vs `partial` matches
      the existing `lowConfidence` rule in `_shared.ts` (where a 40%
      imputation share trips the widget-wide flag), applied per
      dimension so one well-covered dimension is not obscured by the
      domain's worst case.
    - `collectDimensionConfidences(domains)` helper that walks every
      domain and every dimension in scorer order so the widget
      renders a stable grid.
- New render methods in `src/components/ResilienceWidget.ts`:
    - `renderDimensionConfidenceGrid(data)` produces the container.
    - `renderDimensionConfidenceCell(dim)` produces one row per
      dimension with label, coverage bar, and percentage. Status
      enum is on the cell className so CSS can style observed,
      partial, imputed, and absent cells differently.
    - Wired into `renderScoreCard` between the existing domain rows
      and the footer, so the layout is domains, dimension grid,
      footer.
- 8 new tests in `tests/resilience-widget.test.mts` covering:
    - All 13 dimension labels plus the unknown-ID fallback.
    - Observed-heavy classification (observed).
    - Mixed observed and imputed classification (partial).
    - All-imputed classification (imputed).
    - Zero-weight absent classification (absent, `coveragePct=0`,
      `absent: true`).
    - Clamping for out-of-range coverage (above 1, negative) and
      NaN-safe fallback to zero weight and absent status.
    - `collectDimensionConfidences` preserves scorer order across
      domains and returns empty lists for empty responses.

What is deliberately NOT in this PR:

- No imputation class icon per dimension. That requires exposing
  `imputationClass` on the `ResilienceDimension` response type
  (proto change). Tracked as a follow-up after the T1.7 schema pass.
- No freshness badge per dimension. That requires exposing
  `lastObservedAt` and a staleness level on the response type (proto
  change). Tracked as a follow-up after the T1.5 full propagation pass.
- No CSS changes. The new cell classes are scaffolded for styling
  (`--observed`, `--partial`, `--imputed`, `--absent` modifiers) but
  the actual stylesheet edits will be folded into the CSS pass that
  picks up the full three-column dimension row once the icon and
  badge columns land.

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Related in-flight Phase 1 PRs from this session:
- #2941 T1.1 regression test
- #2943 T1.4 dataVersion widget wire
- #2944 T1.7 imputation taxonomy foundation
- #2945 T1.3 methodology mdx promotion
- #2946 T1.8 methodology doc linter
- #2947 T1.5 staleness classifier foundation

Testing:
- npx tsx --test tests/resilience-widget.test.mts: 14/14 pass
  (6 existing + 8 new dimension-confidence tests)
- npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs:
  179/179 pass
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@koala73
koala73 merged commit 40785aa into main Apr 11, 2026
10 checks passed
koala73 added a commit that referenced this pull request Apr 11, 2026
…1.8) (#2946)

Why this PR?

Ships Phase 1 T1.8 of the country-resilience reference-grade upgrade
plan: add a test that fails loudly if the published methodology
document drifts from the scorer's RESILIENCE_DIMENSION_ORDER. This is
the discipline that keeps the methodology page trustworthy over time,
a forever risk on composite indices per the OECD/JRC handbook.

The linter runs on every test pass and checks four things:

1. Every dimension in RESILIENCE_DIMENSION_ORDER has an H4 subsection
   in the methodology document.
2. Every H4 subsection in the methodology maps to a real scorer
   dimension (no stale docs).
3. Every H4 subsection is either a mapped dimension or explicitly
   allowlisted (prevents typos and unwired new sections).
4. HEADING_TO_DIMENSION in the test file maps exactly onto
   RESILIENCE_DIMENSION_ORDER with no extras and no gaps. This makes
   the test file itself the single source of truth for how the doc
   labels map to scorer IDs.

Location-agnostic: the linter looks for the methodology file at a
short list of candidate paths and prefers the newer
country-resilience-index.mdx once T1.3 lands on main. On the current
origin/main it finds the older resilience-index.md and lints that.
This keeps T1.8 independent of T1.3's merge order so the PRs can land
in either sequence.

What this PR commits:
- New test file tests/resilience-methodology-lint.test.mts with 5
  scenarios covering the four checks above plus a smoke test that
  the file locator works.
- Hardcoded HEADING_TO_DIMENSION map (13 entries, one per scorer
  dimension) as the source of truth for the heading-to-ID mapping.
  Any future dimension add must update this map in lockstep with the
  scorer and the methodology doc, which is exactly the drift
  prevention we want.

What is NOT in this PR:
- No changes to the methodology document or the scorer.
- No automated HTML comment markers in the mdx. The hardcoded map in
  the test file is simpler and produces the same drift-detection
  signal.
- No integration with lint-staged or CI-specific gating. The linter
  runs as part of the standard test:data suite so it fires on every
  pre-push hook run.

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Related (not prerequisite) in-flight Phase 1 PRs this session:
- #2941 T1.1 regression test
- #2943 T1.4 dataVersion widget wire
- #2944 T1.7 imputation taxonomy foundation
- #2945 T1.3 methodology mdx promotion

Testing:
- npx tsx --test tests/resilience-methodology-lint.test.mts: 5/5 pass
- npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs:
  176/176 pass
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-authored-by: Claude Opus 4.6 <[email protected]>
koala73 added a commit that referenced this pull request Apr 11, 2026
Why this PR?

Ships the foundation-only slice of Phase 1 T1.5 of the country-
resilience reference-grade upgrade plan: a pure staleness classifier
that maps a `lastObservedAt` timestamp and a source cadence to one of
three staleness levels (fresh, aging, stale). This is the primitive
that T1.6 (widget dimension confidence bar with freshness badge) and
the later T1.5 scorer propagation pass both consume.

Same pattern as the T1.7 foundation PR (#2944): define the type and
the primitive in isolation with comprehensive tests, then land the
consumer wiring in separate PRs so each unit is bounded and
reviewable.

What this PR commits:

- New module `server/_shared/resilience-freshness.ts` (110 lines)
  exporting:
    - `ResilienceCadence` type union covering the 5 cadences the
      methodology document lists (realtime, daily, weekly, monthly,
      annual).
    - `StalenessLevel` type union: fresh / aging / stale.
    - `cadenceUnitMs(cadence)` helper returning a canonical duration
      per cadence: realtime = 1 hour, daily = 1 day, weekly = 7 days,
      monthly = 30 days, annual = 365 days.
    - `FRESH_MULTIPLIER` = 1.5 and `AGING_MULTIPLIER` = 3. A signal is
      fresh when age is strictly less than 1.5x its cadence unit,
      aging when strictly less than 3x, stale otherwise.
    - `classifyStaleness({ lastObservedAtMs, cadence, nowMs })` pure
      function returning `{ staleness, ageMs, ageInCadenceUnits }`.
      Null / undefined / NaN / future timestamps return stale with
      positive-infinity age. `nowMs` is accepted as a deterministic
      override for unit testing.
- New test file `tests/resilience-freshness.test.mts` (170 lines, 10
  tests covering cadence ordering, fresh/aging/stale classification
  across all 5 cadences, defensive handling of null/NaN/future
  timestamps, exact threshold boundaries, internal consistency, and
  classifier purity).

What is deliberately NOT in this PR:

- No changes to the 13 dimension scorers. Propagating `lastObservedAt`
  through each scorer and aggregating max age per dimension is the
  next slice of T1.5 and will consume this classifier as a pure
  import.
- No schema changes (proto, OpenAPI, `ResilienceDimension` response
  type). The schema field `freshness: { lastObservedAt, staleness }`
  lands alongside the widget rendering in T1.6.
- No widget rendering. T1.6 owns the per-dimension freshness badge UI
  and will call `classifyStaleness` at render time.

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Related in-flight Phase 1 PRs this session:
- #2941 T1.1 regression test
- #2943 T1.4 dataVersion widget wire
- #2944 T1.7 imputation taxonomy foundation
- #2945 T1.3 methodology mdx promotion
- #2946 T1.8 methodology doc linter

Testing:
- npx tsx --test tests/resilience-freshness.test.mts: 10/10 pass
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-authored-by: Claude Opus 4.6 <[email protected]>
koala73 added a commit that referenced this pull request Apr 11, 2026
Why this PR?

Ships Phase 1 T1.6 of the country-resilience reference-grade upgrade
plan: a compact per-dimension coverage grid below the 5-domain rows
in the resilience widget so analysts can see per-dimension data
provenance without opening the deep-dive panel.

This is a scope-narrowed slice of T1.6. The plan's full description
adds an imputation class icon and a freshness badge per dimension,
but both of those require proto schema additions that have not
landed yet (T1.7 foundation and T1.5 foundation introduced the types
and classifier, but neither exposes the fields through the response
schema). This PR ships the coverage column immediately using the
existing `coverage`, `observedWeight`, `imputedWeight` fields that
are already on every ResilienceDimension, and leaves two follow-up
columns (imputation class icon, freshness badge) to later PRs once
the schema lands.

What this PR commits:

- New utils in `src/components/resilience-widget-utils.ts`:
    - `DIMENSION_LABELS` map with short display labels for each of
      the 13 scorer dimensions (`Macro`, `Currency`, `Trade`, `Cyber`,
      `Logistics`, `Infra`, `Energy`, `Gov`, `Social`, `Border`,
      `Info`, `Health`, `Food`).
    - `getResilienceDimensionLabel(dimensionId)` helper, matching
      the existing `getResilienceDomainLabel` pattern.
    - `DimensionConfidenceInput`, `DimensionCoverageStatus`, and
      `DimensionConfidence` types for the confidence classifier.
    - `formatDimensionConfidence(input)` pure function: returns
      `{ id, label, coveragePct, status, absent }` where status is
      one of `observed`, `partial`, `imputed`, `absent`. The 80%
      observed-share threshold for `observed` vs `partial` matches
      the existing `lowConfidence` rule in `_shared.ts` (where a 40%
      imputation share trips the widget-wide flag), applied per
      dimension so one well-covered dimension is not obscured by the
      domain's worst case.
    - `collectDimensionConfidences(domains)` helper that walks every
      domain and every dimension in scorer order so the widget
      renders a stable grid.
- New render methods in `src/components/ResilienceWidget.ts`:
    - `renderDimensionConfidenceGrid(data)` produces the container.
    - `renderDimensionConfidenceCell(dim)` produces one row per
      dimension with label, coverage bar, and percentage. Status
      enum is on the cell className so CSS can style observed,
      partial, imputed, and absent cells differently.
    - Wired into `renderScoreCard` between the existing domain rows
      and the footer, so the layout is domains, dimension grid,
      footer.
- 8 new tests in `tests/resilience-widget.test.mts` covering:
    - All 13 dimension labels plus the unknown-ID fallback.
    - Observed-heavy classification (observed).
    - Mixed observed and imputed classification (partial).
    - All-imputed classification (imputed).
    - Zero-weight absent classification (absent, `coveragePct=0`,
      `absent: true`).
    - Clamping for out-of-range coverage (above 1, negative) and
      NaN-safe fallback to zero weight and absent status.
    - `collectDimensionConfidences` preserves scorer order across
      domains and returns empty lists for empty responses.

What is deliberately NOT in this PR:

- No imputation class icon per dimension. That requires exposing
  `imputationClass` on the `ResilienceDimension` response type
  (proto change). Tracked as a follow-up after the T1.7 schema pass.
- No freshness badge per dimension. That requires exposing
  `lastObservedAt` and a staleness level on the response type (proto
  change). Tracked as a follow-up after the T1.5 full propagation pass.
- No CSS changes. The new cell classes are scaffolded for styling
  (`--observed`, `--partial`, `--imputed`, `--absent` modifiers) but
  the actual stylesheet edits will be folded into the CSS pass that
  picks up the full three-column dimension row once the icon and
  badge columns land.

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Related in-flight Phase 1 PRs from this session:
- #2941 T1.1 regression test
- #2943 T1.4 dataVersion widget wire
- #2944 T1.7 imputation taxonomy foundation
- #2945 T1.3 methodology mdx promotion
- #2946 T1.8 methodology doc linter
- #2947 T1.5 staleness classifier foundation

Testing:
- npx tsx --test tests/resilience-widget.test.mts: 14/14 pass
  (6 existing + 8 new dimension-confidence tests)
- npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs:
  179/179 pass
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>
koala73 added a commit that referenced this pull request Apr 11, 2026
* feat(resilience): per-dimension confidence grid in widget (T1.6)

Why this PR?

Ships Phase 1 T1.6 of the country-resilience reference-grade upgrade
plan: a compact per-dimension coverage grid below the 5-domain rows
in the resilience widget so analysts can see per-dimension data
provenance without opening the deep-dive panel.

This is a scope-narrowed slice of T1.6. The plan's full description
adds an imputation class icon and a freshness badge per dimension,
but both of those require proto schema additions that have not
landed yet (T1.7 foundation and T1.5 foundation introduced the types
and classifier, but neither exposes the fields through the response
schema). This PR ships the coverage column immediately using the
existing `coverage`, `observedWeight`, `imputedWeight` fields that
are already on every ResilienceDimension, and leaves two follow-up
columns (imputation class icon, freshness badge) to later PRs once
the schema lands.

What this PR commits:

- New utils in `src/components/resilience-widget-utils.ts`:
    - `DIMENSION_LABELS` map with short display labels for each of
      the 13 scorer dimensions (`Macro`, `Currency`, `Trade`, `Cyber`,
      `Logistics`, `Infra`, `Energy`, `Gov`, `Social`, `Border`,
      `Info`, `Health`, `Food`).
    - `getResilienceDimensionLabel(dimensionId)` helper, matching
      the existing `getResilienceDomainLabel` pattern.
    - `DimensionConfidenceInput`, `DimensionCoverageStatus`, and
      `DimensionConfidence` types for the confidence classifier.
    - `formatDimensionConfidence(input)` pure function: returns
      `{ id, label, coveragePct, status, absent }` where status is
      one of `observed`, `partial`, `imputed`, `absent`. The 80%
      observed-share threshold for `observed` vs `partial` matches
      the existing `lowConfidence` rule in `_shared.ts` (where a 40%
      imputation share trips the widget-wide flag), applied per
      dimension so one well-covered dimension is not obscured by the
      domain's worst case.
    - `collectDimensionConfidences(domains)` helper that walks every
      domain and every dimension in scorer order so the widget
      renders a stable grid.
- New render methods in `src/components/ResilienceWidget.ts`:
    - `renderDimensionConfidenceGrid(data)` produces the container.
    - `renderDimensionConfidenceCell(dim)` produces one row per
      dimension with label, coverage bar, and percentage. Status
      enum is on the cell className so CSS can style observed,
      partial, imputed, and absent cells differently.
    - Wired into `renderScoreCard` between the existing domain rows
      and the footer, so the layout is domains, dimension grid,
      footer.
- 8 new tests in `tests/resilience-widget.test.mts` covering:
    - All 13 dimension labels plus the unknown-ID fallback.
    - Observed-heavy classification (observed).
    - Mixed observed and imputed classification (partial).
    - All-imputed classification (imputed).
    - Zero-weight absent classification (absent, `coveragePct=0`,
      `absent: true`).
    - Clamping for out-of-range coverage (above 1, negative) and
      NaN-safe fallback to zero weight and absent status.
    - `collectDimensionConfidences` preserves scorer order across
      domains and returns empty lists for empty responses.

What is deliberately NOT in this PR:

- No imputation class icon per dimension. That requires exposing
  `imputationClass` on the `ResilienceDimension` response type
  (proto change). Tracked as a follow-up after the T1.7 schema pass.
- No freshness badge per dimension. That requires exposing
  `lastObservedAt` and a staleness level on the response type (proto
  change). Tracked as a follow-up after the T1.5 full propagation pass.
- No CSS changes. The new cell classes are scaffolded for styling
  (`--observed`, `--partial`, `--imputed`, `--absent` modifiers) but
  the actual stylesheet edits will be folded into the CSS pass that
  picks up the full three-column dimension row once the icon and
  badge columns land.

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Related in-flight Phase 1 PRs from this session:
- #2941 T1.1 regression test
- #2943 T1.4 dataVersion widget wire
- #2944 T1.7 imputation taxonomy foundation
- #2945 T1.3 methodology mdx promotion
- #2946 T1.8 methodology doc linter
- #2947 T1.5 staleness classifier foundation

Testing:
- npx tsx --test tests/resilience-widget.test.mts: 14/14 pass
  (6 existing + 8 new dimension-confidence tests)
- npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs:
  179/179 pass
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(resilience): ship CSS + preview data for T1.6 grid (PR #2949 review)

Addresses the P2 REQUEST_CHANGES review on PR #2949:

> "New confidence-grid DOM is added without matching stylesheet
>  support. The widget now renders resilience-widget__dimension-grid
>  and resilience-widget__dimension-cell, but the stylesheet only
>  covers the existing domain rows and footer. That means the new
>  section will render as a tall unstyled stack instead of the
>  compact grid the PR describes. The locked preview path also stays
>  effectively empty because LOCKED_PREVIEW still has empty dimension
>  arrays, so gated users get a blank gap instead of a representative
>  preview."

Two changes in one pass:

1. **CSS for the dimension grid.** Added .resilience-widget__dimension-grid
   (2-column grid on desktop, 1-column under 560px), .__dimension-cell
   (72px label + flex bar + 28px pct), .__dimension-bar-track and
   .__dimension-bar-fill, .__dimension-label, .__dimension-pct, plus
   the four status modifiers (--observed, --partial, --imputed,
   --absent) which tint the bar fill with the existing resilience
   visual-level palette (#84cc16 observed, #eab308 partial, #f97316
   imputed, text-faint absent) so the grid stays in the same
   chromatic family as the domain bars. Added a mobile breakpoint
   rule so the grid collapses to one column on narrow widths.
   Inserted between the existing .__domains and .__footer rules at
   src/styles/country-deep-dive.css so ordering stays obvious.

2. **Populated LOCKED_PREVIEW with representative dimension data.**
   Every domain in the locked preview now carries real-looking
   dimension entries (id, score, coverage, observedWeight,
   imputedWeight) so non-entitled users see a blurred grid that
   matches the shape of a real card, not a blank gap between the
   domain bars and the footer. The exact values do not need to match
   any real country (the preview is blurred + non-interactive via
   the .resilience-widget__preview CSS rule), they just need to fill
   all 13 dimensions with plausible coverage values.

Also moved LOCKED_PREVIEW out of ResilienceWidget.ts and into
resilience-widget-utils.ts so the new regression test (see below)
can import it without dragging in the full ResilienceWidget class
transitive graph. The class indirectly depends on `import.meta.env.DEV`
via proxy.ts, which breaks plain node test runners. The utils file is
already dependency-free, so putting the fixture there is consistent
with the existing split between pure helpers and runtime widget code.

New regression test in tests/resilience-widget.test.mts:
`LOCKED_PREVIEW populates all 13 dimensions for the gated preview`
asserts that collectDimensionConfidences(LOCKED_PREVIEW.domains)
returns exactly 13 entries, every cell resolves to a short display
label (no raw IDs leaking through), and no cell is `absent`. If a
future edit accidentally drops a dimension from the preview, this
test fails loudly instead of producing a silent blank gap for gated
users.

Testing:
- npx tsx --test tests/resilience-widget.test.mts: 15/15 pass
  (14 existing + 1 new LOCKED_PREVIEW regression)
- npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs:
  180/180 pass
- npm run typecheck: clean

Addresses the reviewer's requested changes directly; no DOM changes,
no new helpers, no scope expansion beyond the CSS + preview-data
pass.

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
koala73 added a commit that referenced this pull request Apr 11, 2026
… 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
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
… schema pass) (#2959)

* feat(resilience): expose imputationClass on ResilienceDimension (T1.7 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

* fix(resilience): normalize cached score responses on read (#2959 P2)

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.

* fix(resilience): bump score cache key v7 to v8, drop normalizer (#2959 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 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 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
@koala73
koala73 deleted the feat/resilience-imputation-taxonomy branch June 25, 2026 14:43
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