From bd7681d35b5b74f694097a353d4941bc405a4aac Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 27 Oct 2026 10:04:40 -0400 Subject: [PATCH] v3 validation: one related score per measure and year, and calibration headlines first Related reforms (RelatedReformEstimates): - focusRelatedReformClaims keeps one score per measure, period and metric. A costing with a static and a post-behavioural leg for the same year keeps the static one, which PolicyEngine's static runs compare with. A year that both the source and PolicyEngine score as exactly zero, such as a year before the measure starts, is left out. - A row is titled with the measure as the source describes it (conditions.reform_hint, up to its first comma), not the publication. - The "Different baselines" note no longer runs under related reforms: they are other policies, so their own baselines say nothing about this report. - The section stays closed until the reader opens it. Data calibration (CalibrationMatchSection): - calibrationPreviewTargets puts each group's largest money target and largest count target first (a programme's spending and caseload), then the rest by gap. Six show at once; "Show all N targets" opens the rest. Co-Authored-By: Claude Opus 5.5 --- .../flagship/CalibrationMatches.tsx | 56 ++++++++++-- .../flagship/ScorecardComparisons.tsx | 86 +++++++++++++++---- app/src/libs/flagship/calibrationMatching.ts | 27 ++++++ app/src/libs/flagship/scorecardComparisons.ts | 32 +++++++ .../libs/flagship/calibrationMatchingMocks.ts | 35 ++++++++ .../libs/flagship/scorecardComparisonMocks.ts | 28 ++++++ .../flagship/ScorecardComparisons.test.tsx | 52 ++++++++++- .../flagship/report/ReportValidation.test.tsx | 5 +- .../libs/flagship/calibrationMatching.test.ts | 30 +++++++ .../tests/unit/pages/flagship/Report.test.tsx | 7 +- 10 files changed, 328 insertions(+), 30 deletions(-) diff --git a/app/src/components/flagship/CalibrationMatches.tsx b/app/src/components/flagship/CalibrationMatches.tsx index bf7e8b0e9..ebbb5bf10 100644 --- a/app/src/components/flagship/CalibrationMatches.tsx +++ b/app/src/components/flagship/CalibrationMatches.tsx @@ -9,6 +9,7 @@ import { calibrationFitForGeography, CalibrationMatches, calibrationMatchesForPaths, + calibrationPreviewTargets, CalibrationRing, calibrationTargetLabel, CalibrationTargetRow, @@ -395,6 +396,52 @@ export function GeographyFitSection({ ); } +/** + * One calibration group's target rows: its headline totals and largest + * gaps first (calibrationPreviewTargets), the rest after "Show all". + */ +function CalibrationGroupTargets({ + targets, + group, + countryId, +}: { + targets: CalibrationTargetRow[]; + group: string; + countryId: CalibrationCountry; +}) { + const [showAll, setShowAll] = useState(false); + const { ordered, previewCount } = calibrationPreviewTargets(targets); + const visible = showAll ? ordered : ordered.slice(0, previewCount); + return ( + <> + {visible.map((target) => ( + + + + + + + ))} + {ordered.length > previewCount && ( + + + + + + )} + + ); +} + export function CalibrationMatchSection({ matches, pin, @@ -502,14 +549,7 @@ export function CalibrationMatchSection({ - {match.targets.map((target) => ( - - - - - - - ))} + ); })} diff --git a/app/src/components/flagship/ScorecardComparisons.tsx b/app/src/components/flagship/ScorecardComparisons.tsx index ef2ce855d..8d805388f 100644 --- a/app/src/components/flagship/ScorecardComparisons.tsx +++ b/app/src/components/flagship/ScorecardComparisons.tsx @@ -11,6 +11,7 @@ import { import { claimPeriod, claimValue, + focusRelatedReformClaims, type RelatedScorecardClaim, type ScorecardComparisonsResult, } from '@/libs/flagship/scorecardComparisons'; @@ -399,6 +400,11 @@ export interface ClaimPresentation { export interface ScorecardClaimEntry { row: RelatedScorecardClaim; presentation?: ClaimPresentation; + /** + * A related reform's score: a different policy, so its own baseline says + * nothing about this report, and no baseline note runs under it. + */ + related?: boolean; } function claimTitle(row: RelatedScorecardClaim, presentation?: ClaimPresentation): string { @@ -558,13 +564,17 @@ export function claimGap(row: RelatedScorecardClaim): string { } /** One claim as a table row: what is compared, both values, and the gap. */ -function ScorecardClaimRow({ row, presentation }: ScorecardClaimEntry) { +function ScorecardClaimRow({ row, presentation, related = false }: ScorecardClaimEntry) { const sourceUrl = safeUrl(row.url); const smallLink = { ...linkStyle, fontSize: typography.fontSize.xs }; // Only a period that is recorded; "not recorded" under every value says nothing. const publishedNote = presentation?.externalPeriod ?? claimPeriod(row); const peNote = presentation?.modelPeriod ?? modelPeriod(row); - const notes = differenceNotes(row, presentation?.externalPeriod, presentation?.modelPeriod); + const notes = differenceNotes( + row, + presentation?.externalPeriod, + presentation?.modelPeriod + ).filter((note) => !(related && note.startsWith('Different baselines'))); return ( <> @@ -644,6 +654,7 @@ export function ScorecardClaimTable({ key={entry.row.claim_id} row={entry.row} presentation={entry.presentation} + related={entry.related} /> ))} @@ -653,16 +664,36 @@ export function ScorecardClaimTable({ /** Related reforms shown before the reader asks for all of them. */ export const RELATED_REFORM_PREVIEW = 10; -/** Published scorecard estimates of other policies that change the same rules. */ +/** + * A related reform's title: the measure as the source describes it, up to + * its first comma ("Universal Credit Child Element: Remove the two child + * limit from April 2026"), when the claim records one; else the claim's. + */ +function relatedReformPresentation(row: RelatedScorecardClaim): ClaimPresentation | undefined { + const hint = row.conditions?.reform_hint?.split(',')[0]?.trim(); + return hint ? { title: hint } : undefined; +} + +/** + * Published scorecard estimates of other policies that change the same + * rules: closed until the reader opens them, since they do not score this + * reform. One score per measure and year (focusRelatedReformClaims). + */ export function RelatedReformEstimates({ rows }: { rows: RelatedScorecardClaim[] }) { + const [open, setOpen] = useState(false); const [showAll, setShowAll] = useState(false); + const focused = focusRelatedReformClaims(rows); // A broad measure, such as a threshold freeze, has hundreds of published // scores. Show the ones with a PolicyEngine value first. const ordered = [ - ...rows.filter((row) => typeof row.latest?.value === 'number'), - ...rows.filter((row) => typeof row.latest?.value !== 'number'), + ...focused.filter((row) => typeof row.latest?.value === 'number'), + ...focused.filter((row) => typeof row.latest?.value !== 'number'), ]; + if (ordered.length === 0) { + return null; + } const visible = showAll ? ordered : ordered.slice(0, RELATED_REFORM_PREVIEW); + const toggleStyle = { ...rowButtonStyle, alignSelf: 'flex-start' }; return ( Related reforms @@ -670,18 +701,39 @@ export function RelatedReformEstimates({ rows }: { rows: RelatedScorecardClaim[] Published scores of policies that change the same rules. They do not score this exact reform. - ({ row }))} /> - {ordered.length > RELATED_REFORM_PREVIEW && ( - + + {open && ( + <> + ({ + row, + presentation: relatedReformPresentation(row), + related: true, + }))} + /> + {ordered.length > RELATED_REFORM_PREVIEW && ( + + )} + )} ); diff --git a/app/src/libs/flagship/calibrationMatching.ts b/app/src/libs/flagship/calibrationMatching.ts index aec41ced6..618945e58 100644 --- a/app/src/libs/flagship/calibrationMatching.ts +++ b/app/src/libs/flagship/calibrationMatching.ts @@ -124,6 +124,33 @@ export function calibrationValueKind( return target.measure === 'count' ? 'count' : 'number'; } +/** Targets a calibration group shows before the reader asks for all of them. */ +export const CALIBRATION_PREVIEW = 6; + +/** + * A group's targets in the order the table shows them: its headline totals + * first, the largest money target and the largest count target (a + * programme's spending and its caseload), then the rest in the order given + * (largest gap first). The first `limit` show at once; the rest wait for + * "Show all". + */ +export function calibrationPreviewTargets( + targets: CalibrationTargetRow[], + limit: number = CALIBRATION_PREVIEW +): { ordered: CalibrationTargetRow[]; previewCount: number } { + const largest = (kind: CalibrationValueKind) => + targets + .filter((target) => calibrationValueKind(target) === kind && target.target !== null) + .reduce< + CalibrationTargetRow | undefined + >((best, target) => (!best || Math.abs(target.target!) > Math.abs(best.target!) ? target : best), undefined); + const headline = [largest('money'), largest('count')].filter( + (target): target is CalibrationTargetRow => target !== undefined + ); + const ordered = [...headline, ...targets.filter((target) => !headline.includes(target))]; + return { ordered, previewCount: Math.min(limit, ordered.length) }; +} + /** * A target's official or modelled value: compact for the table ("$3.2B", * "20.6K"), exact for its details ("$3,212,345.68", "20,601"). diff --git a/app/src/libs/flagship/scorecardComparisons.ts b/app/src/libs/flagship/scorecardComparisons.ts index a1684b6f1..a006c8c64 100644 --- a/app/src/libs/flagship/scorecardComparisons.ts +++ b/app/src/libs/flagship/scorecardComparisons.ts @@ -17,6 +17,10 @@ export interface ScorecardClaim { sign_convention?: string | null; /** "unsigned" when the source prints a size and names the direction in words. */ change_direction?: string | null; + /** UK costings: "static", or "post_behavioural" for the leg after behaviour changes. */ + scoring_method?: string | null; + /** UK costings: the measure as the source's table describes it. */ + reform_hint?: string | null; }; program?: string | null; geography?: string; @@ -58,6 +62,34 @@ export interface ScorecardClaim { export interface RelatedScorecardClaim extends ScorecardClaim { matchBasis: 'variable' | 'parameter' | 'program' | 'related-policy'; } +/** + * Related reforms as a reader can take them in: one score per published + * measure, period and metric, and none that says nothing. A costing that + * prints a static leg and a post-behavioural leg for the same year keeps + * the static one, which PolicyEngine's static runs compare with (else the + * one PolicyEngine marks comparable). A year in which both the source and + * PolicyEngine score exactly zero, such as a year before the measure + * starts, is left out. + */ +export function focusRelatedReformClaims(rows: RelatedScorecardClaim[]): RelatedScorecardClaim[] { + const rank = (row: RelatedScorecardClaim) => + (row.conditions?.scoring_method === 'static' ? 0 : 2) + + ((row.latest?.status_effective ?? row.latest?.status) === 'comparable' ? 0 : 1); + const keyOf = (row: RelatedScorecardClaim) => + [row.measure?.key ?? row.name, row.period, row.metric].join('|'); + const kept = new Map(); + for (const row of rows) { + if (row.external_value === 0 && row.latest?.value === 0) { + continue; + } + const current = kept.get(keyOf(row)); + if (!current || rank(row) < rank(current)) { + kept.set(keyOf(row), row); + } + } + return rows.filter((row) => kept.get(keyOf(row)) === row); +} + export interface ScorecardComparisonsResult { built: string | null; rows: RelatedScorecardClaim[]; diff --git a/app/src/tests/fixtures/libs/flagship/calibrationMatchingMocks.ts b/app/src/tests/fixtures/libs/flagship/calibrationMatchingMocks.ts index 359076820..f756078da 100644 --- a/app/src/tests/fixtures/libs/flagship/calibrationMatchingMocks.ts +++ b/app/src/tests/fixtures/libs/flagship/calibrationMatchingMocks.ts @@ -247,3 +247,38 @@ export const mockDcFitRows: CalibrationTargetRow[] = [ } ), ]; + +/** A UK programme's targets as the dashboard sorts them, largest gap first. */ +const ucRow = ( + name: string, + measure: 'total' | 'count', + target: number, + relativeError: number +): CalibrationTargetRow => ({ + name, + source: 'dwp', + sourceLabel: 'Department for Work and Pensions', + variable: 'dwp.universal_credit', + policyengineVariables: ['universal_credit'], + measure, + breakdown: name, + level: 'country', + geography: 'Great Britain', + period: 2025, + target, + estimate: target * (1 + relativeError), + relativeError, + targetRole: null, + sourceUrl: null, + targetLabel: name, + variableLabel: 'Universal Credit', + currency: measure === 'total' ? 'GBP' : undefined, +}); +export const UC_SPENDING_ROW = ucRow('Universal Credit expenditure', 'total', 76.1e9, -0.0004); +export const UC_HOUSEHOLDS_ROW = ucRow('Universal Credit households', 'count', 6.2e6, 0.003); +/** Eight band targets, each with a larger gap than the two totals. */ +export const UC_BAND_ROWS = Array.from({ length: 8 }, (_, index) => + ucRow(`Payment band ${index + 1}`, 'count', 40_000, 0.1 - index * 0.01) +); +/** The dashboard's order: the bands' larger gaps first, the totals last. */ +export const UC_TARGETS_BY_GAP = [...UC_BAND_ROWS, UC_HOUSEHOLDS_ROW, UC_SPENDING_ROW]; diff --git a/app/src/tests/fixtures/libs/flagship/scorecardComparisonMocks.ts b/app/src/tests/fixtures/libs/flagship/scorecardComparisonMocks.ts index 51083c6eb..ebf14a860 100644 --- a/app/src/tests/fixtures/libs/flagship/scorecardComparisonMocks.ts +++ b/app/src/tests/fixtures/libs/flagship/scorecardComparisonMocks.ts @@ -76,3 +76,31 @@ export const ukMeasureClaim = (overrides: Partial = {}): Scoreca }, ...overrides, }); + +/** HM Treasury's two-child-limit costing: a static and a post-behavioural leg for each year. */ +export const TWO_CHILD_LIMIT_HINT = + 'Universal Credit Child Element: Remove the two child limit from April 2026, taking 450,000 children out of poverty'; +export const twoChildLimitClaim = ( + period: number, + scoringMethod: 'static' | 'post_behavioural', + externalValue: number, + peValue: number +): ScorecardClaim => ({ + ...scorecardClaim({ + claim_id: `two-child-${period}-${scoringMethod}`, + country: 'UK', + source: 'hm_treasury', + unit_concept: 'gbp', + name: 'Budget 2025: Policy costings (PU 3595)', + period, + external_value: externalValue, + claim_baseline: 'current_law', + conditions: { scoring_method: scoringMethod, reform_hint: TWO_CHILD_LIMIT_HINT }, + measure: { key: 'ab2025__uc_child_element_remove_two_child_limit' }, + latest: { + value: peValue, + status: scoringMethod === 'static' ? 'comparable' : 'concept_mismatch', + baseline: 'pre_ab2025', + }, + }), +}); diff --git a/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx b/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx index 2d9b22eba..ccf4fdf85 100644 --- a/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx +++ b/app/src/tests/unit/components/flagship/ScorecardComparisons.test.tsx @@ -18,6 +18,7 @@ import { jctCtcClaim, scorecardClaim, stackedJctCtcClaim, + twoChildLimitClaim, utahBaselineClaim, } from '@/tests/fixtures/libs/flagship/scorecardComparisonMocks'; import { @@ -29,9 +30,10 @@ describe('scorecard evidence presentation', () => { test('given a related reform then shows source-policy counterparts, periods and baseline caveats', () => { render(); expect(screen.getByText(/do not score this exact reform/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Show 1 related reform score' })); expect(screen.getByRole('columnheader', { name: 'PolicyEngine' })).toBeInTheDocument(); expect(screen.getByText(/2026 · fiscal year/)).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button')); + fireEvent.click(screen.getByRole('button', { name: 'CTC extension' })); expect(screen.getByText('Different stacking baseline')).toBeInTheDocument(); expect( screen.queryByRole('button', { name: /Find external estimates/ }) @@ -49,6 +51,11 @@ describe('scorecard evidence presentation', () => { matchBasis: 'parameter' as const, })); render(); + await userEvent.click( + screen.getByRole('button', { + name: `Show ${RELATED_REFORM_PREVIEW + 2} related reform scores`, + }) + ); const table = screen.getByRole('table', { name: 'Related reforms' }); const titles = () => within(table) @@ -62,6 +69,49 @@ describe('scorecard evidence presentation', () => { expect(titles()).toHaveLength(RELATED_REFORM_PREVIEW + 2); }); + test('given related reforms then they stay closed until the reader opens them', () => { + render(); + + expect(screen.queryByRole('table', { name: 'Related reforms' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Show 1 related reform score' })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + }); + + test('given a costing with static and post-behavioural legs then one row per year shows, the static one, under the measure name', async () => { + const rows = [ + twoChildLimitClaim(2026, 'static', 0, 0), + twoChildLimitClaim(2026, 'post_behavioural', 0, 0), + twoChildLimitClaim(2027, 'static', -2_055_000_000, -2_308_547_892), + twoChildLimitClaim(2027, 'post_behavioural', -2_400_000_000, -2_308_547_892), + ].map((row) => ({ ...row, matchBasis: 'variable' as const })); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Show 1 related reform score' })); + + const table = screen.getByRole('table', { name: 'Related reforms' }); + const titles = within(table) + .getAllByRole('rowheader') + .map((cell) => cell.textContent); + expect(titles).toHaveLength(1); + expect(titles[0]).toContain( + 'Universal Credit Child Element: Remove the two child limit from April 2026' + ); + expect(within(table).getByText('-£2.1B')).toBeInTheDocument(); + expect(within(table).queryByText(/Different baselines/)).not.toBeInTheDocument(); + }); + + test('given only years in which both sides score zero then no related reforms show', () => { + const rows = [twoChildLimitClaim(2026, 'static', 0, 0)].map((row) => ({ + ...row, + matchBasis: 'variable' as const, + })); + render(); + + expect(screen.queryByText('Related reforms')).not.toBeInTheDocument(); + }); + test('given several claims then one table holds them so their numbers line up', () => { render( { await renderValidation(); const estimates = card('External estimates'); + await userEvent.click( + within(estimates).getByRole('button', { name: 'Show 1 related reform score' }) + ); expect(within(estimates).getByRole('table', { name: 'Related reforms' })).toBeInTheDocument(); expect(within(estimates).getByRole('button', { name: 'CTC extension' })).toBeInTheDocument(); expect(within(estimates).getByText('Joint Committee on Taxation')).toBeInTheDocument(); diff --git a/app/src/tests/unit/libs/flagship/calibrationMatching.test.ts b/app/src/tests/unit/libs/flagship/calibrationMatching.test.ts index 0e743d44b..bae2c5d9c 100644 --- a/app/src/tests/unit/libs/flagship/calibrationMatching.test.ts +++ b/app/src/tests/unit/libs/flagship/calibrationMatching.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { calibrationFitForGeography, calibrationMatchesForPaths, + calibrationPreviewTargets, calibrationTargetLabel, calibrationValueKind, calibrationVariableLabel, @@ -25,6 +26,10 @@ import { mockDcFitRows, mockEitcCalibrationRow, POPULACE_RELEASE_ID, + UC_BAND_ROWS, + UC_HOUSEHOLDS_ROW, + UC_SPENDING_ROW, + UC_TARGETS_BY_GAP, } from '@/tests/fixtures/libs/flagship/calibrationMatchingMocks'; import { NATIONWIDE, @@ -65,6 +70,31 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe('calibrationPreviewTargets', () => { + test('given a programme sorted by gap then its spending and caseload lead, then the largest gaps', () => { + // When + const { ordered, previewCount } = calibrationPreviewTargets(UC_TARGETS_BY_GAP); + + // Then + expect(previewCount).toBe(6); + expect(ordered.slice(0, previewCount)).toEqual([ + UC_SPENDING_ROW, + UC_HOUSEHOLDS_ROW, + ...UC_BAND_ROWS.slice(0, 4), + ]); + expect(ordered).toHaveLength(UC_TARGETS_BY_GAP.length); + }); + + test('given fewer targets than the preview then all show', () => { + // When + const { ordered, previewCount } = calibrationPreviewTargets([UC_HOUSEHOLDS_ROW]); + + // Then + expect(ordered).toEqual([UC_HOUSEHOLDS_ROW]); + expect(previewCount).toBe(1); + }); +}); + describe('geographyForRegion', () => { test('given the country region then the national geography returns', () => { expect(geographyForRegion('us')).toBe('US'); diff --git a/app/src/tests/unit/pages/flagship/Report.test.tsx b/app/src/tests/unit/pages/flagship/Report.test.tsx index 90625cbf2..9d9dd3ea8 100644 --- a/app/src/tests/unit/pages/flagship/Report.test.tsx +++ b/app/src/tests/unit/pages/flagship/Report.test.tsx @@ -324,9 +324,10 @@ describe('FlagshipReportPage', () => { await user.click(screen.getByRole('tab', { name: 'Validation' })); const panel = screen.getByRole('tabpanel'); - expect( - await within(panel).findByRole('table', { name: 'Related reforms' }) - ).toBeInTheDocument(); + await user.click( + await within(panel).findByRole('button', { name: 'Show 1 related reform score' }) + ); + expect(within(panel).getByRole('table', { name: 'Related reforms' })).toBeInTheDocument(); expect(within(panel).getByRole('button', { name: 'CTC extension' })).toBeInTheDocument(); expect( within(panel).queryByText('No external estimate is available for this reform.')