Skip to content

test(resilience): methodology doc linter enforces dimension parity (T1.8)#2946

Merged
koala73 merged 1 commit into
mainfrom
test/resilience-methodology-linter
Apr 11, 2026
Merged

test(resilience): methodology doc linter enforces dimension parity (T1.8)#2946
koala73 merged 1 commit into
mainfrom
test/resilience-methodology-linter

Conversation

@koala73

@koala73 koala73 commented Apr 11, 2026

Copy link
Copy Markdown
Owner

Why this PR?

Ships Phase 1 T1.8 of the country-resilience reference-grade upgrade plan (PR #2938): a linter test that fails loudly if the published methodology document drifts from the scorer's RESILIENCE_DIMENSION_ORDER. This is the discipline that keeps the methodology page trustworthy over time, a forever risk on composite indices per the OECD/JRC handbook.

Without this test, a new scorer dimension can land with no matching methodology subsection (the doc is silently incomplete), or a scorer dimension can be removed without cleaning up the doc (the doc silently describes behavior that no longer exists). Either drift is invisible until a reader catches it.

What the linter checks

  1. Every dimension in RESILIENCE_DIMENSION_ORDER has an H4 subsection in the methodology document. Fails with the list of missing dimensions if any scorer dimension is undocumented.
  2. Every H4 subsection in the methodology maps to a real scorer dimension. Fails if the doc describes a dimension the scorer no longer has.
  3. Every H4 subsection is either a mapped dimension or explicitly allowlisted. Catches typos in headings and unwired new sections.
  4. HEADING_TO_DIMENSION covers exactly RESILIENCE_DIMENSION_ORDER. The mapping table in the test file itself is kept in lockstep with the scorer so there is a single source of truth for heading-to-ID translation.

What this PR commits

  • New test file tests/resilience-methodology-lint.test.mts with 5 scenarios covering the four checks above plus a smoke test that the file locator works.
  • Hardcoded HEADING_TO_DIMENSION map (13 entries, one per scorer dimension). Any future dimension add must update this map in lockstep with the scorer and the methodology doc, which is exactly the drift prevention this task targets.

Location-agnostic by design

The linter looks for the methodology file at a short candidate list and prefers docs/methodology/country-resilience-index.mdx (the post-T1.3 path from PR #2945) but falls back to docs/methodology/resilience-index.md (the pre-T1.3 path on origin/main). That keeps this PR independent of T1.3 merge order. The linter passes on today's main against the older .md file and auto-switches to the .mdx once T1.3 merges.

What is NOT in this PR

  • No changes to the methodology document or the scorer. The linter only reads existing content.
  • No automated HTML comment markers in the mdx. The hardcoded map in the test file is simpler and produces the same drift-detection signal.
  • No integration with lint-staged or CI-specific gating. The linter runs as part of the standard test:data suite, so it fires on every pre-push hook run and on CI.

Prerequisite PRs verified merged

Related (not prerequisite) in-flight Phase 1 PRs from this session

Testing

  • npx tsx --test tests/resilience-methodology-lint.test.mts: 5/5 passing against the pre-T1.3 methodology file on origin/main
  • npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs: 176/176 passing
  • npm run typecheck: clean

Post-Deploy Monitoring & Validation

No additional operational monitoring required: test-only change, no runtime impact. The linter runs in CI on every push so any future methodology drift blocks the offending PR before merge.


Compound Engineering v2.49.0

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

…1.8)

Why this PR?

Ships Phase 1 T1.8 of the country-resilience reference-grade upgrade
plan: add a test that fails loudly if the published methodology
document drifts from the scorer's RESILIENCE_DIMENSION_ORDER. This is
the discipline that keeps the methodology page trustworthy over time,
a forever risk on composite indices per the OECD/JRC handbook.

The linter runs on every test pass and checks four things:

1. Every dimension in RESILIENCE_DIMENSION_ORDER has an H4 subsection
   in the methodology document.
2. Every H4 subsection in the methodology maps to a real scorer
   dimension (no stale docs).
3. Every H4 subsection is either a mapped dimension or explicitly
   allowlisted (prevents typos and unwired new sections).
4. HEADING_TO_DIMENSION in the test file maps exactly onto
   RESILIENCE_DIMENSION_ORDER with no extras and no gaps. This makes
   the test file itself the single source of truth for how the doc
   labels map to scorer IDs.

Location-agnostic: the linter looks for the methodology file at a
short list of candidate paths and prefers the newer
country-resilience-index.mdx once T1.3 lands on main. On the current
origin/main it finds the older resilience-index.md and lints that.
This keeps T1.8 independent of T1.3's merge order so the PRs can land
in either sequence.

What this PR commits:
- New test file tests/resilience-methodology-lint.test.mts with 5
  scenarios covering the four checks above plus a smoke test that
  the file locator works.
- Hardcoded HEADING_TO_DIMENSION map (13 entries, one per scorer
  dimension) as the source of truth for the heading-to-ID mapping.
  Any future dimension add must update this map in lockstep with the
  scorer and the methodology doc, which is exactly the drift
  prevention we want.

What is NOT in this PR:
- No changes to the methodology document or the scorer.
- No automated HTML comment markers in the mdx. The hardcoded map in
  the test file is simpler and produces the same drift-detection
  signal.
- No integration with lint-staged or CI-specific gating. The linter
  runs as part of the standard test:data suite so it fires on every
  pre-push hook run.

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Related (not prerequisite) 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

Testing:
- npx tsx --test tests/resilience-methodology-lint.test.mts: 5/5 pass
- npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs:
  176/176 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 <[email protected]>
@vercel

vercel Bot commented Apr 11, 2026

Copy link
Copy Markdown

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 9:57am

Request Review

@greptile-apps

greptile-apps Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds tests/resilience-methodology-lint.test.mts, a linter that enforces parity between RESILIENCE_DIMENSION_ORDER and the methodology document's H4 subsection headings. The four cross-validating checks are logically complete and the HEADING_TO_DIMENSION map correctly matches all 13 scorer dimensions against the existing .md file.

Confidence Score: 5/5

Safe to merge — test-only change with no runtime impact and correct drift-detection logic.

Both remaining findings are P2: one is a design nit about describe-level initialization vs. a before() hook (test still fails loudly in all real scenarios), and the other is a speculative regex concern about MDX heading anchors that don't appear in this repo's existing MDX files. Neither blocks the primary drift-detection purpose of this PR.

tests/resilience-methodology-lint.test.mts — see comments on describe-level initialization and the heading regex.

Important Files Changed

Filename Overview
tests/resilience-methodology-lint.test.mts New linter test enforcing parity between RESILIENCE_DIMENSION_ORDER and methodology doc H4 headings; logic is sound and covers all drift scenarios, but initialization at describe-body level means a missing file produces a suite error rather than a clean test failure, and the regex doesn't guard against MDX heading anchor syntax that T1.3 may introduce.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Test Suite Starts] --> B{findMethodologyFile}
    B -->|.mdx exists| C[Read .mdx file]
    B -->|.md exists| D[Read .md file]
    B -->|neither found| E[throw Error\ndescribe-level uncaught]
    C --> F[extractH4Headings]
    D --> F
    F --> G[headings array]
    G --> T1[it: smoke test\nnon-empty file + has H4s]
    G --> T2[it: every RESILIENCE_DIMENSION_ORDER\nhas a doc subsection]
    G --> T3[it: every mapped H4\npoints to live dimension]
    G --> T4[it: every H4\nis in HEADING_TO_DIMENSION]
    G --> T5[it: HEADING_TO_DIMENSION\ncovers exactly the registry]
    T2 & T3 & T4 & T5 --> Z[All pass = no drift]
Loading

Reviews (1): Last reviewed commit: "test(resilience): methodology doc linter..." | Re-trigger Greptile

Comment on lines +74 to +82
describe('resilience methodology doc linter (T1.8)', () => {
const methodologyPath = findMethodologyFile();
const source = readFileSync(methodologyPath, 'utf8');
const headings = extractH4Headings(source);

it(`locates a methodology file (${methodologyPath})`, () => {
assert.ok(source.length > 0, 'methodology file should be non-empty');
assert.ok(headings.length > 0, 'methodology file should contain at least one H4 subsection');
});

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 Smoke test is dead for the "file not found" case

findMethodologyFile() is called synchronously inside the describe callback body, before any it is registered. If neither candidate file exists, the callback throws during test collection, so node:test marks the describe block as a suite error and never registers the five it tests — they don't appear in output at all. The smoke test at line 79 can only run after findMethodologyFile() has already succeeded, making the "locates a methodology file" assertion redundant (existsSync already confirmed the file is present). Moving the initialization to a before() hook lets node:test register all five tests before any I/O happens and report proper per-test failures.

import { describe, it, before } from 'node:test';

describe('resilience methodology doc linter (T1.8)', () => {
  let methodologyPath: string;
  let source: string;
  let headings: string[];

  before(() => {
    methodologyPath = findMethodologyFile();
    source = readFileSync(methodologyPath, 'utf8');
    headings = extractH4Headings(source);
  });

  it('locates a methodology file', () => {
    assert.ok(methodologyPath, 'expected a methodology file path');
    assert.ok(source.length > 0, 'methodology file should be non-empty');
    assert.ok(headings.length > 0, 'methodology file should contain at least one H4 subsection');
  });
  // … rest of tests unchanged
});

Comment on lines +61 to +72
function extractH4Headings(source: string): string[] {
// Matches lines of the form `#### <text>` and captures the text.
// Ignores nested # (H5+) and H3 domain headers so the linter only
// checks dimension-level subsections.
const pattern = /^####\s+(.+?)\s*$/gm;
const headings: string[] = [];
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
headings.push(match[1]);
}
return headings;
}

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 Regex may silently fail if MDX introduces heading ID anchors

The pattern (.+?)\s*$ captures the full heading text verbatim. Mintlify MDX files sometimes use explicit heading anchors (e.g., #### Governance {#governance-institutional}), which would make the captured string "Governance {#governance-institutional}" — a key that doesn't exist in HEADING_TO_DIMENSION. All 13 headings would then appear "unmapped," causing test 4 to fail with a confusing wall of errors rather than a drift signal.

T1.3 (PR #2945) creates docs/methodology/country-resilience-index.mdx, which this linter will prefer once merged. Adding an optional strip of the anchor suffix before returning the heading would harden the linter against this format:

const pattern = /^####\s+(.+?)\s*(?:\{#[^}]+\})?\s*$/gm;

koala73 added a commit that referenced this pull request Apr 11, 2026
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 <[email protected]>
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 <[email protected]>
@koala73
koala73 merged commit dcfecb7 into main Apr 11, 2026
11 checks passed
@koala73
koala73 deleted the test/resilience-methodology-linter branch April 11, 2026 15:45
koala73 added a commit that referenced this pull request Apr 11, 2026
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
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