feat(resilience): staleness classifier foundation (T1.5)#2947
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Greptile SummaryThis PR adds the foundation of the T1.5 staleness classifier: a new pure module Confidence Score: 5/5Safe 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
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"]
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); |
There was a problem hiding this comment.
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.
| const ageMs = Math.max(0, nowMs - lastObservedAtMs); | |
| const ageMs = nowMs - lastObservedAtMs; |
There was a problem hiding this comment.
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.
| ageMs: number; | ||
| /** The age expressed as a multiple of the cadence unit. Handy for debugging. */ | ||
| ageInCadenceUnits: number; |
There was a problem hiding this comment.
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.
| 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; | |
| } |
There was a problem hiding this comment.
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.
| 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`); | ||
| } |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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.
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>
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>
* 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>
…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>
…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>
…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
…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
…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.
…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
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
lastObservedAttimestamp 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
server/_shared/resilience-freshness.ts:ResilienceCadencetype union covering the 5 cadences listed in the methodology document (realtime,daily,weekly,monthly,annual).StalenessLeveltype 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 andAGING_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.nowMsis accepted as a deterministic override for unit testing.tests/resilience-freshness.test.mtswith 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 ofageMsandageInCadenceUnits, and classifier purity.What is deliberately NOT in this PR
lastObservedAtthrough 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.ResilienceDimensionresponse type). The schema fieldfreshness: { lastObservedAt, staleness }lands alongside the widget rendering in T1.6.classifyStalenessat render time.Prerequisite PRs verified merged
Related in-flight Phase 1 PRs from this session
dataVersionwidget wireTesting
npx tsx --test tests/resilience-freshness.test.mts: 10/10 passingnpm run typecheck: cleanbuild:full+ version:check) passesPost-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.
Generated with Claude Opus 4.6 (1M context) via Claude Code