// "Need vs growth": ESSB 6346 revenue compared with how far Washington NGF-O spending // has grown beyond population-and-inflation paths. // Run: node millionaires-tax/data/spending_vs_need.js // Inputs: wa-spending/build/*.csv (published as dataset:wa-general-fund-growth:2) plus the // revenue and outlook figures in REV / OUTLOOK below (sources noted on each line). // Outputs: data/spending_vs_need.json and data/spending_vs_need_paths.csv const fs = require("fs"), path = require("path"); const root = path.join(__dirname, "..", ".."); const csv = f => { const [h, ...rows] = fs.readFileSync(path.join(root, f), "utf8").trim().split(/\r?\n/); const keys = h.split(","); return rows.map(r => { const v = r.match(/("([^"]*)"|[^,]*)(,|$)/g).map(s => s.replace(/,$/, "").replace(/^"|"$/g, "")); return Object.fromEntries(keys.map((k, i) => [k, isNaN(+v[i]) || v[i] === "" ? v[i] : +v[i]])); }); }; const bench = csv("wa-spending/build/wa-benchmarks-by-biennium-v2.csv"); const ngf = csv("wa-spending/build/wa-ngf-by-biennium.csv"); const areas = csv("wa-spending/build/wa-ngf-by-functional-area.csv"); const B = Object.fromEntries(bench.map(r => [r.biennium, r])); const S = Object.fromEntries(ngf.map(r => [r.biennium, r.finalNgf / 1e6])); // $B const r1 = x => Math.round(x * 10) / 10, r2 = x => Math.round(x * 100) / 100, pct = x => r1(100 * x); // ---------- Revenue side ($B) ---------- const REV = { // ESSB 6346 fiscal note, OFM FNS package 77653, "Tax on Income" gross line (Mar 2026) fiscalNoteGrossFY: { FY2029: 2.698, FY2030: 3.732, FY2031: 3.167, FY2032: 3.297 }, // ERFC enacted-supplemental budget outlook, 22 Apr 2026: ESSB 6346 net NGF-O, 2027-29 outlookNet2729: 2.30918, // ERFC September 2026 forecast, income tax (via WRC 9/25/2026) erfcSepGross: { "2027-29": 3.503, "2029-31": 9.435 }, }; REV.fiscalNoteGross2729 = REV.fiscalNoteGrossFY.FY2029; // only FY29 falls in 2027-29 REV.fiscalNoteGross2931 = REV.fiscalNoteGrossFY.FY2030 + REV.fiscalNoteGrossFY.FY2031; // I-645 repeals the tax but keeps the relief (AG explanatory statement), so the outlook loses // the gross tax, not the net. Relief in 2027-29 implied by the outlook = gross - net. REV.impliedRelief2729 = REV.fiscalNoteGross2729 - REV.outlookNet2729; // ---------- Spending side: paths from a base biennium to 2023-25 ---------- function paths(base, end = "2023-25") { const b = B[base], e = B[end], s0 = S[base], s1 = S[end]; const pop = e.population / b.population; const out = { base, end, actualBase: r2(s0), actual: r2(s1), growthPct: pct(s1 / s0 - 1), paths: { "pop x US CPI": s0 * pop * e.cpiUs / b.cpiUs, "pop x Seattle CPI": s0 * pop * e.cpiSeattle / b.cpiSeattle, "pop x S&L govt price index": s0 * pop * e.slPriceIndex / b.slPriceIndex, "personal income (constant share)": s0 * e.personalIncomeBiennialMillions / b.personalIncomeBiennialMillions, }, }; for (const k of Object.keys(out.paths)) { const p = out.paths[k]; out.paths[k] = { path: r2(p), aboveB: r2(s1 - p), annualAboveB: r2((s1 - p) / 2) }; } return out; } const results = {}; results.paths = ["2005-07", "2013-15", "2017-19", "2019-21"].map(b => paths(b)); results.shareOfIncome = bench.map(b => ({ biennium: b.biennium, ngfPctPI: r2(100 * S[b.biennium] * 1000 / b.personalIncomeBiennialMillions) })); // 2005-07 share of income applied to 2023-25 income const pi2325 = B["2023-25"].personalIncomeBiennialMillions / 1000; const share0507 = S["2005-07"] / (B["2005-07"].personalIncomeBiennialMillions / 1000); results.at2005share = { share: r2(100 * share0507), pathB: r2(share0507 * pi2325), aboveB: r2(S["2023-25"] - share0507 * pi2325) }; // ---------- Tax revenue as a share of above-path spending ---------- const per2325 = results.paths.find(p => p.base === "2013-15").paths; const taxMeasures = { "outlook net 2027-29": REV.outlookNet2729, "fiscal-note gross 2027-29": REV.fiscalNoteGross2729, "ERFC Sep gross 2027-29": REV.erfcSepGross["2027-29"], "ERFC Sep gross 2029-31 (first full biennium)": REV.erfcSepGross["2029-31"] }; results.taxVsExcess = Object.entries(taxMeasures).map(([k, t]) => ({ tax: k, taxB: t, ...Object.fromEntries(Object.entries(per2325).map(([pk, v]) => [pk, v.aboveB > 0 ? pct(t / v.aboveB) + "%" : "excess <= 0"])), "vs 2005-07 share of PI": results.at2005share.aboveB > 0 ? pct(t / results.at2005share.aboveB) + "%" : "spending below path", })); // ---------- Above-path spending by functional area (2013-15 -> 2023-25, pop x US CPI) ---------- const f = per2325["pop x US CPI"].path / S["2013-15"]; results.benchmarkFactor = r2(f * 1000) / 1000; results.areas = areas.map(a => ({ area: a.area, b1315: r2(a.ngf2013_15 / 1e6), b2325: r2(a.ngf2023_25 / 1e6), aboveB: r2((a.ngf2023_25 - f * a.ngf2013_15) / 1e6) })); // K-12 against enrollment x CPI instead of population x CPI (AAFTE +4.56%, OSPI F-196) const k12 = areas.find(a => a.area.startsWith("Public Schools")); const cpi = B["2023-25"].cpiUs / B["2013-15"].cpiUs; results.k12VsEnrollment = { aboveB: r2((k12.ngf2023_25 - k12.ngf2013_15 * cpi * 1.0456) / 1e6) }; // ---------- Composition of the above-path growth (our classification) ---------- // Human-services program groups from wa-spending/build/wa-program-groups.csv (reorganization-adjusted). const pg = Object.fromEntries(csv("wa-spending/build/wa-program-groups.csv").map(r => [r.group, r.aboveBenchmarkMillions / 1000])); const A = Object.fromEntries(results.areas.map(a => [a.area, a.aboveB])); const grp = k => pg[Object.keys(pg).find(x => x.startsWith(k))]; // LTC: average clients +27.3% vs population +14.7% (2015 LBN p. O-184; 2025 LBN p. O-251) const ltcCaseload = 1.7749 * cpi * (1.273 - 1.147); // Other Education 2013-15 included the Dept of Early Learning (163.7M), counted here under human services const otherEdExDel = 0.10812 - f * (0.205808 - 0.163719); const comp = [ ["Court- or voter-mandated", "K-12 (McCleary order; levy swap; I-732 COLAs are inflation-indexed, so inside the benchmark)", A["Public Schools (K-12)"]], ["Partly court-ordered", "Behavioral health (Trueblood competency services plus discretionary expansion; not separable)", grp("Behavioral health")], ["Entitlement / caseload", "Long-term care: caseload above population growth", ltcCaseload], ["Rates and wages (policy choices inside entitlement programs)", "Long-term care: cost per client above CPI (home-care wages, provider rates)", grp("Long-term care") - ltcCaseload], ["Entitlement / caseload", "Developmental disabilities (waiver caseloads up sharply)", grp("Developmental disabilities")], ["Entitlement / caseload", "Low-income health care (HCA medical assistance)", grp("Low-income health care")], ["Entitlement / caseload", "Corrections", grp("Corrections")], ["Discretionary (new or expanded programs)", "Child care, early learning, economic services (Fair Start Act 2021)", grp("Children")], ["Discretionary (new or expanded programs)", "Commerce (housing and homelessness grants, energy)", grp("Department of Commerce")], ["Discretionary (new or expanded programs)", "Working Families Tax Credit (DOR)", grp("Department of Revenue")], ["Discretionary (new or expanded programs)", "Higher education (Washington College Grant, tuition backfill)", A["Higher Education"]], ["Discretionary (new or expanded programs)", "Natural resources", A["Natural Resources"]], ["Discretionary (new or expanded programs)", "Health (DOH), other human services, other gov ops, judicial, legislative, transportation, other education", grp("Health (DOH)") + grp("All other human services") + grp("All other governmental") + A["Judicial"] + A["Legislative"] + A["Transportation"] + otherEdExDel], ["Fixed obligations", "Special appropriations (debt service, pensions)", A["Special Appropriations"]], ].map(([bucket, item, b]) => ({ bucket, item, aboveB: r2(b) })); results.composition = comp; results.compositionByBucket = Object.entries(comp.reduce((m, c) => (m[c.bucket] = (m[c.bucket] || 0) + c.aboveB, m), {})) .map(([bucket, b]) => ({ bucket, aboveB: r2(b), sharePct: pct(b / per2325["pop x US CPI"].aboveB) })); results.compositionTotal = r2(comp.reduce((s, c) => s + c.aboveB, 0)); // ---------- Biennial growth history ---------- const order = ngf.map(r => r.biennium); results.biennialGrowth = order.slice(1).map((b, i) => ({ biennium: b, growthPct: pct(S[b] / S[order[i]] - 1) })); // ---------- The 2027-29 outlook gap ---------- // OUTLOOK figures: 2026 LBN p. O-3 (ending balance) and ERFC 4/22/2026 outlook (spending). const OUTLOOK = JSON.parse(fs.readFileSync(path.join(__dirname, "outlook_inputs.json"), "utf8")); const g = {}; g.endingBalanceWithTax = OUTLOOK.endingBalance2729NGFO; g.gapNet = r2(g.endingBalanceWithTax - REV.outlookNet2729); // if only the net were lost g.gapGrossApril = r2(g.endingBalanceWithTax - REV.fiscalNoteGross2729); // relief kept: lose gross g.gapGrossSep = r2(g.endingBalanceWithTax - REV.erfcSepGross["2027-29"]); // newer gross, other revenue held // WRC (10/2/2026) balance after removing what it calls dubious assumptions, minus the Sept gross tax g.gapWrcAdjustedSep = r2(OUTLOOK.wrcAdjustedEndingBalance2729 - REV.erfcSepGross["2027-29"]); const s2527 = OUTLOOK.spending2527, s2729 = OUTLOOK.spending2729; g.outlookGrowth2729Pct = pct(s2729 / s2527 - 1); for (const [k, gap] of [["net", g.gapNet], ["grossApril", g.gapGrossApril], ["grossSep", g.gapGrossSep], ["wrcAdjustedSep", g.gapWrcAdjustedSep]]) { const need = Math.max(0, -gap); g["growthToClose_" + k + "Pct"] = pct((s2729 - need) / s2527 - 1); g["cutShareOf2729_" + k + "Pct"] = r2(100 * need / s2729); } // Population x CPI growth for one biennium at recent rates (pop +0.8%/yr OFM 2026; CPI assumptions) for (const cpiYr of [0.025, 0.03]) g["popCpiBiennialGrowthPct_cpi" + cpiYr * 1000 / 10] = pct(Math.pow(1.008 * (1 + cpiYr), 2) - 1); // Where 2027-29 spending would sit against the 2013-15 pop x US CPI path (ASSUMPTIONS: population // +0.8%/yr from the OFM April 2026 estimate of 8,176,300; CPI-U +2.7%/yr from the 2025 average of 321.943) const pop2028 = 8176300 * 1.008 ** 2, cpi2028 = 321.943 * 1.027 ** 3; const path2729 = S["2013-15"] * (pop2028 / B["2013-15"].population) * (cpi2028 / B["2013-15"].cpiUs); g.path2729_popCpi_from1315 = r2(path2729); g.outlook2729AbovePath = r2(s2729 - path2729); g.noTaxGrossApril2729AbovePath = r2(s2729 + g.gapGrossApril - path2729); g.noTaxWrcAdjusted2729AbovePath = r2(s2729 + g.gapWrcAdjustedSep - path2729); results.outlookGap = g; results.revenue = REV; fs.writeFileSync(path.join(__dirname, "spending_vs_need.json"), JSON.stringify(results, null, 2)); const lines = ["base,end,measure,pathB,actualB,aboveB,annualAboveB"]; for (const p of results.paths) for (const [k, v] of Object.entries(p.paths)) lines.push([p.base, p.end, `"${k}"`, v.path, p.actual, v.aboveB, v.annualAboveB].join(",")); fs.writeFileSync(path.join(__dirname, "spending_vs_need_paths.csv"), lines.join("\n") + "\n"); console.log(JSON.stringify(results, null, 1));