| // T1.5 Phase 1 of the country-resilience reference-grade upgrade plan | |
| // (docs/internal/country-resilience-upgrade-plan.md). | |
| // | |
| // Foundation-only slice: the staleness classifier. This module defines | |
| // the cadence taxonomy (Realtime, Daily, Weekly, Monthly, Annual), the | |
| // three-level staleness output (fresh, aging, stale), and a pure | |
| // classifier function that maps a `lastObservedAt` timestamp and a | |
| // source cadence to a staleness level. | |
| // | |
| // What is deliberately NOT in this module: | |
| // | |
| // - No changes to the 22 serialized dimension scorers. Propagating `lastObservedAt` | |
| // through each scorer and aggregating max age per dimension is the | |
| // next slice of T1.5 and will depend on this classifier. Keeping the | |
| // classifier in its own module means that slice becomes a simple | |
| // consumer wiring pass with no test surface for the classifier itself. | |
| // - No schema changes (proto, OpenAPI, ResilienceDimension response | |
| // type). The schema field `freshness: { lastObservedAt, staleness }` | |
| // lands alongside the widget rendering in T1.6 and consumes this | |
| // classifier. | |
| // - No widget rendering. T1.6 owns the per-dimension freshness badge | |
| // UI and will call `classifyStaleness` from the widget path at render | |
| // time given the already-exposed `lastObservedAt` field. | |
| // | |
| // The multiplier thresholds below come from a simple rule: a source is | |
| // fresh if its age is less than 1.5 times its cadence, aging if less | |
| // than 3 times, stale otherwise. This scales gracefully across the 5 | |
| // cadences the methodology document lists without per-cadence ad-hoc | |
| // numbers. | |
| export type ResilienceCadence = 'realtime' | 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'annual'; | |
| export type StalenessLevel = 'fresh' | 'aging' | 'stale'; | |
| // Canonical cadence duration in milliseconds. A "unit" of each cadence. | |
| // Realtime sources are expected to refresh within an hour; daily within | |
| // a day; annual within a year. A simple, defensible base. | |
| const CADENCE_UNIT_MS: Record<ResilienceCadence, number> = { | |
| realtime: 60 * 60 * 1000, // 1 hour | |
| daily: 24 * 60 * 60 * 1000, // 1 day | |
| weekly: 7 * 24 * 60 * 60 * 1000, // 7 days | |
| monthly: 30 * 24 * 60 * 60 * 1000, // 30 days | |
| quarterly: 91 * 24 * 60 * 60 * 1000, // 91 days | |
| annual: 365 * 24 * 60 * 60 * 1000, // 365 days | |
| }; | |
| // Multiplier thresholds applied to the cadence unit. A source is fresh | |
| // when its age is less than `FRESH_MULTIPLIER * cadenceUnit`, aging when | |
| // less than `AGING_MULTIPLIER * cadenceUnit`, stale otherwise. | |
| export const FRESH_MULTIPLIER = 1.5; | |
| export const AGING_MULTIPLIER = 3; | |
| export function cadenceUnitMs(cadence: ResilienceCadence): number { | |
| return CADENCE_UNIT_MS[cadence]; | |
| } | |
| export interface ClassifyStalenessArgs { | |
| /** Unix milliseconds when the signal was last observed. */ | |
| lastObservedAtMs: number | null | undefined; | |
| /** Cadence of the source publishing the signal. */ | |
| cadence: ResilienceCadence; | |
| /** Override the current time for deterministic testing. Defaults to Date.now(). */ | |
| nowMs?: 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` | |
| * (or use `Number.isFinite`) before using this value in arithmetic or | |
| * display formatting, otherwise downstream string concatenation will | |
| * silently emit `Infinity` and `NaN`. | |
| */ | |
| ageMs: number; | |
| /** | |
| * The age expressed as a multiple of the cadence unit. Handy for | |
| * debugging. Same infinity contract as `ageMs`: returns | |
| * `Number.POSITIVE_INFINITY` in the defensive branches. | |
| */ | |
| ageInCadenceUnits: number; | |
| } | |
| /** | |
| * Classify how fresh a signal is relative to its cadence. | |
| * | |
| * Returns `'stale'` when `lastObservedAtMs` is null, undefined, NaN, or | |
| * in the future. Returns `'fresh'` when age is strictly less than | |
| * `FRESH_MULTIPLIER * cadenceUnit`. Returns `'aging'` when age is | |
| * strictly less than `AGING_MULTIPLIER * cadenceUnit`. Returns `'stale'` | |
| * otherwise. | |
| * | |
| * The function is pure: same inputs, same outputs, no side effects. | |
| * `nowMs` is accepted for deterministic unit tests. | |
| */ | |
| export function classifyStaleness(args: ClassifyStalenessArgs): StalenessResult { | |
| const { lastObservedAtMs, cadence } = args; | |
| const nowMs = args.nowMs ?? Date.now(); | |
| const unit = cadenceUnitMs(cadence); | |
| if ( | |
| lastObservedAtMs == null || | |
| !Number.isFinite(lastObservedAtMs) || | |
| lastObservedAtMs > nowMs | |
| ) { | |
| return { staleness: 'stale', ageMs: Number.POSITIVE_INFINITY, ageInCadenceUnits: Number.POSITIVE_INFINITY }; | |
| } | |
| // The defensive branch above already rejected null, undefined, NaN, | |
| // and future timestamps, so `nowMs - lastObservedAtMs` is guaranteed | |
| // to be >= 0 by the time execution reaches this line. No Math.max | |
| // clamp is needed. Removed in PR #2947 review. | |
| const ageMs = nowMs - lastObservedAtMs; | |
| const ageInCadenceUnits = ageMs / unit; | |
| let staleness: StalenessLevel; | |
| if (ageInCadenceUnits < FRESH_MULTIPLIER) { | |
| staleness = 'fresh'; | |
| } else if (ageInCadenceUnits < AGING_MULTIPLIER) { | |
| staleness = 'aging'; | |
| } else { | |
| staleness = 'stale'; | |
| } | |
| return { staleness, ageMs, ageInCadenceUnits }; | |
| } | |