Files
worldmonitor/docs/api/ResilienceService.openapi.yaml
Elie Habib dca2e1ca3c feat(resilience): expose imputationClass on ResilienceDimension (T1.7 schema pass) (#2959)
* feat(resilience): expose imputationClass on ResilienceDimension (T1.7 schema pass)

Ships the Phase 1 T1.7 schema pass of the country-resilience reference
grade upgrade plan. PR #2944 shipped the classifier table foundation
(ImputationClass type, ImputationEntry interface, IMPUTATION/IMPUTE
tagged with four semantic classes) and explicitly deferred the schema
propagation. This PR lands that propagation so downstream consumers can
distinguish "country is stable" from "country is unmonitored" from
"upstream is down" from "structurally not applicable" on a per-dimension
basis.

What this PR commits

- Proto: new imputation_class string field on ResilienceDimension
  (empty string = dimension has any observed data; otherwise one of
  stable-absence, unmonitored, source-failure, not-applicable).
- Generated TS types: regenerated service_server.ts and service_client.ts
  via make generate.
- Scorer: ResilienceDimensionScore carries ImputationClass | null.
  WeightedMetric carries an optional imputationClass that imputation
  paths populate. weightedBlend aggregates the dominant class by
  weight when the dimension is fully imputed, returns null otherwise.
- All IMPUTE.* early-return paths propagate the class from the table
  (IMPUTE.bisEer, IMPUTE.wtoData, IMPUTE.ipcFood, IMPUTE.unhcrDisplacement).
- Response builder: _shared.ts buildDimensionList passes the class
  through to the ResilienceDimension proto field.
- Tests: weightedBlend aggregation semantics (5 cases), dimension-level
  propagation from IMPUTE tables, serialized response includes the field.

What is deliberately NOT in this PR

- No widget icon rendering (T1.6 full grid, PR 3 of 5)
- No source-failure seed-meta consultation (PR 4 of 5)
- No freshness field (T1.5 propagation, PR 2 of 5)
- No cache key bump: the new field is empty-string default, existing
  cached responses continue to deserialize cleanly

Verified

- make generate clean
- npm run typecheck + typecheck:api clean
- tests/resilience-dimension-scorers.test.mts all passing (existing + new)
- tests/resilience-*.test.mts + test:data suite passing (4361 tests)
- npm run lint exits 0

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

Greptile P2 finding on PR #2959: cachedFetchJson and
getCachedResilienceScores return pre-change payloads verbatim, so a
resilience:score:v7 entry written before this PR lands lacks the
imputationClass field. Downstream consumers that read
dim.imputationClass get undefined for up to 6 hours until the cache
TTL expires.

Fix: add normalizeResilienceScoreResponse helper that defaults
missing optional fields in place and apply it at both read sites.
Defaults imputationClass to empty string, matching the proto3 default
for the new imputation_class field.

- ensureResilienceScoreCached applies the normalizer after
  cachedFetchJson returns.
- getCachedResilienceScores applies it after each successful
  JSON.parse on the pipeline result.
- Two new test cases: stale payload without imputationClass gets
  defaulted, present values are preserved.
- Not bumping the cache key: stale-read defaults are safe, the key
  bump would invalidate every cached score for a 6-hour cold-start
  cycle. The normalizer is extensible when PR #2961 adds freshness
  to the same payload.

P3 finding (broken docs reference) verified invalid: the proto
comment points to docs/methodology/country-resilience-index.mdx,
which IS the current file. The .md predecessor was renamed in
PR #2945 (T1.3 methodology doc promotion to CII parity). No change
needed to the comment.

* fix(resilience): bump score cache key v7 to v8, drop normalizer (#2959 P2)

Second fixup for the Greptile P2 finding on #2959. The previous fixup
(40ea22009) added normalizeResilienceScoreResponse to default missing
imputationClass fields on cached payloads to empty string. The
reviewer correctly pushed back: defaulting to empty string is the
proto3 default for "dimension has observed data", which silently
misreports pre-rollout imputed dimensions as observed until the 6h
TTL expires.

Correct fix: bump RESILIENCE_SCORE_CACHE_PREFIX from resilience:score:v7:
to resilience:score:v8:. Invalidates every pre-change cache entry, so
the next request per country repopulates with the correct
imputationClass written by the scorer. Cost: a 6h warmup cycle where
first-request-per-country recomputes the score, ~100ms per country
across hundreds of requests.

Also deletes the normalizeResilienceScoreResponse helper and its two
call sites. It was misleading defense-in-depth that can hide future
schema drift bugs. Future additive field additions should bump the
key, not silently default fields.

- server/worldmonitor/resilience/v1/_shared.ts: prefix v7 to v8,
  delete normalizer function and both call sites.
- scripts/seed-resilience-scores.mjs, validate-resilience-correlation.mjs,
  validate-resilience-backtest.mjs: mirror constants bumped.
- tests/resilience-scores-seed.test.mjs: pin literal v7 to v8.
- tests/resilience-ranking.test.mts: 7 hardcoded cache keys bumped.
- tests/resilience-handlers.test.mts: stray v7 cache key bumped.
- tests/resilience-release-gate.test.mts: the two normalizer test
  cases from 40ea22009 deleted along with the helper.
- docs/methodology/country-resilience-index.mdx: Redis keys table
  updated from v7 to v8 to match the canonical constant.

P3 (broken docs reference) confirmed invalid a second time.
docs/methodology/country-resilience-index.mdx exists on origin/main
AND on the PR branch with the same blob hash
d2ab1ebad3. docs/methodology/resilience-index.md
does not exist on either. No proto comment change.
2026-04-11 23:50:27 +04:00

214 lines
7.7 KiB
YAML

openapi: 3.1.0
info:
title: ResilienceService API
version: 1.0.0
paths:
/api/resilience/v1/get-resilience-score:
get:
tags:
- ResilienceService
summary: GetResilienceScore
operationId: GetResilienceScore
parameters:
- name: countryCode
in: query
required: true
schema:
type: string
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetResilienceScoreResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/resilience/v1/get-resilience-ranking:
get:
tags:
- ResilienceService
summary: GetResilienceRanking
operationId: GetResilienceRanking
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetResilienceRankingResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Error:
type: object
properties:
message:
type: string
description: Error message (e.g., 'user not found', 'database connection failed')
description: Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.
FieldViolation:
type: object
properties:
field:
type: string
description: The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')
description:
type: string
description: Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')
required:
- field
- description
description: FieldViolation describes a single validation error for a specific field.
ValidationError:
type: object
properties:
violations:
type: array
items:
$ref: '#/components/schemas/FieldViolation'
description: List of validation violations
required:
- violations
description: ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.
GetResilienceScoreRequest:
type: object
properties:
countryCode:
type: string
GetResilienceScoreResponse:
type: object
properties:
countryCode:
type: string
overallScore:
type: number
format: double
level:
type: string
domains:
type: array
items:
$ref: '#/components/schemas/ResilienceDomain'
trend:
type: string
change30d:
type: number
format: double
lowConfidence:
type: boolean
imputationShare:
type: number
format: double
baselineScore:
type: number
format: double
stressScore:
type: number
format: double
stressFactor:
type: number
format: double
dataVersion:
type: string
scoreInterval:
$ref: '#/components/schemas/ScoreInterval'
ResilienceDomain:
type: object
properties:
id:
type: string
score:
type: number
format: double
weight:
type: number
format: double
dimensions:
type: array
items:
$ref: '#/components/schemas/ResilienceDimension'
ResilienceDimension:
type: object
properties:
id:
type: string
score:
type: number
format: double
coverage:
type: number
format: double
observedWeight:
type: number
format: double
imputedWeight:
type: number
format: double
imputationClass:
type: string
description: |-
Four-class imputation taxonomy (Phase 1 T1.7). Empty string when the
dimension has any observed data. One of: "stable-absence", "unmonitored",
"source-failure", "not-applicable". See docs/methodology/country-resilience-index.mdx.
ScoreInterval:
type: object
properties:
p05:
type: number
format: double
p95:
type: number
format: double
GetResilienceRankingRequest:
type: object
GetResilienceRankingResponse:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/ResilienceRankingItem'
greyedOut:
type: array
items:
$ref: '#/components/schemas/ResilienceRankingItem'
ResilienceRankingItem:
type: object
properties:
countryCode:
type: string
overallScore:
type: number
format: double
level:
type: string
lowConfidence:
type: boolean
overallCoverage:
type: number
format: double
rankStable:
type: boolean