diff --git a/app/src/adapters/ReportAdapter.ts b/app/src/adapters/ReportAdapter.ts index fab6cd832..8e9dd6454 100644 --- a/app/src/adapters/ReportAdapter.ts +++ b/app/src/adapters/ReportAdapter.ts @@ -2,6 +2,7 @@ import { Report } from '@/types/ingredients/Report'; import { ReportMetadata } from '@/types/metadata/reportMetadata'; import { ReportCreationPayload } from '@/types/payloads/ReportCreationPayload'; import { ReportSetOutputPayload } from '@/types/payloads/ReportSetOutputPayload'; +import { parseRequiredSPMComparisonCalculationProvenance } from '@/types/spm'; import { convertJsonToReportOutput, convertReportOutputToJson } from './conversionHelpers'; /** @@ -37,20 +38,45 @@ export class ReportAdapter { ? [String(metadata.simulation_1_id), String(metadata.simulation_2_id)] : [String(metadata.simulation_1_id)]; + let output = convertJsonToReportOutput(metadata.output) as Report['output']; + let status = this.mapApiStatusToReportStatus(metadata.status); + if (metadata.country_id === 'us' && status === 'complete' && this.isSocietyWideOutput(output)) { + try { + const spm = parseRequiredSPMComparisonCalculationProvenance(output); + output = { + ...(output as unknown as Record), + ...spm, + } as Report['output']; + } catch { + output = null; + status = 'pending'; + } + } + return { id: String(metadata.id), countryId: metadata.country_id, year: metadata.year, apiVersion: metadata.api_version, simulationIds, - status: this.mapApiStatusToReportStatus(metadata.status), + status, ...(metadata.requested_at ? { requestedAt: metadata.requested_at } : {}), ...(metadata.started_at ? { startedAt: metadata.started_at } : {}), ...(metadata.finished_at ? { finishedAt: metadata.finished_at } : {}), - output: convertJsonToReportOutput(metadata.output) as any, // Can be economy or household output + output, }; } + private static isSocietyWideOutput(value: unknown): boolean { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + 'budget' in value && + 'decile' in value + ); + } + /** * Converts Report to format for API POST request * API expects snake_case format - simulation IDs and year needed for creation diff --git a/app/src/adapters/SimulationAdapter.ts b/app/src/adapters/SimulationAdapter.ts index 924c94af7..bca5fd140 100644 --- a/app/src/adapters/SimulationAdapter.ts +++ b/app/src/adapters/SimulationAdapter.ts @@ -1,6 +1,7 @@ import { Simulation } from '@/types/ingredients/Simulation'; import { SimulationMetadata } from '@/types/metadata/simulationMetadata'; import { SimulationCreationPayload, SimulationSetOutputPayload } from '@/types/payloads'; +import { parseOptionalSPMCalculationProvenance } from '@/types/spm'; /** * Adapter for converting between Simulation and API formats @@ -64,6 +65,21 @@ export class SimulationAdapter { } } + let status = this.mapApiStatusToSimulationStatus(metadata.status); + if ( + metadata.population_type === 'household' && + status === 'complete' && + this.hasSPMCalculationFields(parsedOutput) + ) { + try { + const parsedSPM = parseOptionalSPMCalculationProvenance(parsedOutput); + parsedOutput = { ...(parsedOutput as Record), ...parsedSPM }; + } catch { + parsedOutput = null; + status = 'pending'; + } + } + const simulation = { id: String(metadata.id), countryId: metadata.country_id, @@ -74,13 +90,22 @@ export class SimulationAdapter { label: null, isCreated: true, output: parsedOutput, - status: this.mapApiStatusToSimulationStatus(metadata.status), + status, ...this.parseErrorMessage(metadata.error_message), }; return simulation; } + private static hasSPMCalculationFields(value: unknown): boolean { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + ('spm_config' in value || 'spm_provenance' in value) + ); + } + /** * Converts Simulation to format for API POST request */ diff --git a/app/src/api/householdCalculation.ts b/app/src/api/householdCalculation.ts index 5939ef8ed..c5ed50a73 100644 --- a/app/src/api/householdCalculation.ts +++ b/app/src/api/householdCalculation.ts @@ -1,7 +1,7 @@ import type { PolicyEngineBundle } from '@/api/societyWideCalculation'; import { BASE_URL } from '@/constants'; import type { HouseholdCalculationData } from '@/types/calculation/household'; -import type { SPMProvenance, SPMSelection } from '@/types/spm'; +import { parseOptionalSPMCalculationProvenance, type SPMProvenance } from '@/types/spm'; import { householdAPIError, householdAPIErrorFromBody } from './householdError'; export interface HouseholdCalculationResponse { @@ -9,14 +9,12 @@ export interface HouseholdCalculationResponse { result: HouseholdCalculationData | null; error?: string; policyengine_bundle?: PolicyEngineBundle | null; - spm_config?: SPMSelection; spm_provenance?: SPMProvenance; } export interface HouseholdCalculationResult { result: HouseholdCalculationData; policyengine_bundle?: PolicyEngineBundle | null; - spm_config?: SPMSelection; spm_provenance?: SPMProvenance; } @@ -47,17 +45,19 @@ export async function fetchHouseholdCalculationWithBundle( ); } - const data: HouseholdCalculationResponse = await response.json(); + const rawData: unknown = await response.json(); + const data = rawData as HouseholdCalculationResponse; if (data.status === 'error' || !data.result) { - throw householdAPIErrorFromBody(data, 'Household calculation failed'); + throw householdAPIErrorFromBody(rawData, 'Household calculation failed'); } + const spm = parseOptionalSPMCalculationProvenance(rawData); + return { result: data.result, policyengine_bundle: data.policyengine_bundle ?? null, - ...(data.spm_config ? { spm_config: data.spm_config } : {}), - ...(data.spm_provenance ? { spm_provenance: data.spm_provenance } : {}), + ...(spm ?? {}), }; } catch (error) { clearTimeout(timeoutId); diff --git a/app/src/api/householdVariation.ts b/app/src/api/householdVariation.ts index 2519fa5ff..d8ece7643 100644 --- a/app/src/api/householdVariation.ts +++ b/app/src/api/householdVariation.ts @@ -1,13 +1,16 @@ import { BASE_URL } from '@/constants'; import type { HouseholdCalculationData } from '@/types/calculation/household'; -import type { SPMProvenance, SPMSelection } from '@/types/spm'; +import { + parseOptionalSPMCalculationProvenance, + type SPMProvenance, + type SPMSelection, +} from '@/types/spm'; import { householdAPIError, householdAPIErrorFromBody } from './householdError'; export interface HouseholdVariationResponse { status: 'ok' | 'error'; result: HouseholdCalculationData | null; error?: string; - spm_config?: SPMSelection; spm_provenance?: SPMProvenance; } @@ -52,13 +55,16 @@ export async function fetchHouseholdVariationWithProvenance( ); } - const data: HouseholdVariationResponse = await response.json(); + const rawData: unknown = await response.json(); + const data = rawData as HouseholdVariationResponse; if (data.status === 'error' || !data.result) { - throw householdAPIErrorFromBody(data, 'Household variation calculation failed'); + throw householdAPIErrorFromBody(rawData, 'Household variation calculation failed'); } - return { ...data, result: data.result }; + const parsedSPM = parseOptionalSPMCalculationProvenance(rawData); + + return { ...data, ...parsedSPM, result: data.result }; } catch (error) { clearTimeout(timeoutId); diff --git a/app/src/api/societyWideCalculation.ts b/app/src/api/societyWideCalculation.ts index d09587c12..f445e4079 100644 --- a/app/src/api/societyWideCalculation.ts +++ b/app/src/api/societyWideCalculation.ts @@ -1,6 +1,7 @@ import { BASE_URL } from '@/constants'; import { ReportOutputSocietyWideUK } from '@/types/metadata/ReportOutputSocietyWideUK'; import { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; +import { parseRequiredSPMComparisonCalculationProvenance } from '@/types/spm'; export type SocietyWideReportOutput = ReportOutputSocietyWideUS | ReportOutputSocietyWideUK; @@ -95,5 +96,9 @@ export async function fetchSocietyWideCalculation( } const data: SocietyWideCalculationResponse = await response.json(); + if (countryId === 'us' && data.status === 'ok' && data.result) { + const spm = parseRequiredSPMComparisonCalculationProvenance(data.result); + data.result = { ...data.result, ...spm } as ReportOutputSocietyWideUS; + } return mergePolicyEngineBundle(data); } diff --git a/app/src/components/household/SPMMethodologyFootnote.tsx b/app/src/components/household/SPMMethodologyFootnote.tsx index 1c079936a..d22dabfa3 100644 --- a/app/src/components/household/SPMMethodologyFootnote.tsx +++ b/app/src/components/household/SPMMethodologyFootnote.tsx @@ -1,3 +1,4 @@ +import SPMProvenanceDetails from '@/components/report/SPMProvenanceDetails'; import { Stack, Text } from '@/components/ui'; import type { HouseholdCalculationOutput } from '@/types/calculation/household'; @@ -36,14 +37,7 @@ export default function SPMMethodologyFootnote({ {' · '} {item!.spmProvenance!.scenario} - Forecast: {item!.spmProvenance!.forecast_id} -
-            {JSON.stringify(
-              { spm_config: item!.spmConfig, spm_provenance: item!.spmProvenance },
-              null,
-              2
-            )}
-          
+ ))} diff --git a/app/src/components/report/SPMComparisonMethodology.tsx b/app/src/components/report/SPMComparisonMethodology.tsx new file mode 100644 index 000000000..8d82d44fc --- /dev/null +++ b/app/src/components/report/SPMComparisonMethodology.tsx @@ -0,0 +1,31 @@ +import SPMProvenanceDetails from '@/components/report/SPMProvenanceDetails'; +import { Stack, Text } from '@/components/ui'; +import type { SPMComparisonProvenance } from '@/types/spm'; + +/** Display the baseline and reform receipts attached to a society-wide result. */ +export default function SPMComparisonMethodology({ + provenance, +}: { + provenance: SPMComparisonProvenance; +}) { + return ( + + Supplemental Poverty Measure methodology + + + + ); +} diff --git a/app/src/components/report/SPMProvenanceDetails.tsx b/app/src/components/report/SPMProvenanceDetails.tsx new file mode 100644 index 000000000..34ea8c396 --- /dev/null +++ b/app/src/components/report/SPMProvenanceDetails.tsx @@ -0,0 +1,33 @@ +import { Stack, Text } from '@/components/ui'; +import { buildSPMProvenanceDisplayRows, type SPMProvenance } from '@/types/spm'; + +interface SPMProvenanceDetailsProps { + label: string; + receipt: SPMProvenance; + executionCount?: number; +} + +/** Render the compact public receipt without exposing internal diagnostic data. */ +export default function SPMProvenanceDetails({ + label, + receipt, + executionCount, +}: SPMProvenanceDetailsProps) { + const rows = buildSPMProvenanceDisplayRows(receipt); + return ( + + {label} + {executionCount !== undefined && ( + Executions: {executionCount.toLocaleString()} + )} +
+ {rows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+
+ ); +} diff --git a/app/src/hooks/useHouseholdVariation.ts b/app/src/hooks/useHouseholdVariation.ts index 7469c57a5..9fe60031d 100644 --- a/app/src/hooks/useHouseholdVariation.ts +++ b/app/src/hooks/useHouseholdVariation.ts @@ -98,7 +98,6 @@ export function useHouseholdVariation({ id: householdId, countryId: countryId as (typeof countryIds)[number], householdData: calculation.result, - spmConfig: calculation.spm_config, spmProvenance: calculation.spm_provenance, }; return result; diff --git a/app/src/pages/report-output/HouseholdReportOutput.tsx b/app/src/pages/report-output/HouseholdReportOutput.tsx index 723f127a9..98b7132bc 100644 --- a/app/src/pages/report-output/HouseholdReportOutput.tsx +++ b/app/src/pages/report-output/HouseholdReportOutput.tsx @@ -219,7 +219,6 @@ export function HouseholdReportOutput({ role: index === 0 ? 'baseline' : 'reform', household, policy: policy ? convertPoliciesToV1Format([policy]).baseline.data : null, - spmConfig: output?.spm_config, spmProvenance: output?.spm_provenance, policyengineVersion: output?.policyengine_bundle?.policyengine_version ?? @@ -227,7 +226,7 @@ export function HouseholdReportOutput({ null, modelVersion: output?.policyengine_bundle?.model_version ?? - output?.spm_provenance?.runtime_versions?.[`policyengine-${report.countryId}`] ?? + output?.spm_provenance?.runtime_versions?.['policyengine-us'] ?? null, }; } diff --git a/app/src/pages/report-output/HouseholdReportViewModel.ts b/app/src/pages/report-output/HouseholdReportViewModel.ts index f4cb39aa2..2a5ed058d 100644 --- a/app/src/pages/report-output/HouseholdReportViewModel.ts +++ b/app/src/pages/report-output/HouseholdReportViewModel.ts @@ -143,7 +143,6 @@ export class HouseholdReportViewModel { ) .map(({ simulation, householdData }) => ({ id: simulation.id, - spmConfig: (simulation.output as HouseholdCalculationResult)?.spm_config, spmProvenance: (simulation.output as HouseholdCalculationResult)?.spm_provenance, countryId: this.report!.countryId, householdData, diff --git a/app/src/pages/report-output/SocietyWideOverview.story.tsx b/app/src/pages/report-output/SocietyWideOverview.story.tsx index 8b16951a9..1e9bcfff9 100644 --- a/app/src/pages/report-output/SocietyWideOverview.story.tsx +++ b/app/src/pages/report-output/SocietyWideOverview.story.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { CountryProvider } from '@/contexts/CountryContext'; import { ReportYearProvider } from '@/contexts/ReportYearContext'; +import { SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import type { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; import SocietyWideOverview from './SocietyWideOverview'; @@ -35,6 +36,7 @@ function buildMockOutput(overrides: { } = overrides; return { + spm_provenance: SPM_COMPARISON_RECEIPT, budget: { baseline_net_income: 12_000_000_000_000, benefit_spending_impact: budgetaryImpact > 0 ? -budgetaryImpact * 0.4 : budgetaryImpact * 0.6, diff --git a/app/src/pages/report-output/SocietyWideOverview.tsx b/app/src/pages/report-output/SocietyWideOverview.tsx index 1f30e069f..9997f2f2e 100644 --- a/app/src/pages/report-output/SocietyWideOverview.tsx +++ b/app/src/pages/report-output/SocietyWideOverview.tsx @@ -13,6 +13,7 @@ import { normalizeDistrictId } from '@/adapters/congressional-district/congressi import { SocietyWideReportOutput } from '@/api/societyWideCalculation'; import DashboardCard from '@/components/report/DashboardCard'; import MetricCard from '@/components/report/MetricCard'; +import SPMComparisonMethodology from '@/components/report/SPMComparisonMethodology'; import { Group, SegmentedControl, Stack, Text } from '@/components/ui'; import { MapTypeToggle } from '@/components/visualization/choropleth/MapTypeToggle'; import type { MapVisualizationType } from '@/components/visualization/choropleth/types'; @@ -1439,6 +1440,9 @@ export default function SocietyWideOverview({ /> )} + {'spm_provenance' in output && ( + + )} ); } diff --git a/app/src/tests/fixtures/adapters/reportMocks.ts b/app/src/tests/fixtures/adapters/reportMocks.ts index 31de85aa4..2e8f3474f 100644 --- a/app/src/tests/fixtures/adapters/reportMocks.ts +++ b/app/src/tests/fixtures/adapters/reportMocks.ts @@ -1,3 +1,4 @@ +import { SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import { Report } from '@/types/ingredients/Report'; import { ReportMetadata } from '@/types/metadata/reportMetadata'; import { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; @@ -5,6 +6,7 @@ import { ReportCreationPayload } from '@/types/payloads/ReportCreationPayload'; import { ReportSetOutputPayload } from '@/types/payloads/ReportSetOutputPayload'; export const mockReportOutput: ReportOutputSocietyWideUS = { + spm_provenance: SPM_COMPARISON_RECEIPT, budget: { baseline_net_income: 1000000, benefit_spending_impact: -50000, diff --git a/app/src/tests/fixtures/api/reportMocks.ts b/app/src/tests/fixtures/api/reportMocks.ts index 43eca9dd0..3c3e39462 100644 --- a/app/src/tests/fixtures/api/reportMocks.ts +++ b/app/src/tests/fixtures/api/reportMocks.ts @@ -1,4 +1,5 @@ import { ReportAdapter } from '@/adapters/ReportAdapter'; +import { SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import { Report } from '@/types/ingredients/Report'; import { ReportMetadata } from '@/types/metadata/reportMetadata'; import type { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; @@ -28,6 +29,7 @@ export const MOCK_HOUSEHOLD_RESULT = { // Complete mock for US society-wide result export const MOCK_ECONOMY_RESULT: ReportOutputSocietyWideUS = { + spm_provenance: SPM_COMPARISON_RECEIPT, budget: { baseline_net_income: 1000000, benefit_spending_impact: -50000, diff --git a/app/src/tests/fixtures/api/societyWideMocks.ts b/app/src/tests/fixtures/api/societyWideMocks.ts index 4fa2eff4c..69c1d7566 100644 --- a/app/src/tests/fixtures/api/societyWideMocks.ts +++ b/app/src/tests/fixtures/api/societyWideMocks.ts @@ -1,5 +1,6 @@ import { vi } from 'vitest'; import { SocietyWideCalculationResponse } from '@/api/societyWideCalculation'; +import { SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; // Test IDs and constants @@ -52,6 +53,7 @@ export const mockErrorResponse = (status: number) => ({ // Mock US report output export const mockUSReportOutput: ReportOutputSocietyWideUS = { + spm_provenance: SPM_COMPARISON_RECEIPT, budget: { baseline_net_income: 1000000, benefit_spending_impact: -50000, diff --git a/app/src/tests/fixtures/pages/congressional-district/congressionalDistrictComponentMocks.ts b/app/src/tests/fixtures/pages/congressional-district/congressionalDistrictComponentMocks.ts index a1d0ddf87..beb42af16 100644 --- a/app/src/tests/fixtures/pages/congressional-district/congressionalDistrictComponentMocks.ts +++ b/app/src/tests/fixtures/pages/congressional-district/congressionalDistrictComponentMocks.ts @@ -1,3 +1,4 @@ +import { SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import type { MetadataRegionEntry, MetadataState } from '@/types/metadata'; import type { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; import { US_REGION_TYPES } from '@/types/regionTypes'; @@ -52,6 +53,7 @@ export const MOCK_CONGRESSIONAL_DISTRICT_REGIONS: MetadataRegionEntry[] = [ * Mock US report output with congressional district impact data */ export const MOCK_US_REPORT_OUTPUT: ReportOutputSocietyWideUS = { + spm_provenance: SPM_COMPARISON_RECEIPT, congressional_district_impact: { districts: [ { diff --git a/app/src/tests/fixtures/pages/report-output/reproduce-in-python/householdSPMReproductionMocks.ts b/app/src/tests/fixtures/pages/report-output/reproduce-in-python/householdSPMReproductionMocks.ts index 4a65633f1..3af4261a8 100644 --- a/app/src/tests/fixtures/pages/report-output/reproduce-in-python/householdSPMReproductionMocks.ts +++ b/app/src/tests/fixtures/pages/report-output/reproduce-in-python/householdSPMReproductionMocks.ts @@ -4,7 +4,7 @@ import { SPM_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import type { Policy } from '@/types/ingredients/Policy'; import type { Report } from '@/types/ingredients/Report'; import type { Simulation } from '@/types/ingredients/Simulation'; -import type { SPMSelection } from '@/types/spm'; +import type { SPMProvenance, SPMRuntimeVersions } from '@/types/spm'; export const REPRODUCTION_YEAR = '2026'; // Synthetic artifact hashes and package versions; never published artifact pins. @@ -22,20 +22,33 @@ export const REPRODUCTION_RUNTIME_VERSIONS = [ 'spm-calculator': '6.2.0', }, ]; -export const REPRODUCTION_CONFIGS: SPMSelection[] = [ +const REPRODUCTION_PROVENANCE_FIELDS = [ { geography_kind: 'national', - forecast_content_sha256: 'c'.repeat(64), + geography_id: null, + forecast_sha256: 'c'.repeat(64), scenario: 'baseline-null-false', + county_vintage: '2020', as_of: '2026-08-01', }, { geography_kind: 'county', - forecast_content_sha256: 'd'.repeat(64), + geography_id: null, + forecast_sha256: 'd'.repeat(64), scenario: 'county-alternative', + county_vintage: '2020', as_of: '2026-09-01', }, -]; +] as const; + +export const REPRODUCTION_RECEIPTS: SPMProvenance[] = REPRODUCTION_PROVENANCE_FIELDS.map( + (fields, index) => ({ + ...SPM_RECEIPT, + ...fields, + years: [REPRODUCTION_YEAR], + runtime_versions: { ...REPRODUCTION_RUNTIME_VERSIONS[index] }, + }) +); export const REPRODUCTION_HOUSEHOLDS = ['baseline', 'reform'].map((role, index) => Household.fromAppInput({ id: `household-${role}`, @@ -81,17 +94,7 @@ export const REPRODUCTION_SIMULATIONS: Simulation[] = ['baseline', 'reform'].map status: 'complete', output: { result: { people: {} }, - spm_config: REPRODUCTION_CONFIGS[index], - spm_provenance: { - ...SPM_RECEIPT, - forecast_sha256: REPRODUCTION_CONFIGS[index].forecast_content_sha256!, - scenario: REPRODUCTION_CONFIGS[index].scenario!, - geography_kind: REPRODUCTION_CONFIGS[index].geography_kind, - runtime_versions: { - 'policyengine-core': REPRODUCTION_RUNTIME_VERSIONS[index]['policyengine-core'], - 'spm-calculator': REPRODUCTION_RUNTIME_VERSIONS[index]['spm-calculator'], - }, - }, + spm_provenance: REPRODUCTION_RECEIPTS[index], policyengine_bundle: { policyengine_version: index === 0 ? '9.1.0' : '9.2.0', model_version: index === 0 ? '8.1.0' : '8.2.0', @@ -124,7 +127,7 @@ export function reproductionSimulationsWithOutput( ]; } -export function reproductionSimulationsWithoutBundle(wrapperInstalled: boolean): Simulation[] { +export function reproductionSimulationsWithoutBundle(): Simulation[] { return REPRODUCTION_SIMULATIONS.map((simulation, index) => { const output = simulation.output as HouseholdCalculationResult; return { @@ -134,12 +137,7 @@ export function reproductionSimulationsWithoutBundle(wrapperInstalled: boolean): policyengine_bundle: null, spm_provenance: { ...output.spm_provenance!, - runtime_versions: { - ...REPRODUCTION_RUNTIME_VERSIONS[index], - policyengine: wrapperInstalled - ? REPRODUCTION_RUNTIME_VERSIONS[index].policyengine - : null, - }, + runtime_versions: { ...REPRODUCTION_RUNTIME_VERSIONS[index] }, }, }, }; @@ -147,7 +145,7 @@ export function reproductionSimulationsWithoutBundle(wrapperInstalled: boolean): } export function reproductionSimulationsWithRuntimeVersions( - versions: Record + versions: Partial ): Simulation[] { const output = REPRODUCTION_SIMULATIONS[0].output as HouseholdCalculationResult; return reproductionSimulationsWithOutput({ diff --git a/app/src/tests/fixtures/reducers/reportReducerMocks.ts b/app/src/tests/fixtures/reducers/reportReducerMocks.ts index 352d554a5..695b64154 100644 --- a/app/src/tests/fixtures/reducers/reportReducerMocks.ts +++ b/app/src/tests/fixtures/reducers/reportReducerMocks.ts @@ -1,4 +1,5 @@ import { CURRENT_YEAR } from '@/constants'; +import { SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import { ReportOutput } from '@/types/ingredients/Report'; import { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; @@ -13,6 +14,7 @@ export const TEST_TIMESTAMP_UPDATED = `${CURRENT_YEAR}-01-15T10:30:00.000Z`; // Create a minimal valid US report output export const MOCK_REPORT_OUTPUT: ReportOutputSocietyWideUS = { + spm_provenance: SPM_COMPARISON_RECEIPT, budget: { baseline_net_income: 2500000, benefit_spending_impact: -50000, @@ -107,6 +109,7 @@ export const MOCK_REPORT_OUTPUT: ReportOutputSocietyWideUS = { }; export const MOCK_REPORT_OUTPUT_ALTERNATIVE: ReportOutputSocietyWideUS = { + spm_provenance: SPM_COMPARISON_RECEIPT, budget: { baseline_net_income: 1000000, benefit_spending_impact: -25000, diff --git a/app/src/tests/fixtures/spm/reportYearRecoveryMocks.ts b/app/src/tests/fixtures/spm/reportYearRecoveryMocks.ts index bbfb0b811..c2fd41297 100644 --- a/app/src/tests/fixtures/spm/reportYearRecoveryMocks.ts +++ b/app/src/tests/fixtures/spm/reportYearRecoveryMocks.ts @@ -5,7 +5,7 @@ import type { V1HouseholdCreateEnvelope, V1HouseholdMetadataEnvelope, } from '@/models/household/v1Types'; -import { REPRODUCTION_CONFIGS } from '@/tests/fixtures/pages/report-output/reproduce-in-python/householdSPMReproductionMocks'; +import { REPRODUCTION_RECEIPTS } from '@/tests/fixtures/pages/report-output/reproduce-in-python/householdSPMReproductionMocks'; import { GENERIC_ENVELOPES, GENERIC_HOUSEHOLD_IDS, @@ -17,6 +17,12 @@ import { import type { PolicyMetadata } from '@/types/metadata/policyMetadata'; import type { ReportMetadata } from '@/types/metadata/reportMetadata'; import type { SimulationMetadata } from '@/types/metadata/simulationMetadata'; +import { + buildSPMSelectionFromProvenance, + parseSPMSelection, + type SPMProvenance, + type SPMSelection, +} from '@/types/spm'; export const ORIGINAL_YEAR = '2026'; export const SUPPORTED_YEAR = '2023'; @@ -32,6 +38,35 @@ export const YEAR_SAVE_ERROR = { message: 'The selected SPM artifact could not be saved. Choose SPM settings again.', }; +function provenanceForStoredSelection( + selection: SPMSelection, + template: SPMProvenance +): SPMProvenance { + if ( + !selection.forecast_content_sha256 || + !selection.scenario || + !selection.county_vintage || + selection.as_of === undefined + ) { + throw new Error('The calculation fixture requires a fully pinned SPM request selection'); + } + const common = { + ...template, + forecast_sha256: selection.forecast_content_sha256, + scenario: selection.scenario, + county_vintage: selection.county_vintage, + as_of: selection.as_of, + years: [SUPPORTED_YEAR], + }; + return selection.geography_kind === 'metro' + ? { + ...common, + geography_kind: selection.geography_kind, + geography_id: selection.geography_id, + } + : { ...common, geography_kind: selection.geography_kind, geography_id: null }; +} + export function yearRecoveryHouseholds(year = ORIGINAL_YEAR) { return GENERIC_HOUSEHOLD_IDS.map((id, index) => { const secondPerson = index === 0 ? 'your partner' : 'your child'; @@ -41,7 +76,7 @@ export function yearRecoveryHouseholds(year = ORIGINAL_YEAR) { countryId: 'us', year: Number(year), label: index === 0 ? 'Baseline family' : 'Reform family', - spm: { ...REPRODUCTION_CONFIGS[index], county_vintage: '2020' }, + spm: buildSPMSelectionFromProvenance(REPRODUCTION_RECEIPTS[index]), householdData: { people: { you: { @@ -188,6 +223,10 @@ export class ReportYearRecoveryHTTP extends GenericOrchestrationHTTP { const householdData = JSON.parse( JSON.stringify(storedHousehold.household_json) ) as HouseholdCalculationResult['result']; + const spmProvenance = provenanceForStoredSelection( + parseSPMSelection(storedHousehold.spm), + GENERIC_ENVELOPES[index].spm_provenance! + ); const envelope: HouseholdCalculationResult = { ...GENERIC_ENVELOPES[index], result: { @@ -199,14 +238,7 @@ export class ReportYearRecoveryHTTP extends GenericOrchestrationHTTP { }, }, }, - spm_config: storedHousehold.spm, - spm_provenance: { - ...GENERIC_ENVELOPES[index].spm_provenance!, - forecast_sha256: storedHousehold.spm!.forecast_content_sha256!, - scenario: storedHousehold.spm!.scenario!, - geography_kind: storedHousehold.spm!.geography_kind, - years: { [SUPPORTED_YEAR]: { source: 'forecast' } }, - }, + spm_provenance: spmProvenance, }; return new Response(JSON.stringify({ status: 'ok', ...envelope })); } diff --git a/app/src/tests/fixtures/spm/spmMocks.ts b/app/src/tests/fixtures/spm/spmMocks.ts index ad3cc4fbd..2b82068d3 100644 --- a/app/src/tests/fixtures/spm/spmMocks.ts +++ b/app/src/tests/fixtures/spm/spmMocks.ts @@ -1,6 +1,11 @@ import { Household } from '@/models/Household'; import { createMockStateWithData } from '@/tests/fixtures/reducers/metadataReducerMocks'; -import type { SPMMetadata, SPMProvenance, SPMSelection } from '@/types/spm'; +import type { + SPMComparisonProvenance, + SPMMetadata, + SPMProvenance, + SPMSelection, +} from '@/types/spm'; export const SPM_TEST_YEAR = '2026'; export const NATIONAL_SPM: SPMSelection = { geography_kind: 'national' }; @@ -9,15 +14,26 @@ export const CANONICAL_SPM_METADATA: SPMMetadata = { available: true }; export const RESOLVED_CANONICAL_METADATA = createMockStateWithData({ spm: CANONICAL_SPM_METADATA }); export const RESOLVED_LEGACY_METADATA = createMockStateWithData(); export const SPM_RECEIPT: SPMProvenance = { + schema_version: 'canonical-spm-provenance-v2', forecast_id: 'test-canonical-forecast', forecast_sha256: 'a'.repeat(64), scenario: 'test-baseline', geography_kind: 'national', - runtime_versions: { 'policyengine-us': 'test-version' }, - years: { '2026': { source: 'forecast' } }, - geographies: [{ geography_kind: 'national' }], - composition_method: 'test-classified-adults', - storage_method: 'test-artifact', + geography_id: null, + county_vintage: '2020', + as_of: '2026-08-01', + years: ['2026'], + runtime_versions: { + policyengine: '9.1.0', + 'policyengine-core': '7.1.0', + 'policyengine-us': '8.1.0', + 'spm-calculator': '6.1.0', + }, +}; +export const SPM_COMPARISON_RECEIPT: SPMComparisonProvenance = { + schema_version: 'canonical-spm-comparison-v2', + baseline: { receipt: SPM_RECEIPT, execution_count: 20 }, + reform: { receipt: SPM_RECEIPT, execution_count: 20 }, }; export function stateOnlyHousehold() { return Household.starter('us', SPM_TEST_YEAR).setGroupVariableAtYear( diff --git a/app/src/tests/fixtures/spm/spmReviewMocks.ts b/app/src/tests/fixtures/spm/spmReviewMocks.ts index 33580fd75..0768d38b7 100644 --- a/app/src/tests/fixtures/spm/spmReviewMocks.ts +++ b/app/src/tests/fixtures/spm/spmReviewMocks.ts @@ -2,6 +2,7 @@ import metadataReducer from '@/reducers/metadataReducer'; import type { HouseholdCalculationOutput } from '@/types/calculation/household'; import type { Simulation } from '@/types/ingredients/Simulation'; import type { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; +import type { SPMGeographyKind, SPMProvenance } from '@/types/spm'; import { NATIONAL_SPM, SPM_RECEIPT, SPM_TEST_YEAR, stateOnlyHousehold } from './spmMocks'; // Synthetic inputs/receipts for UI integration tests; never published artifacts or results. @@ -48,9 +49,24 @@ export const spmSaveErrors = [ { code: 'SPM_GEOGRAPHY_REQUIRED', message: 'An explicit county FIPS code is required.' }, ]; +function reviewSPMProvenance( + role: 'baseline' | 'reform', + variation: boolean, + geographyKind: SPMGeographyKind +): SPMProvenance { + const common = { + ...SPM_RECEIPT, + forecast_id: `test-${role}-${variation ? 'axes' : 'point'}`, + }; + return geographyKind === 'metro' + ? { ...common, geography_kind: geographyKind, geography_id: '35620' } + : { ...common, geography_kind: geographyKind, geography_id: null }; +} + export function reviewOutput( role: 'baseline' | 'reform', - variation = false + variation = false, + geographyKind: SPMGeographyKind = 'national' ): HouseholdCalculationOutput { const value = (point: number) => ({ [SPM_TEST_YEAR]: variation ? Array(401).fill(point) : point, @@ -58,8 +74,7 @@ export function reviewOutput( return { id: `review-${role}`, countryId: 'us', - spmConfig: NATIONAL_SPM, - spmProvenance: { ...SPM_RECEIPT, forecast_id: `test-${role}-${variation ? 'axes' : 'point'}` }, + spmProvenance: reviewSPMProvenance(role, variation, geographyKind), householdData: { people: { you: { employment_income: value(30000), marginal_tax_rate: value(0.2) } }, households: { household: { members: ['you'], household_net_income: value(25000) } }, diff --git a/app/src/tests/integration/household/GenericHouseholdOrchestration.test.tsx b/app/src/tests/integration/household/GenericHouseholdOrchestration.test.tsx index 78909d7c5..ea099bbb5 100644 --- a/app/src/tests/integration/household/GenericHouseholdOrchestration.test.tsx +++ b/app/src/tests/integration/household/GenericHouseholdOrchestration.test.tsx @@ -139,14 +139,13 @@ async function hydrateAndVerifyReceipts() { role: index === 0 ? 'baseline' : 'reform', household: GENERIC_HOUSEHOLDS[index], policy: convertPoliciesToV1Format([GENERIC_POLICIES[index]]).baseline.data, - spmConfig: output.spm_config, spmProvenance: output.spm_provenance, modelVersion: output.policyengine_bundle!.model_version, policyengineVersion: output.policyengine_bundle!.policyengine_version, }, Number(report.year) ).join('\n'); - expect(code).toContain(output.spm_config!.forecast_content_sha256); + expect(code).toContain(output.spm_provenance!.forecast_sha256); expect(code).toContain(`policyengine-us==${output.policyengine_bundle!.model_version}`); expect(code).toContain(`reform=${index === 0 ? 'baseline' : 'reform'},`); expect(code).toContain('simulation.spm_provenance()'); diff --git a/app/src/tests/integration/household/HouseholdSPMMethodology.test.tsx b/app/src/tests/integration/household/HouseholdSPMMethodology.test.tsx index 1cb06fc57..6215955db 100644 --- a/app/src/tests/integration/household/HouseholdSPMMethodology.test.tsx +++ b/app/src/tests/integration/household/HouseholdSPMMethodology.test.tsx @@ -46,12 +46,11 @@ describe('SPM point and axes receipts in app-owned report methodology', () => { if (url.endsWith('/calculate-full')) { const request = JSON.parse(options!.body as string); const role = request.spm.geography_kind === 'national' ? 'baseline' : 'reform'; - const axes = reviewOutput(role, true); + const axes = reviewOutput(role, true, request.spm.geography_kind); return new Response( JSON.stringify({ status: 'ok', result: axes.householdData, - spm_config: request.spm, spm_provenance: axes.spmProvenance, }) ); @@ -102,7 +101,6 @@ describe('SPM point and axes receipts in app-owned report methodology', () => { output: { status: 'ok' as const, result: point.householdData, - spm_config: point.spmConfig, spm_provenance: point.spmProvenance, }, }; diff --git a/app/src/tests/integration/household/HouseholdVariationSPMReadiness.test.tsx b/app/src/tests/integration/household/HouseholdVariationSPMReadiness.test.tsx index d112e56c0..5c127bdf0 100644 --- a/app/src/tests/integration/household/HouseholdVariationSPMReadiness.test.tsx +++ b/app/src/tests/integration/household/HouseholdVariationSPMReadiness.test.tsx @@ -72,18 +72,19 @@ beforeEach(() => { client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); savedHousehold = stateOnlyHousehold(); fetchMock.mockReset(); - fetchMock.mockImplementation(async (url: string) => - url.endsWith('/calculate-full') - ? new Response( - JSON.stringify({ - status: 'ok', - result: reviewOutput('baseline', true).householdData, - spm_config: savedHousehold.spm, - spm_provenance: reviewOutput('baseline', true).spmProvenance, - }) - ) - : householdResponse() - ); + fetchMock.mockImplementation(async (url: string) => { + if (!url.endsWith('/calculate-full')) { + return householdResponse(); + } + const axes = reviewOutput('baseline', true, savedHousehold.spm?.geography_kind ?? 'national'); + return new Response( + JSON.stringify({ + status: 'ok', + result: axes.householdData, + spm_provenance: axes.spmProvenance, + }) + ); + }); vi.stubGlobal('fetch', fetchMock); }); afterEach(() => { diff --git a/app/src/tests/integration/household/ReportYearSPMRecovery.test.tsx b/app/src/tests/integration/household/ReportYearSPMRecovery.test.tsx index 19f18982e..3d403b9f0 100644 --- a/app/src/tests/integration/household/ReportYearSPMRecovery.test.tsx +++ b/app/src/tests/integration/household/ReportYearSPMRecovery.test.tsx @@ -307,10 +307,10 @@ describe('actual report year selector corrects saved SPM inputs before calculati expect(envelope.result.people.you.age).toEqual({ [SUPPORTED_YEAR]: index === 0 ? 40 : 41, }); - expect(envelope.spm_config).toEqual(CORRECTED_HOUSEHOLD_PAYLOADS[index].spm); - expect(envelope.spm_provenance?.years).toEqual({ - [SUPPORTED_YEAR]: { source: 'forecast' }, - }); + expect(envelope.spm_provenance?.years).toEqual([SUPPORTED_YEAR]); + expect(envelope.spm_provenance?.forecast_sha256).toBe( + CORRECTED_HOUSEHOLD_PAYLOADS[index].spm?.forecast_content_sha256 + ); expect(await fetchPolicyById('us', simulation.policyId!)).toEqual(originalPolicies[index]); } render(providers()); @@ -438,7 +438,9 @@ describe('actual report year selector corrects saved SPM inputs before calculati policyId: GENERIC_POLICY_IDS[index], }); const envelope = simulation.output as HouseholdCalculationResult; - expect(envelope.spm_config).toEqual(CORRECTED_HOUSEHOLD_PAYLOADS[1].spm); + expect(envelope.spm_provenance?.forecast_sha256).toBe( + CORRECTED_HOUSEHOLD_PAYLOADS[1].spm?.forecast_content_sha256 + ); expect(envelope.result.people.you.employment_income).toEqual({ [SUPPORTED_YEAR]: 62000 }); expect(envelope.result.households!.household.county_fips).toEqual({ [SUPPORTED_YEAR]: '06037', diff --git a/app/src/tests/integration/pages/report-output/HouseholdSPMReproduction.test.tsx b/app/src/tests/integration/pages/report-output/HouseholdSPMReproduction.test.tsx index 493f0e7f8..5b86d88db 100644 --- a/app/src/tests/integration/pages/report-output/HouseholdSPMReproduction.test.tsx +++ b/app/src/tests/integration/pages/report-output/HouseholdSPMReproduction.test.tsx @@ -1,10 +1,11 @@ import { render, screen, userEvent, within } from '@test-utils'; import { describe, expect, test, vi } from 'vitest'; +import type { HouseholdCalculationResult } from '@/api/householdCalculation'; import { HouseholdReportOutput } from '@/pages/report-output/HouseholdReportOutput'; import { - REPRODUCTION_CONFIGS, REPRODUCTION_HOUSEHOLDS, REPRODUCTION_POLICIES, + REPRODUCTION_RECEIPTS, REPRODUCTION_REPORT, REPRODUCTION_RUNTIME_VERSIONS, REPRODUCTION_SIMULATIONS, @@ -47,48 +48,23 @@ function codeFor(role: 'Baseline' | 'Reform') { } describe('Household report Python reproduction', () => { - test.each([false, true])( - 'given canonical receipts without a bundle and wrapper installed %s then pins each recorded runtime independently', - (wrapperInstalled) => { - renderReport(REPRODUCTION_POLICIES, reproductionSimulationsWithoutBundle(wrapperInstalled)); - for (const [index, role] of (['Baseline', 'Reform'] as const).entries()) { - const code = codeFor(role); - const versions = REPRODUCTION_RUNTIME_VERSIONS[index]; - expect(code).toContain(`"policyengine-us==${versions['policyengine-us']}"`); - expect(code).toContain(`"policyengine-core==${versions['policyengine-core']}"`); - expect(code).toContain(`"spm-calculator==${versions['spm-calculator']}"`); - expect(code).not.toContain( - `"policyengine-core==${REPRODUCTION_RUNTIME_VERSIONS[1 - index]['policyengine-core']}"` - ); - if (wrapperInstalled) { - expect(code).toContain(`"policyengine[us]==${versions.policyengine}"`); - } else { - expect(code).not.toContain('policyengine[us]'); - } - expect(code).toContain(`reform=${role.toLowerCase()},`); - expect(code).toContain(REPRODUCTION_CONFIGS[index].forecast_content_sha256); - expect(code).toContain('simulation.spm_provenance()'); - } - } - ); - - test.each(['policyengine-core', 'spm-calculator'])( - 'given no recorded %s version then withholds exact code for that simulation', - (packageName) => { - renderReport( - REPRODUCTION_POLICIES, - reproductionSimulationsWithRuntimeVersions({ [packageName]: null }) + test('given canonical receipts without a bundle then pins each recorded runtime independently', () => { + renderReport(REPRODUCTION_POLICIES, reproductionSimulationsWithoutBundle()); + for (const [index, role] of (['Baseline', 'Reform'] as const).entries()) { + const code = codeFor(role); + const versions = REPRODUCTION_RUNTIME_VERSIONS[index]; + expect(code).toContain(`"policyengine-us==${versions['policyengine-us']}"`); + expect(code).toContain(`"policyengine-core==${versions['policyengine-core']}"`); + expect(code).toContain(`"spm-calculator==${versions['spm-calculator']}"`); + expect(code).not.toContain( + `"policyengine-core==${REPRODUCTION_RUNTIME_VERSIONS[1 - index]['policyengine-core']}"` ); - const baseline = within(screen.getByRole('region', { name: 'Baseline simulation' })); - expect(baseline.getByRole('status')).toHaveTextContent( - `no recorded version for ${packageName}` - ); - expect( - baseline.queryByRole('button', { name: 'Copy code to clipboard' }) - ).not.toBeInTheDocument(); - expect(codeFor('Reform')).toContain('policyengine-core==7.2.0'); + expect(code).toContain(`"policyengine[us]==${versions.policyengine}"`); + expect(code).toContain(`reform=${role.toLowerCase()},`); + expect(code).toContain(REPRODUCTION_RECEIPTS[index].forecast_sha256); + expect(code).toContain('simulation.spm_provenance()'); } - ); + }); test.each(['policyengine-us', 'policyengine'])( 'given a conflicting recorded %s version then explains why exact reproduction is unavailable', @@ -127,13 +103,13 @@ describe('Household report Python reproduction', () => { expect(baseline).toContain('"geography_kind": "national"'); expect(reform).toContain('"geography_kind": "county"'); for (const [index, code] of [baseline, reform].entries()) { - expect(code).toContain(REPRODUCTION_CONFIGS[index].forecast_content_sha256); - expect(code).toContain(REPRODUCTION_CONFIGS[index].scenario); - expect(code).toContain(REPRODUCTION_CONFIGS[index].as_of); + expect(code).toContain(REPRODUCTION_RECEIPTS[index].forecast_sha256); + expect(code).toContain(REPRODUCTION_RECEIPTS[index].scenario); + expect(code).toContain(REPRODUCTION_RECEIPTS[index].as_of); expect(code).toContain('"spm_unit_spm_threshold"'); expect(code).toContain('"spm_unit_net_income"'); expect(code).toContain('"spm_unit_is_in_spm_poverty"'); - expect(code).toContain('simulation.spm_config'); + expect(code).not.toContain('print("spm_config"'); expect(code).toContain('simulation.spm_provenance()'); expect(code.indexOf('simulation.spm_provenance()')).toBeGreaterThan( code.indexOf('print(variable, simulation.calculate(variable, 2026))') @@ -176,30 +152,16 @@ describe('Household report Python reproduction', () => { expect(codeFor('Reform')).toContain('reform=reform,'); }); test.each([ + ['missing SPM receipt', { spm_provenance: undefined }, /no SPM receipt/], [ - 'missing bundle and model versions', - { policyengine_bundle: null }, - /no resolved model version/, - ], - [ - 'wrapper without a resolved model version', - { policyengine_bundle: { policyengine_version: '9.1.0' } }, - /no resolved model version/, - ], - [ - 'missing resolved SPM settings', - { spm_config: undefined }, - /no matching resolved SPM settings and receipt/, - ], - [ - 'missing SPM receipt', - { spm_provenance: undefined }, - /no matching resolved SPM settings and receipt/, - ], - [ - 'mismatched artifact identity', - { spm_config: REPRODUCTION_CONFIGS[1] }, - /no matching resolved SPM settings and receipt/, + 'receipt missing the saved calculation year', + { + spm_provenance: { + ...(REPRODUCTION_SIMULATIONS[0].output as HouseholdCalculationResult).spm_provenance!, + years: ['2025'] as string[], + }, + }, + /does not cover the saved calculation year/, ], ] as const)( 'given %s then withholds baseline code and preserves independent reform reproduction', diff --git a/app/src/tests/unit/adapters/ReportAdapter.test.ts b/app/src/tests/unit/adapters/ReportAdapter.test.ts index 2682b7d8c..b7f674591 100644 --- a/app/src/tests/unit/adapters/ReportAdapter.test.ts +++ b/app/src/tests/unit/adapters/ReportAdapter.test.ts @@ -100,6 +100,29 @@ describe('ReportAdapter', () => { expect(result.status).toBe('pending'); }); + test('given persisted US economy output without compact SPM provenance then marks it pending', () => { + const { spm_provenance: _provenance, ...legacyOutput } = mockReportOutput; + const result = ReportAdapter.fromMetadata({ + ...mockReportMetadata, + status: 'complete', + output: JSON.stringify(legacyOutput), + }); + + expect(result.status).toBe('pending'); + expect(result.output).toBeNull(); + }); + + test('given persisted US economy output with the superseded sibling config then marks it pending', () => { + const result = ReportAdapter.fromMetadata({ + ...mockReportMetadata, + status: 'complete', + output: JSON.stringify({ ...mockReportOutput, spm_config: { geography_kind: 'national' } }), + }); + + expect(result.status).toBe('pending'); + expect(result.output).toBeNull(); + }); + test('given metadata with run timestamps then maps them as base report execution metadata', () => { const metadata = { ...mockReportMetadata, diff --git a/app/src/tests/unit/adapters/SimulationAdapter.test.ts b/app/src/tests/unit/adapters/SimulationAdapter.test.ts index 5894e94b5..504fc1eb7 100644 --- a/app/src/tests/unit/adapters/SimulationAdapter.test.ts +++ b/app/src/tests/unit/adapters/SimulationAdapter.test.ts @@ -12,6 +12,7 @@ import { TEST_POPULATION_IDS, TEST_SIMULATION_IDS, } from '@/tests/fixtures/adapters/SimulationAdapterMocks'; +import { SPM_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; describe('SimulationAdapter', () => { describe('fromMetadata', () => { @@ -150,6 +151,21 @@ describe('SimulationAdapter', () => { // Then expect(result.output).toBe('invalid-json'); }); + + it('given persisted household output with the superseded sibling config then marks it pending', () => { + const result = SimulationAdapter.fromMetadata( + mockSimulationMetadata({ + output: { + result: { people: {} }, + spm_config: { geography_kind: 'national' }, + spm_provenance: SPM_RECEIPT, + }, + }) + ); + + expect(result.status).toBe('pending'); + expect(result.output).toBeNull(); + }); }); describe('toCreationPayload', () => { diff --git a/app/src/tests/unit/api/householdSPM.test.ts b/app/src/tests/unit/api/householdSPM.test.ts index 5e38cad40..71ca3b903 100644 --- a/app/src/tests/unit/api/householdSPM.test.ts +++ b/app/src/tests/unit/api/householdSPM.test.ts @@ -65,7 +65,6 @@ describe('SPM request and response plumbing', () => { mockResponse({ status: 'ok', result: { people: {} }, - spm_config: NATIONAL_SPM, spm_provenance: SPM_RECEIPT, }); const result = await fetchHouseholdCalculationWithBundle( @@ -74,7 +73,6 @@ describe('SPM request and response plumbing', () => { 'reform-policy' ); expect(JSON.parse(JSON.stringify(result))).toMatchObject({ - spm_config: NATIONAL_SPM, spm_provenance: SPM_RECEIPT, }); }); @@ -83,7 +81,6 @@ describe('SPM request and response plumbing', () => { const mockFetch = mockResponse({ status: 'ok', result: { people: {} }, - spm_config: NATIONAL_SPM, spm_provenance: SPM_RECEIPT, }); const household = stateOnlyHousehold().toV1CreationPayload().data; @@ -95,4 +92,46 @@ describe('SPM request and response plumbing', () => { }); expect(result.spm_provenance).toEqual(SPM_RECEIPT); }); + + test.each([ + { + label: 'point calculation', + request: () => fetchHouseholdCalculationWithBundle('us', 'saved-household', 'reform-policy'), + }, + { + label: 'earnings variation', + request: () => fetchHouseholdVariationWithProvenance('us', { people: {} }, {}, NATIONAL_SPM), + }, + ])('given legacy receipt in a $label then rejects the response', async ({ request }) => { + mockResponse({ + status: 'ok', + result: { people: {} }, + spm_provenance: { + forecast_id: 'legacy-forecast', + forecast_sha256: 'a'.repeat(64), + scenario: 'legacy', + geography_kind: 'national', + years: { '2026': { source: 'forecast' } }, + runtime_versions: {}, + geographies: [], + composition_method: 'legacy', + storage_method: 'legacy', + }, + }); + + await expect(request()).rejects.toThrow('Invalid SPM provenance'); + }); + + test('given a completed response with the superseded sibling config then rejects it', async () => { + mockResponse({ + status: 'ok', + result: { people: {} }, + spm_config: { geography_kind: 'national' }, + spm_provenance: SPM_RECEIPT, + }); + + await expect( + fetchHouseholdCalculationWithBundle('us', 'saved-household', 'reform-policy') + ).rejects.toThrow('must not include spm_config'); + }); }); diff --git a/app/src/tests/unit/api/societyWideCalculation.test.ts b/app/src/tests/unit/api/societyWideCalculation.test.ts index 6be0e9a4d..918f82b45 100644 --- a/app/src/tests/unit/api/societyWideCalculation.test.ts +++ b/app/src/tests/unit/api/societyWideCalculation.test.ts @@ -131,6 +131,27 @@ describe('societyWide API', () => { expect(result.result?.budget.budgetary_impact).toBe(75000); }); + test('given legacy US SPM provenance then rejects the completed response', async () => { + const response = mockSuccessResponse({ + ...mockCompletedResponse, + result: { + ...mockCompletedResponse.result!, + spm_provenance: { + baseline: [], + reform: [], + }, + }, + }); + (global.fetch as any).mockResolvedValue(response); + + await expect( + fetchSocietyWideCalculation(TEST_COUNTRIES.US, '123', '456', { + region: 'us', + time_period: CURRENT_YEAR, + }) + ).rejects.toThrow('Invalid SPM comparison provenance'); + }); + test('given completed status with policyengine bundle then merges bundle into result', async () => { // Given const countryId = TEST_COUNTRIES.US; diff --git a/app/src/tests/unit/components/household/SPMGeographyChoice.test.tsx b/app/src/tests/unit/components/household/SPMGeographyChoice.test.tsx index b8fc40d14..191a84394 100644 --- a/app/src/tests/unit/components/household/SPMGeographyChoice.test.tsx +++ b/app/src/tests/unit/components/household/SPMGeographyChoice.test.tsx @@ -92,21 +92,23 @@ describe('SPM geography choice', () => { }); test('given a receipt then methodology shows the returned provenance', () => { - render( + const { container } = render( ); expect(screen.getByText('Supplemental Poverty Measure methodology')).toBeInTheDocument(); - expect(screen.getByText(/Forecast: test-canonical-forecast/)).toBeInTheDocument(); - expect(screen.getByText(/"forecast_sha256"/)).toHaveTextContent(SPM_RECEIPT.forecast_sha256); + expect(screen.getByText(SPM_RECEIPT.forecast_id)).toBeInTheDocument(); + expect(screen.getByText('Forecast digest')).toBeInTheDocument(); + expect(screen.getByText(SPM_RECEIPT.forecast_sha256)).toBeInTheDocument(); + expect(container.querySelector('pre')).not.toBeInTheDocument(); + expect(screen.queryByText('forecast_sha256')).not.toBeInTheDocument(); expect(screen.queryByRole('banner')).not.toBeInTheDocument(); }); }); diff --git a/app/src/tests/unit/pages/report-output/HouseholdReportViewModel.test.ts b/app/src/tests/unit/pages/report-output/HouseholdReportViewModel.test.ts index bf1c398df..5b6e85bc1 100644 --- a/app/src/tests/unit/pages/report-output/HouseholdReportViewModel.test.ts +++ b/app/src/tests/unit/pages/report-output/HouseholdReportViewModel.test.ts @@ -3,7 +3,7 @@ import type { HouseholdReportOrchestrator } from '@/libs/calculations/household/ import { HouseholdReportViewModel } from '@/pages/report-output/HouseholdReportViewModel'; import { mockHouseholdResult } from '@/tests/fixtures/api/householdCalculationMocks'; import { CORRECTIVE_SPM_ERRORS, FAILED_SPM_REPORT } from '@/tests/fixtures/spm/reportErrorMocks'; -import { NATIONAL_SPM, SPM_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; +import { SPM_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import type { Report } from '@/types/ingredients/Report'; import type { Simulation } from '@/types/ingredients/Simulation'; @@ -61,7 +61,6 @@ describe('HouseholdReportViewModel', () => { status: 'complete', output: { result: mockHouseholdResult.householdData, - spm_config: NATIONAL_SPM, spm_provenance: SPM_RECEIPT, policyengine_bundle: { policyengine_version: '3.4.1', @@ -77,7 +76,6 @@ describe('HouseholdReportViewModel', () => { id: 'sim-1', countryId: 'us', householdData: mockHouseholdResult.householdData, - spmConfig: NATIONAL_SPM, spmProvenance: SPM_RECEIPT, }, ]); diff --git a/app/src/tests/unit/pages/report-output/SocietyWideOverview.test.tsx b/app/src/tests/unit/pages/report-output/SocietyWideOverview.test.tsx index 7940f8a3d..7d1b249aa 100644 --- a/app/src/tests/unit/pages/report-output/SocietyWideOverview.test.tsx +++ b/app/src/tests/unit/pages/report-output/SocietyWideOverview.test.tsx @@ -7,6 +7,7 @@ import SocietyWideOverview, { getDistrictPayloadAvailability, hasCompleteDistrictOutcomePayload, } from '@/pages/report-output/SocietyWideOverview'; +import { mockUSReportOutput } from '@/tests/fixtures/api/societyWideMocks'; import { createMockSocietyWideOutput } from '@/tests/fixtures/pages/reportOutputMocks'; // Budget with every component exactly zero (an exact no-op). The mock's shallow @@ -117,6 +118,21 @@ describe('SocietyWideOverview', () => { ]); }); + test('given US comparison provenance then displays baseline and reform receipts', () => { + render(); + + expect( + screen.getByRole('region', { name: 'SPM society-wide methodology' }) + ).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Baseline SPM provenance' })).toHaveTextContent( + 'Executions: 20' + ); + expect(screen.getByRole('region', { name: 'Reform SPM provenance' })).toHaveTextContent( + mockUSReportOutput.spm_provenance.reform.receipt.forecast_sha256 + ); + expect(screen.queryByText('median_diagnostics')).not.toBeInTheDocument(); + }); + describe('budgetary impact section', () => { test('given positive budgetary impact then displays formatted value with raise subtext', () => { // Given diff --git a/app/src/tests/unit/types/spm.test.ts b/app/src/tests/unit/types/spm.test.ts new file mode 100644 index 000000000..388e86d0a --- /dev/null +++ b/app/src/tests/unit/types/spm.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from 'vitest'; +import { SPM_COMPARISON_RECEIPT, SPM_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; +import { + buildSPMProvenanceDisplayRows, + buildSPMSelectionFromProvenance, + parseOptionalSPMCalculationProvenance, + parseRequiredSPMComparisonCalculationProvenance, + parseSPMComparisonProvenance, + parseSPMProvenance, + parseSPMSelection, +} from '@/types/spm'; + +describe('SPM wire contracts', () => { + test('given canonical individual provenance then parses every explicit field', () => { + expect(parseSPMProvenance(SPM_RECEIPT)).toEqual(SPM_RECEIPT); + }); + + test('given compact provenance then builds only the approved display rows', () => { + const rows = buildSPMProvenanceDisplayRows(SPM_RECEIPT); + + expect(rows).toContainEqual({ label: 'Forecast', value: SPM_RECEIPT.forecast_id }); + expect(rows).toContainEqual({ + label: 'Forecast digest', + value: SPM_RECEIPT.forecast_sha256, + }); + expect(rows).toContainEqual({ + label: 'SPM Calculator version', + value: SPM_RECEIPT.runtime_versions['spm-calculator'], + }); + expect(rows.map((row) => row.label)).not.toContain('Median diagnostics'); + expect(rows.map((row) => row.label)).not.toContain('Geography mapping'); + }); + + test.each([ + { ...SPM_RECEIPT, schema_version: undefined }, + { ...SPM_RECEIPT, forecast_sha256: 'not-a-sha' }, + { ...SPM_RECEIPT, years: ['2027', '2026'] }, + { ...SPM_RECEIPT, years: ['2026', '2026'] }, + { ...SPM_RECEIPT, geography_kind: 'national', geography_id: '35620' }, + { ...SPM_RECEIPT, geography_kind: 'metro', geography_id: null }, + { ...SPM_RECEIPT, composition_method: 'legacy-diagnostic' }, + { + ...SPM_RECEIPT, + runtime_versions: { + ...SPM_RECEIPT.runtime_versions, + unrecognized_package: '1.0.0', + }, + }, + ])('given invalid or legacy individual provenance then rejects it', (value) => { + expect(() => parseSPMProvenance(value)).toThrow('Invalid SPM provenance'); + }); + + test.each(['policyengine', 'policyengine-core', 'policyengine-us', 'spm-calculator'] as const)( + 'given a null %s runtime version then rejects the receipt', + (packageName) => { + expect(() => + parseSPMProvenance({ + ...SPM_RECEIPT, + runtime_versions: { ...SPM_RECEIPT.runtime_versions, [packageName]: null }, + }) + ).toThrow('Invalid SPM provenance'); + } + ); + + test('given canonical comparison provenance then parses both execution receipts', () => { + expect(parseSPMComparisonProvenance(SPM_COMPARISON_RECEIPT)).toEqual(SPM_COMPARISON_RECEIPT); + }); + + test.each([ + { + ...SPM_COMPARISON_RECEIPT, + schema_version: 'canonical-spm-provenance-v2', + }, + { + ...SPM_COMPARISON_RECEIPT, + baseline: { ...SPM_COMPARISON_RECEIPT.baseline, execution_count: 0 }, + }, + { + ...SPM_COMPARISON_RECEIPT, + reform: { + ...SPM_COMPARISON_RECEIPT.reform, + receipt: { ...SPM_RECEIPT, scenario: 'different-scenario' }, + }, + }, + ])('given invalid comparison provenance then rejects it', (value) => { + expect(() => parseSPMComparisonProvenance(value)).toThrow('Invalid SPM comparison provenance'); + }); + + test('given a metro selection then requires its identifier', () => { + expect(() => parseSPMSelection({ geography_kind: 'metro' })).toThrow('Invalid SPM selection'); + expect(parseSPMSelection({ geography_kind: 'metro', geography_id: '35620' })).toEqual({ + geography_kind: 'metro', + geography_id: '35620', + }); + }); + + test('given a completed calculation then parses its receipt without a sibling config', () => { + expect( + parseOptionalSPMCalculationProvenance({ + spm_provenance: SPM_RECEIPT, + }) + ).toEqual({ spm_provenance: SPM_RECEIPT }); + }); + + test('given a superseded sibling config then rejects the completed calculation', () => { + expect(() => + parseOptionalSPMCalculationProvenance({ + spm_config: { + geography_kind: 'national', + geography_id: null, + forecast_content_sha256: SPM_RECEIPT.forecast_sha256, + scenario: SPM_RECEIPT.scenario, + county_vintage: '2020', + as_of: SPM_RECEIPT.as_of, + }, + spm_provenance: SPM_RECEIPT, + }) + ).toThrow('must not include spm_config'); + }); + + test('given US society-wide output then requires only a comparison receipt', () => { + expect(() => parseRequiredSPMComparisonCalculationProvenance({ budget: {} })).toThrow( + 'requires spm_provenance' + ); + expect( + parseRequiredSPMComparisonCalculationProvenance({ + spm_provenance: SPM_COMPARISON_RECEIPT, + }) + ).toEqual({ + spm_provenance: SPM_COMPARISON_RECEIPT, + }); + }); + + test('given an individual receipt then rebuilds the fully pinned request selection', () => { + expect(buildSPMSelectionFromProvenance(SPM_RECEIPT)).toEqual({ + geography_kind: 'national', + geography_id: null, + forecast_content_sha256: SPM_RECEIPT.forecast_sha256, + scenario: SPM_RECEIPT.scenario, + county_vintage: '2020', + as_of: SPM_RECEIPT.as_of, + }); + }); +}); diff --git a/app/src/types/calculation/household/HouseholdCalculationOutput.ts b/app/src/types/calculation/household/HouseholdCalculationOutput.ts index 0fabeecd2..b361f1338 100644 --- a/app/src/types/calculation/household/HouseholdCalculationOutput.ts +++ b/app/src/types/calculation/household/HouseholdCalculationOutput.ts @@ -1,5 +1,5 @@ import type { CountryId } from '@/libs/countries'; -import type { SPMProvenance, SPMSelection } from '@/types/spm'; +import type { SPMProvenance } from '@/types/spm'; export type HouseholdCalculationScalar = string | number | boolean | null; export type HouseholdCalculationArray = string[] | number[] | boolean[]; @@ -38,7 +38,6 @@ export interface HouseholdCalculationData { } export interface HouseholdCalculationOutput { - spmConfig?: SPMSelection; spmProvenance?: SPMProvenance; id?: string; countryId: CountryId; diff --git a/app/src/types/metadata/ReportOutputSocietyWideUS.ts b/app/src/types/metadata/ReportOutputSocietyWideUS.ts index 35f65afed..16a92b609 100644 --- a/app/src/types/metadata/ReportOutputSocietyWideUS.ts +++ b/app/src/types/metadata/ReportOutputSocietyWideUS.ts @@ -1,6 +1,8 @@ +import type { SPMComparisonProvenance } from '@/types/spm'; import { USCongressionalDistrictBreakdown } from './ReportOutputSocietyWideByCongressionalDistrict'; export interface ReportOutputSocietyWideUS { + spm_provenance: SPMComparisonProvenance; budget: { baseline_net_income: number; benefit_spending_impact: number; diff --git a/app/src/types/spm.ts b/app/src/types/spm.ts index 5919693a1..4fbc5cedb 100644 --- a/app/src/types/spm.ts +++ b/app/src/types/spm.ts @@ -1,11 +1,13 @@ /** Public US SPM settings. The API validates the selected certified artifact. */ +export type SPMGeographyKind = 'national' | 'county' | 'metro'; + export type SPMSelection = { forecast_content_sha256?: string | null; scenario?: string | null; county_vintage?: '2020'; as_of?: string | null; } & ( - | { geography_kind: 'national' | 'county'; geography_id?: never } + | { geography_kind: 'national' | 'county'; geography_id?: null } | { geography_kind: 'metro'; geography_id: string } ); @@ -15,14 +17,401 @@ export interface SPMMetadata { settings_schema?: Record; } -export interface SPMProvenance { +export interface SPMRuntimeVersions { + policyengine: string; + 'policyengine-core': string; + 'policyengine-us': string; + 'spm-calculator': string; +} + +interface SPMProvenanceBase { + schema_version: 'canonical-spm-provenance-v2'; forecast_id: string; forecast_sha256: string; scenario: string; - geography_kind: string; - runtime_versions: Record; - years: Record>; - geographies: Record[]; - composition_method: string; - storage_method: string; + county_vintage: string; + as_of: string | null; + years: string[]; + runtime_versions: SPMRuntimeVersions; +} + +export type SPMProvenance = SPMProvenanceBase & + ( + | { geography_kind: 'national' | 'county'; geography_id: null } + | { geography_kind: 'metro'; geography_id: string } + ); + +export interface SPMExecutionProvenance { + receipt: SPMProvenance; + execution_count: number; +} + +export interface SPMComparisonProvenance { + schema_version: 'canonical-spm-comparison-v2'; + baseline: SPMExecutionProvenance; + reform: SPMExecutionProvenance; +} + +export interface SPMCalculationProvenance { + spm_provenance: SPMProvenance; +} + +export interface SPMComparisonCalculationProvenance { + spm_provenance: SPMComparisonProvenance; +} + +export interface SPMProvenanceDisplayRow { + label: string; + value: string; +} + +const SELECTION_KEYS = new Set([ + 'geography_kind', + 'geography_id', + 'forecast_content_sha256', + 'scenario', + 'county_vintage', + 'as_of', +]); +const PROVENANCE_KEYS = new Set([ + 'schema_version', + 'forecast_id', + 'forecast_sha256', + 'scenario', + 'geography_kind', + 'geography_id', + 'county_vintage', + 'as_of', + 'years', + 'runtime_versions', +]); +const RUNTIME_VERSION_KEYS = new Set([ + 'policyengine', + 'policyengine-core', + 'policyengine-us', + 'spm-calculator', +]); +const COMPARISON_KEYS = new Set(['schema_version', 'baseline', 'reform']); +const EXECUTION_KEYS = new Set(['receipt', 'execution_count']); +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const YEAR_PATTERN = /^\d{4}$/; +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function requireRecord(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function requireExactKeys( + record: Record, + allowedKeys: ReadonlySet, + label: string +): void { + const unexpected = Object.keys(record).filter((key) => !allowedKeys.has(key)); + if (unexpected.length > 0) { + throw new Error(`${label} contains unexpected fields: ${unexpected.join(', ')}`); + } +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +function requireNullableDate(value: unknown, label: string): string | null { + if (value === null) { + return null; + } + const date = requireString(value, label); + const parsed = new Date(`${date}T00:00:00Z`); + if ( + !ISO_DATE_PATTERN.test(date) || + Number.isNaN(parsed.valueOf()) || + parsed.toISOString().slice(0, 10) !== date + ) { + throw new Error(`${label} must be a valid ISO date`); + } + return date; +} + +function requireGeographyKind(value: unknown, label: string): SPMGeographyKind { + if (value === 'national' || value === 'county' || value === 'metro') { + return value; + } + throw new Error(`${label} must be national, county, or metro`); +} + +function parseRuntimeVersions(value: unknown): SPMRuntimeVersions { + const record = requireRecord(value, 'SPM runtime versions'); + requireExactKeys(record, RUNTIME_VERSION_KEYS, 'SPM runtime versions'); + for (const key of RUNTIME_VERSION_KEYS) { + if (!(key in record)) { + throw new Error(`SPM runtime versions must include ${key}`); + } + } + return { + policyengine: requireString(record.policyengine, 'policyengine version'), + 'policyengine-core': requireString(record['policyengine-core'], 'policyengine-core version'), + 'policyengine-us': requireString(record['policyengine-us'], 'policyengine-us version'), + 'spm-calculator': requireString(record['spm-calculator'], 'spm-calculator version'), + }; +} + +function parseYears(value: unknown): string[] { + if (!Array.isArray(value) || !value.every((year) => typeof year === 'string')) { + throw new Error('SPM years must be an array of four-digit strings'); + } + const years = [...value]; + if (years.some((year) => !YEAR_PATTERN.test(year))) { + throw new Error('SPM years must be an array of four-digit strings'); + } + const sortedUnique = [...new Set(years)].sort(); + if ( + sortedUnique.length !== years.length || + years.some((year, index) => sortedUnique[index] !== year) + ) { + throw new Error('SPM years must be sorted and unique'); + } + return years; +} + +/** Parse public SPM settings without accepting undocumented fields. */ +export function parseSPMSelection(value: unknown): SPMSelection { + try { + const record = requireRecord(value, 'SPM selection'); + requireExactKeys(record, SELECTION_KEYS, 'SPM selection'); + const geographyKind = requireGeographyKind(record.geography_kind, 'SPM geography kind'); + const optional = { + ...(record.forecast_content_sha256 !== undefined + ? { + forecast_content_sha256: + record.forecast_content_sha256 === null + ? null + : requireString(record.forecast_content_sha256, 'SPM forecast digest'), + } + : {}), + ...(record.scenario !== undefined + ? { + scenario: + record.scenario === null ? null : requireString(record.scenario, 'SPM scenario'), + } + : {}), + ...(record.county_vintage !== undefined + ? { + county_vintage: + record.county_vintage === '2020' + ? ('2020' as const) + : (() => { + throw new Error('SPM county vintage must be 2020'); + })(), + } + : {}), + ...(record.as_of !== undefined + ? { as_of: requireNullableDate(record.as_of, 'SPM as-of date') } + : {}), + }; + if (geographyKind === 'metro') { + return { + geography_kind: geographyKind, + geography_id: requireString(record.geography_id, 'SPM metro identifier'), + ...optional, + }; + } + if (record.geography_id !== undefined && record.geography_id !== null) { + throw new Error('SPM geography identifier is only valid for metro selections'); + } + return { + geography_kind: geographyKind, + ...(record.geography_id === null ? { geography_id: null } : {}), + ...optional, + }; + } catch (error) { + const detail = error instanceof Error ? error.message : 'unknown error'; + throw new Error(`Invalid SPM selection: ${detail}`); + } +} + +/** Parse the compact, versioned SPM receipt and reject legacy diagnostic payloads. */ +export function parseSPMProvenance(value: unknown): SPMProvenance { + try { + const record = requireRecord(value, 'SPM provenance'); + requireExactKeys(record, PROVENANCE_KEYS, 'SPM provenance'); + for (const key of PROVENANCE_KEYS) { + if (!(key in record)) { + throw new Error(`SPM provenance must include ${key}`); + } + } + if (record.schema_version !== 'canonical-spm-provenance-v2') { + throw new Error('SPM provenance has an unsupported schema version'); + } + const digest = requireString(record.forecast_sha256, 'SPM forecast digest'); + if (!SHA256_PATTERN.test(digest)) { + throw new Error('SPM forecast digest must be a lowercase SHA-256 value'); + } + const countyVintage = requireString(record.county_vintage, 'SPM county vintage'); + if (!YEAR_PATTERN.test(countyVintage)) { + throw new Error('SPM county vintage must be a four-digit year'); + } + const geographyKind = requireGeographyKind(record.geography_kind, 'SPM geography kind'); + const common: SPMProvenanceBase = { + schema_version: 'canonical-spm-provenance-v2', + forecast_id: requireString(record.forecast_id, 'SPM forecast identifier'), + forecast_sha256: digest, + scenario: requireString(record.scenario, 'SPM scenario'), + county_vintage: countyVintage, + as_of: requireNullableDate(record.as_of, 'SPM as-of date'), + years: parseYears(record.years), + runtime_versions: parseRuntimeVersions(record.runtime_versions), + }; + if (geographyKind === 'metro') { + return { + ...common, + geography_kind: geographyKind, + geography_id: requireString(record.geography_id, 'SPM metro identifier'), + }; + } + if (record.geography_id !== null) { + throw new Error('SPM geography identifier must be null outside metro calculations'); + } + return { ...common, geography_kind: geographyKind, geography_id: null }; + } catch (error) { + const detail = error instanceof Error ? error.message : 'unknown error'; + throw new Error(`Invalid SPM provenance: ${detail}`); + } +} + +function parseExecutionProvenance(value: unknown): SPMExecutionProvenance { + const record = requireRecord(value, 'SPM execution provenance'); + requireExactKeys(record, EXECUTION_KEYS, 'SPM execution provenance'); + const count = record.execution_count; + if (typeof count !== 'number' || !Number.isInteger(count) || count < 1) { + throw new Error('SPM execution count must be a positive integer'); + } + return { receipt: parseSPMProvenance(record.receipt), execution_count: count }; +} + +/** Parse one compact baseline/reform receipt for a society-wide calculation. */ +export function parseSPMComparisonProvenance(value: unknown): SPMComparisonProvenance { + try { + const record = requireRecord(value, 'SPM comparison provenance'); + requireExactKeys(record, COMPARISON_KEYS, 'SPM comparison provenance'); + if (record.schema_version !== 'canonical-spm-comparison-v2') { + throw new Error('SPM comparison provenance has an unsupported schema version'); + } + const baseline = parseExecutionProvenance(record.baseline); + const reform = parseExecutionProvenance(record.reform); + if (JSON.stringify(baseline.receipt) !== JSON.stringify(reform.receipt)) { + throw new Error('Baseline and reform SPM receipts must match'); + } + return { + schema_version: 'canonical-spm-comparison-v2', + baseline, + reform, + }; + } catch (error) { + const detail = error instanceof Error ? error.message : 'unknown error'; + throw new Error(`Invalid SPM comparison provenance: ${detail}`); + } +} + +function hasOwn(record: Record, key: string): boolean { + return Object.hasOwn(record, key); +} + +/** Parse an optional compact receipt while rejecting the superseded sibling config. */ +export function parseOptionalSPMCalculationProvenance( + value: unknown +): SPMCalculationProvenance | null { + const record = requireRecord(value, 'Household calculation'); + const hasProvenance = hasOwn(record, 'spm_provenance') && record.spm_provenance !== undefined; + if (hasOwn(record, 'spm_config')) { + throw new Error('Completed SPM calculations must not include spm_config'); + } + if (!hasProvenance) { + return null; + } + const spmProvenance = parseSPMProvenance(record.spm_provenance); + return { spm_provenance: spmProvenance }; +} + +/** Parse the required compact receipt from a completed US society-wide result. */ +export function parseRequiredSPMComparisonCalculationProvenance( + value: unknown +): SPMComparisonCalculationProvenance { + const record = requireRecord(value, 'US society-wide calculation'); + const hasProvenance = hasOwn(record, 'spm_provenance') && record.spm_provenance !== undefined; + if (hasOwn(record, 'spm_config')) { + throw new Error('Completed US society-wide calculations must not include spm_config'); + } + if (!hasProvenance) { + throw new Error('US society-wide calculation requires spm_provenance'); + } + const spmProvenance = parseSPMComparisonProvenance(record.spm_provenance); + return { spm_provenance: spmProvenance }; +} + +/** Build a fully pinned request selection from the compact calculation receipt. */ +export function buildSPMSelectionFromProvenance(receipt: SPMProvenance): SPMSelection { + if (receipt.county_vintage !== '2020') { + throw new Error(`Unsupported SPM county vintage: ${receipt.county_vintage}`); + } + const common = { + forecast_content_sha256: receipt.forecast_sha256, + scenario: receipt.scenario, + county_vintage: '2020' as const, + as_of: receipt.as_of, + }; + if (receipt.geography_kind === 'metro') { + return { + ...common, + geography_kind: receipt.geography_kind, + geography_id: receipt.geography_id, + }; + } + return { ...common, geography_kind: receipt.geography_kind, geography_id: null }; +} + +/** Build the concise, human-readable fields shown for a calculation receipt. */ +export function buildSPMProvenanceDisplayRows(receipt: SPMProvenance): SPMProvenanceDisplayRow[] { + const geography = + receipt.geography_kind === 'national' + ? 'National' + : receipt.geography_kind === 'county' + ? 'County' + : 'Metropolitan area'; + const rows: SPMProvenanceDisplayRow[] = [ + { label: 'Forecast', value: receipt.forecast_id }, + { label: 'Scenario', value: receipt.scenario }, + { label: 'Geography', value: geography }, + ]; + if (receipt.geography_id !== null) { + rows.push({ label: 'Geography identifier', value: receipt.geography_id }); + } + rows.push( + { label: 'County vintage', value: receipt.county_vintage }, + { label: 'As of', value: receipt.as_of ?? 'Not specified' }, + { label: 'Years', value: receipt.years.length > 0 ? receipt.years.join(', ') : 'None' }, + { label: 'Forecast digest', value: receipt.forecast_sha256 }, + { + label: 'PolicyEngine version', + value: receipt.runtime_versions.policyengine, + }, + { + label: 'PolicyEngine Core version', + value: receipt.runtime_versions['policyengine-core'], + }, + { + label: 'PolicyEngine US version', + value: receipt.runtime_versions['policyengine-us'], + }, + { + label: 'SPM Calculator version', + value: receipt.runtime_versions['spm-calculator'], + } + ); + return rows; } diff --git a/app/src/utils/reproducibilityCode.ts b/app/src/utils/reproducibilityCode.ts index 0dd923f3a..fc76fe381 100644 --- a/app/src/utils/reproducibilityCode.ts +++ b/app/src/utils/reproducibilityCode.ts @@ -13,7 +13,11 @@ import { cleanPythonPackageHouseholdNullValuesForYear, } from '@/models/household/pythonPackageCodec'; import type { PythonPackageHouseholdSituation } from '@/models/household/pythonPackageTypes'; -import type { SPMProvenance, SPMSelection } from '@/types/spm'; +import { + buildSPMSelectionFromProvenance, + type SPMProvenance, + type SPMSelection, +} from '@/types/spm'; // Default year fallback - use the app's current year constant const DEFAULT_YEAR = parseInt(CURRENT_YEAR, 10); @@ -24,7 +28,6 @@ export interface HouseholdReproduction { role: 'baseline' | 'reform'; household: Household | null; policy: Record | null; - spmConfig?: SPMSelection; spmProvenance?: SPMProvenance; policyengineVersion?: string | null; modelVersion?: string | null; @@ -32,7 +35,7 @@ export interface HouseholdReproduction { // These distributions own the calculation runtime and canonical SPM formulas. // Country releases can allow a range of core versions, so pin the returned receipt. -const SPM_RUNTIME_PACKAGES = ['policyengine-core', 'spm-calculator'] as const; +const SPM_ADDITIONAL_RUNTIME_PACKAGES = ['policyengine-core', 'spm-calculator'] as const; // Maps region prefixes (from metadata) to HuggingFace subfolder names. // Note: place/ is NOT included — places use the parent state's dataset + filtering. @@ -319,7 +322,7 @@ function getSituationCode( household: Household | null, earningVariation: boolean, role: 'baseline' | 'reform' = 'reform', - spmConfig: SPMSelection | undefined = household?.spm + spmSelection: SPMSelection | undefined = household?.spm ): string[] { if (type !== 'household') { return []; @@ -336,8 +339,8 @@ function getSituationCode( lines.push(` reform=${role},`); } - if (spmConfig) { - const spm = Object.entries(spmConfig) + if (spmSelection) { + const spm = Object.entries(spmSelection) .filter(([, value]) => value !== undefined) .map( ([key, value]) => @@ -355,7 +358,7 @@ function getSituationCode( 'print(output)' ); - if (countryId === 'us' && spmConfig) { + if (countryId === 'us' && spmSelection) { lines.push( '', '# Calculate SPM outputs before reading the receipt.', @@ -367,7 +370,6 @@ function getSituationCode( ']', 'for variable in spm_variables:', ` print(variable, simulation.calculate(variable, ${year}))`, - 'print("spm_config", simulation.spm_config)', 'print("spm_provenance", simulation.spm_provenance())' ); } @@ -386,36 +388,26 @@ export function getHouseholdReproductionUnavailableReason( if (!year || !Number.isInteger(year)) { return 'This report has no saved calculation year. Exact reproduction is unavailable.'; } - if (!reproduction.modelVersion) { - return 'This simulation has no resolved model version. Complete its calculation to save the version needed for exact reproduction. Older reports may need to be calculated again.'; - } - const { spmConfig, spmProvenance } = reproduction; - if (countryId === 'us' && (reproduction.household.spm || spmConfig || spmProvenance)) { - if ( - !spmProvenance || - !spmConfig?.forecast_content_sha256 || - !spmConfig.scenario || - spmConfig.forecast_content_sha256 !== spmProvenance?.forecast_sha256 || - spmConfig.scenario !== spmProvenance.scenario || - spmConfig.geography_kind !== spmProvenance.geography_kind - ) { - return 'This simulation has no matching resolved SPM settings and receipt. Complete its calculation to save the artifact identity needed for exact reproduction.'; + const { spmProvenance } = reproduction; + if (countryId === 'us' && (reproduction.household.spm || spmProvenance)) { + if (!spmProvenance) { + return 'This simulation has no SPM receipt. Complete its calculation to save the artifact identity needed for exact reproduction.'; } - const runtimeVersions = spmProvenance.runtime_versions; - const missingPackages = SPM_RUNTIME_PACKAGES.filter((name) => !runtimeVersions?.[name]); - if (missingPackages.length > 0) { - return `This simulation has no recorded version for ${missingPackages.join(' and ')}. Exact reproduction is unavailable until its calculation saves these runtime versions.`; + if (!spmProvenance.years.includes(String(year))) { + return "This simulation's SPM receipt does not cover the saved calculation year. Calculate it again before generating exact reproduction code."; } - const recordedModelVersion = runtimeVersions[`policyengine-${countryId}`]; + const runtimeVersions = spmProvenance.runtime_versions; + const recordedModelVersion = runtimeVersions['policyengine-us']; const recordedWrapperVersion = runtimeVersions.policyengine; if ( - (recordedModelVersion && recordedModelVersion !== reproduction.modelVersion) || - (recordedWrapperVersion && - reproduction.policyengineVersion && + (reproduction.modelVersion && recordedModelVersion !== reproduction.modelVersion) || + (reproduction.policyengineVersion && recordedWrapperVersion !== reproduction.policyengineVersion) ) { return 'This simulation has conflicting package versions in its calculation metadata and SPM receipt. Exact reproduction is unavailable until they agree.'; } + } else if (!reproduction.modelVersion) { + return 'This simulation has no resolved model version. Complete its calculation to save the version needed for exact reproduction. Older reports may need to be calculated again.'; } return null; } @@ -427,7 +419,7 @@ export function getHouseholdReproducibilityCode( year: number | null, earningVariation: boolean = false ): string[] { - const { role, household, policy, policyengineVersion, modelVersion, spmConfig } = reproduction; + const { role, household, policy, spmProvenance } = reproduction; if (getHouseholdReproductionUnavailableReason(countryId, reproduction, year)) { return []; } @@ -435,6 +427,10 @@ export function getHouseholdReproducibilityCode( baseline: { data: role === 'baseline' ? policy : {} }, reform: { data: role === 'reform' ? policy : {} }, }; + const policyengineVersion = + spmProvenance?.runtime_versions.policyengine ?? reproduction.policyengineVersion; + const modelVersion = + spmProvenance?.runtime_versions['policyengine-us'] ?? reproduction.modelVersion; const header = getHeaderCode( 'household', countryId, @@ -452,8 +448,8 @@ export function getHouseholdReproducibilityCode( header.unshift(`%pip install ${modelPin}`, ''); } } - if (countryId === 'us' && spmConfig) { - for (const packageName of SPM_RUNTIME_PACKAGES) { + if (countryId === 'us' && spmProvenance) { + for (const packageName of SPM_ADDITIONAL_RUNTIME_PACKAGES) { header[0] += ` "${packageName}==${reproduction.spmProvenance!.runtime_versions[packageName]}"`; } } @@ -472,7 +468,7 @@ export function getHouseholdReproducibilityCode( household, earningVariation, role, - spmConfig + spmProvenance ? buildSPMSelectionFromProvenance(spmProvenance) : household?.spm ), ]; }