diff --git a/README.md b/README.md index 4c4f2d2..cce9198 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Model how life events affect a household's healthcare coverage and monthly out-of-pocket cost across **Medicaid**, **CHIP**, and the **ACA marketplace**. -Live: https://coverage-compass-policy-engine.vercel.app/ +Live: https://coverage-compass.vercel.app/ ## What it covers diff --git a/frontend/src/app/api/baseline/route.ts b/frontend/src/app/api/baseline/route.ts index 53fbed0..01494e7 100644 --- a/frontend/src/app/api/baseline/route.ts +++ b/frontend/src/app/api/baseline/route.ts @@ -1,38 +1,9 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; +import { proxyToBackend } from '@/lib/backendProxy'; -const BACKEND_URL = process.env.BACKEND_URL; +// Warm-up calls hit the same slow backend as simulate; keep the same budget. +export const maxDuration = 120; export async function POST(request: NextRequest) { - if (!BACKEND_URL) { - return NextResponse.json( - { error: 'Backend not configured. Set BACKEND_URL environment variable.' }, - { status: 503 } - ); - } - - try { - const body = await request.json(); - - // Modal URLs are the endpoint directly, Cloud Run needs /api/baseline suffix - const url = BACKEND_URL.includes('modal.run') - ? BACKEND_URL - : `${BACKEND_URL}/api/baseline`; - - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Backend request failed'); - } - - const result = await response.json(); - return NextResponse.json(result); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to fetch baseline'; - return NextResponse.json({ error: message }, { status: 500 }); - } + return proxyToBackend(request, 'baseline', 'Failed to fetch baseline'); } diff --git a/frontend/src/app/api/simulate/route.ts b/frontend/src/app/api/simulate/route.ts index 4bf95ed..fe7bed5 100644 --- a/frontend/src/app/api/simulate/route.ts +++ b/frontend/src/app/api/simulate/route.ts @@ -1,38 +1,10 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest } from 'next/server'; +import { proxyToBackend } from '@/lib/backendProxy'; -const BACKEND_URL = process.env.BACKEND_URL; +// PolicyEngine sims + Modal cold starts can take over a minute; without +// this the platform default duration kills the function mid-simulation. +export const maxDuration = 120; export async function POST(request: NextRequest) { - if (!BACKEND_URL) { - return NextResponse.json( - { error: 'Backend not configured. Set BACKEND_URL environment variable.' }, - { status: 503 } - ); - } - - try { - const body = await request.json(); - - // Modal URLs are the endpoint directly, Cloud Run needs /api/simulate suffix - const url = BACKEND_URL.includes('modal.run') - ? BACKEND_URL - : `${BACKEND_URL}/api/simulate`; - - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Backend request failed'); - } - - const result = await response.json(); - return NextResponse.json(result); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to simulate'; - return NextResponse.json({ error: message }, { status: 500 }); - } + return proxyToBackend(request, 'simulate', 'Failed to simulate'); } diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx new file mode 100644 index 0000000..cd7cf64 --- /dev/null +++ b/frontend/src/app/error.tsx @@ -0,0 +1,32 @@ +'use client'; + +// Route-level error boundary: catches render errors so users get a retry +// button instead of a blank page. +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
+

Something went wrong

+

+ The page hit an unexpected error. Your household details may need to be re-entered. +

+ {error.digest && ( +

Reference: {error.digest}

+ )} + +
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 3aa4b21..957dc39 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import HouseholdWizard from '@/components/HouseholdWizard'; import ChangeWizard from '@/components/ChangeWizard'; import ResultsView from '@/components/ResultsView'; @@ -24,7 +24,10 @@ function encodeScenario(household: Household, event: LifeEventType, params: Reco function decodeScenario(encoded: string): { household: Household; event: LifeEventType; params: Record } | null { try { const data = JSON.parse(atob(encoded)); - if (data.h && data.e) { + // Only accept event types the app actually supports — a crafted link + // with an unknown type would otherwise render an "undefined …" + // headline and hit the backend with an unvetted event. + if (data.h && LIFE_EVENTS.some((ev) => ev.type === data.e)) { const household: Household = { state: data.h.state || 'CA', zipCode: data.h.zipCode || undefined, @@ -37,6 +40,7 @@ function decodeScenario(encoded: string): { household: Household; event: LifeEve hasESI: data.h.hasESI ?? false, spouseHasESI: data.h.spouseHasESI ?? false, year: data.h.year ?? 2026, + pregnantMember: data.h.pregnantMember ?? null, }; return { household, event: data.e, params: data.p || {} }; } @@ -59,6 +63,11 @@ export default function Home() { const result = scenarios.find((s) => s.id === currentScenarioId)?.result ?? null; const otherScenarios = scenarios.filter((s) => s.id !== currentScenarioId); + // Monotonic id for in-flight simulations. A response only lands if its id + // still matches, so a slow request can't resurrect state after the user + // starts over or kicks off a newer simulation. + const requestSeq = useRef(0); + // Fire a no-op /api/baseline request when the wizard completes. This // doesn't return useful data anymore, but it warms the Modal container // so the subsequent /api/simulate call on Apply doesn't pay a cold start. @@ -79,6 +88,7 @@ export default function Home() { event: LifeEventType, params: Record, ) => { + const reqId = ++requestSeq.current; setIsLoading(true); setError(null); try { @@ -90,7 +100,15 @@ export default function Home() { lifeEvent: { type: event, params }, }), }); + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.includes('application/json')) { + // Platform timeouts and gateway errors return HTML/text bodies. + throw new Error( + `The simulation service returned an unexpected response (HTTP ${response.status}). Please try again.`, + ); + } const data = await response.json(); + if (reqId !== requestSeq.current) return; // stale response; a newer action superseded it if (data.error) throw new Error(data.error); if (!data.before || !data.after) throw new Error('Invalid response from simulation'); const id = newScenarioId(); @@ -101,13 +119,18 @@ export default function Home() { setShareUrl(url); window.history.replaceState({}, '', `?s=${encoded}`); } catch (err) { + if (reqId !== requestSeq.current) return; setError(err instanceof Error ? err.message : 'Something went wrong'); } finally { - setIsLoading(false); + if (reqId === requestSeq.current) { + setIsLoading(false); + } } }, []); const restoreScenario = useCallback((s: SavedScenario, h: Household) => { + requestSeq.current++; // an in-flight simulation must not override this restore + setIsLoading(false); setCurrentScenarioId(s.id); setSelectedEvent(s.event); setEventParams(s.params); @@ -137,7 +160,9 @@ export default function Home() { }, [runSimulation]); const handleWizardComplete = (h: Household) => { + requestSeq.current++; // drop any in-flight simulation from a previous household setHousehold(h); + setIsLoading(false); setSelectedEvent(null); setEventParams({}); setScenarios([]); @@ -171,6 +196,8 @@ export default function Home() { const handleTryAnother = () => { // Keep household + scenario history. Clear the active event/result so // the user can model a new what-if. + requestSeq.current++; + setIsLoading(false); setSelectedEvent(null); setEventParams({}); setCurrentScenarioId(null); @@ -180,6 +207,8 @@ export default function Home() { }; const handleReset = () => { + requestSeq.current++; + setIsLoading(false); setHousehold(null); setSelectedEvent(null); setEventParams({}); @@ -301,8 +330,10 @@ export default function Home() { )} - {/* Household entered: show change wizard until results are ready */} - {household && !isLoading && !result && ( + {/* Household entered: show change wizard until results are ready. + Hidden while an error card is up so the two don't contradict + each other — the error card's own buttons route back here. */} + {household && !isLoading && !result && !error && ( { @@ -568,22 +599,45 @@ function describeScenarioChanges( } } +// Describe a set of people ("You", "Spouse", "Child 1", …) as a phrase. +function describeWho(labels: string[]): string { + const hasYou = labels.includes('You'); + const others = labels.filter((l) => l !== 'You'); + if (hasYou && others.length === 0) return 'you'; + if (hasYou) return 'you and your family'; + if (others.length === 1 && others[0] === 'Spouse') return 'your partner'; + if (others.every((l) => l.startsWith('Child'))) { + return others.length === 1 ? 'your child' : 'your children'; + } + return 'your family'; +} + // Build the hero headline strictly from observable transitions in the // simulation result so the copy can't contradict the table beneath it. +// Per-person transitions take priority: "your family qualifies for +// Medicaid" is the story, not the mechanical side effect that the ACA +// tax credit ended because of it. function getHeroHeadline(eventType: LifeEventType, result: SimulationResult): string { const beforePeople = result.healthcareBefore?.people ?? []; const afterPeople = result.healthcareAfter?.people ?? []; - const head = (label: string) => label === 'You'; - // Per-person transitions for the head of household. - const headBefore = beforePeople.find((p) => head(p.label))?.coverage ?? null; - const headAfter = afterPeople.find((p) => head(p.label))?.coverage ?? null; + // Per-person coverage transitions, keyed by label. + const beforeByLabel = new Map(beforePeople.map((p) => [p.label, p.coverage])); + const afterByLabel = new Map(afterPeople.map((p) => [p.label, p.coverage])); + const allLabels = Array.from(new Set([...beforeByLabel.keys(), ...afterByLabel.keys()])); - // Household-level transitions (any person). - const anyMedicaidBefore = beforePeople.some((p) => p.coverage === 'Medicaid'); - const anyMedicaidAfter = afterPeople.some((p) => p.coverage === 'Medicaid'); - const anyMarketplaceBefore = beforePeople.some((p) => p.coverage === 'Marketplace'); - const anyMarketplaceAfter = afterPeople.some((p) => p.coverage === 'Marketplace'); + const gainedMedicaid = allLabels.filter( + (l) => beforeByLabel.get(l) !== 'Medicaid' && afterByLabel.get(l) === 'Medicaid', + ); + const lostMedicaid = allLabels.filter( + (l) => beforeByLabel.get(l) === 'Medicaid' && afterByLabel.get(l) !== 'Medicaid', + ); + const isMarketplace = (c: string | null | undefined) => !!c && c.startsWith('Marketplace'); + const anyMarketplaceBefore = beforePeople.some((p) => isMarketplace(p.coverage)); + const anyMarketplaceAfter = afterPeople.some((p) => isMarketplace(p.coverage)); + const gainedMedicare = allLabels.filter( + (l) => beforeByLabel.get(l) !== 'Medicare' && afterByLabel.get(l) === 'Medicare', + ); // Metric-level transitions. const metrics = result.before.metrics ?? []; @@ -604,23 +658,29 @@ function getHeroHeadline(eventType: LifeEventType, result: SimulationResult): st }; const subject = verbs[eventType]; - // Salient transition takes priority over generic copy. - if (!anyMedicaidBefore && anyMedicaidAfter) return `${subject} qualifies you for Medicaid.`; - if (anyMedicaidBefore && !anyMedicaidAfter) return `${subject} moves you out of Medicaid.`; - if (lostPTC) return `${subject} ends your ACA tax credit.`; + // Coverage-program transitions take priority over tax-credit mechanics. + if (gainedMedicaid.length > 0) { + return `${subject} qualifies ${describeWho(gainedMedicaid)} for Medicaid.`; + } + if (lostMedicaid.length > 0) { + return `${subject} moves ${describeWho(lostMedicaid)} out of Medicaid.`; + } + if (gainedMedicare.length > 0) { + return `${subject} moves ${describeWho(gainedMedicare)} onto Medicare.`; + } + if (lostPTC) return `${subject} ends your ACA tax credit — you'd pay full price.`; if (gainedPTC) return `${subject} qualifies you for an ACA tax credit.`; + const headBefore = beforeByLabel.get('You') ?? null; + const headAfter = afterByLabel.get('You') ?? null; if (headBefore === 'ESI' && headAfter !== 'ESI') return `${subject} ends your employer health insurance.`; if (headBefore !== 'ESI' && headAfter === 'ESI') return `${subject} starts employer health insurance.`; if (!anyMarketplaceBefore && anyMarketplaceAfter) return `${subject} moves you onto the ACA marketplace.`; if (anyMarketplaceBefore && !anyMarketplaceAfter) return `${subject} moves you off the ACA marketplace.`; // Detect a fully-unchanged outcome so we don't promise a "change" that - // didn't happen. Compare before/after person-by-person. - const beforePeopleByLabel = new Map(beforePeople.map((p) => [p.label, p.coverage])); - const afterPeopleByLabel = new Map(afterPeople.map((p) => [p.label, p.coverage])); - const allLabels = new Set([...beforePeopleByLabel.keys(), ...afterPeopleByLabel.keys()]); - const coverageUnchanged = Array.from(allLabels).every( - (label) => beforePeopleByLabel.get(label) === afterPeopleByLabel.get(label), + // didn't happen. + const coverageUnchanged = allLabels.every( + (label) => beforeByLabel.get(label) === afterByLabel.get(label), ); if (coverageUnchanged) { return `${subject} doesn't change your coverage.`; diff --git a/frontend/src/components/HouseholdWizard.tsx b/frontend/src/components/HouseholdWizard.tsx index 357aa36..b9f8ecc 100644 --- a/frontend/src/components/HouseholdWizard.tsx +++ b/frontend/src/components/HouseholdWizard.tsx @@ -38,6 +38,9 @@ export default function HouseholdWizard({ onComplete, onBack, onPartialChange }: const [hasESI, setHasESI] = useState(false); const [spouseHasESI, setSpouseHasESI] = useState(false); const [esiTouched, setEsiTouched] = useState(false); + // Answering "Yes" to employer coverage is a dead end for this tool, so we + // say so immediately at step 5 instead of after the remaining questions. + const [esiBlocked, setEsiBlocked] = useState(false); const [childAges, setChildAges] = useState>([]); const [hasKids, setHasKids] = useState<'yes' | 'no' | null>(null); @@ -107,8 +110,13 @@ export default function HouseholdWizard({ onComplete, onBack, onPartialChange }: setHasESI(selfESI); setSpouseHasESI(partnerESI); setEsiTouched(true); - setStep(6); onPartialChange?.(buildPartial({ hasESI: selfESI, spouseHasESI: partnerESI })); + if (selfESI || partnerESI) { + setEsiBlocked(true); + return; + } + setEsiBlocked(false); + setStep(6); } function addChild() { @@ -377,7 +385,51 @@ export default function HouseholdWizard({ onComplete, onBack, onPartialChange }: )} - {step === 5 && ( + {step === 5 && esiBlocked && ( +
+
+ Heads up +
+

+ Coverage Compass isn't designed for households with employer health insurance. +

+

+ We model how life events affect ACA marketplace coverage, Medicaid, and CHIP. + We don't have data on your employer's premium contribution, plan options, or out-of-pocket + costs, so any comparison we showed would be incomplete. +

+

+ If you're thinking about losing employer coverage and want to see what the marketplace + would look like, you can continue without employer coverage instead. +

+
+ + +
+
+ )} + + {step === 5 && !esiBlocked && (

Does anyone in your household have health insurance through an employer? diff --git a/frontend/src/components/ResultsView.tsx b/frontend/src/components/ResultsView.tsx index 983ee93..fc88126 100644 --- a/frontend/src/components/ResultsView.tsx +++ b/frontend/src/components/ResultsView.tsx @@ -62,8 +62,13 @@ function getCoverageLabel(type: string | null): string { return 'Medicaid'; case 'CHIP': return 'CHIP'; - default: + case null: return 'No coverage'; + default: + // Pass through backend-provided labels like "Medicare" or + // "Marketplace (full price)" so new coverage types never silently + // render as "No coverage". + return type; } } @@ -73,7 +78,9 @@ function getCoverageLabel(type: string | null): string { const COVERAGE_TONES: Record = { ESI: 'bg-blue-50 text-blue-800 border-blue-200', Marketplace: 'bg-[#E6FFFA] text-[#285E61] border-[#319795]/40', + 'Marketplace (full price)': 'bg-[#E6FFFA] text-[#285E61] border-[#319795]/40', Medicaid: 'bg-violet-50 text-violet-800 border-violet-200', + Medicare: 'bg-indigo-50 text-indigo-800 border-indigo-200', CHIP: 'bg-pink-50 text-pink-800 border-pink-200', }; @@ -361,6 +368,11 @@ function MobileMetricCard({ } export default function ResultsView({ result, eventType, onTryAnother, onReset }: ResultsViewProps) { + // Hooks must run unconditionally, before the incomplete-data guard below — + // otherwise a re-render across data shapes crashes with a hooks-order error. + const [selectedTier, setSelectedTier] = useState('silver'); + const [showBreakdown, setShowBreakdown] = useState(false); + if (!result?.before || !result?.after) { return (
@@ -376,9 +388,6 @@ export default function ResultsView({ result, eventType, onTryAnother, onReset } ); } - const [selectedTier, setSelectedTier] = useState('silver'); - const [showBreakdown, setShowBreakdown] = useState(false); - const metrics = result.before.metrics || []; // ACA plan options. @@ -714,7 +723,7 @@ export default function ResultsView({ result, eventType, onTryAnother, onReset } {showAcaPlans && acaScope && (

Silver is the ACA's benchmark plan: your tax credit is set to keep its monthly cost at a fixed share of your - income, so silver's cost doesn't change when only your state or area changes. + income{eventType === 'moving_states' && ', so silver’s cost doesn’t change when only your state or area changes'}. {acaScope.bronzeGross > 0 && ' Bronze costs less per month but has higher deductibles, and its cost floats with local premiums.'}

)} diff --git a/frontend/src/lib/backendProxy.ts b/frontend/src/lib/backendProxy.ts new file mode 100644 index 0000000..661b77e --- /dev/null +++ b/frontend/src/lib/backendProxy.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const BACKEND_URL = process.env.BACKEND_URL; + +// PolicyEngine simulations regularly take 30-60s on a Modal cold start. +// Give the backend fetch a hard deadline below the route's maxDuration so +// we can return a friendly timeout message instead of a platform error. +export const BACKEND_TIMEOUT_MS = 110_000; + +/** + * Proxy a POST body to the Flask/Modal backend and normalize errors. + * + * Guarantees the client always receives JSON with an `error` string on + * failure — never raw HTML from a gateway or a platform timeout page. + */ +export async function proxyToBackend( + request: NextRequest, + path: 'simulate' | 'baseline', + fallbackMessage: string, +): Promise { + if (!BACKEND_URL) { + return NextResponse.json( + { error: 'Backend not configured. Set BACKEND_URL environment variable.' }, + { status: 503 }, + ); + } + + try { + const body = await request.json(); + + // Modal URLs are the endpoint directly, Cloud Run needs the /api suffix + const url = BACKEND_URL.includes('modal.run') + ? BACKEND_URL + : `${BACKEND_URL}/api/${path}`; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(BACKEND_TIMEOUT_MS), + }); + + const contentType = response.headers.get('content-type') ?? ''; + + if (!response.ok) { + // Error bodies from gateways (502/504) are often HTML — only parse + // JSON when the backend says it's JSON, and never leak raw bodies. + let message = `The simulation service returned an error (HTTP ${response.status}). Please try again.`; + if (contentType.includes('application/json')) { + try { + const err = await response.json(); + if (typeof err?.error === 'string') message = err.error; + } catch { + // fall through to the generic message + } + } + return NextResponse.json( + { error: message }, + { status: response.status >= 500 ? 502 : response.status }, + ); + } + + if (!contentType.includes('application/json')) { + return NextResponse.json({ error: fallbackMessage }, { status: 502 }); + } + + const result = await response.json(); + return NextResponse.json(result); + } catch (error) { + if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) { + return NextResponse.json( + { + error: + 'The simulation took too long to respond. The service may be starting up — please try again in a moment.', + }, + { status: 504 }, + ); + } + const message = error instanceof Error ? error.message : fallbackMessage; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/healthcare_crossroads/api.py b/healthcare_crossroads/api.py index d90aaac..bcc25a6 100644 --- a/healthcare_crossroads/api.py +++ b/healthcare_crossroads/api.py @@ -9,7 +9,7 @@ from flask_cors import CORS from .aca_data import get_bronze_silver_ratio -from .compare import compare, compare_multiple, run_baseline +from .compare import SimulationComputeError, compare, compare_multiple, run_baseline from .events import ( ChildAgingOut, Divorce, @@ -295,19 +295,22 @@ def format_result_for_frontend(result) -> dict: lcbp_change = result.changes.get("lcbp") ptc_change = result.changes.get("premium_tax_credit") if slcsp_change and (slcsp_change.before > 0 or slcsp_change.after > 0): - state = _extract_state_code(result.before_situation) + # Use each side's own state so a move estimates the after-side + # bronze premium with the destination state's bronze/silver ratio. + before_state = _extract_state_code(result.before_situation) + after_state = _extract_state_code(result.after_situation) response["acaPremiums"] = { "before": _aca_premium_side( slcsp=slcsp_change.before, lcbp=lcbp_change.before if lcbp_change else 0, ptc=ptc_change.before if ptc_change else 0, - state=state, + state=before_state, ), "after": _aca_premium_side( slcsp=slcsp_change.after, lcbp=lcbp_change.after if lcbp_change else 0, ptc=ptc_change.after if ptc_change else 0, - state=state, + state=after_state, ), } @@ -403,8 +406,15 @@ def simulate(): return jsonify(format_result_for_frontend(result)) except ValueError as e: return jsonify({"error": str(e)}), 400 - except Exception as e: - return jsonify({"error": f"Simulation failed: {str(e)}"}), 500 + except SimulationComputeError as e: + # Engine-side failure, not a client error: message is user-safe, + # details were already logged where it was raised. + return jsonify({"error": str(e)}), 500 + except Exception: + # Don't leak internal exception details (file paths, PolicyEngine + # internals) to clients; the traceback goes to the server log. + app.logger.exception("Simulation failed") + return jsonify({"error": "Simulation failed due to an internal error."}), 500 @app.route("/api/baseline", methods=["POST"]) @@ -422,8 +432,11 @@ def baseline(): return jsonify(run_baseline_response(household_dict)) except ValueError as e: return jsonify({"error": str(e)}), 400 - except Exception as e: - return jsonify({"error": f"Baseline simulation failed: {str(e)}"}), 500 + except SimulationComputeError as e: + return jsonify({"error": str(e)}), 500 + except Exception: + app.logger.exception("Baseline simulation failed") + return jsonify({"error": "Baseline simulation failed due to an internal error."}), 500 @app.route("/api/health", methods=["GET"]) diff --git a/healthcare_crossroads/compare.py b/healthcare_crossroads/compare.py index 28d2430..b51f430 100644 --- a/healthcare_crossroads/compare.py +++ b/healthcare_crossroads/compare.py @@ -2,12 +2,23 @@ from __future__ import annotations +import logging from copy import deepcopy from dataclasses import dataclass, field from typing import Any from policyengine_us import Simulation +logger = logging.getLogger(__name__) + + +class SimulationComputeError(RuntimeError): + """A core output variable could not be computed for a household. + + The message is safe to show to end users; the underlying exception is + logged server-side only. + """ + from .events.base import LifeEvent from .events.divorce import Divorce from .events.marriage import Marriage @@ -28,6 +39,12 @@ "lcbp", # gross lowest-cost bronze plan (annual, 2026+) ] +# Variables whose absence is a data gap rather than a failed simulation: +# lcbp has no data for state-exchange states, and chip_premium only exists +# in the 17 states that charge CHIP enrollment fees. These default to 0; +# any other variable failing raises so bad input can't return silent zeros. +OPTIONAL_VARIABLES = {"lcbp", "chip_premium"} + # State-specific labels for the federal Basic Health Program (42 USC 18051). # PolicyEngine models all four under the same `is_basic_health_program_eligible` @@ -48,8 +65,9 @@ class PersonHealthcare: label: str # "You", "Spouse", "Child 1", etc. medicaid: bool = False chip: bool = False - marketplace: bool = False # ACA marketplace (inferred from PTC) + marketplace: bool = False # ACA marketplace with a premium tax credit esi: bool = False # Employer-sponsored insurance + medicare: bool = False # Age 65+ (Medicare-eligible) bhp: bool = False # Basic Health Program (NY Essential Plan, MinnesotaCare, etc.) bhp_label: str | None = None # State-specific BHP brand name @@ -58,6 +76,10 @@ def coverage_type(self) -> str | None: """Return the primary coverage type for this person.""" if self.esi: return "ESI" + # 65+ dual-eligibles (Medicare + Medicaid) surface as Medicare, the + # primary payer. + if self.medicare: + return "Medicare" # BHP enrollees (NY Essential Plan, MinnesotaCare, OHP Bridge, Healthy DC) # are surfaced as Medicaid for now — same UX bucket, no separate pill. # The bhp / bhp_label fields stay populated so we can differentiate later. @@ -67,7 +89,10 @@ def coverage_type(self) -> str | None: return "CHIP" if self.marketplace: return "Marketplace" - return None + # Anyone else can still buy a marketplace plan — just without a + # subsidy (e.g. income above the 400% FPL cliff). "No coverage" + # would misread as losing insurance entirely. + return "Marketplace (full price)" @dataclass @@ -287,11 +312,23 @@ def to_dict(self) -> dict[str, Any]: return result -def _run_simulation(situation: dict[str, Any], year: int) -> tuple[dict[str, float], Simulation]: +def _run_simulation( + situation: dict[str, Any], + year: int, + strict: bool = True, +) -> tuple[dict[str, float], Simulation]: """Run a PolicyEngine simulation and extract key outputs. Returns both the aggregated results and the simulation object for further per-person analysis. + + With strict=True (the default), a core variable failing to compute + raises SimulationComputeError instead of silently reporting $0 — an + all-zeros response is indistinguishable from a real "no coverage, + no cost" answer. Counterfactual side-simulations (marriage/divorce) + pass strict=False because their aggregated results are discarded; + only the per-person coverage extraction (which has its own soft + fallbacks) is used. """ sim = Simulation(situation=situation) results = {} @@ -305,7 +342,16 @@ def _run_simulation(situation: dict[str, Any], year: int) -> tuple[dict[str, flo else: results[var] = float(value) except Exception: - results[var] = 0.0 + if var in OPTIONAL_VARIABLES or not strict: + results[var] = 0.0 + else: + # Full details go to the server log; the raised message + # is user-safe (no internal exception text or paths). + logger.exception("Failed to compute %s for year %s", var, year) + raise SimulationComputeError( + f"The simulation engine could not compute {var} for this " + "household. Please check your inputs and try again." + ) return results, sim @@ -379,19 +425,26 @@ def _extract_healthcare_coverage( # Check ESI first (from household member data) has_esi = member.has_esi + on_medicare = member.age >= 65 on_medicaid = medicaid_amount > 0 and not has_esi on_chip = chip_amount > 0 and not has_esi on_bhp = on_bhp_raw and not has_esi and not on_medicaid and not on_chip # BHP replaces marketplace for eligible adults — don't double-assign. on_marketplace = ( - has_ptc and not has_esi and not on_medicaid and not on_chip and not on_bhp + has_ptc + and not has_esi + and not on_medicare + and not on_medicaid + and not on_chip + and not on_bhp ) people.append(PersonHealthcare( person_index=i, label=_get_person_label(i, household), esi=has_esi, + medicare=on_medicare, medicaid=on_medicaid, chip=on_chip, marketplace=on_marketplace, @@ -428,7 +481,7 @@ def _extract_counterfactual_before_coverage( zip_code=household.zip_code, ) _, spouse_sim = _run_simulation( - spouse_household.to_situation(), spouse_household.year + spouse_household.to_situation(), spouse_household.year, strict=False ) spouse_coverage = _extract_healthcare_coverage( spouse_sim, @@ -448,6 +501,7 @@ def _extract_counterfactual_before_coverage( chip=person.chip, marketplace=person.marketplace, esi=person.esi, + medicare=person.medicare, bhp=person.bhp, bhp_label=person.bhp_label, ) @@ -512,7 +566,7 @@ def _extract_counterfactual_after_coverage( zip_code=household.zip_code, ) _, ex_spouse_sim = _run_simulation( - ex_spouse_household.to_situation(), ex_spouse_household.year + ex_spouse_household.to_situation(), ex_spouse_household.year, strict=False ) ex_spouse_coverage = _extract_healthcare_coverage( ex_spouse_sim, @@ -532,6 +586,7 @@ def _extract_counterfactual_after_coverage( chip=person.chip, marketplace=person.marketplace, esi=person.esi, + medicare=person.medicare, bhp=person.bhp, bhp_label=person.bhp_label, ) diff --git a/healthcare_crossroads/metadata.py b/healthcare_crossroads/metadata.py index 21af15e..63d9864 100644 --- a/healthcare_crossroads/metadata.py +++ b/healthcare_crossroads/metadata.py @@ -28,7 +28,11 @@ "chip": ("CHIP", "benefit", 1), # ACA marketplace premiums "slcsp": ("Silver Plan (Gross)", "aca_premium", 1), + "lcbp": ("Bronze Plan (Gross)", "aca_premium", 1), "marketplace_net_premium": ("Silver Plan (Your Cost)", "aca_premium", 1), + # CHIP enrollment fee is money the family pays — categorize it as a + # premium-like cost so totalBenefits doesn't count it as a benefit. + "chip_premium": ("CHIP Enrollment Fee", "aca_premium", 1), # Energy/utilities "liheap": ("LIHEAP (Energy)", "benefit", 2), "lifeline": ("Lifeline (Phone)", "benefit", 2), diff --git a/modal_app.py b/modal_app.py index 32e09e9..89df54d 100644 --- a/modal_app.py +++ b/modal_app.py @@ -37,7 +37,11 @@ def simulate(data: dict) -> dict: create_event_from_request, format_result_for_frontend, ) - from healthcare_crossroads.compare import compare, compare_multiple + from healthcare_crossroads.compare import ( + SimulationComputeError, + compare, + compare_multiple, + ) try: cache_key = get_cache_key(data) @@ -83,10 +87,17 @@ def simulate(data: dict) -> dict: pass return response - except ValueError as e: + except (ValueError, SimulationComputeError) as e: + # Validation and compute-error messages are written to be user-safe. return {"error": str(e)} - except Exception as e: - return {"error": f"Simulation failed: {str(e)}"} + except Exception: + # Match the Flask API: log the traceback in the container, never + # send internal exception text (paths, PolicyEngine internals) to + # the client. + import traceback + + print(traceback.format_exc()) + return {"error": "Simulation failed due to an internal error."} @app.function(image=image) diff --git a/tests/test_compare.py b/tests/test_compare.py index 5cd5531..b5a13d8 100644 --- a/tests/test_compare.py +++ b/tests/test_compare.py @@ -135,7 +135,7 @@ def calculate(self, variable: str, year: int): return [0.0] raise KeyError(variable) - def fake_run_simulation(situation: dict, year: int): + def fake_run_simulation(situation: dict, year: int, strict: bool = True): people = situation["people"] first_person = people["person_0"] first_income = first_person["employment_income"][year]