From 0a1b59fd68620c34977679cb16a96398afea9d9d Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:55:58 +0400 Subject: [PATCH 1/5] Parse compact SPM provenance contracts --- app/src/adapters/ReportAdapter.ts | 30 +- app/src/adapters/SimulationAdapter.ts | 27 +- app/src/api/householdCalculation.ts | 20 +- app/src/api/householdVariation.ts | 18 +- app/src/api/societyWideCalculation.ts | 5 + .../report-output/HouseholdReportOutput.tsx | 2 +- .../SocietyWideOverview.story.tsx | 3 + .../tests/fixtures/adapters/reportMocks.ts | 3 + app/src/tests/fixtures/api/reportMocks.ts | 3 + .../tests/fixtures/api/societyWideMocks.ts | 3 + .../congressionalDistrictComponentMocks.ts | 3 + .../householdSPMReproductionMocks.ts | 44 +- .../fixtures/reducers/reportReducerMocks.ts | 5 + .../fixtures/spm/reportYearRecoveryMocks.ts | 32 +- app/src/tests/fixtures/spm/spmMocks.ts | 37 +- app/src/tests/fixtures/spm/spmReviewMocks.ts | 10 +- .../tests/unit/adapters/ReportAdapter.test.ts | 16 + .../unit/adapters/SimulationAdapter.test.ts | 16 + app/src/tests/unit/api/householdSPM.test.ts | 43 +- .../unit/api/societyWideCalculation.test.ts | 21 + .../household/SPMGeographyChoice.test.tsx | 3 +- app/src/tests/unit/types/spm.test.ts | 128 ++++++ .../household/HouseholdCalculationOutput.ts | 4 +- .../metadata/ReportOutputSocietyWideUS.ts | 3 + app/src/types/spm.ts | 421 +++++++++++++++++- 25 files changed, 841 insertions(+), 59 deletions(-) create mode 100644 app/src/tests/unit/types/spm.test.ts 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..b1ad19dfc 100644 --- a/app/src/api/householdCalculation.ts +++ b/app/src/api/householdCalculation.ts @@ -1,7 +1,11 @@ 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 ResolvedSPMSelection, + type SPMProvenance, +} from '@/types/spm'; import { householdAPIError, householdAPIErrorFromBody } from './householdError'; export interface HouseholdCalculationResponse { @@ -9,14 +13,14 @@ export interface HouseholdCalculationResponse { result: HouseholdCalculationData | null; error?: string; policyengine_bundle?: PolicyEngineBundle | null; - spm_config?: SPMSelection; + spm_config?: ResolvedSPMSelection; spm_provenance?: SPMProvenance; } export interface HouseholdCalculationResult { result: HouseholdCalculationData; policyengine_bundle?: PolicyEngineBundle | null; - spm_config?: SPMSelection; + spm_config?: ResolvedSPMSelection; spm_provenance?: SPMProvenance; } @@ -47,17 +51,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..a7474f582 100644 --- a/app/src/api/householdVariation.ts +++ b/app/src/api/householdVariation.ts @@ -1,13 +1,18 @@ import { BASE_URL } from '@/constants'; import type { HouseholdCalculationData } from '@/types/calculation/household'; -import type { SPMProvenance, SPMSelection } from '@/types/spm'; +import { + parseOptionalSPMCalculationProvenance, + type ResolvedSPMSelection, + 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_config?: ResolvedSPMSelection; spm_provenance?: SPMProvenance; } @@ -52,13 +57,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/pages/report-output/HouseholdReportOutput.tsx b/app/src/pages/report-output/HouseholdReportOutput.tsx index 723f127a9..15194106a 100644 --- a/app/src/pages/report-output/HouseholdReportOutput.tsx +++ b/app/src/pages/report-output/HouseholdReportOutput.tsx @@ -227,7 +227,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/SocietyWideOverview.story.tsx b/app/src/pages/report-output/SocietyWideOverview.story.tsx index 8b16951a9..b55d0e379 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 { RESOLVED_NATIONAL_SPM, SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import type { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; import SocietyWideOverview from './SocietyWideOverview'; @@ -35,6 +36,8 @@ function buildMockOutput(overrides: { } = overrides; return { + spm_config: RESOLVED_NATIONAL_SPM, + 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/tests/fixtures/adapters/reportMocks.ts b/app/src/tests/fixtures/adapters/reportMocks.ts index 31de85aa4..7f99f3cab 100644 --- a/app/src/tests/fixtures/adapters/reportMocks.ts +++ b/app/src/tests/fixtures/adapters/reportMocks.ts @@ -1,3 +1,4 @@ +import { RESOLVED_NATIONAL_SPM, 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,8 @@ import { ReportCreationPayload } from '@/types/payloads/ReportCreationPayload'; import { ReportSetOutputPayload } from '@/types/payloads/ReportSetOutputPayload'; export const mockReportOutput: ReportOutputSocietyWideUS = { + spm_config: RESOLVED_NATIONAL_SPM, + 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..5c70c0541 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 { RESOLVED_NATIONAL_SPM, 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,8 @@ export const MOCK_HOUSEHOLD_RESULT = { // Complete mock for US society-wide result export const MOCK_ECONOMY_RESULT: ReportOutputSocietyWideUS = { + spm_config: RESOLVED_NATIONAL_SPM, + 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..5d2bb4088 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 { RESOLVED_NATIONAL_SPM, SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; // Test IDs and constants @@ -52,6 +53,8 @@ export const mockErrorResponse = (status: number) => ({ // Mock US report output export const mockUSReportOutput: ReportOutputSocietyWideUS = { + spm_config: RESOLVED_NATIONAL_SPM, + 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..1b6522e3c 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 { RESOLVED_NATIONAL_SPM, 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,8 @@ export const MOCK_CONGRESSIONAL_DISTRICT_REGIONS: MetadataRegionEntry[] = [ * Mock US report output with congressional district impact data */ export const MOCK_US_REPORT_OUTPUT: ReportOutputSocietyWideUS = { + spm_config: RESOLVED_NATIONAL_SPM, + 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..c530008ac 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 { ResolvedSPMSelection, SPMProvenance } from '@/types/spm'; export const REPRODUCTION_YEAR = '2026'; // Synthetic artifact hashes and package versions; never published artifact pins. @@ -22,20 +22,49 @@ export const REPRODUCTION_RUNTIME_VERSIONS = [ 'spm-calculator': '6.2.0', }, ]; -export const REPRODUCTION_CONFIGS: SPMSelection[] = [ +export const REPRODUCTION_CONFIGS: ResolvedSPMSelection[] = [ { geography_kind: 'national', + geography_id: null, forecast_content_sha256: 'c'.repeat(64), scenario: 'baseline-null-false', + county_vintage: '2020', as_of: '2026-08-01', }, { geography_kind: 'county', + geography_id: null, forecast_content_sha256: 'd'.repeat(64), scenario: 'county-alternative', + county_vintage: '2020', as_of: '2026-09-01', }, ]; + +function buildReproductionReceipt(index: number): SPMProvenance { + const config = REPRODUCTION_CONFIGS[index]; + const common = { + ...SPM_RECEIPT, + forecast_sha256: config.forecast_content_sha256, + scenario: config.scenario, + county_vintage: config.county_vintage, + as_of: config.as_of, + years: [REPRODUCTION_YEAR], + runtime_versions: { ...REPRODUCTION_RUNTIME_VERSIONS[index] }, + }; + if (config.geography_kind === 'metro') { + return { + ...common, + geography_kind: config.geography_kind, + geography_id: config.geography_id, + }; + } + return { + ...common, + geography_kind: config.geography_kind, + geography_id: null, + }; +} export const REPRODUCTION_HOUSEHOLDS = ['baseline', 'reform'].map((role, index) => Household.fromAppInput({ id: `household-${role}`, @@ -82,16 +111,7 @@ export const REPRODUCTION_SIMULATIONS: Simulation[] = ['baseline', 'reform'].map 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: buildReproductionReceipt(index), policyengine_bundle: { policyengine_version: index === 0 ? '9.1.0' : '9.2.0', model_version: index === 0 ? '8.1.0' : '8.2.0', diff --git a/app/src/tests/fixtures/reducers/reportReducerMocks.ts b/app/src/tests/fixtures/reducers/reportReducerMocks.ts index 352d554a5..d7b147503 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 { RESOLVED_NATIONAL_SPM, SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import { ReportOutput } from '@/types/ingredients/Report'; import { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; @@ -13,6 +14,8 @@ 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_config: RESOLVED_NATIONAL_SPM, + spm_provenance: SPM_COMPARISON_RECEIPT, budget: { baseline_net_income: 2500000, benefit_spending_impact: -50000, @@ -107,6 +110,8 @@ export const MOCK_REPORT_OUTPUT: ReportOutputSocietyWideUS = { }; export const MOCK_REPORT_OUTPUT_ALTERNATIVE: ReportOutputSocietyWideUS = { + spm_config: RESOLVED_NATIONAL_SPM, + 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..37c930798 100644 --- a/app/src/tests/fixtures/spm/reportYearRecoveryMocks.ts +++ b/app/src/tests/fixtures/spm/reportYearRecoveryMocks.ts @@ -17,6 +17,7 @@ import { import type { PolicyMetadata } from '@/types/metadata/policyMetadata'; import type { ReportMetadata } from '@/types/metadata/reportMetadata'; import type { SimulationMetadata } from '@/types/metadata/simulationMetadata'; +import type { SPMProvenance } from '@/types/spm'; export const ORIGINAL_YEAR = '2026'; export const SUPPORTED_YEAR = '2023'; @@ -188,6 +189,27 @@ export class ReportYearRecoveryHTTP extends GenericOrchestrationHTTP { const householdData = JSON.parse( JSON.stringify(storedHousehold.household_json) ) as HouseholdCalculationResult['result']; + const resolvedConfig = REPRODUCTION_CONFIGS[index]; + const provenanceCommon = { + ...GENERIC_ENVELOPES[index].spm_provenance!, + forecast_sha256: resolvedConfig.forecast_content_sha256, + scenario: resolvedConfig.scenario, + county_vintage: resolvedConfig.county_vintage, + as_of: resolvedConfig.as_of, + years: [SUPPORTED_YEAR], + }; + const spmProvenance: SPMProvenance = + resolvedConfig.geography_kind === 'metro' + ? { + ...provenanceCommon, + geography_kind: resolvedConfig.geography_kind, + geography_id: resolvedConfig.geography_id, + } + : { + ...provenanceCommon, + geography_kind: resolvedConfig.geography_kind, + geography_id: null, + }; const envelope: HouseholdCalculationResult = { ...GENERIC_ENVELOPES[index], result: { @@ -199,14 +221,8 @@ 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_config: resolvedConfig, + 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..5ae1ba3e0 100644 --- a/app/src/tests/fixtures/spm/spmMocks.ts +++ b/app/src/tests/fixtures/spm/spmMocks.ts @@ -1,6 +1,12 @@ import { Household } from '@/models/Household'; import { createMockStateWithData } from '@/tests/fixtures/reducers/metadataReducerMocks'; -import type { SPMMetadata, SPMProvenance, SPMSelection } from '@/types/spm'; +import type { + ResolvedSPMSelection, + SPMComparisonProvenance, + SPMMetadata, + SPMProvenance, + SPMSelection, +} from '@/types/spm'; export const SPM_TEST_YEAR = '2026'; export const NATIONAL_SPM: SPMSelection = { geography_kind: 'national' }; @@ -9,15 +15,34 @@ 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 RESOLVED_NATIONAL_SPM: ResolvedSPMSelection = { + 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, +}; +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..cd4997563 100644 --- a/app/src/tests/fixtures/spm/spmReviewMocks.ts +++ b/app/src/tests/fixtures/spm/spmReviewMocks.ts @@ -2,7 +2,13 @@ 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 { NATIONAL_SPM, SPM_RECEIPT, SPM_TEST_YEAR, stateOnlyHousehold } from './spmMocks'; +import { + NATIONAL_SPM, + RESOLVED_NATIONAL_SPM, + SPM_RECEIPT, + SPM_TEST_YEAR, + stateOnlyHousehold, +} from './spmMocks'; // Synthetic inputs/receipts for UI integration tests; never published artifacts or results. export const reviewMetadata = { @@ -58,7 +64,7 @@ export function reviewOutput( return { id: `review-${role}`, countryId: 'us', - spmConfig: NATIONAL_SPM, + spmConfig: RESOLVED_NATIONAL_SPM, spmProvenance: { ...SPM_RECEIPT, forecast_id: `test-${role}-${variation ? 'axes' : 'point'}` }, householdData: { people: { you: { employment_income: value(30000), marginal_tax_rate: value(0.2) } }, diff --git a/app/src/tests/unit/adapters/ReportAdapter.test.ts b/app/src/tests/unit/adapters/ReportAdapter.test.ts index 2682b7d8c..a56b91c50 100644 --- a/app/src/tests/unit/adapters/ReportAdapter.test.ts +++ b/app/src/tests/unit/adapters/ReportAdapter.test.ts @@ -100,6 +100,22 @@ describe('ReportAdapter', () => { expect(result.status).toBe('pending'); }); + test('given persisted US economy output without compact SPM provenance then marks it pending', () => { + const { + spm_config: _config, + 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 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..7eb9489b9 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 { RESOLVED_NATIONAL_SPM, 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 legacy SPM provenance then marks it pending', () => { + const result = SimulationAdapter.fromMetadata( + mockSimulationMetadata({ + output: { + result: { people: {} }, + spm_config: RESOLVED_NATIONAL_SPM, + spm_provenance: { ...SPM_RECEIPT, schema_version: 'legacy' }, + }, + }) + ); + + 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..960a4819a 100644 --- a/app/src/tests/unit/api/householdSPM.test.ts +++ b/app/src/tests/unit/api/householdSPM.test.ts @@ -3,7 +3,12 @@ import { createHousehold, fetchHouseholdById } from '@/api/household'; import { fetchHouseholdCalculationWithBundle } from '@/api/householdCalculation'; import { fetchHouseholdVariationWithProvenance } from '@/api/householdVariation'; import { Household } from '@/models/Household'; -import { NATIONAL_SPM, SPM_RECEIPT, stateOnlyHousehold } from '@/tests/fixtures/spm/spmMocks'; +import { + NATIONAL_SPM, + RESOLVED_NATIONAL_SPM, + SPM_RECEIPT, + stateOnlyHousehold, +} from '@/tests/fixtures/spm/spmMocks'; function mockResponse(body: unknown) { const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status: 200 })); @@ -65,7 +70,7 @@ describe('SPM request and response plumbing', () => { mockResponse({ status: 'ok', result: { people: {} }, - spm_config: NATIONAL_SPM, + spm_config: RESOLVED_NATIONAL_SPM, spm_provenance: SPM_RECEIPT, }); const result = await fetchHouseholdCalculationWithBundle( @@ -74,7 +79,7 @@ describe('SPM request and response plumbing', () => { 'reform-policy' ); expect(JSON.parse(JSON.stringify(result))).toMatchObject({ - spm_config: NATIONAL_SPM, + spm_config: RESOLVED_NATIONAL_SPM, spm_provenance: SPM_RECEIPT, }); }); @@ -83,7 +88,7 @@ describe('SPM request and response plumbing', () => { const mockFetch = mockResponse({ status: 'ok', result: { people: {} }, - spm_config: NATIONAL_SPM, + spm_config: RESOLVED_NATIONAL_SPM, spm_provenance: SPM_RECEIPT, }); const household = stateOnlyHousehold().toV1CreationPayload().data; @@ -95,4 +100,34 @@ 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_config: RESOLVED_NATIONAL_SPM, + 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'); + }); }); 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..133deb8ff 100644 --- a/app/src/tests/unit/components/household/SPMGeographyChoice.test.tsx +++ b/app/src/tests/unit/components/household/SPMGeographyChoice.test.tsx @@ -8,6 +8,7 @@ import { NATIONAL_SPM, RESOLVED_CANONICAL_METADATA, RESOLVED_LEGACY_METADATA, + RESOLVED_NATIONAL_SPM, SPM_RECEIPT, SPM_TEST_YEAR, stateOnlyHousehold, @@ -98,7 +99,7 @@ describe('SPM geography choice', () => { { countryId: 'us', householdData: { people: {} }, - spmConfig: NATIONAL_SPM, + spmConfig: RESOLVED_NATIONAL_SPM, spmProvenance: SPM_RECEIPT, }, ]} 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..f22febe00 --- /dev/null +++ b/app/src/tests/unit/types/spm.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from 'vitest'; +import { SPM_COMPARISON_RECEIPT, SPM_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; +import { + 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.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('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 matching calculation fields then parses the selection and receipt together', () => { + const spmConfig = { + 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, + }; + expect( + parseOptionalSPMCalculationProvenance({ + spm_config: spmConfig, + spm_provenance: SPM_RECEIPT, + }) + ).toEqual({ spm_config: spmConfig, spm_provenance: SPM_RECEIPT }); + }); + + test('given a mismatched receipt then rejects the calculation fields', () => { + expect(() => + parseOptionalSPMCalculationProvenance({ + spm_config: { + geography_kind: 'county', + 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('does not match'); + }); + + test('given US society-wide output then requires a comparison receipt and selection', () => { + expect(() => parseRequiredSPMComparisonCalculationProvenance({ budget: {} })).toThrow( + 'requires spm_config and spm_provenance' + ); + expect( + parseRequiredSPMComparisonCalculationProvenance({ + 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_COMPARISON_RECEIPT, + }) + ).toEqual({ + 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_COMPARISON_RECEIPT, + }); + }); +}); diff --git a/app/src/types/calculation/household/HouseholdCalculationOutput.ts b/app/src/types/calculation/household/HouseholdCalculationOutput.ts index 0fabeecd2..862f32ec8 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 { ResolvedSPMSelection, SPMProvenance } from '@/types/spm'; export type HouseholdCalculationScalar = string | number | boolean | null; export type HouseholdCalculationArray = string[] | number[] | boolean[]; @@ -38,7 +38,7 @@ export interface HouseholdCalculationData { } export interface HouseholdCalculationOutput { - spmConfig?: SPMSelection; + spmConfig?: ResolvedSPMSelection; spmProvenance?: SPMProvenance; id?: string; countryId: CountryId; diff --git a/app/src/types/metadata/ReportOutputSocietyWideUS.ts b/app/src/types/metadata/ReportOutputSocietyWideUS.ts index 35f65afed..1209ead5a 100644 --- a/app/src/types/metadata/ReportOutputSocietyWideUS.ts +++ b/app/src/types/metadata/ReportOutputSocietyWideUS.ts @@ -1,6 +1,9 @@ +import type { ResolvedSPMSelection, SPMComparisonProvenance } from '@/types/spm'; import { USCongressionalDistrictBreakdown } from './ReportOutputSocietyWideByCongressionalDistrict'; export interface ReportOutputSocietyWideUS { + spm_config: ResolvedSPMSelection; + 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..cf8a8fac0 100644 --- a/app/src/types/spm.ts +++ b/app/src/types/spm.ts @@ -1,11 +1,23 @@ /** 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 } +); + +export type ResolvedSPMSelection = { + forecast_content_sha256: string; + scenario: string; + county_vintage: '2020'; + as_of: string | null; +} & ( + | { geography_kind: 'national' | 'county'; geography_id: null } | { geography_kind: 'metro'; geography_id: string } ); @@ -15,14 +27,407 @@ export interface SPMMetadata { settings_schema?: Record; } -export interface SPMProvenance { +export interface SPMRuntimeVersions { + policyengine: string | null; + 'policyengine-core': string | null; + 'policyengine-us': string | null; + 'spm-calculator': string | null; +} + +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_config: ResolvedSPMSelection; + spm_provenance: SPMProvenance; +} + +export interface SPMComparisonCalculationProvenance { + spm_config: ResolvedSPMSelection; + spm_provenance: SPMComparisonProvenance; +} + +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 requireNullableString(value: unknown, label: string): string | null { + if (value === null) { + return null; + } + return requireString(value, label); +} + +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: requireNullableString(record.policyengine, 'policyengine version'), + 'policyengine-core': requireNullableString( + record['policyengine-core'], + 'policyengine-core version' + ), + 'policyengine-us': requireNullableString(record['policyengine-us'], 'policyengine-us version'), + 'spm-calculator': requireNullableString(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 fully resolved settings stored beside a completed SPM receipt. */ +export function parseResolvedSPMSelection(value: unknown): ResolvedSPMSelection { + try { + const record = requireRecord(value, 'Resolved SPM selection'); + requireExactKeys(record, SELECTION_KEYS, 'Resolved SPM selection'); + for (const key of SELECTION_KEYS) { + if (!(key in record)) { + throw new Error(`Resolved SPM selection must include ${key}`); + } + } + const digest = requireString(record.forecast_content_sha256, 'SPM forecast digest'); + if (!SHA256_PATTERN.test(digest)) { + throw new Error('SPM forecast digest must be a lowercase SHA-256 value'); + } + if (record.county_vintage !== '2020') { + throw new Error('SPM county vintage must be 2020'); + } + const common = { + forecast_content_sha256: digest, + scenario: requireString(record.scenario, 'SPM scenario'), + county_vintage: '2020' as const, + as_of: requireNullableDate(record.as_of, 'SPM as-of date'), + }; + const geographyKind = requireGeographyKind(record.geography_kind, 'SPM geography kind'); + 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 resolved 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 assertSelectionMatchesReceipt( + selection: ResolvedSPMSelection, + receipt: SPMProvenance +): void { + const mismatched = + selection.geography_kind !== receipt.geography_kind || + selection.geography_id !== receipt.geography_id || + selection.forecast_content_sha256 !== receipt.forecast_sha256 || + selection.scenario !== receipt.scenario || + selection.county_vintage !== receipt.county_vintage || + selection.as_of !== receipt.as_of; + if (mismatched) { + throw new Error('SPM selection does not match the calculation receipt'); + } +} + +function hasOwn(record: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + +/** Parse optional SPM fields from a household calculation envelope. */ +export function parseOptionalSPMCalculationProvenance( + value: unknown +): SPMCalculationProvenance | null { + const record = requireRecord(value, 'Household calculation'); + const hasConfig = hasOwn(record, 'spm_config') && record.spm_config !== undefined; + const hasProvenance = hasOwn(record, 'spm_provenance') && record.spm_provenance !== undefined; + if (!hasConfig && !hasProvenance) { + return null; + } + if (!hasConfig || !hasProvenance) { + throw new Error('SPM calculation requires spm_config and spm_provenance together'); + } + const spmConfig = parseResolvedSPMSelection(record.spm_config); + const spmProvenance = parseSPMProvenance(record.spm_provenance); + assertSelectionMatchesReceipt(spmConfig, spmProvenance); + return { spm_config: spmConfig, spm_provenance: spmProvenance }; +} + +/** Parse required SPM fields from a completed US society-wide result. */ +export function parseRequiredSPMComparisonCalculationProvenance( + value: unknown +): SPMComparisonCalculationProvenance { + const record = requireRecord(value, 'US society-wide calculation'); + const hasConfig = hasOwn(record, 'spm_config') && record.spm_config !== undefined; + const hasProvenance = hasOwn(record, 'spm_provenance') && record.spm_provenance !== undefined; + if (!hasConfig || !hasProvenance) { + throw new Error('US society-wide calculation requires spm_config and spm_provenance'); + } + const spmConfig = parseResolvedSPMSelection(record.spm_config); + const spmProvenance = parseSPMComparisonProvenance(record.spm_provenance); + assertSelectionMatchesReceipt(spmConfig, spmProvenance.baseline.receipt); + assertSelectionMatchesReceipt(spmConfig, spmProvenance.reform.receipt); + return { spm_config: spmConfig, spm_provenance: spmProvenance }; } From 1533706216cba8f23c5c5cb469903485c51e0cfa Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:10:45 +0400 Subject: [PATCH 2/5] Reproduce SPM calculations from compact receipts --- .../householdSPMReproductionMocks.ts | 9 +-- .../fixtures/spm/reportYearRecoveryMocks.ts | 4 +- .../household/ReportYearSPMRecovery.test.tsx | 4 +- .../HouseholdSPMReproduction.test.tsx | 72 ++++++++++--------- app/src/utils/reproducibilityCode.ts | 47 +++++++----- 5 files changed, 73 insertions(+), 63 deletions(-) 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 c530008ac..067e13eb6 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 @@ -144,7 +144,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 { @@ -154,12 +154,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] }, }, }, }; diff --git a/app/src/tests/fixtures/spm/reportYearRecoveryMocks.ts b/app/src/tests/fixtures/spm/reportYearRecoveryMocks.ts index 37c930798..d8dc2cfb1 100644 --- a/app/src/tests/fixtures/spm/reportYearRecoveryMocks.ts +++ b/app/src/tests/fixtures/spm/reportYearRecoveryMocks.ts @@ -17,7 +17,7 @@ import { import type { PolicyMetadata } from '@/types/metadata/policyMetadata'; import type { ReportMetadata } from '@/types/metadata/reportMetadata'; import type { SimulationMetadata } from '@/types/metadata/simulationMetadata'; -import type { SPMProvenance } from '@/types/spm'; +import { parseResolvedSPMSelection, type SPMProvenance } from '@/types/spm'; export const ORIGINAL_YEAR = '2026'; export const SUPPORTED_YEAR = '2023'; @@ -189,7 +189,7 @@ export class ReportYearRecoveryHTTP extends GenericOrchestrationHTTP { const householdData = JSON.parse( JSON.stringify(storedHousehold.household_json) ) as HouseholdCalculationResult['result']; - const resolvedConfig = REPRODUCTION_CONFIGS[index]; + const resolvedConfig = parseResolvedSPMSelection(storedHousehold.spm); const provenanceCommon = { ...GENERIC_ENVELOPES[index].spm_provenance!, forecast_sha256: resolvedConfig.forecast_content_sha256, diff --git a/app/src/tests/integration/household/ReportYearSPMRecovery.test.tsx b/app/src/tests/integration/household/ReportYearSPMRecovery.test.tsx index 19f18982e..e71762ae2 100644 --- a/app/src/tests/integration/household/ReportYearSPMRecovery.test.tsx +++ b/app/src/tests/integration/household/ReportYearSPMRecovery.test.tsx @@ -308,9 +308,7 @@ describe('actual report year selector corrects saved SPM inputs before calculati [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(await fetchPolicyById('us', simulation.policyId!)).toEqual(originalPolicies[index]); } render(providers()); 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..72f0b4695 100644 --- a/app/src/tests/integration/pages/report-output/HouseholdSPMReproduction.test.tsx +++ b/app/src/tests/integration/pages/report-output/HouseholdSPMReproduction.test.tsx @@ -1,5 +1,6 @@ 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, @@ -47,32 +48,25 @@ 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('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']}"` + ); + expect(code).toContain(`"policyengine[us]==${versions.policyengine}"`); + 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'])( + test.each(['policyengine', 'policyengine-core', 'policyengine-us', 'spm-calculator'])( 'given no recorded %s version then withholds exact code for that simulation', (packageName) => { renderReport( @@ -176,16 +170,6 @@ describe('Household report Python reproduction', () => { expect(codeFor('Reform')).toContain('reform=reform,'); }); test.each([ - [ - '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 }, @@ -201,6 +185,26 @@ describe('Household report Python reproduction', () => { { spm_config: REPRODUCTION_CONFIGS[1] }, /no matching resolved SPM settings and receipt/, ], + [ + 'mismatched nullable selection field', + { + spm_config: { + ...REPRODUCTION_CONFIGS[0], + as_of: null, + }, + }, + /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', (_case, output, message) => { diff --git a/app/src/utils/reproducibilityCode.ts b/app/src/utils/reproducibilityCode.ts index 0dd923f3a..c675dd9c7 100644 --- a/app/src/utils/reproducibilityCode.ts +++ b/app/src/utils/reproducibilityCode.ts @@ -13,7 +13,7 @@ import { cleanPythonPackageHouseholdNullValuesForYear, } from '@/models/household/pythonPackageCodec'; import type { PythonPackageHouseholdSituation } from '@/models/household/pythonPackageTypes'; -import type { SPMProvenance, SPMSelection } from '@/types/spm'; +import type { ResolvedSPMSelection, SPMProvenance, SPMSelection } from '@/types/spm'; // Default year fallback - use the app's current year constant const DEFAULT_YEAR = parseInt(CURRENT_YEAR, 10); @@ -24,7 +24,7 @@ export interface HouseholdReproduction { role: 'baseline' | 'reform'; household: Household | null; policy: Record | null; - spmConfig?: SPMSelection; + spmConfig?: ResolvedSPMSelection; spmProvenance?: SPMProvenance; policyengineVersion?: string | null; modelVersion?: string | null; @@ -32,7 +32,13 @@ 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_RUNTIME_PACKAGES = [ + 'policyengine', + 'policyengine-core', + 'policyengine-us', + '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. @@ -386,36 +392,39 @@ 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 || + spmConfig.forecast_content_sha256 !== spmProvenance.forecast_sha256 || spmConfig.scenario !== spmProvenance.scenario || - spmConfig.geography_kind !== spmProvenance.geography_kind + spmConfig.geography_kind !== spmProvenance.geography_kind || + spmConfig.geography_id !== spmProvenance.geography_id || + spmConfig.county_vintage !== spmProvenance.county_vintage || + spmConfig.as_of !== spmProvenance.as_of ) { return 'This simulation has no matching resolved SPM settings and receipt. Complete its calculation to save the artifact identity needed for exact reproduction.'; } + 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 runtimeVersions = spmProvenance.runtime_versions; - const missingPackages = SPM_RUNTIME_PACKAGES.filter((name) => !runtimeVersions?.[name]); + 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.`; } - const recordedModelVersion = runtimeVersions[`policyengine-${countryId}`]; + 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 +436,7 @@ export function getHouseholdReproducibilityCode( year: number | null, earningVariation: boolean = false ): string[] { - const { role, household, policy, policyengineVersion, modelVersion, spmConfig } = reproduction; + const { role, household, policy, spmConfig, spmProvenance } = reproduction; if (getHouseholdReproductionUnavailableReason(countryId, reproduction, year)) { return []; } @@ -435,6 +444,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, @@ -453,7 +466,7 @@ export function getHouseholdReproducibilityCode( } } if (countryId === 'us' && spmConfig) { - for (const packageName of SPM_RUNTIME_PACKAGES) { + for (const packageName of SPM_ADDITIONAL_RUNTIME_PACKAGES) { header[0] += ` "${packageName}==${reproduction.spmProvenance!.runtime_versions[packageName]}"`; } } From b86ecc0479bf52ec6d0cd10f532e649cef77e4cb Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:10:50 +0400 Subject: [PATCH 3/5] Display compact SPM methodology receipts --- .../household/SPMMethodologyFootnote.tsx | 10 +--- .../report/SPMComparisonMethodology.tsx | 31 ++++++++++++ .../report/SPMProvenanceDetails.tsx | 33 +++++++++++++ .../report-output/SocietyWideOverview.tsx | 4 ++ app/src/tests/fixtures/spm/spmReviewMocks.ts | 42 ++++++++++++---- .../HouseholdSPMMethodology.test.tsx | 4 +- .../HouseholdVariationSPMReadiness.test.tsx | 26 +++++----- .../household/SPMGeographyChoice.test.tsx | 9 ++-- .../SocietyWideOverview.test.tsx | 16 +++++++ app/src/tests/unit/types/spm.test.ts | 17 +++++++ app/src/types/spm.ts | 48 ++++++++++++++++++- 11 files changed, 204 insertions(+), 36 deletions(-) create mode 100644 app/src/components/report/SPMComparisonMethodology.tsx create mode 100644 app/src/components/report/SPMProvenanceDetails.tsx 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/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/spm/spmReviewMocks.ts b/app/src/tests/fixtures/spm/spmReviewMocks.ts index cd4997563..66f0d1729 100644 --- a/app/src/tests/fixtures/spm/spmReviewMocks.ts +++ b/app/src/tests/fixtures/spm/spmReviewMocks.ts @@ -2,13 +2,8 @@ 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 { - NATIONAL_SPM, - RESOLVED_NATIONAL_SPM, - SPM_RECEIPT, - SPM_TEST_YEAR, - stateOnlyHousehold, -} from './spmMocks'; +import type { ResolvedSPMSelection, 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. export const reviewMetadata = { @@ -54,9 +49,36 @@ export const spmSaveErrors = [ { code: 'SPM_GEOGRAPHY_REQUIRED', message: 'An explicit county FIPS code is required.' }, ]; +function resolvedReviewSPMSelection(geographyKind: SPMGeographyKind): ResolvedSPMSelection { + const common = { + forecast_content_sha256: SPM_RECEIPT.forecast_sha256, + scenario: SPM_RECEIPT.scenario, + county_vintage: '2020' as const, + as_of: SPM_RECEIPT.as_of, + }; + return geographyKind === 'metro' + ? { ...common, geography_kind: geographyKind, geography_id: '35620' } + : { ...common, geography_kind: geographyKind, geography_id: null }; +} + +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, @@ -64,8 +86,8 @@ export function reviewOutput( return { id: `review-${role}`, countryId: 'us', - spmConfig: RESOLVED_NATIONAL_SPM, - spmProvenance: { ...SPM_RECEIPT, forecast_id: `test-${role}-${variation ? 'axes' : 'point'}` }, + spmConfig: resolvedReviewSPMSelection(geographyKind), + 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/HouseholdSPMMethodology.test.tsx b/app/src/tests/integration/household/HouseholdSPMMethodology.test.tsx index 1cb06fc57..20837c161 100644 --- a/app/src/tests/integration/household/HouseholdSPMMethodology.test.tsx +++ b/app/src/tests/integration/household/HouseholdSPMMethodology.test.tsx @@ -46,12 +46,12 @@ 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_config: axes.spmConfig, spm_provenance: axes.spmProvenance, }) ); diff --git a/app/src/tests/integration/household/HouseholdVariationSPMReadiness.test.tsx b/app/src/tests/integration/household/HouseholdVariationSPMReadiness.test.tsx index d112e56c0..ac0d35768 100644 --- a/app/src/tests/integration/household/HouseholdVariationSPMReadiness.test.tsx +++ b/app/src/tests/integration/household/HouseholdVariationSPMReadiness.test.tsx @@ -72,18 +72,20 @@ 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_config: axes.spmConfig, + spm_provenance: axes.spmProvenance, + }) + ); + }); vi.stubGlobal('fetch', fetchMock); }); afterEach(() => { diff --git a/app/src/tests/unit/components/household/SPMGeographyChoice.test.tsx b/app/src/tests/unit/components/household/SPMGeographyChoice.test.tsx index 133deb8ff..7c467cd01 100644 --- a/app/src/tests/unit/components/household/SPMGeographyChoice.test.tsx +++ b/app/src/tests/unit/components/household/SPMGeographyChoice.test.tsx @@ -93,7 +93,7 @@ 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/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 index f22febe00..9c35776da 100644 --- a/app/src/tests/unit/types/spm.test.ts +++ b/app/src/tests/unit/types/spm.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import { SPM_COMPARISON_RECEIPT, SPM_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import { + buildSPMProvenanceDisplayRows, parseOptionalSPMCalculationProvenance, parseRequiredSPMComparisonCalculationProvenance, parseSPMComparisonProvenance, @@ -13,6 +14,22 @@ describe('SPM wire contracts', () => { 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' }, diff --git a/app/src/types/spm.ts b/app/src/types/spm.ts index cf8a8fac0..e90c3313a 100644 --- a/app/src/types/spm.ts +++ b/app/src/types/spm.ts @@ -72,6 +72,11 @@ export interface SPMComparisonCalculationProvenance { spm_provenance: SPMComparisonProvenance; } +export interface SPMProvenanceDisplayRow { + label: string; + value: string; +} + const SELECTION_KEYS = new Set([ 'geography_kind', 'geography_id', @@ -393,7 +398,7 @@ function assertSelectionMatchesReceipt( } function hasOwn(record: Record, key: string): boolean { - return Object.prototype.hasOwnProperty.call(record, key); + return Object.hasOwn(record, key); } /** Parse optional SPM fields from a household calculation envelope. */ @@ -431,3 +436,44 @@ export function parseRequiredSPMComparisonCalculationProvenance( assertSelectionMatchesReceipt(spmConfig, spmProvenance.reform.receipt); return { spm_config: spmConfig, spm_provenance: spmProvenance }; } + +/** 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 ?? 'Not recorded', + }, + { + label: 'PolicyEngine Core version', + value: receipt.runtime_versions['policyengine-core'] ?? 'Not recorded', + }, + { + label: 'PolicyEngine US version', + value: receipt.runtime_versions['policyengine-us'] ?? 'Not recorded', + }, + { + label: 'SPM Calculator version', + value: receipt.runtime_versions['spm-calculator'] ?? 'Not recorded', + } + ); + return rows; +} From f773e1f10499b05b6bf3fc93ed66539aa83b603b Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:35:04 +0400 Subject: [PATCH 4/5] Require complete SPM runtime versions --- .../householdSPMReproductionMocks.ts | 4 +-- .../HouseholdSPMReproduction.test.tsx | 18 ---------- app/src/tests/unit/types/spm.test.ts | 12 +++++++ app/src/types/spm.ts | 34 +++++++------------ app/src/utils/reproducibilityCode.ts | 10 ------ 5 files changed, 26 insertions(+), 52 deletions(-) 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 067e13eb6..6f5038ec4 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 { ResolvedSPMSelection, SPMProvenance } from '@/types/spm'; +import type { ResolvedSPMSelection, SPMProvenance, SPMRuntimeVersions } from '@/types/spm'; export const REPRODUCTION_YEAR = '2026'; // Synthetic artifact hashes and package versions; never published artifact pins. @@ -162,7 +162,7 @@ export function reproductionSimulationsWithoutBundle(): Simulation[] { } export function reproductionSimulationsWithRuntimeVersions( - versions: Record + versions: Partial ): Simulation[] { const output = REPRODUCTION_SIMULATIONS[0].output as HouseholdCalculationResult; return reproductionSimulationsWithOutput({ 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 72f0b4695..a993481ec 100644 --- a/app/src/tests/integration/pages/report-output/HouseholdSPMReproduction.test.tsx +++ b/app/src/tests/integration/pages/report-output/HouseholdSPMReproduction.test.tsx @@ -66,24 +66,6 @@ describe('Household report Python reproduction', () => { } }); - test.each(['policyengine', 'policyengine-core', 'policyengine-us', 'spm-calculator'])( - 'given no recorded %s version then withholds exact code for that simulation', - (packageName) => { - renderReport( - REPRODUCTION_POLICIES, - reproductionSimulationsWithRuntimeVersions({ [packageName]: null }) - ); - 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'); - } - ); - test.each(['policyengine-us', 'policyengine'])( 'given a conflicting recorded %s version then explains why exact reproduction is unavailable', (packageName) => { diff --git a/app/src/tests/unit/types/spm.test.ts b/app/src/tests/unit/types/spm.test.ts index 9c35776da..49cbb42e2 100644 --- a/app/src/tests/unit/types/spm.test.ts +++ b/app/src/tests/unit/types/spm.test.ts @@ -49,6 +49,18 @@ describe('SPM wire contracts', () => { 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); }); diff --git a/app/src/types/spm.ts b/app/src/types/spm.ts index e90c3313a..65ac47920 100644 --- a/app/src/types/spm.ts +++ b/app/src/types/spm.ts @@ -28,10 +28,10 @@ export interface SPMMetadata { } export interface SPMRuntimeVersions { - policyengine: string | null; - 'policyengine-core': string | null; - 'policyengine-us': string | null; - 'spm-calculator': string | null; + policyengine: string; + 'policyengine-core': string; + 'policyengine-us': string; + 'spm-calculator': string; } interface SPMProvenanceBase { @@ -134,13 +134,6 @@ function requireString(value: unknown, label: string): string { return value; } -function requireNullableString(value: unknown, label: string): string | null { - if (value === null) { - return null; - } - return requireString(value, label); -} - function requireNullableDate(value: unknown, label: string): string | null { if (value === null) { return null; @@ -173,13 +166,10 @@ function parseRuntimeVersions(value: unknown): SPMRuntimeVersions { } } return { - policyengine: requireNullableString(record.policyengine, 'policyengine version'), - 'policyengine-core': requireNullableString( - record['policyengine-core'], - 'policyengine-core version' - ), - 'policyengine-us': requireNullableString(record['policyengine-us'], 'policyengine-us version'), - 'spm-calculator': requireNullableString(record['spm-calculator'], 'spm-calculator version'), + 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'), }; } @@ -460,19 +450,19 @@ export function buildSPMProvenanceDisplayRows(receipt: SPMProvenance): SPMProven { label: 'Forecast digest', value: receipt.forecast_sha256 }, { label: 'PolicyEngine version', - value: receipt.runtime_versions.policyengine ?? 'Not recorded', + value: receipt.runtime_versions.policyengine, }, { label: 'PolicyEngine Core version', - value: receipt.runtime_versions['policyengine-core'] ?? 'Not recorded', + value: receipt.runtime_versions['policyengine-core'], }, { label: 'PolicyEngine US version', - value: receipt.runtime_versions['policyengine-us'] ?? 'Not recorded', + value: receipt.runtime_versions['policyengine-us'], }, { label: 'SPM Calculator version', - value: receipt.runtime_versions['spm-calculator'] ?? 'Not recorded', + value: receipt.runtime_versions['spm-calculator'], } ); return rows; diff --git a/app/src/utils/reproducibilityCode.ts b/app/src/utils/reproducibilityCode.ts index c675dd9c7..1594d0265 100644 --- a/app/src/utils/reproducibilityCode.ts +++ b/app/src/utils/reproducibilityCode.ts @@ -32,12 +32,6 @@ 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', - 'policyengine-core', - 'policyengine-us', - 'spm-calculator', -] as const; const SPM_ADDITIONAL_RUNTIME_PACKAGES = ['policyengine-core', 'spm-calculator'] as const; // Maps region prefixes (from metadata) to HuggingFace subfolder names. @@ -410,10 +404,6 @@ export function getHouseholdReproductionUnavailableReason( return "This simulation's SPM receipt does not cover the saved calculation year. Calculate it again before generating exact reproduction code."; } 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.`; - } const recordedModelVersion = runtimeVersions['policyengine-us']; const recordedWrapperVersion = runtimeVersions.policyengine; if ( From dd20e7ffb4705c3adbbd778511313ec331801fda Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:57:03 +0400 Subject: [PATCH 5/5] Consume provenance-only SPM results --- app/src/api/householdCalculation.ts | 8 +- app/src/api/householdVariation.ts | 2 - app/src/hooks/useHouseholdVariation.ts | 1 - .../report-output/HouseholdReportOutput.tsx | 1 - .../report-output/HouseholdReportViewModel.ts | 1 - .../SocietyWideOverview.story.tsx | 3 +- .../tests/fixtures/adapters/reportMocks.ts | 3 +- app/src/tests/fixtures/api/reportMocks.ts | 3 +- .../tests/fixtures/api/societyWideMocks.ts | 3 +- .../congressionalDistrictComponentMocks.ts | 3 +- .../householdSPMReproductionMocks.ts | 39 ++---- .../fixtures/reducers/reportReducerMocks.ts | 4 +- .../fixtures/spm/reportYearRecoveryMocks.ts | 66 ++++++---- app/src/tests/fixtures/spm/spmMocks.ts | 9 -- app/src/tests/fixtures/spm/spmReviewMocks.ts | 15 +-- .../GenericHouseholdOrchestration.test.tsx | 3 +- .../HouseholdSPMMethodology.test.tsx | 2 - .../HouseholdVariationSPMReadiness.test.tsx | 1 - .../household/ReportYearSPMRecovery.test.tsx | 8 +- .../HouseholdSPMReproduction.test.tsx | 38 +----- .../tests/unit/adapters/ReportAdapter.test.ts | 17 ++- .../unit/adapters/SimulationAdapter.test.ts | 8 +- app/src/tests/unit/api/householdSPM.test.ts | 24 ++-- .../household/SPMGeographyChoice.test.tsx | 2 - .../HouseholdReportViewModel.test.ts | 4 +- app/src/tests/unit/types/spm.test.ts | 51 +++----- .../household/HouseholdCalculationOutput.ts | 3 +- .../metadata/ReportOutputSocietyWideUS.ts | 3 +- app/src/types/spm.ts | 120 +++++------------- app/src/utils/reproducibilityCode.ts | 39 +++--- 30 files changed, 176 insertions(+), 308 deletions(-) diff --git a/app/src/api/householdCalculation.ts b/app/src/api/householdCalculation.ts index b1ad19dfc..c5ed50a73 100644 --- a/app/src/api/householdCalculation.ts +++ b/app/src/api/householdCalculation.ts @@ -1,11 +1,7 @@ import type { PolicyEngineBundle } from '@/api/societyWideCalculation'; import { BASE_URL } from '@/constants'; import type { HouseholdCalculationData } from '@/types/calculation/household'; -import { - parseOptionalSPMCalculationProvenance, - type ResolvedSPMSelection, - type SPMProvenance, -} from '@/types/spm'; +import { parseOptionalSPMCalculationProvenance, type SPMProvenance } from '@/types/spm'; import { householdAPIError, householdAPIErrorFromBody } from './householdError'; export interface HouseholdCalculationResponse { @@ -13,14 +9,12 @@ export interface HouseholdCalculationResponse { result: HouseholdCalculationData | null; error?: string; policyengine_bundle?: PolicyEngineBundle | null; - spm_config?: ResolvedSPMSelection; spm_provenance?: SPMProvenance; } export interface HouseholdCalculationResult { result: HouseholdCalculationData; policyengine_bundle?: PolicyEngineBundle | null; - spm_config?: ResolvedSPMSelection; spm_provenance?: SPMProvenance; } diff --git a/app/src/api/householdVariation.ts b/app/src/api/householdVariation.ts index a7474f582..d8ece7643 100644 --- a/app/src/api/householdVariation.ts +++ b/app/src/api/householdVariation.ts @@ -2,7 +2,6 @@ import { BASE_URL } from '@/constants'; import type { HouseholdCalculationData } from '@/types/calculation/household'; import { parseOptionalSPMCalculationProvenance, - type ResolvedSPMSelection, type SPMProvenance, type SPMSelection, } from '@/types/spm'; @@ -12,7 +11,6 @@ export interface HouseholdVariationResponse { status: 'ok' | 'error'; result: HouseholdCalculationData | null; error?: string; - spm_config?: ResolvedSPMSelection; spm_provenance?: SPMProvenance; } 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 15194106a..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 ?? 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 b55d0e379..1e9bcfff9 100644 --- a/app/src/pages/report-output/SocietyWideOverview.story.tsx +++ b/app/src/pages/report-output/SocietyWideOverview.story.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { CountryProvider } from '@/contexts/CountryContext'; import { ReportYearProvider } from '@/contexts/ReportYearContext'; -import { RESOLVED_NATIONAL_SPM, SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; +import { SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import type { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; import SocietyWideOverview from './SocietyWideOverview'; @@ -36,7 +36,6 @@ function buildMockOutput(overrides: { } = overrides; return { - spm_config: RESOLVED_NATIONAL_SPM, spm_provenance: SPM_COMPARISON_RECEIPT, budget: { baseline_net_income: 12_000_000_000_000, diff --git a/app/src/tests/fixtures/adapters/reportMocks.ts b/app/src/tests/fixtures/adapters/reportMocks.ts index 7f99f3cab..2e8f3474f 100644 --- a/app/src/tests/fixtures/adapters/reportMocks.ts +++ b/app/src/tests/fixtures/adapters/reportMocks.ts @@ -1,4 +1,4 @@ -import { RESOLVED_NATIONAL_SPM, SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; +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'; @@ -6,7 +6,6 @@ import { ReportCreationPayload } from '@/types/payloads/ReportCreationPayload'; import { ReportSetOutputPayload } from '@/types/payloads/ReportSetOutputPayload'; export const mockReportOutput: ReportOutputSocietyWideUS = { - spm_config: RESOLVED_NATIONAL_SPM, spm_provenance: SPM_COMPARISON_RECEIPT, budget: { baseline_net_income: 1000000, diff --git a/app/src/tests/fixtures/api/reportMocks.ts b/app/src/tests/fixtures/api/reportMocks.ts index 5c70c0541..3c3e39462 100644 --- a/app/src/tests/fixtures/api/reportMocks.ts +++ b/app/src/tests/fixtures/api/reportMocks.ts @@ -1,5 +1,5 @@ import { ReportAdapter } from '@/adapters/ReportAdapter'; -import { RESOLVED_NATIONAL_SPM, SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; +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'; @@ -29,7 +29,6 @@ export const MOCK_HOUSEHOLD_RESULT = { // Complete mock for US society-wide result export const MOCK_ECONOMY_RESULT: ReportOutputSocietyWideUS = { - spm_config: RESOLVED_NATIONAL_SPM, spm_provenance: SPM_COMPARISON_RECEIPT, budget: { baseline_net_income: 1000000, diff --git a/app/src/tests/fixtures/api/societyWideMocks.ts b/app/src/tests/fixtures/api/societyWideMocks.ts index 5d2bb4088..69c1d7566 100644 --- a/app/src/tests/fixtures/api/societyWideMocks.ts +++ b/app/src/tests/fixtures/api/societyWideMocks.ts @@ -1,6 +1,6 @@ import { vi } from 'vitest'; import { SocietyWideCalculationResponse } from '@/api/societyWideCalculation'; -import { RESOLVED_NATIONAL_SPM, SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; +import { SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; // Test IDs and constants @@ -53,7 +53,6 @@ export const mockErrorResponse = (status: number) => ({ // Mock US report output export const mockUSReportOutput: ReportOutputSocietyWideUS = { - spm_config: RESOLVED_NATIONAL_SPM, spm_provenance: SPM_COMPARISON_RECEIPT, budget: { baseline_net_income: 1000000, diff --git a/app/src/tests/fixtures/pages/congressional-district/congressionalDistrictComponentMocks.ts b/app/src/tests/fixtures/pages/congressional-district/congressionalDistrictComponentMocks.ts index 1b6522e3c..beb42af16 100644 --- a/app/src/tests/fixtures/pages/congressional-district/congressionalDistrictComponentMocks.ts +++ b/app/src/tests/fixtures/pages/congressional-district/congressionalDistrictComponentMocks.ts @@ -1,4 +1,4 @@ -import { RESOLVED_NATIONAL_SPM, SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; +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'; @@ -53,7 +53,6 @@ export const MOCK_CONGRESSIONAL_DISTRICT_REGIONS: MetadataRegionEntry[] = [ * Mock US report output with congressional district impact data */ export const MOCK_US_REPORT_OUTPUT: ReportOutputSocietyWideUS = { - spm_config: RESOLVED_NATIONAL_SPM, 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 6f5038ec4..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 { ResolvedSPMSelection, SPMProvenance, SPMRuntimeVersions } 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,11 +22,11 @@ export const REPRODUCTION_RUNTIME_VERSIONS = [ 'spm-calculator': '6.2.0', }, ]; -export const REPRODUCTION_CONFIGS: ResolvedSPMSelection[] = [ +const REPRODUCTION_PROVENANCE_FIELDS = [ { geography_kind: 'national', geography_id: null, - forecast_content_sha256: 'c'.repeat(64), + forecast_sha256: 'c'.repeat(64), scenario: 'baseline-null-false', county_vintage: '2020', as_of: '2026-08-01', @@ -34,37 +34,21 @@ export const REPRODUCTION_CONFIGS: ResolvedSPMSelection[] = [ { geography_kind: 'county', geography_id: null, - forecast_content_sha256: 'd'.repeat(64), + forecast_sha256: 'd'.repeat(64), scenario: 'county-alternative', county_vintage: '2020', as_of: '2026-09-01', }, -]; +] as const; -function buildReproductionReceipt(index: number): SPMProvenance { - const config = REPRODUCTION_CONFIGS[index]; - const common = { +export const REPRODUCTION_RECEIPTS: SPMProvenance[] = REPRODUCTION_PROVENANCE_FIELDS.map( + (fields, index) => ({ ...SPM_RECEIPT, - forecast_sha256: config.forecast_content_sha256, - scenario: config.scenario, - county_vintage: config.county_vintage, - as_of: config.as_of, + ...fields, years: [REPRODUCTION_YEAR], runtime_versions: { ...REPRODUCTION_RUNTIME_VERSIONS[index] }, - }; - if (config.geography_kind === 'metro') { - return { - ...common, - geography_kind: config.geography_kind, - geography_id: config.geography_id, - }; - } - return { - ...common, - geography_kind: config.geography_kind, - geography_id: null, - }; -} + }) +); export const REPRODUCTION_HOUSEHOLDS = ['baseline', 'reform'].map((role, index) => Household.fromAppInput({ id: `household-${role}`, @@ -110,8 +94,7 @@ export const REPRODUCTION_SIMULATIONS: Simulation[] = ['baseline', 'reform'].map status: 'complete', output: { result: { people: {} }, - spm_config: REPRODUCTION_CONFIGS[index], - spm_provenance: buildReproductionReceipt(index), + 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', diff --git a/app/src/tests/fixtures/reducers/reportReducerMocks.ts b/app/src/tests/fixtures/reducers/reportReducerMocks.ts index d7b147503..695b64154 100644 --- a/app/src/tests/fixtures/reducers/reportReducerMocks.ts +++ b/app/src/tests/fixtures/reducers/reportReducerMocks.ts @@ -1,5 +1,5 @@ import { CURRENT_YEAR } from '@/constants'; -import { RESOLVED_NATIONAL_SPM, SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; +import { SPM_COMPARISON_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import { ReportOutput } from '@/types/ingredients/Report'; import { ReportOutputSocietyWideUS } from '@/types/metadata/ReportOutputSocietyWideUS'; @@ -14,7 +14,6 @@ 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_config: RESOLVED_NATIONAL_SPM, spm_provenance: SPM_COMPARISON_RECEIPT, budget: { baseline_net_income: 2500000, @@ -110,7 +109,6 @@ export const MOCK_REPORT_OUTPUT: ReportOutputSocietyWideUS = { }; export const MOCK_REPORT_OUTPUT_ALTERNATIVE: ReportOutputSocietyWideUS = { - spm_config: RESOLVED_NATIONAL_SPM, spm_provenance: SPM_COMPARISON_RECEIPT, budget: { baseline_net_income: 1000000, diff --git a/app/src/tests/fixtures/spm/reportYearRecoveryMocks.ts b/app/src/tests/fixtures/spm/reportYearRecoveryMocks.ts index d8dc2cfb1..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,7 +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 { parseResolvedSPMSelection, type SPMProvenance } from '@/types/spm'; +import { + buildSPMSelectionFromProvenance, + parseSPMSelection, + type SPMProvenance, + type SPMSelection, +} from '@/types/spm'; export const ORIGINAL_YEAR = '2026'; export const SUPPORTED_YEAR = '2023'; @@ -33,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'; @@ -42,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: { @@ -189,27 +223,10 @@ export class ReportYearRecoveryHTTP extends GenericOrchestrationHTTP { const householdData = JSON.parse( JSON.stringify(storedHousehold.household_json) ) as HouseholdCalculationResult['result']; - const resolvedConfig = parseResolvedSPMSelection(storedHousehold.spm); - const provenanceCommon = { - ...GENERIC_ENVELOPES[index].spm_provenance!, - forecast_sha256: resolvedConfig.forecast_content_sha256, - scenario: resolvedConfig.scenario, - county_vintage: resolvedConfig.county_vintage, - as_of: resolvedConfig.as_of, - years: [SUPPORTED_YEAR], - }; - const spmProvenance: SPMProvenance = - resolvedConfig.geography_kind === 'metro' - ? { - ...provenanceCommon, - geography_kind: resolvedConfig.geography_kind, - geography_id: resolvedConfig.geography_id, - } - : { - ...provenanceCommon, - geography_kind: resolvedConfig.geography_kind, - geography_id: null, - }; + const spmProvenance = provenanceForStoredSelection( + parseSPMSelection(storedHousehold.spm), + GENERIC_ENVELOPES[index].spm_provenance! + ); const envelope: HouseholdCalculationResult = { ...GENERIC_ENVELOPES[index], result: { @@ -221,7 +238,6 @@ export class ReportYearRecoveryHTTP extends GenericOrchestrationHTTP { }, }, }, - spm_config: resolvedConfig, 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 5ae1ba3e0..2b82068d3 100644 --- a/app/src/tests/fixtures/spm/spmMocks.ts +++ b/app/src/tests/fixtures/spm/spmMocks.ts @@ -1,7 +1,6 @@ import { Household } from '@/models/Household'; import { createMockStateWithData } from '@/tests/fixtures/reducers/metadataReducerMocks'; import type { - ResolvedSPMSelection, SPMComparisonProvenance, SPMMetadata, SPMProvenance, @@ -31,14 +30,6 @@ export const SPM_RECEIPT: SPMProvenance = { 'spm-calculator': '6.1.0', }, }; -export const RESOLVED_NATIONAL_SPM: ResolvedSPMSelection = { - 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, -}; export const SPM_COMPARISON_RECEIPT: SPMComparisonProvenance = { schema_version: 'canonical-spm-comparison-v2', baseline: { receipt: SPM_RECEIPT, execution_count: 20 }, diff --git a/app/src/tests/fixtures/spm/spmReviewMocks.ts b/app/src/tests/fixtures/spm/spmReviewMocks.ts index 66f0d1729..0768d38b7 100644 --- a/app/src/tests/fixtures/spm/spmReviewMocks.ts +++ b/app/src/tests/fixtures/spm/spmReviewMocks.ts @@ -2,7 +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 { ResolvedSPMSelection, SPMGeographyKind, SPMProvenance } from '@/types/spm'; +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. @@ -49,18 +49,6 @@ export const spmSaveErrors = [ { code: 'SPM_GEOGRAPHY_REQUIRED', message: 'An explicit county FIPS code is required.' }, ]; -function resolvedReviewSPMSelection(geographyKind: SPMGeographyKind): ResolvedSPMSelection { - const common = { - forecast_content_sha256: SPM_RECEIPT.forecast_sha256, - scenario: SPM_RECEIPT.scenario, - county_vintage: '2020' as const, - as_of: SPM_RECEIPT.as_of, - }; - return geographyKind === 'metro' - ? { ...common, geography_kind: geographyKind, geography_id: '35620' } - : { ...common, geography_kind: geographyKind, geography_id: null }; -} - function reviewSPMProvenance( role: 'baseline' | 'reform', variation: boolean, @@ -86,7 +74,6 @@ export function reviewOutput( return { id: `review-${role}`, countryId: 'us', - spmConfig: resolvedReviewSPMSelection(geographyKind), spmProvenance: reviewSPMProvenance(role, variation, geographyKind), householdData: { people: { you: { employment_income: value(30000), marginal_tax_rate: value(0.2) } }, 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 20837c161..6215955db 100644 --- a/app/src/tests/integration/household/HouseholdSPMMethodology.test.tsx +++ b/app/src/tests/integration/household/HouseholdSPMMethodology.test.tsx @@ -51,7 +51,6 @@ describe('SPM point and axes receipts in app-owned report methodology', () => { JSON.stringify({ status: 'ok', result: axes.householdData, - spm_config: axes.spmConfig, 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 ac0d35768..5c127bdf0 100644 --- a/app/src/tests/integration/household/HouseholdVariationSPMReadiness.test.tsx +++ b/app/src/tests/integration/household/HouseholdVariationSPMReadiness.test.tsx @@ -81,7 +81,6 @@ beforeEach(() => { JSON.stringify({ status: 'ok', result: axes.householdData, - spm_config: axes.spmConfig, spm_provenance: axes.spmProvenance, }) ); diff --git a/app/src/tests/integration/household/ReportYearSPMRecovery.test.tsx b/app/src/tests/integration/household/ReportYearSPMRecovery.test.tsx index e71762ae2..3d403b9f0 100644 --- a/app/src/tests/integration/household/ReportYearSPMRecovery.test.tsx +++ b/app/src/tests/integration/household/ReportYearSPMRecovery.test.tsx @@ -307,8 +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]); + 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()); @@ -436,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 a993481ec..5b86d88db 100644 --- a/app/src/tests/integration/pages/report-output/HouseholdSPMReproduction.test.tsx +++ b/app/src/tests/integration/pages/report-output/HouseholdSPMReproduction.test.tsx @@ -3,9 +3,9 @@ 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, @@ -61,7 +61,7 @@ describe('Household report Python reproduction', () => { ); expect(code).toContain(`"policyengine[us]==${versions.policyengine}"`); expect(code).toContain(`reform=${role.toLowerCase()},`); - expect(code).toContain(REPRODUCTION_CONFIGS[index].forecast_content_sha256); + expect(code).toContain(REPRODUCTION_RECEIPTS[index].forecast_sha256); expect(code).toContain('simulation.spm_provenance()'); } }); @@ -103,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))') @@ -152,31 +152,7 @@ describe('Household report Python reproduction', () => { expect(codeFor('Reform')).toContain('reform=reform,'); }); test.each([ - [ - '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/, - ], - [ - 'mismatched nullable selection field', - { - spm_config: { - ...REPRODUCTION_CONFIGS[0], - as_of: null, - }, - }, - /no matching resolved SPM settings and receipt/, - ], + ['missing SPM receipt', { spm_provenance: undefined }, /no SPM receipt/], [ 'receipt missing the saved calculation year', { diff --git a/app/src/tests/unit/adapters/ReportAdapter.test.ts b/app/src/tests/unit/adapters/ReportAdapter.test.ts index a56b91c50..b7f674591 100644 --- a/app/src/tests/unit/adapters/ReportAdapter.test.ts +++ b/app/src/tests/unit/adapters/ReportAdapter.test.ts @@ -101,11 +101,7 @@ describe('ReportAdapter', () => { }); test('given persisted US economy output without compact SPM provenance then marks it pending', () => { - const { - spm_config: _config, - spm_provenance: _provenance, - ...legacyOutput - } = mockReportOutput; + const { spm_provenance: _provenance, ...legacyOutput } = mockReportOutput; const result = ReportAdapter.fromMetadata({ ...mockReportMetadata, status: 'complete', @@ -116,6 +112,17 @@ describe('ReportAdapter', () => { 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 7eb9489b9..504fc1eb7 100644 --- a/app/src/tests/unit/adapters/SimulationAdapter.test.ts +++ b/app/src/tests/unit/adapters/SimulationAdapter.test.ts @@ -12,7 +12,7 @@ import { TEST_POPULATION_IDS, TEST_SIMULATION_IDS, } from '@/tests/fixtures/adapters/SimulationAdapterMocks'; -import { RESOLVED_NATIONAL_SPM, SPM_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; +import { SPM_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; describe('SimulationAdapter', () => { describe('fromMetadata', () => { @@ -152,13 +152,13 @@ describe('SimulationAdapter', () => { expect(result.output).toBe('invalid-json'); }); - it('given persisted household output with legacy SPM provenance then marks it pending', () => { + it('given persisted household output with the superseded sibling config then marks it pending', () => { const result = SimulationAdapter.fromMetadata( mockSimulationMetadata({ output: { result: { people: {} }, - spm_config: RESOLVED_NATIONAL_SPM, - spm_provenance: { ...SPM_RECEIPT, schema_version: 'legacy' }, + spm_config: { geography_kind: 'national' }, + spm_provenance: SPM_RECEIPT, }, }) ); diff --git a/app/src/tests/unit/api/householdSPM.test.ts b/app/src/tests/unit/api/householdSPM.test.ts index 960a4819a..71ca3b903 100644 --- a/app/src/tests/unit/api/householdSPM.test.ts +++ b/app/src/tests/unit/api/householdSPM.test.ts @@ -3,12 +3,7 @@ import { createHousehold, fetchHouseholdById } from '@/api/household'; import { fetchHouseholdCalculationWithBundle } from '@/api/householdCalculation'; import { fetchHouseholdVariationWithProvenance } from '@/api/householdVariation'; import { Household } from '@/models/Household'; -import { - NATIONAL_SPM, - RESOLVED_NATIONAL_SPM, - SPM_RECEIPT, - stateOnlyHousehold, -} from '@/tests/fixtures/spm/spmMocks'; +import { NATIONAL_SPM, SPM_RECEIPT, stateOnlyHousehold } from '@/tests/fixtures/spm/spmMocks'; function mockResponse(body: unknown) { const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status: 200 })); @@ -70,7 +65,6 @@ describe('SPM request and response plumbing', () => { mockResponse({ status: 'ok', result: { people: {} }, - spm_config: RESOLVED_NATIONAL_SPM, spm_provenance: SPM_RECEIPT, }); const result = await fetchHouseholdCalculationWithBundle( @@ -79,7 +73,6 @@ describe('SPM request and response plumbing', () => { 'reform-policy' ); expect(JSON.parse(JSON.stringify(result))).toMatchObject({ - spm_config: RESOLVED_NATIONAL_SPM, spm_provenance: SPM_RECEIPT, }); }); @@ -88,7 +81,6 @@ describe('SPM request and response plumbing', () => { const mockFetch = mockResponse({ status: 'ok', result: { people: {} }, - spm_config: RESOLVED_NATIONAL_SPM, spm_provenance: SPM_RECEIPT, }); const household = stateOnlyHousehold().toV1CreationPayload().data; @@ -114,7 +106,6 @@ describe('SPM request and response plumbing', () => { mockResponse({ status: 'ok', result: { people: {} }, - spm_config: RESOLVED_NATIONAL_SPM, spm_provenance: { forecast_id: 'legacy-forecast', forecast_sha256: 'a'.repeat(64), @@ -130,4 +121,17 @@ describe('SPM request and response plumbing', () => { 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/components/household/SPMGeographyChoice.test.tsx b/app/src/tests/unit/components/household/SPMGeographyChoice.test.tsx index 7c467cd01..191a84394 100644 --- a/app/src/tests/unit/components/household/SPMGeographyChoice.test.tsx +++ b/app/src/tests/unit/components/household/SPMGeographyChoice.test.tsx @@ -8,7 +8,6 @@ import { NATIONAL_SPM, RESOLVED_CANONICAL_METADATA, RESOLVED_LEGACY_METADATA, - RESOLVED_NATIONAL_SPM, SPM_RECEIPT, SPM_TEST_YEAR, stateOnlyHousehold, @@ -99,7 +98,6 @@ describe('SPM geography choice', () => { { countryId: 'us', householdData: { people: {} }, - spmConfig: RESOLVED_NATIONAL_SPM, spmProvenance: SPM_RECEIPT, }, ]} 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/types/spm.test.ts b/app/src/tests/unit/types/spm.test.ts index 49cbb42e2..388e86d0a 100644 --- a/app/src/tests/unit/types/spm.test.ts +++ b/app/src/tests/unit/types/spm.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'; import { SPM_COMPARISON_RECEIPT, SPM_RECEIPT } from '@/tests/fixtures/spm/spmMocks'; import { buildSPMProvenanceDisplayRows, + buildSPMSelectionFromProvenance, parseOptionalSPMCalculationProvenance, parseRequiredSPMComparisonCalculationProvenance, parseSPMComparisonProvenance, @@ -93,28 +94,19 @@ describe('SPM wire contracts', () => { }); }); - test('given matching calculation fields then parses the selection and receipt together', () => { - const spmConfig = { - 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, - }; + test('given a completed calculation then parses its receipt without a sibling config', () => { expect( parseOptionalSPMCalculationProvenance({ - spm_config: spmConfig, spm_provenance: SPM_RECEIPT, }) - ).toEqual({ spm_config: spmConfig, spm_provenance: SPM_RECEIPT }); + ).toEqual({ spm_provenance: SPM_RECEIPT }); }); - test('given a mismatched receipt then rejects the calculation fields', () => { + test('given a superseded sibling config then rejects the completed calculation', () => { expect(() => parseOptionalSPMCalculationProvenance({ spm_config: { - geography_kind: 'county', + geography_kind: 'national', geography_id: null, forecast_content_sha256: SPM_RECEIPT.forecast_sha256, scenario: SPM_RECEIPT.scenario, @@ -123,35 +115,30 @@ describe('SPM wire contracts', () => { }, spm_provenance: SPM_RECEIPT, }) - ).toThrow('does not match'); + ).toThrow('must not include spm_config'); }); - test('given US society-wide output then requires a comparison receipt and selection', () => { + test('given US society-wide output then requires only a comparison receipt', () => { expect(() => parseRequiredSPMComparisonCalculationProvenance({ budget: {} })).toThrow( - 'requires spm_config and spm_provenance' + 'requires spm_provenance' ); expect( parseRequiredSPMComparisonCalculationProvenance({ - 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_COMPARISON_RECEIPT, }) ).toEqual({ - 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_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 862f32ec8..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 { ResolvedSPMSelection, SPMProvenance } 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?: ResolvedSPMSelection; spmProvenance?: SPMProvenance; id?: string; countryId: CountryId; diff --git a/app/src/types/metadata/ReportOutputSocietyWideUS.ts b/app/src/types/metadata/ReportOutputSocietyWideUS.ts index 1209ead5a..16a92b609 100644 --- a/app/src/types/metadata/ReportOutputSocietyWideUS.ts +++ b/app/src/types/metadata/ReportOutputSocietyWideUS.ts @@ -1,8 +1,7 @@ -import type { ResolvedSPMSelection, SPMComparisonProvenance } from '@/types/spm'; +import type { SPMComparisonProvenance } from '@/types/spm'; import { USCongressionalDistrictBreakdown } from './ReportOutputSocietyWideByCongressionalDistrict'; export interface ReportOutputSocietyWideUS { - spm_config: ResolvedSPMSelection; spm_provenance: SPMComparisonProvenance; budget: { baseline_net_income: number; diff --git a/app/src/types/spm.ts b/app/src/types/spm.ts index 65ac47920..4fbc5cedb 100644 --- a/app/src/types/spm.ts +++ b/app/src/types/spm.ts @@ -11,16 +11,6 @@ export type SPMSelection = { | { geography_kind: 'metro'; geography_id: string } ); -export type ResolvedSPMSelection = { - forecast_content_sha256: string; - scenario: string; - county_vintage: '2020'; - as_of: string | null; -} & ( - | { geography_kind: 'national' | 'county'; geography_id: null } - | { geography_kind: 'metro'; geography_id: string } -); - export interface SPMMetadata { available: boolean; defaults?: SPMSelection; @@ -63,12 +53,10 @@ export interface SPMComparisonProvenance { } export interface SPMCalculationProvenance { - spm_config: ResolvedSPMSelection; spm_provenance: SPMProvenance; } export interface SPMComparisonCalculationProvenance { - spm_config: ResolvedSPMSelection; spm_provenance: SPMComparisonProvenance; } @@ -247,47 +235,6 @@ export function parseSPMSelection(value: unknown): SPMSelection { } } -/** Parse the fully resolved settings stored beside a completed SPM receipt. */ -export function parseResolvedSPMSelection(value: unknown): ResolvedSPMSelection { - try { - const record = requireRecord(value, 'Resolved SPM selection'); - requireExactKeys(record, SELECTION_KEYS, 'Resolved SPM selection'); - for (const key of SELECTION_KEYS) { - if (!(key in record)) { - throw new Error(`Resolved SPM selection must include ${key}`); - } - } - const digest = requireString(record.forecast_content_sha256, 'SPM forecast digest'); - if (!SHA256_PATTERN.test(digest)) { - throw new Error('SPM forecast digest must be a lowercase SHA-256 value'); - } - if (record.county_vintage !== '2020') { - throw new Error('SPM county vintage must be 2020'); - } - const common = { - forecast_content_sha256: digest, - scenario: requireString(record.scenario, 'SPM scenario'), - county_vintage: '2020' as const, - as_of: requireNullableDate(record.as_of, 'SPM as-of date'), - }; - const geographyKind = requireGeographyKind(record.geography_kind, 'SPM geography kind'); - 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 resolved SPM selection: ${detail}`); - } -} - /** Parse the compact, versioned SPM receipt and reject legacy diagnostic payloads. */ export function parseSPMProvenance(value: unknown): SPMProvenance { try { @@ -371,60 +318,61 @@ export function parseSPMComparisonProvenance(value: unknown): SPMComparisonProve } } -function assertSelectionMatchesReceipt( - selection: ResolvedSPMSelection, - receipt: SPMProvenance -): void { - const mismatched = - selection.geography_kind !== receipt.geography_kind || - selection.geography_id !== receipt.geography_id || - selection.forecast_content_sha256 !== receipt.forecast_sha256 || - selection.scenario !== receipt.scenario || - selection.county_vintage !== receipt.county_vintage || - selection.as_of !== receipt.as_of; - if (mismatched) { - throw new Error('SPM selection does not match the calculation receipt'); - } -} - function hasOwn(record: Record, key: string): boolean { return Object.hasOwn(record, key); } -/** Parse optional SPM fields from a household calculation envelope. */ +/** 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 hasConfig = hasOwn(record, 'spm_config') && record.spm_config !== undefined; const hasProvenance = hasOwn(record, 'spm_provenance') && record.spm_provenance !== undefined; - if (!hasConfig && !hasProvenance) { - return null; + if (hasOwn(record, 'spm_config')) { + throw new Error('Completed SPM calculations must not include spm_config'); } - if (!hasConfig || !hasProvenance) { - throw new Error('SPM calculation requires spm_config and spm_provenance together'); + if (!hasProvenance) { + return null; } - const spmConfig = parseResolvedSPMSelection(record.spm_config); const spmProvenance = parseSPMProvenance(record.spm_provenance); - assertSelectionMatchesReceipt(spmConfig, spmProvenance); - return { spm_config: spmConfig, spm_provenance: spmProvenance }; + return { spm_provenance: spmProvenance }; } -/** Parse required SPM fields from a completed US society-wide result. */ +/** 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 hasConfig = hasOwn(record, 'spm_config') && record.spm_config !== undefined; const hasProvenance = hasOwn(record, 'spm_provenance') && record.spm_provenance !== undefined; - if (!hasConfig || !hasProvenance) { - throw new Error('US society-wide calculation requires spm_config and spm_provenance'); + 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 spmConfig = parseResolvedSPMSelection(record.spm_config); const spmProvenance = parseSPMComparisonProvenance(record.spm_provenance); - assertSelectionMatchesReceipt(spmConfig, spmProvenance.baseline.receipt); - assertSelectionMatchesReceipt(spmConfig, spmProvenance.reform.receipt); - return { spm_config: spmConfig, spm_provenance: spmProvenance }; + 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. */ diff --git a/app/src/utils/reproducibilityCode.ts b/app/src/utils/reproducibilityCode.ts index 1594d0265..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 { ResolvedSPMSelection, 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?: ResolvedSPMSelection; spmProvenance?: SPMProvenance; policyengineVersion?: string | null; modelVersion?: string | null; @@ -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,19 +388,10 @@ export function getHouseholdReproductionUnavailableReason( if (!year || !Number.isInteger(year)) { return 'This report has no saved calculation year. Exact reproduction is unavailable.'; } - const { spmConfig, spmProvenance } = reproduction; - if (countryId === 'us' && (reproduction.household.spm || spmConfig || spmProvenance)) { - if ( - !spmProvenance || - !spmConfig || - spmConfig.forecast_content_sha256 !== spmProvenance.forecast_sha256 || - spmConfig.scenario !== spmProvenance.scenario || - spmConfig.geography_kind !== spmProvenance.geography_kind || - spmConfig.geography_id !== spmProvenance.geography_id || - spmConfig.county_vintage !== spmProvenance.county_vintage || - spmConfig.as_of !== spmProvenance.as_of - ) { - 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.'; } 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."; @@ -426,7 +419,7 @@ export function getHouseholdReproducibilityCode( year: number | null, earningVariation: boolean = false ): string[] { - const { role, household, policy, spmConfig, spmProvenance } = reproduction; + const { role, household, policy, spmProvenance } = reproduction; if (getHouseholdReproductionUnavailableReason(countryId, reproduction, year)) { return []; } @@ -455,7 +448,7 @@ export function getHouseholdReproducibilityCode( header.unshift(`%pip install ${modelPin}`, ''); } } - if (countryId === 'us' && spmConfig) { + if (countryId === 'us' && spmProvenance) { for (const packageName of SPM_ADDITIONAL_RUNTIME_PACKAGES) { header[0] += ` "${packageName}==${reproduction.spmProvenance!.runtime_versions[packageName]}"`; } @@ -475,7 +468,7 @@ export function getHouseholdReproducibilityCode( household, earningVariation, role, - spmConfig + spmProvenance ? buildSPMSelectionFromProvenance(spmProvenance) : household?.spm ), ]; }