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 (
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]