File size: 5,324 Bytes
20f83d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
// 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 };
}