Skip to content

Commit 0af9500

Browse files
MaxGhenisclaude
andcommitted
Add the interactive simulator (app/) and deploy to snap-qc-sim.vercel.app
Static tool on ui-kit design tokens: state selector with verification badges for the seven oracle-verified states, audit-volume and simplification-lever controls, in-browser Monte Carlo mirroring snap_qc_sim.simulate exactly, measured-rate histogram with cost-share tier bands, tier-probability bars, and the method caveats inline. Per-state case arrays built by scripts_build_data.py from the QC public-use file. Light and dark modes from the tokens' own layers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 076e509 commit 0af9500

10 files changed

Lines changed: 904 additions & 0 deletions

File tree

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@ __pycache__/
33
.venv/
44
examples/results_by_state.json
55
uv.lock
6+
app/node_modules/
7+
app/public/.vercel/
8+
app/public/.env.local
9+
app/bun.lock

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# snap-qc-sim
22

3+
**Interactive tool: https://snap-qc-sim.vercel.app** — pick a state, adjust
4+
audit volume and simplification options, watch the measured-rate
5+
distribution and the cost-share bill respond. States whose encoded rules
6+
are verified against their full QC sample (CO, NY, CA, AZ, GA, MD, TX)
7+
carry a verification badge.
8+
39
Monte Carlo simulation of SNAP payment error rates: given a state's USDA
410
Quality Control sample, simulate the distribution of its **measured** payment
511
error rate — and therefore its 7 USC 2013(a)(2) cost-share tier and dollars —

‎app/package.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"dependencies": {
3+
"@policyengine/ui-kit": "^0.12.1"
4+
}
5+
}

‎app/public/.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.vercel
2+
.env*

‎app/public/app.css‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/* Layout + chart styling on ui-kit Layer-1 tokens (:root/.dark blocks of
2+
tokens.css; the Tailwind @theme layer is not readable from plain CSS).
3+
Tier ramp steps are the ui-kit blue scale values (tokens.ts blue
4+
300/500/700/900 for light; 800/600/400/200 for dark). */
5+
:root {
6+
--tier-0: #7DD3FC;
7+
--tier-5: #0EA5E9;
8+
--tier-10: #026AA2;
9+
--tier-15: #0C4A6E;
10+
--ok: #15803D;
11+
--bad: #B91C1C;
12+
}
13+
.dark {
14+
--tier-0: #075985;
15+
--tier-5: #0284C7;
16+
--tier-10: #38BDF8;
17+
--tier-15: #BAE6FD;
18+
--ok: #4ADE80;
19+
--bad: #F87171;
20+
}
21+
* { box-sizing: border-box; }
22+
body {
23+
margin: 0;
24+
background: var(--background);
25+
color: var(--foreground);
26+
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
27+
line-height: 1.5;
28+
}
29+
.wrap { max-width: 960px; margin: 0 auto; padding: 0 20px; }
30+
.site-header { padding: 40px 0 8px; }
31+
.brand { font-weight: 700; color: var(--primary); letter-spacing: 0.01em; margin-bottom: 12px; }
32+
h1 { font-size: 1.9rem; margin: 0 0 10px; }
33+
.lede { color: var(--muted-foreground); max-width: 760px; margin: 0; }
34+
h2 { font-size: 1.05rem; margin: 0 0 4px; }
35+
.sub { color: var(--muted-foreground); font-size: 0.85rem; margin: 0 0 10px; }
36+
37+
.controls {
38+
display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
39+
gap: 16px; margin: 24px 0; padding: 16px;
40+
border: 1px solid var(--border); border-radius: 10px;
41+
background: var(--secondary);
42+
}
43+
.control label, .control .label { display: block; font-size: 0.82rem; font-weight: 600; margin-bottom: 6px; }
44+
.control .hint { font-size: 0.72rem; color: var(--muted-foreground); margin-top: 4px; }
45+
.control select, .control input[type="range"] { width: 100%; }
46+
.control select { padding: 6px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--background); color: inherit; }
47+
.levers label { display: flex; gap: 7px; align-items: center; font-weight: 400; font-size: 0.85rem; margin: 3px 0; }
48+
.val { font-weight: 400; color: var(--muted-foreground); }
49+
.badge {
50+
display: inline-block; margin-top: 8px; font-size: 0.72rem; font-weight: 600;
51+
padding: 3px 8px; border-radius: 999px;
52+
background: color-mix(in srgb, var(--primary) 12%, var(--background)); color: var(--primary);
53+
border: 1px solid var(--primary);
54+
}
55+
.dark .badge { background: transparent; color: var(--primary); }
56+
57+
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; margin-bottom: 24px; }
58+
.tile { border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; }
59+
.tile .k { font-size: 1.45rem; font-weight: 700; font-variant-numeric: tabular-nums; }
60+
.tile .l { font-size: 0.75rem; color: var(--muted-foreground); margin-top: 2px; }
61+
.tile .d { font-size: 0.8rem; margin-top: 4px; font-variant-numeric: tabular-nums; }
62+
.delta-good { color: var(--ok); }
63+
.delta-bad { color: var(--bad); }
64+
65+
.chart-card { border: 1px solid var(--border); border-radius: 10px; padding: 16px; margin-bottom: 20px; }
66+
.chart svg { width: 100%; height: auto; display: block; }
67+
.chart text { font-family: inherit; }
68+
69+
.fine { font-size: 0.8rem; color: var(--muted-foreground); margin: 26px 0; }
70+
.fine summary { cursor: pointer; font-weight: 600; color: var(--foreground); }
71+
.fine ul { padding-left: 18px; }
72+
.verify-cta { border: 1px solid var(--border); border-left: 4px solid var(--primary); border-radius: 8px; padding: 12px 16px; margin-bottom: 30px; font-size: 0.9rem; }
73+
.site-footer { border-top: 1px solid var(--border); padding: 16px 0 40px; font-size: 0.8rem; color: var(--muted-foreground); }
74+
75+
.tooltip {
76+
position: fixed; pointer-events: none; z-index: 10;
77+
background: var(--background); color: var(--foreground);
78+
border: 1px solid var(--border); border-radius: 6px;
79+
padding: 6px 9px; font-size: 0.75rem; box-shadow: 0 4px 14px rgba(0,0,0,0.12);
80+
font-variant-numeric: tabular-nums;
81+
}

‎app/public/app.js‎

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// SNAP payment error simulator — in-browser Monte Carlo over the state's
2+
// QC sample. Method mirrors snap_qc_sim.simulate (Python) exactly.
3+
4+
const TIERS = [[6, 0], [8, 5], [10, 10], [Infinity, 15]];
5+
const TIER_LABELS = { 0: "0% share", 5: "5% share", 10: "10% share", 15: "15% share" };
6+
const TIER_VARS = { 0: "--tier-0", 5: "--tier-5", 10: "--tier-10", 15: "--tier-15" };
7+
const LEVER_IDS = ["smd", "ssed", "heat_and_eat", "bbce_resources"];
8+
const DRAWS = 4000;
9+
10+
const fmtM = (v) => {
11+
const m = v / 1e6;
12+
return m >= 1000 ? `$${(m / 1000).toFixed(2)}B` : `$${m.toFixed(0)}M`;
13+
};
14+
const tierOf = (r) => { for (const [cut, s] of TIERS) if (r < cut) return s; return 15; };
15+
16+
function mulberry32(seed) {
17+
let a = seed >>> 0;
18+
return () => {
19+
a |= 0; a = (a + 0x6d2b79f5) | 0;
20+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
21+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
22+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
23+
};
24+
}
25+
26+
let DATA = null;
27+
const $ = (id) => document.getElementById(id);
28+
const tooltip = document.createElement("div");
29+
tooltip.className = "tooltip";
30+
tooltip.hidden = true;
31+
document.body.appendChild(tooltip);
32+
33+
function scenarioErrors(st, levers, eff) {
34+
const n = st.w.length;
35+
const err = new Float64Array(n);
36+
for (let i = 0; i < n; i++) {
37+
let e = st.err[i];
38+
if (e > 0 && st.hits[i] !== 0) {
39+
const h = st.hits[i]; // [total, smd, ssed, hne, bbce]
40+
let hit = 0;
41+
for (let k = 0; k < 4; k++) if (levers[k]) hit += h[k + 1];
42+
e *= 1 - (eff * hit) / h[0];
43+
}
44+
err[i] = e;
45+
}
46+
return err;
47+
}
48+
49+
function simulate(st, { extra = 0, levers = [0, 0, 0, 0], eff = 0.5, seed = 11 } = {}) {
50+
const n = st.w.length;
51+
const err = scenarioErrors(st, levers, eff);
52+
const we = new Float64Array(n), wi = new Float64Array(n);
53+
let sumWE0 = 0, sumWE = 0, sumWI = 0;
54+
for (let i = 0; i < n; i++) {
55+
we[i] = st.w[i] * err[i];
56+
wi[i] = st.w[i] * st.iss[i];
57+
sumWE0 += st.w[i] * st.err[i];
58+
sumWE += we[i];
59+
sumWI += wi[i];
60+
}
61+
const point0 = (100 * sumWE0) / sumWI;
62+
const point1 = (100 * sumWE) / sumWI;
63+
const level = st.official + (point1 - point0);
64+
const m = n + extra;
65+
const rng = mulberry32(seed);
66+
const rates = new Float64Array(DRAWS);
67+
for (let d = 0; d < DRAWS; d++) {
68+
let e = 0, s = 0;
69+
for (let j = 0; j < m; j++) {
70+
const i = (rng() * n) | 0;
71+
e += we[i]; s += wi[i];
72+
}
73+
rates[d] = level + (100 * e) / s - point1;
74+
}
75+
return rates;
76+
}
77+
78+
function summarize(rates, issuance) {
79+
let mean = 0;
80+
const pt = { 0: 0, 5: 0, 10: 0, 15: 0 };
81+
let eShare = 0, e2 = 0;
82+
for (const r of rates) {
83+
mean += r;
84+
const s = tierOf(r);
85+
pt[s]++;
86+
const d = (s / 100) * issuance;
87+
eShare += d; e2 += d * d;
88+
}
89+
mean /= rates.length;
90+
for (const k in pt) pt[k] /= rates.length;
91+
eShare /= rates.length;
92+
const sd = Math.sqrt(Math.max(0, e2 / rates.length - eShare * eShare));
93+
return { mean, pt, eShare, sd };
94+
}
95+
96+
const cssVar = (name) =>
97+
getComputedStyle(document.documentElement).getPropertyValue(name).trim();
98+
99+
function histogram(el, base, scen, official) {
100+
const lo = Math.min(4, Math.floor(Math.min(...base, ...scen)) - 0.5);
101+
const hi = Math.max(12, Math.ceil(Math.max(...base, ...scen)) + 0.5);
102+
const bins = 60, W = 920, H = 260, L = 42, R = 12, T = 12, B = 30;
103+
const bw = (hi - lo) / bins;
104+
const count = (rates) => {
105+
const c = new Array(bins).fill(0);
106+
for (const r of rates) {
107+
const b = Math.min(bins - 1, Math.max(0, Math.floor((r - lo) / bw)));
108+
c[b]++;
109+
}
110+
return c.map((v) => v / rates.length);
111+
};
112+
const cb = count(base), cs = count(scen);
113+
const ymax = Math.max(...cb, ...cs) * 1.12;
114+
const x = (r) => L + ((r - lo) / (hi - lo)) * (W - L - R);
115+
const y = (p) => T + (1 - p / ymax) * (H - T - B);
116+
const ink = cssVar("--muted-foreground") || "#475569";
117+
const border = cssVar("--border") || "#E2E8F0";
118+
const fill = cssVar("--chart-2") || "#0EA5E9";
119+
let s = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">`;
120+
const bands = [[lo, 6, 0], [6, 8, 5], [8, 10, 10], [10, hi, 15]];
121+
for (const [a, b, t] of bands) {
122+
if (b <= lo || a >= hi) continue;
123+
const xa = x(Math.max(a, lo)), xb = x(Math.min(b, hi));
124+
s += `<rect x="${xa}" y="${T}" width="${xb - xa}" height="${H - T - B}" fill="var(${TIER_VARS[t]})" opacity="0.10"/>`;
125+
s += `<text x="${(xa + xb) / 2}" y="${T + 13}" text-anchor="middle" font-size="10" fill="${ink}">${TIER_LABELS[t]}</text>`;
126+
}
127+
for (let b = 0; b < bins; b++) {
128+
if (cs[b] === 0) continue;
129+
const x0 = x(lo + b * bw) + 0.6, wpx = (W - L - R) / bins - 1.2;
130+
s += `<rect class="bin" data-b="${b}" x="${x0}" y="${y(cs[b])}" width="${wpx}" height="${y(0) - y(cs[b])}" rx="1.5" fill="${fill}" opacity="0.85"/>`;
131+
}
132+
let path = "";
133+
for (let b = 0; b < bins; b++) {
134+
const x0 = x(lo + b * bw), x1 = x(lo + (b + 1) * bw), yy = y(cb[b]);
135+
path += `${b === 0 ? "M" : "L"}${x0},${yy} L${x1},${yy} `;
136+
}
137+
s += `<path d="${path}" fill="none" stroke="${ink}" stroke-width="1.6" opacity="0.9"/>`;
138+
s += `<line x1="${x(official)}" x2="${x(official)}" y1="${T}" y2="${H - B}" stroke="${ink}" stroke-width="1.4" stroke-dasharray="4 3"/>`;
139+
s += `<text x="${x(official) + 4}" y="${H - B - 6}" font-size="10" fill="${ink}">official ${official.toFixed(2)}%</text>`;
140+
for (let v = Math.ceil(lo); v <= hi; v += 2) {
141+
s += `<line x1="${x(v)}" x2="${x(v)}" y1="${H - B}" y2="${H - B + 4}" stroke="${border}"/>`;
142+
s += `<text x="${x(v)}" y="${H - 8}" text-anchor="middle" font-size="10" fill="${ink}">${v}%</text>`;
143+
}
144+
s += `<line x1="${L}" x2="${W - R}" y1="${H - B}" y2="${H - B}" stroke="${border}"/>`;
145+
s += "</svg>";
146+
el.innerHTML = s;
147+
el.querySelectorAll(".bin").forEach((r) => {
148+
r.addEventListener("mousemove", (ev) => {
149+
const b = +r.dataset.b;
150+
const a = lo + b * bw;
151+
tooltip.textContent = `${a.toFixed(1)}–${(a + bw).toFixed(1)}%: ${(cs[b] * 100).toFixed(1)}% of draws (baseline ${(cb[b] * 100).toFixed(1)}%)`;
152+
tooltip.style.left = ev.clientX + 12 + "px";
153+
tooltip.style.top = ev.clientY + 12 + "px";
154+
tooltip.hidden = false;
155+
});
156+
r.addEventListener("mouseleave", () => (tooltip.hidden = true));
157+
});
158+
}
159+
160+
function tierBars(el, rows) {
161+
const W = 920, H = 46 * rows.length + 8, L = 78, R = 12;
162+
const ink = cssVar("--muted-foreground") || "#475569";
163+
let s = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">`;
164+
rows.forEach(([name, pt], i) => {
165+
const y0 = 8 + i * 46;
166+
s += `<text x="0" y="${y0 + 20}" font-size="12" fill="${ink}">${name}</text>`;
167+
let xacc = L;
168+
for (const t of [0, 5, 10, 15]) {
169+
const w = pt[t] * (W - L - R);
170+
if (w <= 0) continue;
171+
s += `<rect class="seg" data-t="${t}" data-p="${pt[t]}" data-row="${name}" x="${xacc}" y="${y0}" width="${Math.max(0, w - 2)}" height="30" rx="3" fill="var(${TIER_VARS[t]})"/>`;
172+
if (pt[t] >= 0.07) {
173+
const darkMode = document.documentElement.classList.contains("dark");
174+
const light = darkMode ? t >= 10 : t <= 5;
175+
s += `<text x="${xacc + w / 2}" y="${y0 + 20}" text-anchor="middle" font-size="11" fill="${light ? "#1E293B" : "white"}">${TIER_LABELS[t].split(" ")[0]} · ${(pt[t] * 100).toFixed(0)}%</text>`;
176+
}
177+
xacc += w;
178+
}
179+
});
180+
s += "</svg>";
181+
el.innerHTML = s;
182+
el.querySelectorAll(".seg").forEach((r) => {
183+
r.addEventListener("mousemove", (ev) => {
184+
tooltip.textContent = `${r.dataset.row}: ${(r.dataset.p * 100).toFixed(1)}% chance of the ${TIER_LABELS[r.dataset.t]} tier`;
185+
tooltip.style.left = ev.clientX + 12 + "px";
186+
tooltip.style.top = ev.clientY + 12 + "px";
187+
tooltip.hidden = false;
188+
});
189+
r.addEventListener("mouseleave", () => (tooltip.hidden = true));
190+
});
191+
}
192+
193+
function render() {
194+
const code = $("state").value;
195+
const st = DATA.states[code];
196+
const levers = LEVER_IDS.map((id) => ($("lever-" + id).checked ? 1 : 0));
197+
const eff = +$("eff").value / 100;
198+
const extra = +$("audits").value;
199+
$("audits-val").textContent = "+" + extra;
200+
$("eff-val").textContent = Math.round(eff * 100) + "%";
201+
202+
const base = simulate(st, {});
203+
const scen = simulate(st, { extra, levers, eff });
204+
const sb = summarize(base, st.issuance);
205+
const ss = summarize(scen, st.issuance);
206+
207+
$("t-official").textContent = st.official.toFixed(2) + "%";
208+
$("t-expected").textContent = fmtM(ss.eShare) + "/yr";
209+
const d = sb.eShare - ss.eShare;
210+
$("t-delta").textContent = (d >= 0 ? "−" : "+") + fmtM(Math.abs(d)).slice(1) + " vs baseline";
211+
$("t-delta").className = "d " + (d >= 0 ? "delta-good" : "delta-bad");
212+
const top = Object.entries(ss.pt).sort((a, b) => b[1] - a[1])[0];
213+
$("t-tier").textContent = TIER_LABELS[top[0]];
214+
$("t-tierp").textContent = (top[1] * 100).toFixed(0) + "% of draws";
215+
$("t-sd").textContent = fmtM(ss.sd);
216+
$("t-sdd").textContent = "baseline " + fmtM(sb.sd);
217+
218+
histogram($("hist"), base, scen, st.official);
219+
tierBars($("tiers"), [["Baseline", sb.pt], ["Scenario", ss.pt]]);
220+
221+
const badge = $("verified-badge");
222+
const cta = $("verify-cta");
223+
if (st.verified) {
224+
badge.hidden = false;
225+
badge.textContent = `Rules verified: ${st.verified}/${st.verified} QC cases exact`;
226+
cta.innerHTML = `This state's encoded SNAP rules are verified against all ${st.verified} of its FY 2024 QC reviews — every case and every computation stage exact. <a href="https://github.com/TheAxiomFoundation/axiom-oracles">Verification harness</a> · <a href="https://axiom.org/reports/colorado-snap-qc-fy2024">example state report</a>`;
227+
} else {
228+
badge.hidden = true;
229+
cta.innerHTML = `Simulation for this state uses the public QC file only — its encoded rules are not yet independently verified. Seven states are (CO, NY, CA, AZ, GA, MD, TX). Interested in verification for your state? <a href="mailto:hello@policyengine.org">hello@policyengine.org</a>`;
230+
}
231+
}
232+
233+
async function main() {
234+
DATA = await (await fetch("data.json")).json();
235+
const sel = $("state");
236+
for (const [code, st] of Object.entries(DATA.states)) {
237+
const o = document.createElement("option");
238+
o.value = code;
239+
o.textContent = code + (st.verified ? " ✓" : "");
240+
sel.appendChild(o);
241+
}
242+
sel.value = "CO";
243+
const dark = matchMedia("(prefers-color-scheme: dark)");
244+
const setMode = () => document.documentElement.classList.toggle("dark", dark.matches);
245+
dark.addEventListener("change", () => { setMode(); render(); });
246+
setMode();
247+
let raf = 0;
248+
const queue = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(render); };
249+
sel.addEventListener("change", queue);
250+
$("audits").addEventListener("input", queue);
251+
$("eff").addEventListener("input", queue);
252+
for (const id of LEVER_IDS) $("lever-" + id).addEventListener("change", queue);
253+
render();
254+
}
255+
main();

‎app/public/data.json‎

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)