Skip to content

feat(resilience): staleness classifier foundation (T1.5)#2947

Merged
koala73 merged 1 commit into
mainfrom
feat/resilience-staleness-classifier
Apr 11, 2026
Merged

feat(resilience): staleness classifier foundation (T1.5)#2947
koala73 merged 1 commit into
mainfrom
feat/resilience-staleness-classifier

Conversation

@koala73
Copy link
Copy Markdown
Owner

@koala73 koala73 commented Apr 11, 2026

Why this PR?

Ships the foundation-only slice of Phase 1 T1.5 of the country-resilience reference-grade upgrade plan (PR #2938): 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:
    • ResilienceCadence type union covering the 5 cadences listed in the methodology document (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 exported constants. 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 with 10 tests covering cadence ordering, fresh/aging/stale classification across all 5 cadences, defensive handling of null/undefined/NaN/future timestamps, exact threshold boundaries, internal consistency of ageMs and ageInCadenceUnits, 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. Splitting it this way keeps the classifier testable in isolation.
  • 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

Related in-flight Phase 1 PRs from this session

Testing

  • npx tsx --test tests/resilience-freshness.test.mts: 10/10 passing
  • npm run typecheck: clean
  • Pre-push hook (typecheck + build:full + version:check) passes

Post-Deploy Monitoring & Validation

No additional operational monitoring required: this PR only adds a new pure module and its tests. No runtime behavior change, no schema change, no new Redis keys, no new API surface. No existing file is modified.


Compound Engineering v2.49.0

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

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 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 11, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Apr 11, 2026 10:03am

Request Review

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 11, 2026

Greptile Summary

This PR adds the foundation of the T1.5 staleness classifier: a new pure module server/_shared/resilience-freshness.ts defining the ResilienceCadence / StalenessLevel types, cadenceUnitMs() helper, threshold constants, and classifyStaleness(), accompanied by 10 deterministic unit tests. No existing files are modified, and all findings are P2 style suggestions.

Confidence Score: 5/5

Safe to merge — isolated new module with no existing-code changes and no P0/P1 findings.

All three comments are P2 style/completeness suggestions (a redundant Math.max, a missing JSDoc note, and missing test assertions). The classifier logic, guards, and boundary semantics are correct.

No files require special attention.

Important Files Changed

Filename Overview
server/_shared/resilience-freshness.ts New pure staleness classifier module — logic is correct, guards are sound, one redundant Math.max(0, …) after the future-timestamp guard (P2 only).
tests/resilience-freshness.test.mts 10 deterministic tests with fixed NOW anchor; boundary, defensive, and purity cases all covered; minor gap: ageInCadenceUnits not asserted for defensive (null/NaN/future) cases.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["classifyStaleness(args)"] --> B{"lastObservedAtMs\nnull / undefined / NaN\nor > nowMs?"}
    B -- yes --> C["return stale\nageMs = +Infinity\nageInCadenceUnits = +Infinity"]
    B -- no --> D["ageMs = nowMs − lastObservedAtMs\nageInCadenceUnits = ageMs / cadenceUnit"]
    D --> E{"ageInCadenceUnits\n< FRESH_MULTIPLIER (1.5)?"}
    E -- yes --> F["return fresh"]
    E -- no --> G{"ageInCadenceUnits\n< AGING_MULTIPLIER (3)?"}
    G -- yes --> H["return aging"]
    G -- no --> I["return stale"]
Loading

Reviews (1): Last reviewed commit: "feat(resilience): staleness classifier f..." | Re-trigger Greptile

return { staleness: 'stale', ageMs: Number.POSITIVE_INFINITY, ageInCadenceUnits: Number.POSITIVE_INFINITY };
}

const ageMs = Math.max(0, nowMs - lastObservedAtMs);
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 Redundant Math.max(0, …) guard

The lastObservedAtMs > nowMs check three lines above already ensures the function returns stale for any future timestamp, so by the time execution reaches this line, nowMs - lastObservedAtMs is guaranteed to be ≥ 0 and Math.max is always a no-op. Removing it avoids a misleading hint to future readers that a negative value could arrive here.

Suggested change
const ageMs = Math.max(0, nowMs - lastObservedAtMs);
const ageMs = nowMs - lastObservedAtMs;

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.

Fixes landed post-merge in follow-up PR #2953. Removed in the follow-up. Added an inline comment explaining that the defensive branch above already rejects future timestamps, so nowMs - lastObservedAtMs is guaranteed non-negative.

Comment on lines +67 to +69
ageMs: number;
/** The age expressed as a multiple of the cadence unit. Handy for debugging. */
ageInCadenceUnits: number;
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 ageMs can be Infinity — worth documenting on the field

When lastObservedAtMs is null/undefined/NaN/future, classifyStaleness returns ageMs: Number.POSITIVE_INFINITY. The TypeScript type number is technically correct, but callers doing arithmetic on ageMs (e.g. formatting a "last seen X ago" label) will silently produce Infinity strings or NaN without a guard. Adding a JSDoc note on the field would help consumers.

Suggested change
ageMs: number;
/** The age expressed as a multiple of the cadence unit. Handy for debugging. */
ageInCadenceUnits: number;
export interface StalenessResult {
staleness: StalenessLevel;
/**
* Age in milliseconds. `Number.POSITIVE_INFINITY` when `lastObservedAtMs`
* is null, undefined, NaN, or in the future. Always check for `Infinity`
* before using this value in arithmetic or display formatting.
*/
ageMs: number;
/** The age expressed as a multiple of the cadence unit. Handy for debugging. */
ageInCadenceUnits: number;
}

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.

Fixes landed post-merge in follow-up PR #2953. Added JSDoc to both ageMs and ageInCadenceUnits explaining the Number.POSITIVE_INFINITY contract and recommending Number.isFinite before arithmetic or string formatting.

Comment on lines +84 to +101
const missingNull = classifyStaleness({ lastObservedAtMs: null, cadence, nowMs: NOW });
assert.equal(missingNull.staleness, 'stale', `${cadence} null should be stale`);
assert.equal(missingNull.ageMs, Number.POSITIVE_INFINITY);

const missingUndefined = classifyStaleness({ lastObservedAtMs: undefined, cadence, nowMs: NOW });
assert.equal(missingUndefined.staleness, 'stale', `${cadence} undefined should be stale`);

const nanResult = classifyStaleness({ lastObservedAtMs: Number.NaN, cadence, nowMs: NOW });
assert.equal(nanResult.staleness, 'stale', `${cadence} NaN should be stale`);

// A timestamp 10 minutes in the future is nonsensical and treated as stale.
const futureResult = classifyStaleness({
lastObservedAtMs: NOW + 10 * 60 * 1000,
cadence,
nowMs: NOW,
});
assert.equal(futureResult.staleness, 'stale', `${cadence} future timestamp should be stale`);
}
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 Missing ageInCadenceUnits assertion for defensive cases

The null branch asserts ageMs === POSITIVE_INFINITY (line 86), but neither ageInCadenceUnits nor ageMs is checked for the undefined, NaN, or future-timestamp branches. Since the contract is that all three fields are POSITIVE_INFINITY in these cases, pinning that would prevent a future regression where one field is accidentally omitted from the defensive return.

Suggested change
const missingNull = classifyStaleness({ lastObservedAtMs: null, cadence, nowMs: NOW });
assert.equal(missingNull.staleness, 'stale', `${cadence} null should be stale`);
assert.equal(missingNull.ageMs, Number.POSITIVE_INFINITY);
const missingUndefined = classifyStaleness({ lastObservedAtMs: undefined, cadence, nowMs: NOW });
assert.equal(missingUndefined.staleness, 'stale', `${cadence} undefined should be stale`);
const nanResult = classifyStaleness({ lastObservedAtMs: Number.NaN, cadence, nowMs: NOW });
assert.equal(nanResult.staleness, 'stale', `${cadence} NaN should be stale`);
// A timestamp 10 minutes in the future is nonsensical and treated as stale.
const futureResult = classifyStaleness({
lastObservedAtMs: NOW + 10 * 60 * 1000,
cadence,
nowMs: NOW,
});
assert.equal(futureResult.staleness, 'stale', `${cadence} future timestamp should be stale`);
}
const missingNull = classifyStaleness({ lastObservedAtMs: null, cadence, nowMs: NOW });
assert.equal(missingNull.staleness, 'stale', `${cadence} null should be stale`);
assert.equal(missingNull.ageMs, Number.POSITIVE_INFINITY);
assert.equal(missingNull.ageInCadenceUnits, Number.POSITIVE_INFINITY);
const missingUndefined = classifyStaleness({ lastObservedAtMs: undefined, cadence, nowMs: NOW });
assert.equal(missingUndefined.staleness, 'stale', `${cadence} undefined should be stale`);
assert.equal(missingUndefined.ageMs, Number.POSITIVE_INFINITY);
assert.equal(missingUndefined.ageInCadenceUnits, Number.POSITIVE_INFINITY);
const nanResult = classifyStaleness({ lastObservedAtMs: Number.NaN, cadence, nowMs: NOW });
assert.equal(nanResult.staleness, 'stale', `${cadence} NaN should be stale`);
assert.equal(nanResult.ageMs, Number.POSITIVE_INFINITY);
assert.equal(nanResult.ageInCadenceUnits, Number.POSITIVE_INFINITY);
const futureResult = classifyStaleness({
lastObservedAtMs: NOW + 10 * 60 * 1000,
cadence,
nowMs: NOW,
});
assert.equal(futureResult.staleness, 'stale', `${cadence} future timestamp should be stale`);
assert.equal(futureResult.ageMs, Number.POSITIVE_INFINITY);
assert.equal(futureResult.ageInCadenceUnits, Number.POSITIVE_INFINITY);

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.

Fixes landed post-merge in follow-up PR #2953. Extended the defensive-cases test to pin both ageMs and ageInCadenceUnits as Number.POSITIVE_INFINITY across all four branches (null, undefined, NaN, future). Coverage went from 1/8 to 8/8.

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 <noreply@anthropic.com>
@koala73 koala73 merged commit d35dc41 into main Apr 11, 2026
11 checks passed
@koala73 koala73 deleted the feat/resilience-staleness-classifier branch April 11, 2026 15:48
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
koala73 added a commit that referenced this pull request Apr 11, 2026
…sifier)

Addresses all three Greptile P2 comments on PR #2947:

1. **Redundant `Math.max(0, ...)` guard** (line 97).
   The defensive branch three lines above already rejects null,
   undefined, NaN, and future timestamps, so `nowMs - lastObservedAtMs`
   is guaranteed to be `>= 0` by the time execution reaches the age
   calculation. The `Math.max` was a misleading hint that a negative
   value could arrive. Removed, with a comment explaining why the
   branch above covers the case.

2. **`ageMs` can be `Infinity`, worth documenting on the field.**
   When the defensive branch returns, `ageMs` and
   `ageInCadenceUnits` are both `Number.POSITIVE_INFINITY`, but the
   field type was just `number`, so callers doing arithmetic or
   string formatting on the value (e.g. a "last seen X ago" label)
   would silently emit `Infinity` / `NaN` strings. Added JSDoc to
   both fields explaining the infinity contract and recommending
   `Number.isFinite` before arithmetic.

3. **Missing `ageInCadenceUnits` assertion for defensive cases.**
   The earlier test checked `ageMs === POSITIVE_INFINITY` only for
   the null branch, and never checked `ageInCadenceUnits` at all.
   Hardened the defensive-cases test to pin BOTH fields as
   `Number.POSITIVE_INFINITY` across every branch (null, undefined,
   NaN, future). Coverage went from 1 of 8 field/branch pairs to all
   8. If a future regression drops `ageInCadenceUnits` from the
   defensive return, the test now fails loudly.

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 <noreply@anthropic.com>
koala73 added a commit that referenced this pull request Apr 11, 2026
…sifier) (#2953)

Addresses all three Greptile P2 comments on PR #2947:

1. **Redundant `Math.max(0, ...)` guard** (line 97).
   The defensive branch three lines above already rejects null,
   undefined, NaN, and future timestamps, so `nowMs - lastObservedAtMs`
   is guaranteed to be `>= 0` by the time execution reaches the age
   calculation. The `Math.max` was a misleading hint that a negative
   value could arrive. Removed, with a comment explaining why the
   branch above covers the case.

2. **`ageMs` can be `Infinity`, worth documenting on the field.**
   When the defensive branch returns, `ageMs` and
   `ageInCadenceUnits` are both `Number.POSITIVE_INFINITY`, but the
   field type was just `number`, so callers doing arithmetic or
   string formatting on the value (e.g. a "last seen X ago" label)
   would silently emit `Infinity` / `NaN` strings. Added JSDoc to
   both fields explaining the infinity contract and recommending
   `Number.isFinite` before arithmetic.

3. **Missing `ageInCadenceUnits` assertion for defensive cases.**
   The earlier test checked `ageMs === POSITIVE_INFINITY` only for
   the null branch, and never checked `ageInCadenceUnits` at all.
   Hardened the defensive-cases test to pin BOTH fields as
   `Number.POSITIVE_INFINITY` across every branch (null, undefined,
   NaN, future). Coverage went from 1 of 8 field/branch pairs to all
   8. If a future regression drops `ageInCadenceUnits` from the
   defensive return, the test now fails loudly.

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 <noreply@anthropic.com>
koala73 added a commit that referenced this pull request Apr 11, 2026
…ass)

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
koala73 added a commit that referenced this pull request Apr 11, 2026
…ass)

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
koala73 added a commit that referenced this pull request Apr 11, 2026
…ass) (#2961)

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

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

What this PR commits

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

Aggregation rules

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

What is deliberately NOT in this PR

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

Verified

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

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

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

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

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

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

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

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

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

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

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

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

What this PR commits

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

Aggregation rules

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

What is deliberately NOT in this PR

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

Verified

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

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

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

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

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

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

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

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

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

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

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

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

What this PR commits

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

What is deliberately NOT in this PR

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

Verified

- typecheck + typecheck:api clean
- tests/resilience-widget-utils.test.mts passing
- tests/resilience-*.test.mts full suite passing
- test:data clean
- lint exit 0
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