Prop 40, the comparison: the reproduction AI generated, not reviewed yet
The BSZ analysis and the JR analysis put on one common output, and the gap between them explained one disputed input at a time
Prop 40 comparison: Overview · Open Output: Explorer · Open Analysis: Report (here) and Slides · Open Materials
Also in this project: BSZ analysis overview · JR analysis overview
Introduction
Proposition 40 would tax Californians worth $1 billion or more once, at 5% of their net worth. The BSZ analysis estimates about $100 billion of wealth tax (its Table 5 prints $104B). Adding the income tax raised on assets sold to pay it, and subtracting the income tax lost to billionaires who leave over 5 years, gives this OPA’s net total for the BSZ analysis, $106.8B. The JR analysis finds the state about $39B poorer in today’s dollars, net of the income tax lost for ever (their paper prints an average of −$38.9B). Two inputs are among the three largest both on the explorer’s path and averaged over every order: how long the lost income tax is counted, and the chance the tax survives a court challenge. The third depends on the order, because the inputs multiply each other: a departure matters more when more wealth is left to tax. On the explorer’s path it is the wealth of billionaires who have already left. Averaged over every order (the order-free check at the end of The bridge) it is the share of income tax lost, which shares a large joint effect with the years counted. This report puts both on one output and walks from one to the other.
Versions and citations
Proposition 40, the Billionaire Tax Act, taxes people who lived in California on 1 January 2026 and are worth $1 billion or more. The tax is 5% of their whole net worth as valued on 31 December 2026. It is payable at once or in five equal yearly installments, and each later installment carries a deferral charge of 7.5% a year on the unpaid balance (§50301(c)). This report cites each analysis in its latest version.
The BSZ analysis is Boll, Saez and Zucman, NBER WP 35218 (August 2026). NBER is the National Bureau of Economic Research, which circulates working papers. Its Table 5 row 1 prints $104B of wealth tax, reproduced here as $103.8B. Adding the income tax raised on assets sold to pay it and subtracting the income tax lost to leavers over 5 years gives this OPA’s net total, $106.8B, not a figure BSZ print.
The JR analysis is Jaros and Rauh, NBER chapter c15504, Tax Policy and the Economy vol. 41 (September 2026). It revises the March 2026 version by Rauh, Jaros, Kearney, Doran and Cosso. By its own account the tax would collect about $30B, for a net present value of −$38.9B.
Today’s dollars (present value) means what money received or lost in future years is worth now.
When the output and the bridge were fixed
The common output, both its definitions and the bridge’s direction were fixed on 23 September 2026, before any bridge number (PLAN.md). After results, PLAN.md decisions 9 to 13 changed the bridge. Its JR end moved to the September 2026 version (14 October 2026), its steps became one path through the explorer’s dials (15 October), the discount rate became a dial with 2.73% at the JR end (15 October), and the path follows the equation’s order (15 October).
This report is part of the comparison’s Open Policy Analysis (OPA):
- Open Output: the explorer, with the two main estimates on the net fiscal effect. Its two panels have one slider per disputed input that moves the estimate, from one analysis’s value to the other’s. The browser computes each panel’s estimate with
explorer/model.js, a copy ofscore_common()that a test checks against the R values. The explorer’s equation shows two of the bridge’s nine steps in this report on their own (D and q). It adds up the four steps of the loss box (H, f, C and r) into one term, and the other inputs (W, a and the minor switch) into another. - Open Analysis: this document
- Open Materials: the repository folder, every script, test and data file (guide: the materials page)
The two main estimates are the ones each analysis’s overview page shows: the BSZ analysis’s net total over 5 years, $106.8B (this OPA’s construction from Table 5 row 1), and the JR analysis’s mean net present value in perpetuity, −$38.9B as printed (NBER, September 2026) and −$39.0B as computed exactly here. On the common output (b), the net fiscal effect, they are the bridge’s two ends. The BSZ end is the same $106.8B. The JR number is the exact expectation of the authors’ Monte Carlo, the average computed by formula: −$38.98B, the JR end. Jaros and Rauh print −$38.9B from one seeded run of that Monte Carlo (a run of the random draws started from a fixed seed number, so it gives the same draws every time). The gap between the two ends, $145.8B, is what the bridge explains. The arithmetic is in The two main estimates.
Neither analysis reports the other’s number, or the gap. Each keeps its own main estimate on its own overview page. What this report adds is one definition both can be estimated on, and an accounting of the distance between them.
What “reproduced” means here. The comparison does not re-run either analysis. It reads each analysis’s export contract: two files, inputs.csv and outputs.csv, that each analysis’s own pipeline writes under export/r/ for others to read. It also reads a short list of values transcribed from the PDFs with their page (comparison/data/). From these it rebuilds both ends with one estimating function and checks each end against the number its authors print.
How to read this report
Every number in running text is computed when this page is rendered, from the files comparison/run.R writes. Open any “code” fold to see the R functions behind an exhibit and their Python twins (a Python twin: the same computation written a second time in Python and checked against the R result). Page numbers are PDF pages. The Shapley average, an order-free check on the bridge, is defined at the end of The bridge.
Decisions this comparison makes
The two papers answer different questions in different units. To put them side by side, this comparison makes the decisions below. Each one is a choice of this OPA, not of either author team. Each says what the choice changes and what the alternative would be.
- One common output for both, the net fiscal effect. It is the wealth tax collected, plus the extra income tax from assets sold to pay it, minus the income tax lost to billionaires who leave, in today’s dollars (the definition). The BSZ paper’s headline is the wealth tax alone. The JR paper’s net present value fits this definition, with no extra income tax from asset sales.
- The BSZ end adds income tax effects that the BSZ headline leaves out. The BSZ headline is the wealth tax alone, $103.8B (Table 5 row 1, printed $104B). Adding $3.7B of income tax from asset sales and subtracting $0.7B of income tax lost over 5 years gives $106.8B. The alternative is the wealth tax alone, which leaves out the income tax effects that the JR paper puts at the center of its result (The two main estimates).
- The JR end is the average of the authors’ simulation. The JR paper draws four inputs at random 100,000 times. The comparison uses the average of the draws, −$39.0B (exact value −$38.98B; the paper prints −$38.9B from one seeded run). This is the figure the paper’s abstract leads with. The alternative is the whole distribution. 90% of the draws fall between −$107.9B and $15.9B, all of them between −$208.8B and $47.7B, and 85% are below zero. The bridge splits the gap between averages, so its steps describe the averages, not single draws (The bridge). This decision sets aside a feature of the JR analysis, its effort to report and assess the uncertainty in its estimate as a distribution of outcomes rather than one number. The comparison focuses on the average because its goal is to represent the two estimates as the current policy debate uses them, one number on each side: the voter guide’s arguments cite about $100 billion in favor and a $25 billion loss against.
- The rate at the JR end is 2.73%, not 3%. The JR simulation draws r − g between 1.5% and 4.5%. A single rate of 2.73% gives the same average as that draw, so the JR end matches the paper’s mean. At the midpoint of the draw, 3% (the middle column of the paper’s Table 10), the JR end would be −$32.3B instead (The discount rate).
- Litigation risk at the JR end is a 50% chance on the $72B ceiling. The JR simulation draws the revenue anywhere from zero (the tax struck down) to $72B. The comparison uses 50% of the ceiling, $36B. This keeps the average revenue but not its spread (The litigation dial).
- Each end keeps its own horizon. The BSZ end counts the lost income tax for 5 years, the JR end for ever. Moving the horizon is the largest single step on the bridge’s path, −$65.4B. The alternative is one common horizon for both. The explorer’s years dial, H, shows what each horizon gives.
The common output, and what each analysis reports
The BSZ analysis says how much the one-time tax collects. The JR analysis says what the state gains or loses in present value once income tax lost to billionaires who leave is subtracted. Before any step of a bridge means anything, both are put on the same two outputs. Output (b) comes first, because it is the question both main estimates answer:
- (b) net fiscal effect: the one-time wealth tax collected, plus extra income tax from assets sold to pay the tax, minus the present value of income tax lost to departures over a horizon of H years.
- (a) gross revenue: the one-time wealth tax collected, the secondary output.
The horizon H is itself disputed. The BSZ analysis argues that the losses should be temporary and gives five years, the payment period, as an example (“If they last say only 5 years”, BSZ PDF p.27); its Table 5 itself takes no stand on how long they last. The 5 years at the BSZ end are this OPA’s reading of that example. The JR analysis counts the losses forever, because it assumes departed billionaires do not return once the Act permanently lifts the cap on intangible property taxes (chapter PDF p.32).
The discount rate r is an input of the bridge too, with its own dial (author decision, 15 October 2026). The BSZ analysis gives no rate. This OPA discounts its 5-year loss at 3%, the rate of the BSZ analysis overview and the middle of the JR range (a scenario). The JR analysis values a growing loss: its rate is r − g, the interest rate minus the lost income tax’s yearly growth (a real rate; chapter eqs. 13-14, PDF p.31). It draws r − g at random, uniformly between 1.5% and 4.5% (eq. 18, PDF p.35), and averages the results. That draw is worth the same as one rate, 2.73%, which is the JR value of the dial. The derivation is short. A perpetuity of 1 a year is worth 1/r at the rate r. Averaged over the draw it is worth E[1/r] = ln(4.5% / 1.5%) / (4.5% − 1.5%) = ln(3)/0.03 = 36.6. That is more than the 33.3 it is worth at the middle rate, 3%, because 1/r curves upward, so low rates add more than high rates take away. The one rate worth the same is r = 1 / E[1/r] = 0.03 / ln(3) = 2.7307%, shown as 2.73% (a value derived from the authors’ draw). It matches the draw exactly only for a loss that lasts for ever. With it the common function returns the authors’ exact mean.
Conventions. All amounts are billions of dollars. Present values are as of the date the wealth tax is assessed, and the first year of lost income tax is counted one year later. The Act makes the tax due with the 2026 income tax return, or in five yearly installments (§50301(c)), and the pages’ “today’s dollars” are dollars of that date. The wealth tax is counted at its face value when assessed (the BSZ analysis values the base on 1 July 2026; the JR analysis’s September version grows the base it used in March, the 2025 Forbes list, by 7% to 31 December 2026), undiscounted, as both analyses count it, and the comparison follows them. The Act lets a taxpayer pay at once or in five yearly installments, each later one with a deferral charge of 7.5% a year on the unpaid balance (§50301(c)). An Optional Deferral Account (§50304) can delay the tax on illiquid assets. Neither analysis counts either provision, and this OPA does not estimate them. The lost income tax is a flat yearly amount, discounted at the rate dial’s value, r. For a loss that grows, the same present value comes from discounting a flat loss at the interest rate minus the growth. The JR analysis draws that rate, r − g, the interest rate minus the lost tax’s yearly growth (a real rate; chapter eqs. 14 and 18), so a flat loss at its rate is the chapter’s growing loss. Its code, eq. 18 and its NBER README all draw r − g; one sentence after eq. 19 writes r alone (the JR analysis’s note 3). The BSZ end’s 3% is read the same way, as r − g, and is the middle of the same draw.
R or Python code for the two analyses on the common output
# comparison/R/model.R, lines 43-47
annuity <- function(r, H) {
if (is.infinite(H)) return(1 / r)
if (H == 0) return(0)
(1 - (1 + r)^(-H)) / r
}
# comparison/R/model.R, lines 49-62
score_common <- function(x) {
Wn <- (x$W_core + x$W_noncit) * (1 - x$re)
D <- (1 - x$lam) * x$dep_b + x$lam * x$dep_j
d_conf <- D / Wn
R0 <- x$tau * Wn
S <- 1 - d_conf
WT <- R0 * (1 - x$alpha) * S * x$q
X <- WT * x$s * x$g * x$t_cg
f <- x$alpha * x$m + x$own * x$L / x$C + x$f0
A <- annuity(x$r, x$H)
PV <- f * x$C * A
c(a = WT, b = WT + X - PV, R0 = R0, S = S, d_conf = d_conf, D = D, X = X, f = f,
annual_loss = f * x$C, annuity = A, PV = PV)
}
# comparison/R/model.R, lines 299-304
expected_annuity <- function(H, r_min, r_max) {
if (is.infinite(H)) return(log(r_max / r_min) / (r_max - r_min))
if (H == 0) return(0)
f <- function(r) (1 - (1 + r)^(-H)) / r
stats::integrate(f, r_min, r_max, rel.tol = 1e-12, abs.tol = 0)$value / (r_max - r_min)
}
# comparison/R/model.R, lines 306-306
jr_discount_rate <- function(r_min, r_max) 1 / expected_annuity(Inf, r_min, r_max)
# comparison/R/model.R, lines 308-312
rauh_nber_expectation <- function(wt_min, wt_max, f_min, f_max, c_min, c_max, r_min, r_max, H = Inf) {
e_wt <- (wt_min + wt_max) / 2
c(a = e_wt, b = e_wt - (f_min + f_max) / 2 * (c_min + c_max) / 2 *
expected_annuity(H, r_min, r_max))
}# The Python twin (comparison/py/bridge.py); it writes comparison/export/py/.
# comparison/py/bridge.py, lines 73-79 (read-only display)
def annuity(r: float, H: float) -> float:
"""The value of 1 a year for H years at rate r; H = inf gives the perpetuity 1/r."""
if math.isinf(H):
return 1 / r
if H == 0:
return 0.0
return (1 - (1 + r) ** (-H)) / r
# comparison/py/bridge.py, lines 82-94 (read-only display)
def score_common(x: dict) -> dict:
Wn = (x["W_core"] + x["W_noncit"]) * (1 - x["re"])
D = (1 - x["lam"]) * x["dep_b"] + x["lam"] * x["dep_j"]
d_conf = D / Wn
R0 = x["tau"] * Wn
S = 1 - d_conf
WT = R0 * (1 - x["alpha"]) * S * x["q"]
X = WT * x["s"] * x["g"] * x["t_cg"]
f = x["alpha"] * x["m"] + x["own"] * x["L"] / x["C"] + x["f0"]
A = annuity(x["r"], x["H"])
PV = f * x["C"] * A
return {"a": WT, "b": WT + X - PV, "R0": R0, "S": S, "d_conf": d_conf, "D": D, "X": X,
"f": f, "annual_loss": f * x["C"], "annuity": A, "PV": PV}
# comparison/py/bridge.py, lines 266-272 (read-only display)
def expected_annuity(H: float, r_min: float, r_max: float) -> float:
if math.isinf(H):
return math.log(r_max / r_min) / (r_max - r_min)
if H == 0:
return 0.0
val, _ = quad(lambda r: (1 - (1 + r) ** (-H)) / r, r_min, r_max, epsabs=0, epsrel=1e-12)
return val / (r_max - r_min)
# comparison/py/bridge.py, lines 275-276 (read-only display)
def jr_discount_rate(r_min: float, r_max: float) -> float:
return 1 / expected_annuity(math.inf, r_min, r_max)
# comparison/py/bridge.py, lines 279-282 (read-only display)
def rauh_nber_expectation(wt_min, wt_max, f_min, f_max, c_min, c_max, r_min, r_max, H=math.inf):
e_wt = (wt_min + wt_max) / 2
return {"a": e_wt, "b": e_wt - (f_min + f_max) / 2 * (c_min + c_max) / 2 *
expected_annuity(H, r_min, r_max)}| Analysis | (b) net fiscal effect | (a) gross revenue | What the authors print |
|---|---|---|---|
| BSZ analysis: Boll, Saez and Zucman, NBER WP 35218, Table 5 row 1 (Aug 2026) | $106.8B | $103.8B | $104B revenue (PDF p.39); no net figure |
| JR analysis: Jaros and Rauh, NBER c15504 (Sep 2026) | −$38.98B | $36.0B | mean NPV −$38.9B (one seeded run); revenue ‘approximately $30B’ (p.1) |
Output (b) for the BSZ analysis is not printed by the paper. It is Table 5 row 1’s own three columns combined here: the $103.8B revenue, plus $3.68B of extra income tax from asset sales, minus $0.70B, the present value of five years of its income tax loss, at 3%. For the JR analysis both outputs are the expectation of the authors’ own Monte Carlo, computed exactly rather than by drawing.
One function, both ends
One function gives both ends of the net fiscal effect: $106.8B at the BSZ inputs (discounted at 3%) and −$39.0B at the JR inputs (discounted at 2.73%), the JR analysis’s own exact mean.
One estimating function, score_common(), takes every disputed input as an argument. Each argument takes either the BSZ analysis’s value or the JR analysis’s. At the all-BSZ setting the function must return the BSZ end: Table 5 row 1 on (a), and its net on (b). At the all-JR setting it must return the exact expectation of the September 2026 version’s Monte Carlo, on both outputs. The draws are independent, so the expectation is the inputs at their means, with the rate at the one value worth the same as its draw (above). The JR values are those of that version, the latest (its code and person-level file are in the authors’ public repository).
In billions of dollars, with WT the wealth tax, f the share of all billionaires’ income tax that is lost, C the yearly income tax of California’s billionaires and r − g the interest rate minus the loss’s yearly growth:
E[NPV] = E[WT] − E[f] × E[C] × E[1/(r − g)] = 36 − 0.45 × 4.55 × 36.62 = −38.98
The −$38.9B that the authors print is the average of one seeded run of the same draws, so it differs from this mean by simulation noise.
The litigation dial. In the line above, E[WT] is where the chance that the tax survives a court challenge, q, enters the JR end. The authors’ Monte Carlo draws the revenue anywhere between zero (the tax struck down) and the ceiling, $72B, so the mean revenue is half the ceiling, $36B. The JR analysis’s own explorer has a litigation dial that reads 1 at the main estimate, because there this draw already halves the mean revenue. The common function has no revenue draw. So the comparison applies the same half as q = 50% of the ceiling, and its JR end collects the same $36B. This keeps the average revenue but not its spread: in the authors’ simulation each draw’s revenue can be anything from zero to the ceiling (The bridge shows the spread of the outcomes).
In words, the function computes seven quantities in order.
The seven quantities, step by step
Each argument is written as score_common() names it and, where a step of the bridge switches it, followed by that step’s title in quotation marks; tau, m, g and t_cg are shared by both ends.
Steps 1 to 3 give the wealth tax collected, the explorer’s 5% × (W − D) × (1 − a) × q. In the code, 5% is tau, and W is (W_core + W_noncit) * (1 - re): the list’s wealth with two of the minor inputs. D and q keep their names, and a is alpha.
R0 = tau * (W_core + W_noncit) * (1 - re): the revenue with no behavioural response.tauis the tax rate;W_core(the base) andW_noncit(the non-US-citizen residents) are in billions of dollars (“Forbes source: which list and valuation date”, “Non-US-citizen residents”);reis the share of the revenue the real-estate deduction removes (“Real-estate deduction”).S = 1 - d_conf: the share of the base still taxed.d_conf = D / Wnis the share of the base that had already left (“Billionaires who moved out in time”):Dis their wealth, valued on the Forbes list in use (lampicks it), andWnthe base net of real estate. The departures dial has three stops: none, the BSZ analysis’s Table 5 row 3 scenario, which counts four named billionaires as having moved in time, and the JR analysis’s seven. For the JR analysis,R0 * Sis its ceiling, the most the tax can collect after the seven publicly reported moves.WT = R0 * (1 - alpha) * S * q: output (a), the gross one-time revenue, after the avoidance allowancealpha, a share of the base (“Avoidance and evasion allowance”), weighted byq, the chance that the Act survives a court challenge (the step “Chance the tax does not survive litigation” moves it from 1 to the JR value). The JR Monte Carlo draws the revenue anywhere between zero (struck down) and the ceiling; the mean of that draw is the ceiling times one half, itsq.X = WT * s * g * t_cg: the extra income tax from assets sold to pay.sis the share of the tax paid by selling assets (“Extra income tax from selling assets to pay”),gthe capital-gain share of a sale andt_cgthe top California rate on realized gains. Steps 5 and 6 give the income tax lost, the explorer’s f × C × A(H, r). A(H, r) isannuity(r, H), about 4.6 for 5 years at 3% and 36.6 for ever at 2.73%.f = alpha*m + own * L / C + f0: the share of billionaires’ income tax lost (“Share of billionaires’ income tax lost”). The first term is the BSZ movers’ share (mis the part of the avoidance allowance that is people leaving California): the movers in a take the same share of the income tax as of the wealth (5% at the BSZ values; BSZ PDF p.24). Under the BSZ rule (own = 1) named leavers take only their own income tax,Ldollars a year as BSZ Table 5 counts it for the people at the departures stop, which is small for their wealth (BSZ Table 4). Under the JR rule (own = 0) a flat sharef0of all billionaires’ income tax is lost: 45% at the JR value, the middle of the JR analysis’s independent draw of 30% to 60%. It is more than the 28.6% of wealth reported to have left, because income tax need not leave in step with wealth and some moves go unreported (chapter PDF pp.31, 33). At the middle stop the BSZ end reproduces Table 5 row 3 in all three columns.PV = f * C * annuity(r, H): the present value of the lost income tax.Cis billionaires’ yearly California income tax, in billions of dollars a year (“Billionaires’ yearly California income tax”), counted forHyears (“How long the lost income tax is counted”; for ever at the JR end);annuity(r, H) = (1 - (1 + r)^-H) / ris what 1 a year forHyears is worth today at the discount rater(“Discount rate for the lost income tax”: 3% at the BSZ end, 2.73% at the JR end).NET = WT + X - PV: output (b), the net fiscal effect.
The BSZ end matches the number its authors print, up to rounding. The JR end matches the exact mean of the authors’ Monte Carlo, and that mean matches their printed number up to the sampling error of one run.
The endpoint checks
R or Python code for the endpoint checks
# comparison/R/build.R, lines 14-58
bridge_inputs <- function(k) {
si <- k$bsz$inputs; so <- k$bsz$outputs
oi <- k$rjkdc$inputs; oo <- k$rjkdc$outputs
d <- k$docs
W0 <- pick(si, "baseline_net_worth")
noncit <- pick(d, "ggss_noncitizen_wealth")
C_sup <- pick(si, "ca_inctax_billionaires")
tau <- pick(oi, "tax_rate")
stopifnot(isTRUE(all.equal(tau, pick(si, "tax_rate"))))
base_r <- pick(oi, "baseline_net_worth") * (1 + pick(oi, "nber_growth_rate")) # 2,027.4 at 31 Dec 2026
R0_r <- pick(oo, "nber_domestic_grown") # 100.906, 5% of the grown base net of real estate
ceiling <- pick(oi, "nber_ceiling_hardcoded") # 72, after the seven publicly reported moves
# Departed wealth, the same people valued on each list. On the BSZ list (1 Jul 2026,
# Table 5): the four pre-2026 leavers, plus Brin, plus Spielberg and Sacks, whom the BSZ
# workbook does not list by name and who are valued at the JR file's net worth. On the JR
# list (grown 7%, net of real estate): the four from the person-level file, and all seven
# as the base less the script's 72 ceiling (the file itself sums to 72.05; JR note 4).
four_b <- pick(si, "pre2026_leavers_wealth")
seven_b <- four_b + pick(si, "brin_wealth") + pick(oi, "nber_spielberg_sacks_net_worth")
four_j <- pick(oi, "nber_departed_four_grown")
seven_j <- (R0_r - ceiling) / tau
# The leavers' own income tax at each stop, as the BSZ analysis counts it (Table 5): its
# aggressive row loses the tax of all seven named leavers (four escape the wealth tax, three
# pay it and leave later); the seven publicly reported moves are Page, Thiel, Hankey and
# Kalanick, Brin, and Spielberg and Sacks at BSZ's rate for billionaires without SEC data.
L_bsz <- pick(si, "leavers_annual_inctax")
L_seven <- pick(si, "pre2026_leavers_annual_inctax") + pick(si, "brin_annual_inctax") +
pick(si, "resid_inctax_rate") * pick(oi, "nber_spielberg_sacks_net_worth")
shared <- list(tau = tau, m = pick(si, "mobility_share"), g = pick(si, "gains_share"),
t_cg = pick(si, "ca_cg_rate"))
sup <- c(shared, list(
W_noncit = noncit, W_core = W0 - noncit, lam = 0, re = 0, dep_b = 0, dep_j = 0, L = 0,
alpha = pick(si, "avoidance_rate"), q = 1,
own = 1, f0 = 0, C = C_sup, H = pick(d, "bsz_loss_horizon"), r = pick(d, BSZ_RATE_ID),
s = pick(si, "sell_share")))
rauh <- c(shared, list(
W_noncit = 0, W_core = base_r, lam = 1, re = 1 - R0_r / (tau * base_r),
dep_b = seven_b, dep_j = seven_j, L = L_seven, alpha = 0, q = pick(oi, "q_litigation_survival"),
own = 0, f0 = (pick(oi, "nber_mc_f_min") + pick(oi, "nber_mc_f_max")) / 2,
C = (pick(oi, "rauh_mc_c_min") + pick(oi, "rauh_mc_c_max")) / 2,
H = pick(d, "rauh_loss_horizon"),
r = jr_discount_rate(pick(oi, "rauh_mc_r_min"), pick(oi, "rauh_mc_r_max")), s = 0))
list(sup = sup, rauh = rauh,
ladder = list(dep_b = c(0, four_b, seven_b), dep_j = c(0, four_j, seven_j), L = c(0, L_bsz, L_seven)))
}
# comparison/R/model.R, lines 49-62
score_common <- function(x) {
Wn <- (x$W_core + x$W_noncit) * (1 - x$re)
D <- (1 - x$lam) * x$dep_b + x$lam * x$dep_j
d_conf <- D / Wn
R0 <- x$tau * Wn
S <- 1 - d_conf
WT <- R0 * (1 - x$alpha) * S * x$q
X <- WT * x$s * x$g * x$t_cg
f <- x$alpha * x$m + x$own * x$L / x$C + x$f0
A <- annuity(x$r, x$H)
PV <- f * x$C * A
c(a = WT, b = WT + X - PV, R0 = R0, S = S, d_conf = d_conf, D = D, X = X, f = f,
annual_loss = f * x$C, annuity = A, PV = PV)
}# The Python twin (comparison/py/bridge.py); it writes comparison/export/py/.
# comparison/py/bridge.py, lines 286-324 (read-only display)
def bridge_inputs(k):
si, so = k["bsz"]["inputs"], k["bsz"]["outputs"]
oi, oo = k["rjkdc"]["inputs"], k["rjkdc"]["outputs"]
d = k["docs"]
W0 = pick(si, "baseline_net_worth")
noncit = pick(d, "ggss_noncitizen_wealth")
C_sup = pick(si, "ca_inctax_billionaires")
tau = pick(oi, "tax_rate")
assert abs(tau - pick(si, "tax_rate")) < 1e-12
base_r = pick(oi, "baseline_net_worth") * (1 + pick(oi, "nber_growth_rate"))
R0_r = pick(oo, "nber_domestic_grown")
ceiling = pick(oi, "nber_ceiling_hardcoded")
# Departed wealth, the same people valued on each list (see R/build.R): on the BSZ list the
# four pre-2026 leavers, Brin, and Spielberg and Sacks at the JR file's net worth; on the
# JR list the four from the person-level file, and all seven as the base less the 72 ceiling.
four_b = pick(si, "pre2026_leavers_wealth")
seven_b = four_b + pick(si, "brin_wealth") + pick(oi, "nber_spielberg_sacks_net_worth")
four_j = pick(oi, "nber_departed_four_grown")
seven_j = (R0_r - ceiling) / tau
# The leavers' own income tax at each stop, as BSZ Table 5 counts it (see R/build.R).
L_bsz = pick(si, "leavers_annual_inctax")
L_seven = (pick(si, "pre2026_leavers_annual_inctax") + pick(si, "brin_annual_inctax") +
pick(si, "resid_inctax_rate") * pick(oi, "nber_spielberg_sacks_net_worth"))
shared = {"tau": tau, "m": pick(si, "mobility_share"), "g": pick(si, "gains_share"),
"t_cg": pick(si, "ca_cg_rate")}
sup = dict(shared, W_noncit=noncit, W_core=W0 - noncit, lam=0.0, re=0.0,
dep_b=0.0, dep_j=0.0, L=0.0,
alpha=pick(si, "avoidance_rate"), q=1.0,
own=1.0, f0=0.0, C=C_sup,
H=pick(d, "bsz_loss_horizon"), r=pick(d, BSZ_RATE_ID), s=pick(si, "sell_share"))
rauh = dict(shared, W_noncit=0.0, W_core=base_r, lam=1.0, re=1 - R0_r / (tau * base_r),
dep_b=seven_b, dep_j=seven_j, L=L_seven, alpha=0.0, q=pick(oi, "q_litigation_survival"),
own=0.0, f0=(pick(oi, "nber_mc_f_min") + pick(oi, "nber_mc_f_max")) / 2,
C=(pick(oi, "rauh_mc_c_min") + pick(oi, "rauh_mc_c_max")) / 2,
H=pick(d, "rauh_loss_horizon"),
r=jr_discount_rate(pick(oi, "rauh_mc_r_min"), pick(oi, "rauh_mc_r_max")), s=0.0)
ladder = {"dep_b": [0.0, four_b, seven_b], "dep_j": [0.0, four_j, seven_j],
"L": [0.0, L_bsz, L_seven]}
return sup, rauh, ladder
# comparison/py/bridge.py, lines 82-94 (read-only display)
def score_common(x: dict) -> dict:
Wn = (x["W_core"] + x["W_noncit"]) * (1 - x["re"])
D = (1 - x["lam"]) * x["dep_b"] + x["lam"] * x["dep_j"]
d_conf = D / Wn
R0 = x["tau"] * Wn
S = 1 - d_conf
WT = R0 * (1 - x["alpha"]) * S * x["q"]
X = WT * x["s"] * x["g"] * x["t_cg"]
f = x["alpha"] * x["m"] + x["own"] * x["L"] / x["C"] + x["f0"]
A = annuity(x["r"], x["H"])
PV = f * x["C"] * A
return {"a": WT, "b": WT + X - PV, "R0": R0, "S": S, "d_conf": d_conf, "D": D, "X": X,
"f": f, "annual_loss": f * x["C"], "annuity": A, "PV": PV}| End | Computed here | Printed | Where | Difference |
|---|---|---|---|---|
| BSZ row 1 net of its own extra income tax and 5 years of losses (not printed by BSZ; constructed here) | 106.796 | not printed | ||
| BSZ Table 5 row 1 wealth tax revenue = model at all-BSZ inputs | 103.815 | 104.0 | BSZ Table 5, PDF p.39 | 0.185 |
| JR mean NPV, analytic expectation of the Monte Carlo = model at all-JR inputs (the rate at 1 / E[1/r]) | -38.980 | -38.9 | Jaros-Rauh NBER p.1, p.35 | 0.080 |
| JR expected revenue in the Monte Carlo, E[WT] = (0 + 72)/2 = model at all-JR inputs | 36.000 | not printed |
The BSZ differences are rounding of the printed number. The JR difference is the sampling error of the authors’ one seeded run: the exact mean of the Monte Carlo, −$38.98B, rounds to −$39.0B, while their run of it prints −$38.9B.
The two main estimates
The BSZ main estimate is $106.8B and the JR main estimate −$39.0B. On the common output the two ends are $106.8B and −$39.0B, a gap of $145.8B. The JR main estimate is the JR end: the exact mean of the authors’ Monte Carlo. The BSZ net total is this OPA’s choice, not the authors’. Their own headline is the wealth tax alone, Table 5 row 1 (printed $104B), and the BSZ analysis overview explains the choice.
Steps 1 to 4, and output (a)
Step 1: the BSZ main estimate, the BSZ end on (b). At the all-BSZ inputs, the wealth tax is the tax rate times the base, less the avoidance allowance. The base is the $2,307B that Forbes lists for California’s billionaires on 1 July 2026, the non-US-citizen residents ($150B) included. The BSZ analysis applies no real-estate deduction and removes no named leaver from the base. Its 10% avoidance allowance includes the 5% of billionaires who left before 1 January 2026 (BSZ PDF p.24), which is where its income tax loss comes from. So the wealth tax is 5% × $2,307B × (1 − 10%) = $103.815B, which is Table 5 row 1 (printed $104B, PDF p.39). Output (b) adds $3.682B of extra income tax from assets sold to pay and subtracts $0.701B, five years of the $0.153B annual income tax loss discounted at 3%: $106.796B, shown as $106.8B, the BSZ analysis overview’s main estimate.
Step 2: the JR main estimate, and the JR end on (b). In the Monte Carlo of Jaros and Rauh’s NBER version the revenue is drawn uniformly between zero and $72B, the share of all billionaires’ income tax that is lost between 0.3 and 0.6, the annual income tax between $3.3B and $5.8B, and the interest rate minus the loss’s yearly growth, r − g, between 1.5% and 4.5%, all independently. The mean net present value is therefore $36.0B − 0.45 × $4.550B × 36.620 (the average of 1/r, or 1/r at 2.73%) = −$38.980B, the JR end on (b). The authors print −$38.9B from one seeded run of the same Monte Carlo, which the JR pipeline reproduces as −$38.886B. The comparison’s pages use the exact mean as the JR main estimate, shown as −$39.0B. The mean summarizes a wide distribution. In the authors’ run, 90% of the draws fall between −$107.9B and $15.9B, all of them between −$208.8B and $47.7B, and 85.2% are below zero (PDF p.35, Figure 3).
Step 3: the gap on (b). $106.796B − (−$38.980B) = $145.776B, shown as $145.8B.
Step 4: where the gap comes from. The next section splits it into one step per dial of the explorer. The steps add up to −$145.776B, the gap read from the BSZ end: the common function returns both ends exactly, and the steps go from one to the other.
On output (a), the secondary setting, the two ends are the wealth tax alone: $103.815B and the NBER expected revenue, $36.0B (the midpoint of zero and $72B; the authors print “approximately $30B”, p.1). The gap is $67.815B. The order-free check at the end of the next section splits it too.
R or Python code for the two main estimates
# comparison/R/build.R, lines 14-58
bridge_inputs <- function(k) {
si <- k$bsz$inputs; so <- k$bsz$outputs
oi <- k$rjkdc$inputs; oo <- k$rjkdc$outputs
d <- k$docs
W0 <- pick(si, "baseline_net_worth")
noncit <- pick(d, "ggss_noncitizen_wealth")
C_sup <- pick(si, "ca_inctax_billionaires")
tau <- pick(oi, "tax_rate")
stopifnot(isTRUE(all.equal(tau, pick(si, "tax_rate"))))
base_r <- pick(oi, "baseline_net_worth") * (1 + pick(oi, "nber_growth_rate")) # 2,027.4 at 31 Dec 2026
R0_r <- pick(oo, "nber_domestic_grown") # 100.906, 5% of the grown base net of real estate
ceiling <- pick(oi, "nber_ceiling_hardcoded") # 72, after the seven publicly reported moves
# Departed wealth, the same people valued on each list. On the BSZ list (1 Jul 2026,
# Table 5): the four pre-2026 leavers, plus Brin, plus Spielberg and Sacks, whom the BSZ
# workbook does not list by name and who are valued at the JR file's net worth. On the JR
# list (grown 7%, net of real estate): the four from the person-level file, and all seven
# as the base less the script's 72 ceiling (the file itself sums to 72.05; JR note 4).
four_b <- pick(si, "pre2026_leavers_wealth")
seven_b <- four_b + pick(si, "brin_wealth") + pick(oi, "nber_spielberg_sacks_net_worth")
four_j <- pick(oi, "nber_departed_four_grown")
seven_j <- (R0_r - ceiling) / tau
# The leavers' own income tax at each stop, as the BSZ analysis counts it (Table 5): its
# aggressive row loses the tax of all seven named leavers (four escape the wealth tax, three
# pay it and leave later); the seven publicly reported moves are Page, Thiel, Hankey and
# Kalanick, Brin, and Spielberg and Sacks at BSZ's rate for billionaires without SEC data.
L_bsz <- pick(si, "leavers_annual_inctax")
L_seven <- pick(si, "pre2026_leavers_annual_inctax") + pick(si, "brin_annual_inctax") +
pick(si, "resid_inctax_rate") * pick(oi, "nber_spielberg_sacks_net_worth")
shared <- list(tau = tau, m = pick(si, "mobility_share"), g = pick(si, "gains_share"),
t_cg = pick(si, "ca_cg_rate"))
sup <- c(shared, list(
W_noncit = noncit, W_core = W0 - noncit, lam = 0, re = 0, dep_b = 0, dep_j = 0, L = 0,
alpha = pick(si, "avoidance_rate"), q = 1,
own = 1, f0 = 0, C = C_sup, H = pick(d, "bsz_loss_horizon"), r = pick(d, BSZ_RATE_ID),
s = pick(si, "sell_share")))
rauh <- c(shared, list(
W_noncit = 0, W_core = base_r, lam = 1, re = 1 - R0_r / (tau * base_r),
dep_b = seven_b, dep_j = seven_j, L = L_seven, alpha = 0, q = pick(oi, "q_litigation_survival"),
own = 0, f0 = (pick(oi, "nber_mc_f_min") + pick(oi, "nber_mc_f_max")) / 2,
C = (pick(oi, "rauh_mc_c_min") + pick(oi, "rauh_mc_c_max")) / 2,
H = pick(d, "rauh_loss_horizon"),
r = jr_discount_rate(pick(oi, "rauh_mc_r_min"), pick(oi, "rauh_mc_r_max")), s = 0))
list(sup = sup, rauh = rauh,
ladder = list(dep_b = c(0, four_b, seven_b), dep_j = c(0, four_j, seven_j), L = c(0, L_bsz, L_seven)))
}
# comparison/R/model.R, lines 49-62
score_common <- function(x) {
Wn <- (x$W_core + x$W_noncit) * (1 - x$re)
D <- (1 - x$lam) * x$dep_b + x$lam * x$dep_j
d_conf <- D / Wn
R0 <- x$tau * Wn
S <- 1 - d_conf
WT <- R0 * (1 - x$alpha) * S * x$q
X <- WT * x$s * x$g * x$t_cg
f <- x$alpha * x$m + x$own * x$L / x$C + x$f0
A <- annuity(x$r, x$H)
PV <- f * x$C * A
c(a = WT, b = WT + X - PV, R0 = R0, S = S, d_conf = d_conf, D = D, X = X, f = f,
annual_loss = f * x$C, annuity = A, PV = PV)
}
# comparison/R/model.R, lines 308-312
rauh_nber_expectation <- function(wt_min, wt_max, f_min, f_max, c_min, c_max, r_min, r_max, H = Inf) {
e_wt <- (wt_min + wt_max) / 2
c(a = e_wt, b = e_wt - (f_min + f_max) / 2 * (c_min + c_max) / 2 *
expected_annuity(H, r_min, r_max))
}# The Python twin (comparison/py/bridge.py); it writes comparison/export/py/.
# comparison/py/bridge.py, lines 286-324 (read-only display)
def bridge_inputs(k):
si, so = k["bsz"]["inputs"], k["bsz"]["outputs"]
oi, oo = k["rjkdc"]["inputs"], k["rjkdc"]["outputs"]
d = k["docs"]
W0 = pick(si, "baseline_net_worth")
noncit = pick(d, "ggss_noncitizen_wealth")
C_sup = pick(si, "ca_inctax_billionaires")
tau = pick(oi, "tax_rate")
assert abs(tau - pick(si, "tax_rate")) < 1e-12
base_r = pick(oi, "baseline_net_worth") * (1 + pick(oi, "nber_growth_rate"))
R0_r = pick(oo, "nber_domestic_grown")
ceiling = pick(oi, "nber_ceiling_hardcoded")
# Departed wealth, the same people valued on each list (see R/build.R): on the BSZ list the
# four pre-2026 leavers, Brin, and Spielberg and Sacks at the JR file's net worth; on the
# JR list the four from the person-level file, and all seven as the base less the 72 ceiling.
four_b = pick(si, "pre2026_leavers_wealth")
seven_b = four_b + pick(si, "brin_wealth") + pick(oi, "nber_spielberg_sacks_net_worth")
four_j = pick(oi, "nber_departed_four_grown")
seven_j = (R0_r - ceiling) / tau
# The leavers' own income tax at each stop, as BSZ Table 5 counts it (see R/build.R).
L_bsz = pick(si, "leavers_annual_inctax")
L_seven = (pick(si, "pre2026_leavers_annual_inctax") + pick(si, "brin_annual_inctax") +
pick(si, "resid_inctax_rate") * pick(oi, "nber_spielberg_sacks_net_worth"))
shared = {"tau": tau, "m": pick(si, "mobility_share"), "g": pick(si, "gains_share"),
"t_cg": pick(si, "ca_cg_rate")}
sup = dict(shared, W_noncit=noncit, W_core=W0 - noncit, lam=0.0, re=0.0,
dep_b=0.0, dep_j=0.0, L=0.0,
alpha=pick(si, "avoidance_rate"), q=1.0,
own=1.0, f0=0.0, C=C_sup,
H=pick(d, "bsz_loss_horizon"), r=pick(d, BSZ_RATE_ID), s=pick(si, "sell_share"))
rauh = dict(shared, W_noncit=0.0, W_core=base_r, lam=1.0, re=1 - R0_r / (tau * base_r),
dep_b=seven_b, dep_j=seven_j, L=L_seven, alpha=0.0, q=pick(oi, "q_litigation_survival"),
own=0.0, f0=(pick(oi, "nber_mc_f_min") + pick(oi, "nber_mc_f_max")) / 2,
C=(pick(oi, "rauh_mc_c_min") + pick(oi, "rauh_mc_c_max")) / 2,
H=pick(d, "rauh_loss_horizon"),
r=jr_discount_rate(pick(oi, "rauh_mc_r_min"), pick(oi, "rauh_mc_r_max")), s=0.0)
ladder = {"dep_b": [0.0, four_b, seven_b], "dep_j": [0.0, four_j, seven_j],
"L": [0.0, L_bsz, L_seven]}
return sup, rauh, ladder
# comparison/py/bridge.py, lines 82-94 (read-only display)
def score_common(x: dict) -> dict:
Wn = (x["W_core"] + x["W_noncit"]) * (1 - x["re"])
D = (1 - x["lam"]) * x["dep_b"] + x["lam"] * x["dep_j"]
d_conf = D / Wn
R0 = x["tau"] * Wn
S = 1 - d_conf
WT = R0 * (1 - x["alpha"]) * S * x["q"]
X = WT * x["s"] * x["g"] * x["t_cg"]
f = x["alpha"] * x["m"] + x["own"] * x["L"] / x["C"] + x["f0"]
A = annuity(x["r"], x["H"])
PV = f * x["C"] * A
return {"a": WT, "b": WT + X - PV, "R0": R0, "S": S, "d_conf": d_conf, "D": D, "X": X,
"f": f, "annual_loss": f * x["C"], "annuity": A, "PV": PV}
# comparison/py/bridge.py, lines 279-282 (read-only display)
def rauh_nber_expectation(wt_min, wt_max, f_min, f_max, c_min, c_max, r_min, r_max, H=math.inf):
e_wt = (wt_min + wt_max) / 2
return {"a": e_wt, "b": e_wt - (f_min + f_max) / 2 * (c_min + c_max) / 2 *
expected_annuity(H, r_min, r_max)}The bridge, step by step
The bridge follows the explorer’s dials. Start from the BSZ panel, where every dial is at the BSZ value, and move each dial to its far end (the JR value), in the order of the explorer’s equation. That order is D, then q, then the four loss-box dials (f, C, H and r), then W, a and the minor switch. Each step is what one dial changes. The nine steps take the BSZ end, $106.8B, to the JR end, −$39.0B, exactly. On this path, three of them make most of the $145.8B gap: how long the lost income tax is counted (−$65.4B), the chance the tax does not survive litigation (−$39.4B) and the billionaires who moved out in time (−$29.9B). The other 6 together add −$11.0B.
What the bridge decomposes. The JR analysis’s result is a distribution of outcomes, not one number. Its simulation (Section 5.4 of the paper) draws the wealth tax revenue, the share of income tax lost, the annual income tax and r − g at random, 100,000 times. The bridge works with the average of those draws, the JR main estimate on these pages, and its steps split the gap between the two analyses’ averages. The steps are exact for the averages because the four draws are independent, so the average net present value is the formula at the average revenue, share and income tax, and the average of 1/(r − g). They do not describe single draws. The net present value is not linear in the inputs, and the draws spread from −$208.8B to $47.7B, 90% of them between −$107.9B and $15.9B. The last row of the table and the line on the figure’s JR bar show that spread. The authors’ code runs the full simulation.
The inputs interact, so a step’s size depends on where the other dials stand when it moves. A departure matters more when the base is larger, for example. This path uses the equation’s order, left to right, so a reader can reproduce every step with the dials (The explorer’s equation below). An order-free check, the Shapley average, closes this section.
R or Python code for the bridge table and the figure
# comparison/R/model.R, lines 147-172
slider_inputs <- function(from, to, positions, ladder = NULL) {
x <- from
for (st in names(step_inputs)) {
p <- positions[[st]]
if (is.null(p)) p <- 0
mx <- slider_max(st)
stopifnot(p %in% 0:mx)
t <- p / mx
if (st == "confirmed_departures") {
stopifnot(!is.null(ladder))
k <- if (from$dep_b <= to$dep_b) p + 1 else mx + 1 - p
x$dep_b <- ladder$dep_b[k]; x$dep_j <- ladder$dep_j[k]; x$L <- ladder$L[k]
next
}
for (nm in step_inputs[[st]]) {
if (nm == "H") {
lad <- if (from$H <= to$H) SLIDER_HORIZONS else rev(SLIDER_HORIZONS)
stopifnot(lad[1] == from$H, lad[length(lad)] == to$H)
x$H <- lad[p + 1]
} else {
x[[nm]] <- (1 - t) * from[[nm]] + t * to[[nm]]
}
}
}
x
}
# comparison/R/model.R, lines 175-180
slider_value <- function(bi, panel = c("bsz", "rjkdc"), positions) {
panel <- match.arg(panel)
from <- if (panel == "bsz") bi$sup else bi$rauh
to <- if (panel == "bsz") bi$rauh else bi$sup
score_common(slider_inputs(from, to, positions, bi$ladder))[["b"]]
}
# comparison/R/model.R, lines 226-244
dial_path <- function(bi, order = DIAL_PATH_ORDER) {
st <- bridge_steps()
pos <- list()
prev <- slider_value(bi, "bsz", pos)
rows <- lapply(seq_along(order), function(k) {
id <- order[k]
for (d in dial_ids(id)) pos[[d]] <<- slider_max(d)
cur <- slider_value(bi, "bsz", pos)
title <- if (id %in% names(DIAL_PATH_TITLES)) DIAL_PATH_TITLES[[id]] else st$title[st$step_id == id]
out <- data.frame(step = k, step_id = id, symbol = DIAL_PATH_SYMBOLS[[id]], title = title,
from = prev, to = cur, change = cur - prev, stringsAsFactors = FALSE)
prev <<- cur
out
})
out <- do.call(rbind, rows)
out$step <- as.integer(out$step)
rownames(out) <- NULL
out
}# The Python twin (comparison/py/bridge.py); it writes comparison/export/py/.
# comparison/py/bridge.py, lines 143-164 (read-only display)
def slider_inputs(frm: dict, to: dict, positions: dict, ladder: dict | None = None) -> dict:
x = dict(frm)
for st in IDS:
p = positions.get(st, 0)
mx = slider_max(st)
assert p in range(mx + 1)
t = p / mx
if st == "confirmed_departures":
assert ladder is not None
k = p if frm["dep_b"] <= to["dep_b"] else mx - p # R's 1-based p + 1 / mx + 1 - p
x["dep_b"] = ladder["dep_b"][k]
x["dep_j"] = ladder["dep_j"][k]
x["L"] = ladder["L"][k]
continue
for nm in STEP_INPUTS[st]:
if nm == "H":
lad = SLIDER_HORIZONS if frm["H"] <= to["H"] else SLIDER_HORIZONS[::-1]
assert lad[0] == frm["H"] and lad[-1] == to["H"]
x["H"] = lad[p]
else:
x[nm] = (1 - t) * frm[nm] + t * to[nm]
return x
# comparison/py/bridge.py, lines 167-169 (read-only display)
def slider_value(sup, rauh, panel, positions, ladder):
frm, to = (sup, rauh) if panel == "bsz" else (rauh, sup)
return score_common(slider_inputs(frm, to, positions, ladder))["b"]
# comparison/py/bridge.py, lines 208-221 (read-only display)
def dial_path(sup, rauh, ladder, order=DIAL_PATH_ORDER):
titles = {s[0]: s[2] for s in STEPS}
titles["minor_inputs"] = "Three minor inputs"
pos = {}
prev = slider_value(sup, rauh, "bsz", pos, ladder)
rows = []
for k, id in enumerate(order, start=1):
for d in (EXPLORER_MINOR if id == "minor_inputs" else [id]):
pos[d] = slider_max(d)
cur = slider_value(sup, rauh, "bsz", pos, ladder)
rows.append({"step": k, "step_id": id, "symbol": DIAL_PATH_SYMBOLS[id], "title": titles[id],
"from": prev, "to": cur, "change": cur - prev})
prev = cur
return rows
Output (b), net fiscal effect, each analysis’s own horizon. The horizon dial moves from the BSZ analysis’s 5 years to a perpetuity.
| Dial | DISPUTES row | Kind | BSZ value | JR value | Change | Running average |
|---|---|---|---|---|---|---|
| BSZ end, (b) | $106.80B | |||||
| D, Billionaires who moved out in time | 3 | guesswork on data | none counted by name | seven counted by name: $614B on the BSZ list, $578B on the JR list | −$29.89B | $76.90B |
| q, Chance the tax does not survive litigation | 9 | guesswork | 100% | 50% | −$39.44B | $37.47B |
| f, Share of billionaires’ income tax lost | 6 | guesswork | the share of wealth assumed to leave, except named leavers, who take only their own tax | 45% of all, more than the share of wealth counted as gone | −$5.04B | $32.43B |
| C, Billionaires’ yearly California income tax | 5 | research vs guesswork | $3.06B/yr | $4.55B/yr | −$3.41B | $29.02B |
| H, How long the lost income tax is counted | 2 | scenario | 5 years | for ever | −$65.41B | −$36.40B |
| r, Discount rate for the lost income tax | 10 | scenario vs derived | 3% | 2.73% | −$7.48B | −$43.88B |
| W, Forbes source: which list and valuation date | 7 | data vs guesswork on data | Forbes 2026, $2,157B | Forbes 2025 grown 7%, $2,027.4B | −$2.18B | −$46.05B |
| a, Avoidance and evasion allowance | (not a row) | guesswork | 10.0% | 0.0% | +$12.47B | −$33.58B |
| Three minor inputs | 8, 7, (not a row) | data vs guesswork, guesswork vs data, guesswork | $150B; 0.0%; 1/3 | $0B; 0.5%; 0% | −$5.40B | −$38.98B |
| Sum of the steps | −$145.78B | |||||
| JR end, (b), the JR main estimate | −$38.98B | |||||
| The spread of the authors’ 100,000 draws behind the JR end | 90% between −$107.9B and $15.9B; all between −$208.8B and $47.7B |
Reading the table: the horizon step is the largest because it moves after f and C, when the share lost and the yearly income tax are already at the JR values, and a perpetuity multiplies that annual loss. One step raises the number. The JR analysis applies no avoidance allowance beyond migration, so dropping the BSZ analysis’s 10% adds +$12.5B. The rate step, −$7.5B, is what the authors’ random draw of the rate (r − g) adds to a loss that lasts for ever: at 2.73% a perpetuity is worth 36.6 a year’s loss, against 33.3 at 3%.
The explorer’s equation. The equation at the top of the explorer walks this path and groups its nine steps into four terms. The D and q terms are single steps of the dial path. Lost income tax is the four loss-box dials of the path added up (H, f, C and r). The last term is the other three steps added up (W, a and the minor switch). H is listed first because its step is the largest. The walk moves the four in the order f, C, H and r. The four amounts, rounded to two decimals, add up exactly from $106.80B to −$38.98B. The equation prints its two ends at one decimal, $106.8B and −$39.0B, the figures of the two main estimates.
The equation adds up only along this path. In another order the same dials split the gap differently among the terms. Moved first, q would take −$53.75B instead of −$39.44B. With D still at the BSZ value, the revenue still counts the tax on the billionaires who already left, so halving it halves that tax too.
The Apply buttons under the equation work from left to right. A term can be applied once every term to its left is applied, and undone once every term to its right is undone, so each press changes the BSZ panel by that term’s amount. Reset and Apply all always work. The Walk the path button in each panel moves one dial per press along the same path. The JR panel’s button walks it backwards, from the last term to the first.
For scale, the lost income tax itself is worth $75.0B today in the JR analysis and $0.7B in the BSZ analysis. The equation’s lost income tax term, $81.34B, is neither. It is the change along the path, from $2.0B to $83.3B. It ends above the JR analysis’s $75.0B because the avoidance dial, a, has not moved yet. Until it does, the share lost is 50% instead of 45%, because it still holds the BSZ movers’ 5%.
An order-free check: the Shapley average
The path’s steps depend on the order the dials move in. The same dial moved earlier or later changes the estimate by a different amount, because the inputs multiply each other. The Shapley average removes the order. It takes every order the inputs could be switched in and averages each input’s step over them. Its steps also add up to the gap, so it checks the path without choosing an order. Here each step switches one input from the BSZ value to the JR value of its September 2026 version (the share-lost step switches its two arguments together). The tables show two answers:
- Shapley values. Each step’s switch is averaged over all 39,916,800 orders of the 11 steps. The code computes this exactly, by estimating the model at every one of the 2,048 subsets of switched inputs. The steps then add up to the total gap whatever order they are read in.
- One order: a single sequence, the order of a previous comparison.1
R or Python code for the Shapley tables
# comparison/R/model.R, lines 115-119
switch_inputs <- function(sup, rauh, on) {
x <- sup
for (st in on) for (nm in step_inputs[[st]]) x[[nm]] <- rauh[[nm]]
x
}
# comparison/R/model.R, lines 259-273
shapley_bridge <- function(sup, rauh, players, output = c("a", "b"), v = NULL) {
output <- match.arg(output)
n <- length(players)
masks <- 0:(2^n - 1)
if (is.null(v)) v <- coalition_values(sup, rauh, players, output)
size <- vapply(masks, function(mk) sum(bitwAnd(mk, 2^(0:(n - 1))) > 0), 0)
# weight of a coalition of `size` others (the full coalition never lacks i)
w <- ifelse(size < n, factorial(size) * factorial(pmax(n - size - 1, 0)) / factorial(n), 0)
phi <- vapply(seq_len(n), function(i) {
bit <- 2^(i - 1)
without <- masks[bitwAnd(masks, bit) == 0]
sum(w[without + 1] * (v[without + bit + 1] - v[without + 1]))
}, 0)
stats::setNames(phi, players)
}
# comparison/R/model.R, lines 275-285
sequential_bridge <- function(sup, rauh, order, output = c("a", "b")) {
output <- match.arg(output)
prev <- score_common(sup)[[output]]
out <- numeric(length(order))
for (k in seq_along(order)) {
cur <- score_common(switch_inputs(sup, rauh, order[seq_len(k)]))[[output]]
out[k] <- cur - prev
prev <- cur
}
stats::setNames(out, order)
}# The Python twin (comparison/py/bridge.py); it writes comparison/export/py/.
# comparison/py/bridge.py, lines 121-126 (read-only display)
def switch_inputs(sup: dict, rauh: dict, on) -> dict:
x = dict(sup)
for st in on:
for nm in STEP_INPUTS[st]:
x[nm] = rauh[nm]
return x
# comparison/py/bridge.py, lines 235-250 (read-only display)
def shapley_bridge(sup, rauh, players, output, v=None):
n = len(players)
if v is None:
v = coalition_values(sup, rauh, players, output)
phi = []
for i in range(n):
bit = 1 << i
tot = 0.0
for mask in range(2 ** n):
if mask & bit:
continue
size = bin(mask).count("1")
w = math.factorial(size) * math.factorial(n - size - 1) / math.factorial(n)
tot += w * (v[mask | bit] - v[mask])
phi.append(tot)
return dict(zip(players, phi))
# comparison/py/bridge.py, lines 253-260 (read-only display)
def sequential_bridge(sup, rauh, order, output):
prev = score_common(sup)[output]
out = {}
for k in range(len(order)):
cur = score_common(switch_inputs(sup, rauh, order[:k + 1]))[output]
out[order[k]] = cur - prev
prev = cur
return outOutput (b), net fiscal effect, each analysis’s own horizon. The horizon step switches from the BSZ analysis’s 5 years to a perpetuity.
| Step | DISPUTES row | Kind | BSZ value | JR value | Shapley | One order |
|---|---|---|---|---|---|---|
| BSZ end, (b) | $106.80B | $106.80B | ||||
| Non-US-citizen residents | 8 | data vs guesswork | $150B | $0B | −$5.40B | −$6.99B |
| Forbes source: which list and valuation date | 7 | data vs guesswork on data | Forbes 2026, $2,157B | Forbes 2025 grown 7%, $2,027.4B | −$4.08B | −$6.04B |
| Real-estate deduction | 7 | guesswork vs data | 0.0% | 0.5% | −$0.36B | −$0.24B |
| Billionaires who moved out in time | 3 | guesswork on data | none counted by name | seven counted by name: $614B on the BSZ list, $578B on the JR list | −$23.63B | −$28.21B |
| Avoidance and evasion allowance | (not a row) | guesswork | 10.0% | 0.0% | +$11.35B | +$8.20B |
| Chance the tax does not survive litigation | 9 | guesswork | 100% | 50% | −$45.00B | −$37.52B |
| Share of billionaires’ income tax lost | 6 | guesswork | the share of wealth assumed to leave, except named leavers, who take only their own tax | 45% of all, more than the share of wealth counted as gone | −$32.62B | −$5.04B |
| Billionaires’ yearly California income tax | 5 | research vs guesswork | $3.06B/yr | $4.55B/yr | −$9.05B | −$3.07B |
| How long the lost income tax is counted | 2 | scenario | 5 years | for ever | −$32.33B | −$58.87B |
| Discount rate for the lost income tax | 10 | scenario vs derived | 3% | 2.73% | −$2.25B | −$6.73B |
| Extra income tax from selling assets to pay | (not a row) | guesswork | 1/3 | 0% | −$2.40B | −$1.28B |
| Sum of the steps | −$145.78B | −$145.78B | ||||
| JR end, (b) | −$38.98B | −$38.98B |
Reading the (b) table: the three largest Shapley steps are chance the tax does not survive litigation (−$45.0B); share of billionaires’ income tax lost (−$32.6B); how long the lost income tax is counted (−$32.3B). The horizon is the largest step read in that one order (−$58.9B, switched last, when the loss is already JR-sized) but −$32.3B averaged over orders, because a perpetuity multiplies whatever annual loss the other inputs imply. The Shapley average splits each interaction equally between the inputs that create it. The largest is between the share lost and the years counted: at the BSZ values, moving both changes the estimate by −$39.6B more than moving each alone (−$56.7B at the JR values), and half of it sits in each one’s average. One step raises the number: the JR analysis applies no avoidance allowance beyond migration, so dropping the BSZ analysis’s 10% adds +$11.3B on (b) and +$7.2B on (a).
Output (a), gross revenue
Output (a), gross revenue, the secondary output. It does not depend on the horizon.
| Step | DISPUTES row | Kind | BSZ value | JR value | Shapley | One order |
|---|---|---|---|---|---|---|
| BSZ end, (a) | $103.82B | $103.82B | ||||
| Non-US-citizen residents | 8 | data vs guesswork | $150B | $0B | −$5.30B | −$6.75B |
| Forbes source: which list and valuation date | 7 | data vs guesswork on data | Forbes 2026, $2,157B | Forbes 2025 grown 7%, $2,027.4B | −$4.00B | −$5.83B |
| Real-estate deduction | 7 | guesswork vs data | 0.0% | 0.5% | −$0.36B | −$0.23B |
| Billionaires who moved out in time | 3 | guesswork on data | none counted by name | seven counted by name: $614B on the BSZ list, $578B on the JR list | −$21.18B | −$26.02B |
| Avoidance and evasion allowance | (not a row) | guesswork | 10.0% | 0.0% | +$7.18B | +$7.25B |
| Chance the tax does not survive litigation | 9 | guesswork | 100% | 50% | −$44.17B | −$36.23B |
| Share of billionaires’ income tax lost | 6 | guesswork | the share of wealth assumed to leave, except named leavers, who take only their own tax | 45% of all, more than the share of wealth counted as gone | +$0.00B | +$0.00B |
| Billionaires’ yearly California income tax | 5 | research vs guesswork | $3.06B/yr | $4.55B/yr | +$0.00B | +$0.00B |
| How long the lost income tax is counted | 2 | scenario | 5 years | for ever | +$0.00B | +$0.00B |
| Discount rate for the lost income tax | 10 | scenario vs derived | 3% | 2.73% | +$0.00B | +$0.00B |
| Extra income tax from selling assets to pay | (not a row) | guesswork | 1/3 | 0% | +$0.00B | +$0.00B |
| Sum of the steps | −$67.82B | −$67.82B | ||||
| JR end, (a) | $36.00B | $36.00B |
Reading the (a) table: the three largest Shapley steps are chance the tax does not survive litigation (−$44.2B); billionaires who moved out in time (−$21.2B); avoidance and evasion allowance (+$7.2B). Order matters most for chance the tax does not survive litigation: −$44.2B averaged over all orders, −$36.2B in the one order shown, because the revenue inputs multiply each other.
Which revenue of the JR analysis the steps reach
The September 2026 version prints two revenue figures. On output (a) the steps reach the one consistent with its mean NPV, the expected revenue in the same Monte Carlo, because it is the revenue that NPV assumes. Using the other would add a further step of the size shown.
The two revenue figures
R or Python code for the candidates
# comparison/R/build.R, lines 110-272
build_all <- function(k = read_contracts()) {
bi <- bridge_inputs(k); sup <- bi$sup; rauh <- bi$rauh
so <- k$bsz$outputs; oo <- k$rjkdc$outputs; d <- k$docs
oi <- k$rjkdc$inputs
ggss_printed <- pick(so, "ggss_headline", "printed_value")
bsz_row1 <- pick(so, "tab5_row1_wealth_tax_revenue")
# The JR analysis's own Monte Carlo (NPV_dist_v8.R), as the authors ship it.
nber_args <- list(wt_min = 0, wt_max = pick(oi, "nber_ceiling_hardcoded"),
f_min = pick(oi, "nber_mc_f_min"), f_max = pick(oi, "nber_mc_f_max"),
c_min = pick(oi, "rauh_mc_c_min"), c_max = pick(oi, "rauh_mc_c_max"),
r_min = pick(oi, "rauh_mc_r_min"), r_max = pick(oi, "rauh_mc_r_max"))
nber_own <- function(H) do.call(rauh_nber_expectation, c(nber_args, list(H = H)))
steps <- bridge_steps(); ids <- steps$step_id
settings <- c("own", paste0("common_", ifelse(is.infinite(HORIZONS), "Inf", HORIZONS)))
bridge <- list(); ends <- list(); board <- list()
for (st in settings) {
s2 <- sup; r2 <- rauh
players <- ids
if (st != "own") {
H <- as.numeric(sub("common_", "", st))
s2$H <- H; r2$H <- H
players <- setdiff(ids, "horizon")
}
Hr <- r2$H
v_sup <- score_common(s2); v_rauh <- score_common(r2)
own_n <- nber_own(Hr)
# bit i-1 of a switchboard mask = input i in the bridge's order (ids); with a common
# horizon the horizon is not a player, so only masks with its bit off are listed
full_bit <- 2^(match(players, ids) - 1)
for (out in c("a", "b")) {
v <- coalition_values(s2, r2, players, out)
sh <- shapley_bridge(s2, r2, players, out, v = v)
cmask <- 0:(2^length(players) - 1)
board[[length(board) + 1]] <- data.frame(
output = out, horizon_setting = st,
mask = vapply(cmask, function(m) sum(full_bit[bitwAnd(m, 2^(seq_along(players) - 1)) > 0]), 0),
value = v, stringsAsFactors = FALSE)
sq <- sequential_bridge(s2, r2, intersect(sequential_order(), players), out)
full_sh <- stats::setNames(rep(0, length(ids)), ids); full_sh[players] <- sh
full_sq <- stats::setNames(rep(0, length(ids)), ids); full_sq[names(sq)] <- sq
bridge[[length(bridge) + 1]] <- data.frame(
horizon_setting = st, output = out, step_id = ids,
shapley = unname(full_sh), sequential = unname(full_sq),
sequential_rank = match(ids, sequential_order()), stringsAsFactors = FALSE)
# jr_own is the JR analysis's own expectation (its rate drawn between r_min and
# r_max); jr_residual, what the common function at the JR inputs misses of it, is
# zero in perpetuity (the JR rate r_jr is defined so) and kept so the identity is
# tested, not assumed. At a finite common horizon it is what one rate misses of the
# annuity averaged over the draw, f0 C (annuity(r_jr, H) - E_r[annuity(r, H)]).
ends[[length(ends) + 1]] <- data.frame(
horizon_setting = st, output = out,
bsz_model = v_sup[[out]], jr_model = v_rauh[[out]],
jr_own = own_n[[out]], jr_residual = own_n[[out]] - v_rauh[[out]],
stringsAsFactors = FALSE)
}
}
bridge <- do.call(rbind, bridge); ends <- do.call(rbind, ends)
board <- do.call(rbind, board)
board <- board[order(board$output, match(board$horizon_setting, settings), board$mask), ]
board$mask <- as.integer(board$mask)
rownames(board) <- NULL
bridge <- merge(bridge, steps, by = "step_id", sort = FALSE)
bridge <- bridge[order(bridge$horizon_setting, bridge$output, match(bridge$step_id, ids)), ]
rownames(bridge) <- NULL
# ---- endpoint checks against printed numbers ---------------------------------
own <- ends[ends$horizon_setting == "own", ]
ea <- own[own$output == "a", ]; eb <- own[own$output == "b", ]
endpoints <- data.frame(
endpoint_id = c("ggss_headline", "ggss_scoring", "bsz_tab5_row1", "bsz_net_constructed",
"rauh_nber_revenue_mc", "rauh_nber_npv"),
description = c(
"GGSS headline: BSZ model + their rounding step",
"GGSS estimate before rounding (p.4, '$104 billion')",
"BSZ Table 5 row 1 wealth tax revenue = model at all-BSZ inputs",
"BSZ row 1 net of its own extra income tax and 5 years of losses (not printed by BSZ; constructed here)",
"JR expected revenue in the Monte Carlo, E[WT] = (0 + 72)/2 = model at all-JR inputs",
"JR mean NPV, analytic expectation of the Monte Carlo = model at all-JR inputs (the rate at 1 / E[1/r])"),
value = c(ea$bsz_model + (ggss_printed - ea$bsz_model),
ea$bsz_model, ea$bsz_model, eb$bsz_model,
ea$jr_own, eb$jr_own),
printed_value = c(ggss_printed, pick(so, "ggss_scoring", "printed_value"),
pick(so, "tab5_row1_wealth_tax_revenue", "printed_value"), NA,
NA, pick(oo, "npv_mc_nber_mean", "printed_value")),
printed_where = c("GGSS p.4", "GGSS p.4", "BSZ Table 5, PDF p.39", "",
"", "Jaros-Rauh NBER p.1, p.35"),
contract_value = c(pick(so, "ggss_headline"), pick(so, "ggss_scoring"), bsz_row1, NA,
NA, pick(oo, "npv_mc_nber_mean")),
stringsAsFactors = FALSE)
endpoints$abs_diff_printed <- abs(endpoints$value - endpoints$printed_value)
# ---- output (a): which JR revenue is "the" revenue? (decided: MC mean, PLAN.md) ------
anchors <- data.frame(
anchor_id = c("mc_expected", "preferred_about_30"),
label = c(sprintf("Expected WT in the Monte Carlo behind the %s mean NPV",
formatC(pick(oo, "npv_mc_nber_mean", "printed_value"), format = "f", digits = 1)),
"Preferred estimate, 'approximately $30 billion'"),
value = c(ea$jr_own, pick(d, "nber_headline_revenue")),
where = c("NBER p.35, NPV_dist_v8.R (WT uniform between 0 and the 72 ceiling)",
"NBER p.1 abstract, p.23 (0.5 x the recognized base, Zuckerberg excluded)"),
default = c(TRUE, FALSE), stringsAsFactors = FALSE)
anchors$residual_vs_model <- anchors$value - ea$jr_model
# ---- annual income tax loss: every source on one scale ------------------------
# 5-year values at the BSZ end's rate (3%), perpetuities at the JR end's (r_jr, 2.73%), so
# the JR row in perpetuity is the JR analysis's own present value
A5 <- annuity(sup$r, 5); Ainf <- annuity(rauh$r, Inf)
loss <- data.frame(
source_id = c("bsz_row1", "bsz_row3", "lao_upper", "rauh_nber", "walczak_low", "walczak_high"),
label = c("BSZ Table 5 row 1 (benchmark)", "BSZ Table 5 row 3 (four reported leavers counted)",
"LAO ballot analysis (upper bound, 'less than $1 billion')",
"JR, Jaros and Rauh NBER: E[f] x E[C]",
"Walczak / CalTax, low (incl. spillovers)", "Walczak / CalTax, high (incl. spillovers)"),
side = c("bsz", "bsz", "neutral", "rjkdc", "walczak (third party)", "walczak (third party)"),
annual_loss = c(-pick(so, "tab5_row1_annual_ca_inctax_loss"), -pick(so, "tab5_row3_annual_ca_inctax_loss"),
pick(d, "lao_loss_upper"),
(nber_args$f_min + nber_args$f_max) / 2 * (nber_args$c_min + nber_args$c_max) / 2,
pick(d, "walczak_loss_low"), pick(d, "walczak_loss_high")),
where = c("BSZ PDF p.39", "BSZ PDF p.39", "LAO p.2", "NBER p.35", "Walczak p.1", "Walczak p.1"),
stringsAsFactors = FALSE)
loss$pv_5yr <- loss$annual_loss * A5
loss$pv_perpetuity <- loss$annual_loss * Ainf
# Each loss netted against the JR analysis's expected revenue on (a), in perpetuity.
loss$net_at_nber_revenue_perpetuity <- ea$jr_own - loss$pv_perpetuity
# ---- Hoopes (2026) Figure 2 beside a Hoopes-shaped chain of ours ----------------
hz <- k$hoopes
seqa <- bridge[bridge$horizon_setting == "own" & bridge$output == "a", ]
sq <- function(ids) sum(seqa$sequential[seqa$step_id %in% ids])
ours <- c(galle_estimate = ggss_printed,
ggss_rounding = ea$bsz_model - ggss_printed,
residency_correction = sq(c("noncitizens", "base_list")),
confirmed_departures = sq("confirmed_departures"),
additional_movers = NA,
additional_behavioral_response = sq(c("avoidance", "litigation_survival")),
real_estate_exclusion = sq("real_estate"),
rauh_rounding = ea$jr_residual,
income_tax_loss = eb$jr_model - ea$jr_model)
hoopes <- data.frame(step_id = names(ours), ours = unname(ours), stringsAsFactors = FALSE)
hoopes$hoopes <- hz$delta[match(hoopes$step_id, hz$step_id)]
hoopes$hoopes_uncertainty <- hz$uncertainty[match(hoopes$step_id, hz$step_id)]
hoopes$our_steps <- c("GGSS printed headline", "GGSS's own rounding of their estimate",
"Non-US-citizen residents + base list and valuation date",
"Billionaires who moved out in time",
"(none: Rauh's Monte Carlo headline does not use the ten 'expanded' departures)",
"Avoidance allowance + chance the tax does not survive litigation (his bar is the March 2026 version's elasticity response, which the September version does not use)",
"Real-estate deduction", "(none: the common function returns the September version's expectation exactly)",
"The JR analysis's own NPV minus its own expected revenue (all income-tax steps together)")
list(inputs = bridge_input_table(bi), bridge = bridge, ends = ends, endpoints = endpoints,
anchors = anchors, loss = loss, hoopes = hoopes, switchboard = board,
slider_sample = slider_sample(bi),
dial_path = dial_path(bi),
meta = data.frame(key = c("A_5", "A_inf", "L_bsz_aggressive", "R0_rauh", "S_rauh",
"X_bsz", "PV_bsz_own", "f_rauh", "C_rauh",
"r_bsz", "r_jr", "r_min_jr", "r_max_jr", "A_inf_jr_draw"),
value = c(A5, Ainf, bi$ladder$L[2], score_common(rauh)[["R0"]],
score_common(rauh)[["S"]], score_common(sup)[["X"]],
score_common(sup)[["PV"]], score_common(rauh)[["f"]], rauh$C,
sup$r, rauh$r, nber_args$r_min, nber_args$r_max,
expected_annuity(Inf, nber_args$r_min, nber_args$r_max))))
}# The Python twin (comparison/py/bridge.py); it writes comparison/export/py/.
# comparison/py/bridge.py, lines 327-433 (read-only display)
def build_all(k):
sup, rauh, ladder = bridge_inputs(k)
so, oo, d = k["bsz"]["outputs"], k["rjkdc"]["outputs"], k["docs"]
oi = k["rjkdc"]["inputs"]
ggss_printed = pick(so, "ggss_headline", "printed_value")
# The JR analysis's own Monte Carlo (NPV_dist_v8.R), as the authors ship it.
na = dict(wt_min=0.0, wt_max=pick(oi, "nber_ceiling_hardcoded"),
f_min=pick(oi, "nber_mc_f_min"), f_max=pick(oi, "nber_mc_f_max"),
c_min=pick(oi, "rauh_mc_c_min"), c_max=pick(oi, "rauh_mc_c_max"),
r_min=pick(oi, "rauh_mc_r_min"), r_max=pick(oi, "rauh_mc_r_max"))
settings = ["own"] + [f"common_{'Inf' if math.isinf(h) else h}" for h in HORIZONS]
bridge, ends, board = [], [], []
for st in settings:
s2, r2, players = dict(sup), dict(rauh), list(IDS)
if st != "own":
H = math.inf if st.endswith("Inf") else float(st.split("_")[1])
s2["H"] = r2["H"] = H
players = [p for p in IDS if p != "horizon"]
v_sup, v_rauh = score_common(s2), score_common(r2)
own_n = rauh_nber_expectation(**na, H=r2["H"])
# bit i of a switchboard mask = input i in the bridge's order (IDS); with a common
# horizon the horizon is not a player, so only masks with its bit off are listed
full_bit = [1 << IDS.index(p) for p in players]
for out in ("a", "b"):
v = coalition_values(s2, r2, players, out)
sh = shapley_bridge(s2, r2, players, out, v=v)
for m, val in enumerate(v):
board.append({"output": out, "horizon_setting": st,
"mask": sum(b for i, b in enumerate(full_bit) if m & (1 << i)),
"value": val})
sq = sequential_bridge(s2, r2, [p for p in SEQUENTIAL if p in players], out)
for sid, row, title, kind in STEPS:
bridge.append({"step_id": sid, "horizon_setting": st, "output": out,
"shapley": sh.get(sid, 0.0), "sequential": sq.get(sid, 0.0),
"sequential_rank": SEQUENTIAL.index(sid) + 1,
"disputes_row": row, "title": title, "kind": kind})
ends.append({"horizon_setting": st, "output": out,
"bsz_model": v_sup[out], "jr_model": v_rauh[out],
"jr_own": own_n[out], "jr_residual": own_n[out] - v_rauh[out]})
bridge.sort(key=lambda r: (r["horizon_setting"], r["output"], IDS.index(r["step_id"])))
board.sort(key=lambda r: (r["output"], settings.index(r["horizon_setting"]), r["mask"]))
ea = [e for e in ends if e["horizon_setting"] == "own" and e["output"] == "a"][0]
eb = [e for e in ends if e["horizon_setting"] == "own" and e["output"] == "b"][0]
endpoints = []
vals = [("ggss_headline", ea["bsz_model"] + (ggss_printed - ea["bsz_model"]),
pick(so, "ggss_headline", "printed_value")),
("ggss_scoring", ea["bsz_model"], pick(so, "ggss_scoring", "printed_value")),
("bsz_tab5_row1", ea["bsz_model"], pick(so, "tab5_row1_wealth_tax_revenue", "printed_value")),
("bsz_net_constructed", eb["bsz_model"], math.nan),
("rauh_nber_revenue_mc", ea["jr_own"], math.nan),
("rauh_nber_npv", eb["jr_own"], pick(oo, "npv_mc_nber_mean", "printed_value"))]
for eid, v, p in vals:
endpoints.append({"endpoint_id": eid, "value": v, "printed_value": p,
"abs_diff_printed": abs(v - p)})
anchors = [{"anchor_id": a, "value": v, "residual_vs_model": v - ea["jr_model"]} for a, v in [
("mc_expected", ea["jr_own"]),
("preferred_about_30", pick(d, "nber_headline_revenue"))]]
# 5-year values at the BSZ end's rate, perpetuities at the JR end's (see R/build.R)
A5 = annuity(sup["r"], 5)
Ainf = annuity(rauh["r"], math.inf)
loss = []
for sid, al in [("bsz_row1", -pick(so, "tab5_row1_annual_ca_inctax_loss")),
("bsz_row3", -pick(so, "tab5_row3_annual_ca_inctax_loss")),
("lao_upper", pick(d, "lao_loss_upper")),
("rauh_nber", (na["f_min"] + na["f_max"]) / 2 * (na["c_min"] + na["c_max"]) / 2),
("walczak_low", pick(d, "walczak_loss_low")),
("walczak_high", pick(d, "walczak_loss_high"))]:
loss.append({"source_id": sid, "annual_loss": al, "pv_5yr": al * A5,
"pv_perpetuity": al * Ainf,
"net_at_nber_revenue_perpetuity": ea["jr_own"] - al * Ainf})
hz = {r["step_id"]: r for r in k["hoopes"]}
sqa = {r["step_id"]: r["sequential"] for r in bridge
if r["horizon_setting"] == "own" and r["output"] == "a"}
ours = [("galle_estimate", ggss_printed),
("ggss_rounding", ea["bsz_model"] - ggss_printed),
("residency_correction", sqa["noncitizens"] + sqa["base_list"]),
("confirmed_departures", sqa["confirmed_departures"]),
("additional_movers", math.nan),
("additional_behavioral_response",
sqa["avoidance"] + sqa["litigation_survival"]),
("real_estate_exclusion", sqa["real_estate"]),
("rauh_rounding", ea["jr_residual"]),
("income_tax_loss", eb["jr_model"] - ea["jr_model"])]
hoopes = [{"step_id": sid, "ours": v,
"hoopes": num(hz[sid]["delta"]) if sid in hz else math.nan,
"hoopes_uncertainty": num(hz[sid]["uncertainty"]) if sid in hz else math.nan}
for sid, v in ours]
s_r = score_common(rauh)
meta = [{"key": "A_5", "value": A5}, {"key": "A_inf", "value": Ainf},
{"key": "L_bsz_aggressive", "value": ladder["L"][1]},
{"key": "R0_rauh", "value": s_r["R0"]}, {"key": "S_rauh", "value": s_r["S"]},
{"key": "X_bsz", "value": score_common(sup)["X"]},
{"key": "PV_bsz_own", "value": score_common(sup)["PV"]},
{"key": "f_rauh", "value": s_r["f"]}, {"key": "C_rauh", "value": rauh["C"]},
{"key": "r_bsz", "value": sup["r"]}, {"key": "r_jr", "value": rauh["r"]},
{"key": "r_min_jr", "value": na["r_min"]}, {"key": "r_max_jr", "value": na["r_max"]},
{"key": "A_inf_jr_draw", "value": expected_annuity(math.inf, na["r_min"], na["r_max"])}]
return {"bridge": bridge, "ends": ends, "endpoints": endpoints, "anchors": anchors,
"loss": loss, "hoopes": hoopes, "meta": meta, "switchboard": board,
"slider_sample": slider_sample(sup, rauh, ladder),
"dial_path": dial_path(sup, rauh, ladder)}| Candidate | Value | Where | Extra step vs the bridge end |
|---|---|---|---|
| Expected wealth tax in the Monte Carlo behind that version’s mean NPV (used) | $36.00B | NBER p.35, NPV_dist_v8.R (WT uniform between 0 and the 72 ceiling) | none |
| Preferred estimate, ‘approximately $30 billion’ | $30.00B | NBER p.1 abstract, p.23 (0.5 x the recognized base, Zuckerberg excluded) | −$6.00B |
The disputed inputs
This table tracks, one row at a time, every input on which the two estimates differ: what each analysis assumes, where it comes from, and what kind of claim it is. Each row is one step of the bridge above and one dial in the explorer. The Row column numbers the disagreements as the project’s crosswalk does (comparison/DISPUTES.md, which quotes each analysis on each point); row 7 has two parts here, and two rows cover inputs the crosswalk does not list. In the source columns, GGSS is the expert report by Galle, Gamage, Saez and Shanske (updated July 2026), on which the BSZ analysis builds.
R or Python code for the disputed inputs
# comparison/R/build.R, lines 14-58
bridge_inputs <- function(k) {
si <- k$bsz$inputs; so <- k$bsz$outputs
oi <- k$rjkdc$inputs; oo <- k$rjkdc$outputs
d <- k$docs
W0 <- pick(si, "baseline_net_worth")
noncit <- pick(d, "ggss_noncitizen_wealth")
C_sup <- pick(si, "ca_inctax_billionaires")
tau <- pick(oi, "tax_rate")
stopifnot(isTRUE(all.equal(tau, pick(si, "tax_rate"))))
base_r <- pick(oi, "baseline_net_worth") * (1 + pick(oi, "nber_growth_rate")) # 2,027.4 at 31 Dec 2026
R0_r <- pick(oo, "nber_domestic_grown") # 100.906, 5% of the grown base net of real estate
ceiling <- pick(oi, "nber_ceiling_hardcoded") # 72, after the seven publicly reported moves
# Departed wealth, the same people valued on each list. On the BSZ list (1 Jul 2026,
# Table 5): the four pre-2026 leavers, plus Brin, plus Spielberg and Sacks, whom the BSZ
# workbook does not list by name and who are valued at the JR file's net worth. On the JR
# list (grown 7%, net of real estate): the four from the person-level file, and all seven
# as the base less the script's 72 ceiling (the file itself sums to 72.05; JR note 4).
four_b <- pick(si, "pre2026_leavers_wealth")
seven_b <- four_b + pick(si, "brin_wealth") + pick(oi, "nber_spielberg_sacks_net_worth")
four_j <- pick(oi, "nber_departed_four_grown")
seven_j <- (R0_r - ceiling) / tau
# The leavers' own income tax at each stop, as the BSZ analysis counts it (Table 5): its
# aggressive row loses the tax of all seven named leavers (four escape the wealth tax, three
# pay it and leave later); the seven publicly reported moves are Page, Thiel, Hankey and
# Kalanick, Brin, and Spielberg and Sacks at BSZ's rate for billionaires without SEC data.
L_bsz <- pick(si, "leavers_annual_inctax")
L_seven <- pick(si, "pre2026_leavers_annual_inctax") + pick(si, "brin_annual_inctax") +
pick(si, "resid_inctax_rate") * pick(oi, "nber_spielberg_sacks_net_worth")
shared <- list(tau = tau, m = pick(si, "mobility_share"), g = pick(si, "gains_share"),
t_cg = pick(si, "ca_cg_rate"))
sup <- c(shared, list(
W_noncit = noncit, W_core = W0 - noncit, lam = 0, re = 0, dep_b = 0, dep_j = 0, L = 0,
alpha = pick(si, "avoidance_rate"), q = 1,
own = 1, f0 = 0, C = C_sup, H = pick(d, "bsz_loss_horizon"), r = pick(d, BSZ_RATE_ID),
s = pick(si, "sell_share")))
rauh <- c(shared, list(
W_noncit = 0, W_core = base_r, lam = 1, re = 1 - R0_r / (tau * base_r),
dep_b = seven_b, dep_j = seven_j, L = L_seven, alpha = 0, q = pick(oi, "q_litigation_survival"),
own = 0, f0 = (pick(oi, "nber_mc_f_min") + pick(oi, "nber_mc_f_max")) / 2,
C = (pick(oi, "rauh_mc_c_min") + pick(oi, "rauh_mc_c_max")) / 2,
H = pick(d, "rauh_loss_horizon"),
r = jr_discount_rate(pick(oi, "rauh_mc_r_min"), pick(oi, "rauh_mc_r_max")), s = 0))
list(sup = sup, rauh = rauh,
ladder = list(dep_b = c(0, four_b, seven_b), dep_j = c(0, four_j, seven_j), L = c(0, L_bsz, L_seven)))
}
# comparison/R/build.R, lines 62-108
bridge_input_table <- function(bi) {
s <- bi$sup; r <- bi$rauh
data.frame(
step_id = c("noncitizens", "base_list", "base_list", "real_estate", "confirmed_departures", "confirmed_departures", "confirmed_departures", "avoidance",
"litigation_survival", "income_loss_share", "income_loss_share",
"income_level", "horizon", "discount_rate", "asset_sales"),
input = c("W_noncit", "W_core", "lam", "re", "dep_b", "dep_j", "L", "alpha", "q", "own", "f0", "C", "H", "r", "s"),
bsz_value = c(s$W_noncit, s$W_core, s$lam, s$re, s$dep_b, s$dep_j, s$L, s$alpha, s$q, s$own, s$f0,
s$C, s$H, s$r, s$s),
jr_value = c(r$W_noncit, r$W_core, r$lam, r$re, r$dep_b, r$dep_j, r$L, r$alpha, r$q, r$own, r$f0,
r$C, r$H, r$r, r$s),
unit = c("$B", "$B", "list", "share", "$B", "$B", "$B/yr", "share", "probability", "rule", "share",
"$B/yr", "years", "rate", "share"),
bsz_source = c(
"GGSS 20 Jul 2026 p.2 fn.1 (24 people, about $150B); BSZ PDF p.54 ($132B at 1 Jan)",
"BSZ export baseline_net_worth ($2,307B, 1 Jul 2026; BSZ PDF p.24, GGSS p.4) minus the non-citizens",
"the Forbes 2026 list (0 = BSZ list)",
"no separate deduction (BSZ PDF p.24: 90% x 5% x $2,307B)",
"the movers are inside the avoidance allowance, half of the 10%, 5% of the wealth (BSZ PDF p.24); the benchmark taxes all 1 Jan 2026 residents and names no leaver (BSZ PDF p.23 fn.30); row 3, which BSZ label 'Aggressive assumptions for reported leavers' (Page, Thiel, Hankey, Kalanick, BSZ export pre2026_leavers_wealth), is the dial's middle stop",
"no departures (on the JR list)",
"no leavers' income tax lost",
"BSZ export avoidance_rate (GGSS p.4; BSZ PDF p.24)",
"the Act as written, collected in full: neither BSZ nor GGSS discounts for litigation; GGSS argue the Act was designed to stay clear of constitutional issues (GGSS p.11, section Constitutionality Issues, pp.11-13)",
"at the benchmark, the movers among the avoiders, who take a proportional share of the income tax with them (BSZ PDF p.24); with departures, the leavers' own income tax, in dollars, as BSZ Table 5 counts it (data: SEC filings for the top 3 and a wealth-proportional rate for the rest, BSZ PDF p.26, Table 4 p.38)",
"no flat share: the loss follows the people who leave",
"BSZ export ca_inctax_billionaires (Tab2!C14; BSZ PDF p.11): research, top wealth holders' income is 50% of top earners' (Balkir et al. 2025)",
"BSZ PDF p.27 ('If they last say only 5 years')",
"no rate in the BSZ analysis: 3%, the rate this OPA's BSZ overview uses for the 5-year loss (bsz-analysis/R/site_exports.R, site_pv_rate), the middle of the JR draw (scenario)",
"BSZ export sell_share (BSZ PDF p.24)"),
jr_source = c(
"excluded as too uncertain to collect: 28 international billionaires, $146.3B (treaty preemption and the Act's apportionment clause, \u00a750306(b); JR export nber_international_net_worth; NBER chapter Sec. 3.5, PDF pp.17-18)",
"chapter Sec. 3.2, PDF pp.12-13 ($2,027.4B at 7% growth, the average yearly growth of billionaire wealth that the Act's Section 1(w) states; a VAR(2) forecast gives $2,036.6B, PDF p.13); JR export baseline_net_worth (212 billionaires, 2025 Forbes list) grown 7% to 31 Dec 2026 (JR export nber_growth_rate; NBER README, chapter p.19)",
"the Forbes 2025 list grown 7% (1 = JR list)",
"chapter Table 6, PDF p.17 (residential real estate excluded); 1 - nber_domestic_grown / (5% x grown base) from the JR export: the person-level file nets out directly held real estate (NBER README; chapter p.19); the chapter prints the deduction as $8.19B, 'only around 0.6%' of the stayers' base (PDF p.16); this OPA's 0.5% is of the whole grown base",
"the seven publicly reported moves (Page, Brin, Thiel, Hankey, Spielberg, Kalanick, Sacks) valued on the BSZ list: BSZ exports pre2026_leavers_wealth and brin_wealth (Table 5, 1 Jul 2026), plus Spielberg and Sacks at the JR file's net worth (JR export nber_spielberg_sacks_net_worth), as the BSZ workbook does not list them by name",
"chapter Table 7, PDF p.21; the same seven on the JR list: (nber_domestic_grown - 72) / 5%, the script's ceiling literal (NPV_dist_v8.R:19; the person-level file sums to 72.05, JR note 4)",
"the seven's own income tax as the BSZ analysis counts it: BSZ exports pre2026_leavers_annual_inctax and brin_annual_inctax, plus Spielberg and Sacks at resid_inctax_rate (used only under the BSZ rule for the share lost)",
"none: avoidance channels other than leaving are left out because US securities are third-party reported (chapter PDF p.24 fn.25); the baseline makes no avoidance assumption and corrects the list instead (chapter PDF p.19 fn.21)",
"q = 0.50 'as our central estimate', the ASC 740-10 'more likely than not' threshold (chapter eq. 3, PDF pp.19-20; NBER README). The script draws WT ~ U[0, 72], whose mean is 0.5 x 72; its comment says the draw 'embeds litigation range', and the chapter also grounds the 0 floor in unreported departures (fn.32, PDF p.34; JR note 5)",
"the BSZ rule (the leavers' own tax) is not applied",
"midpoint of f ~ U[0.30, 0.60], drawn independently of the revenue, 'centered conservatively around the observed wealth-base departure fraction' and covering departures 'not publicly documented' (chapter PDF p.31 and p.33; eq. 19, PDF p.35; NPV_dist_v8.R:20): guesswork",
"midpoint of C ~ U[3.3, 5.8] (chapter Sec. 5.1, Table 9, PDF pp.28-30; NPV_dist_v8.R:21): the top is the Pareto fit at K = 212; the floor's K = 500 is the authors' judgement ('a reasonable lower bound', PDF p.28)",
"perpetuity: departed billionaires are assumed not to return (chapter Sec. 5.2, eqs. 13-14, PDF pp.30-32)",
"the one rate worth the same as the draw (r \u2212 g) ~ U[1.5%, 4.5%], the interest rate minus the loss's yearly growth (a real rate; chapter PDF p.32 and eq. 18, PDF p.35; JR export rauh_mc_r_min and rauh_mc_r_max; NPV_dist_v8.R:22): 1 / E[1/(r \u2212 g)] = 0.03 / ln(3) (derived)",
"not modelled"),
stringsAsFactors = FALSE)
}# The Python twin (comparison/py/bridge.py); it writes comparison/export/py/.
# comparison/py/bridge.py, lines 286-324 (read-only display)
def bridge_inputs(k):
si, so = k["bsz"]["inputs"], k["bsz"]["outputs"]
oi, oo = k["rjkdc"]["inputs"], k["rjkdc"]["outputs"]
d = k["docs"]
W0 = pick(si, "baseline_net_worth")
noncit = pick(d, "ggss_noncitizen_wealth")
C_sup = pick(si, "ca_inctax_billionaires")
tau = pick(oi, "tax_rate")
assert abs(tau - pick(si, "tax_rate")) < 1e-12
base_r = pick(oi, "baseline_net_worth") * (1 + pick(oi, "nber_growth_rate"))
R0_r = pick(oo, "nber_domestic_grown")
ceiling = pick(oi, "nber_ceiling_hardcoded")
# Departed wealth, the same people valued on each list (see R/build.R): on the BSZ list the
# four pre-2026 leavers, Brin, and Spielberg and Sacks at the JR file's net worth; on the
# JR list the four from the person-level file, and all seven as the base less the 72 ceiling.
four_b = pick(si, "pre2026_leavers_wealth")
seven_b = four_b + pick(si, "brin_wealth") + pick(oi, "nber_spielberg_sacks_net_worth")
four_j = pick(oi, "nber_departed_four_grown")
seven_j = (R0_r - ceiling) / tau
# The leavers' own income tax at each stop, as BSZ Table 5 counts it (see R/build.R).
L_bsz = pick(si, "leavers_annual_inctax")
L_seven = (pick(si, "pre2026_leavers_annual_inctax") + pick(si, "brin_annual_inctax") +
pick(si, "resid_inctax_rate") * pick(oi, "nber_spielberg_sacks_net_worth"))
shared = {"tau": tau, "m": pick(si, "mobility_share"), "g": pick(si, "gains_share"),
"t_cg": pick(si, "ca_cg_rate")}
sup = dict(shared, W_noncit=noncit, W_core=W0 - noncit, lam=0.0, re=0.0,
dep_b=0.0, dep_j=0.0, L=0.0,
alpha=pick(si, "avoidance_rate"), q=1.0,
own=1.0, f0=0.0, C=C_sup,
H=pick(d, "bsz_loss_horizon"), r=pick(d, BSZ_RATE_ID), s=pick(si, "sell_share"))
rauh = dict(shared, W_noncit=0.0, W_core=base_r, lam=1.0, re=1 - R0_r / (tau * base_r),
dep_b=seven_b, dep_j=seven_j, L=L_seven, alpha=0.0, q=pick(oi, "q_litigation_survival"),
own=0.0, f0=(pick(oi, "nber_mc_f_min") + pick(oi, "nber_mc_f_max")) / 2,
C=(pick(oi, "rauh_mc_c_min") + pick(oi, "rauh_mc_c_max")) / 2,
H=pick(d, "rauh_loss_horizon"),
r=jr_discount_rate(pick(oi, "rauh_mc_r_min"), pick(oi, "rauh_mc_r_max")), s=0.0)
ladder = {"dep_b": [0.0, four_b, seven_b], "dep_j": [0.0, four_j, seven_j],
"L": [0.0, L_bsz, L_seven]}
return sup, rauh, ladder| Row | Dispute | Kind | BSZ value and source | JR value and source |
|---|---|---|---|---|
| 8 | Non-US-citizen residents | data vs guesswork | $150B. GGSS 20 Jul 2026 p.2 fn.1 (24 people, about $150B); BSZ PDF p.54 ($132B at 1 Jan) | $0B. excluded as too uncertain to collect: 28 international billionaires, $146.3B (treaty preemption and the Act’s apportionment clause, §50306(b); JR export nber_international_net_worth; NBER chapter Sec. 3.5, PDF pp.17-18) |
| 7 | Forbes source: which list and valuation date | data vs guesswork on data | Forbes 2026, $2,157B. BSZ export baseline_net_worth ($2,307B, 1 Jul 2026; BSZ PDF p.24, GGSS p.4) minus the non-citizens | Forbes 2025 grown 7%, $2,027.4B. chapter Sec. 3.2, PDF pp.12-13 ($2,027.4B at 7% growth, the average yearly growth of billionaire wealth that the Act’s Section 1(w) states; a VAR(2) forecast gives $2,036.6B, PDF p.13); JR export baseline_net_worth (212 billionaires, 2025 Forbes list) grown 7% to 31 Dec 2026 (JR export nber_growth_rate; NBER README, chapter p.19) |
| 7 | Real-estate deduction | guesswork vs data | 0.0%. no separate deduction (BSZ PDF p.24: 90% x 5% x $2,307B) | 0.5%. chapter Table 6, PDF p.17 (residential real estate excluded); 1 - nber_domestic_grown / (5% x grown base) from the JR export: the person-level file nets out directly held real estate (NBER README; chapter p.19); the chapter prints the deduction as $8.19B, ‘only around 0.6%’ of the stayers’ base (PDF p.16); this OPA’s 0.5% is of the whole grown base |
| 3 | Billionaires who moved out in time | guesswork on data | none counted by name. the movers are inside the avoidance allowance, half of the 10%, 5% of the wealth (BSZ PDF p.24); the benchmark taxes all 1 Jan 2026 residents and names no leaver (BSZ PDF p.23 fn.30); row 3, which BSZ label ‘Aggressive assumptions for reported leavers’ (Page, Thiel, Hankey, Kalanick, BSZ export pre2026_leavers_wealth), is the dial’s middle stop | seven counted by name: $614B on the BSZ list, $578B on the JR list. the seven publicly reported moves (Page, Brin, Thiel, Hankey, Spielberg, Kalanick, Sacks) valued on the BSZ list: BSZ exports pre2026_leavers_wealth and brin_wealth (Table 5, 1 Jul 2026), plus Spielberg and Sacks at the JR file’s net worth (JR export nber_spielberg_sacks_net_worth), as the BSZ workbook does not list them by name; chapter Table 7, PDF p.21; the same seven on the JR list: (nber_domestic_grown - 72) / 5%, the script’s ceiling literal (NPV_dist_v8.R:19; the person-level file sums to 72.05, JR note 4) |
| (not a row) | Avoidance and evasion allowance | guesswork | 10.0%. BSZ export avoidance_rate (GGSS p.4; BSZ PDF p.24) | 0.0%. none: avoidance channels other than leaving are left out because US securities are third-party reported (chapter PDF p.24 fn.25); the baseline makes no avoidance assumption and corrects the list instead (chapter PDF p.19 fn.21) |
| 9 | Chance the tax does not survive litigation | guesswork | 100%. the Act as written, collected in full: neither BSZ nor GGSS discounts for litigation; GGSS argue the Act was designed to stay clear of constitutional issues (GGSS p.11, section Constitutionality Issues, pp.11-13) | 50%. q = 0.50 ‘as our central estimate’, the ASC 740-10 ‘more likely than not’ threshold (chapter eq. 3, PDF pp.19-20; NBER README). The script draws WT ~ U[0, 72], whose mean is 0.5 x 72; its comment says the draw ‘embeds litigation range’, and the chapter also grounds the 0 floor in unreported departures (fn.32, PDF p.34; JR note 5) |
| 6 | Share of billionaires’ income tax lost | guesswork | the share of wealth assumed to leave, except named leavers, who take only their own tax. at the benchmark, the movers among the avoiders, who take a proportional share of the income tax with them (BSZ PDF p.24); with departures, the leavers’ own income tax, in dollars, as BSZ Table 5 counts it (data: SEC filings for the top 3 and a wealth-proportional rate for the rest, BSZ PDF p.26, Table 4 p.38); no flat share: the loss follows the people who leave | 45% of all, more than the share of wealth counted as gone. midpoint of f ~ U[0.30, 0.60], drawn independently of the revenue, ‘centered conservatively around the observed wealth-base departure fraction’ and covering departures ‘not publicly documented’ (chapter PDF p.31 and p.33; eq. 19, PDF p.35; NPV_dist_v8.R:20): guesswork; the BSZ rule (the leavers’ own tax) is not applied |
| 5 | Billionaires’ yearly California income tax | research vs guesswork | $3.06B/yr. BSZ export ca_inctax_billionaires (Tab2!C14; BSZ PDF p.11): research, top wealth holders’ income is 50% of top earners’ (Balkir et al. 2025) | $4.55B/yr. midpoint of C ~ U[3.3, 5.8] (chapter Sec. 5.1, Table 9, PDF pp.28-30; NPV_dist_v8.R:21): the top is the Pareto fit at K = 212; the floor’s K = 500 is the authors’ judgement (‘a reasonable lower bound’, PDF p.28) |
| 2 | How long the lost income tax is counted | scenario | 5 years. BSZ PDF p.27 (‘If they last say only 5 years’) | for ever. perpetuity: departed billionaires are assumed not to return (chapter Sec. 5.2, eqs. 13-14, PDF pp.30-32) |
| 10 | Discount rate for the lost income tax | scenario vs derived | 3%. no rate in the BSZ analysis: 3%, the rate this OPA’s BSZ overview uses for the 5-year loss (bsz-analysis/R/site_exports.R, site_pv_rate), the middle of the JR draw (scenario) | 2.73%. the one rate worth the same as the draw (r − g) ~ U[1.5%, 4.5%], the interest rate minus the loss’s yearly growth (a real rate; chapter PDF p.32 and eq. 18, PDF p.35; JR export rauh_mc_r_min and rauh_mc_r_max; NPV_dist_v8.R:22): 1 / E[1/(r − g)] = 0.03 / ln(3) (derived) |
| (not a row) | Extra income tax from selling assets to pay | guesswork | 1/3. BSZ export sell_share (BSZ PDF p.24) | 0%. not modelled |
Every input, by origin
The OPA framework sorts every input by where it comes from: data (settled by observable facts), research (a published estimate), guesswork (an assumption with no data or study behind it, legal judgements included) and scenario (a choice of which world is estimated). This OPA adds derived (computed from the analysis’s own inputs) and guesswork on data (a judgement applied to observed facts). Each bridge step carries one of these kinds (the tables above); a kind written “A vs B” is A for the BSZ value and B for the JR value. A kind says what evidence is behind a value, not whether the quantity itself can be observed. The values the comparison transcribes from the PDFs itself, because neither contract carries them, are in comparison/data/document-inputs.csv.
The transcribed inputs
R or Python code for reading the inputs
# comparison/R/contract.R, lines 29-39
read_contracts <- function(root = comparison_root()) {
side <- function(s) list(
inputs = read_csv_plain(file.path(root, s, "export", "r", "inputs.csv")),
outputs = read_csv_plain(file.path(root, s, "export", "r", "outputs.csv")))
docs <- read_csv_plain(file.path(root, "comparison", "data", "document-inputs.csv"))
docs$value <- as.numeric(docs$value) # "Inf" parses to Inf
list(bsz = side("bsz-analysis"),
rjkdc = side("rjkdc-analysis"),
docs = docs,
hoopes = read_csv_plain(file.path(root, "comparison", "data", "hoopes-fig2.csv")))
}
# comparison/R/contract.R, lines 42-47
pick <- function(df, id, col = "value", key = NULL) {
key <- key %||% names(df)[1]
hit <- df[df[[key]] == id, , drop = FALSE]
if (nrow(hit) != 1) stop(sprintf("expected exactly one row '%s', found %d", id, nrow(hit)))
hit[[col]]
}# The Python twin (comparison/py/bridge.py); it writes comparison/export/py/.
# comparison/py/bridge.py, lines 57-63 (read-only display)
def read_contracts(root: str) -> dict:
side = lambda s: {"inputs": read_rows(os.path.join(root, s, "export", "r", "inputs.csv")),
"outputs": read_rows(os.path.join(root, s, "export", "r", "outputs.csv"))}
return {"bsz": side("bsz-analysis"),
"rjkdc": side("rjkdc-analysis"),
"docs": read_rows(os.path.join(root, "comparison", "data", "document-inputs.csv")),
"hoopes": read_rows(os.path.join(root, "comparison", "data", "hoopes-fig2.csv"))}
# comparison/py/bridge.py, lines 49-54 (read-only display)
def pick(rows: list[dict], key_value: str, col: str = "value") -> float:
key = list(rows[0].keys())[0]
hit = [r for r in rows if r[key] == key_value]
if len(hit) != 1:
raise KeyError(f"expected exactly one row '{key_value}', found {len(hit)}")
return num(hit[0][col])| Origin | Analysis | Input | Value | Source |
|---|---|---|---|---|
| data | BSZ | Non-US-citizen CA billionaires added since the February draft (24 people) | 150 $B | GGSS 20 Jul 2026 p.2 fn.1 (“about $150 billion”, 1 Jul 2026 values) |
| data | BSZ | Non-US-citizen CA billionaires at 1 Jan 2026 (25 people; cross-check only) | 132 $B | BSZ Aug 2026 Methodological Appendix A |
| data | BSZ | Taxable CA billionaire wealth at 1 Jan 2026 (240 people; cross-check only) | 2055 $B | BSZ Aug 2026 p.22 folio (PDF p.24) and Appendix A |
| data | Neutral | Billionaires in the GGSS text Hoopes’s estimate uses (February 2026 GGSS) | 213 count | Hoopes SSRN 6428578 p.3 and fn.1 |
| data | Neutral | Wealth in the GGSS text Hoopes’s estimate uses | 2182 $B | Hoopes SSRN 6428578 p.3 fn.1 (“$2.182 trillion”) |
| data | Neutral | Real-estate exclusion as Hoopes states it (a reduction of the taxable BASE) | 8 $B | Hoopes SSRN 6428578 p.4 |
| data | JR | Revenue effect of the Rauh et al. residency corrections (Ellison, Houston, Snyder out; Sacks in) | 12.84 $B | Rauh et al. SSRN 6340778 Sec 2.2 |
| derived | Neutral | GGSS (February) gross revenue before avoidance | 109 $B | Hoopes SSRN 6428578 p.3 fn.1 |
| derived | Neutral | GGSS (February) revenue after 10% avoidance | 99 $B | Hoopes SSRN 6428578 p.3 fn.1 |
| derived | Neutral | Rauh revenue as Hoopes states it | 40 $B | Hoopes SSRN 6428578 p.4 and Figure 1 (p.2) |
| derived | JR | Preferred revenue estimate (abstract and Sec 4.5) | 40 $B | Rauh et al. SSRN 6340778 abstract p.1 and Sec 4.5 p.14 (range 35-46) |
| derived | JR | NBER preferred revenue estimate (range 28-36) | 30 $B | Jaros and Rauh NBER c15504 abstract |
| derived | JR | NBER mean NPV (printed) | -38.9 $B | Jaros and Rauh NBER c15504 abstract and Sec 5.4 |
| guesswork | Neutral | Walczak (a third party): ongoing annual state tax loss incl. spillovers: low | 3.53 $B/yr | Walczak, California Tax Foundation, 22 Apr 2026, p.1 |
| guesswork | Neutral | Walczak (a third party): ongoing annual state tax loss incl. spillovers: high | 4.49 $B/yr | Walczak, California Tax Foundation, 22 Apr 2026, p.1 |
| guesswork | Neutral | LAO: possible ongoing income tax decrease ‘of less than $1 billion per year’ (upper bound) | 1 $B/yr | LAO Prop 40 ballot analysis, Fiscal Effects |
| scenario | BSZ | Years the lost income tax is counted (BSZ’s example of ‘annual temporary’ losses; Table 5 takes no stand on how long) | 5 years | BSZ Aug 2026 PDF p.27 (“If they last say only 5 years”) and Table 5 header p.39 |
| scenario | BSZ | Discount rate for the lost income tax at the BSZ end of the comparison (the BSZ analysis gives none; author decisions 2026-10-15): the rate the BSZ overview uses for its 5-year loss, the middle of the JR draw | 0.03 rate | Comparison choice, author decisions 2026-10-15 (feedback/DECISIONS.md): the BSZ overview discounts at 3% (bsz-analysis/R/site_exports.R, site_pv_rate); the JR analysis draws (r - g) ~ U[1.5%, 4.5%], the interest rate minus the loss’s yearly growth (a real rate), whose middle is 3% (NPV_dist_v8.R:22; Jaros and Rauh NBER chapter p.32). The JR end of the rate dial is derived from that draw in comparison/R/build.R |
| scenario | JR | Years the lost income tax is counted (perpetuity) | Inf years | Jaros and Rauh NBER c15504 Sec. 5.2, eqs. 13-14 (a growing perpetuity; departed billionaires are assumed not to return, PDF p.32); first version Rauh et al. SSRN 6340778 eq.17-18 |
19 transcribed inputs: data 7, derived 6, guesswork 3, scenario 3.
What reproduces
- Both ends. At the all-BSZ inputs the estimating function returns BSZ Table 5 row 1 exactly ($103.815B), and its net on (b) ($106.796B). At the inputs of the JR analysis’s September 2026 version it returns that version’s exact expected revenue on (a), $36.000B. It uses the script’s ceiling literal, 72, rather than the 72.05 the person-level file sums to (the JR analysis’s note 4), so that the two agree exactly. On (b), with the rate at 2.73% (1 / E[1/r] over the authors’ draw), it returns the authors’ exact Monte Carlo mean, −$38.980B, to 1e-9, against the printed −$38.9B from one seeded run.
test-endpoints.Rchecks both, and that one over 2.73% equals the average of 1/r over the draw. - The bridge is the explorer’s own.
test-dial-path.Rchecks that the nine steps of the dial path add up to the gap, and that each equals the difference between two explorer values in both panels, in R and inmodel.js. - The code does what the method says. Shapley values sum to the gap by construction;
test-shapley.Rchecks that this OPA’s do in every horizon setting and for both outputs, and that they equal a brute-force average over orders. This is a check of the code, not a finding about the analyses. - R and Python agree. The Python twin writes the same tables; the largest difference on the ends, the bridge, the dial path and the endpoints is 5.0e-13 (
test-parity.Rrequires 1e-9). - Not reproduced. The comparison does not re-run either analysis: it takes each analysis’s export contract as given, and each analysis’s own reproduction is on its overview page.
Reproducing this document
Rscript comparison/run.R # exports (R and Python), the overview page and the explorer
Rscript comparison/tests/testthat.R # the test suite
cd comparison/site && quarto render repro.qmdThe setup code below reads the exports and defines the helpers every exhibit uses.
R or Python code for the setup
# The report never retypes a result. It reads the files comparison/run.R writes
# (comparison/export/r/*.csv) and the two analyses' export contracts. The functions in
# comparison/R/ are sourced so the folds can show their code, and so a few lines below
# can recompute the two main estimates from the contracts and check them against the export.
for (f in list.files("../R", pattern = "[.]R$", full.names = TRUE)) source(f, keep.source = TRUE)
root <- comparison_root()
ex <- read_exports(file.path(root, "comparison", "export", "r")) # R/site.R
k <- read_contracts(root) # R/contract.R
ep <- ex$endpoints; en <- ex$ends; br <- ex$bridge; inp <- ex$inputs
# the dial path: the explorer's dials moved in the equation's order, each to its far end (R/model.R)
dp <- read_csv_plain(file.path(root, "comparison", "export", "r", "dial_path.csv"))
own <- en[en$horizon_setting == "own", ]
ea <- own[own$output == "a", ]; eb <- own[own$output == "b", ]
epv <- function(id, col = "value") ep[[col]][ep$endpoint_id == id]
steps <- bridge_steps()
# The discount rate at each end (the rate dial, author decision 2026-10-15) and the JR
# analysis's draw range, from the export
mv <- function(key) ex$meta$value[ex$meta$key == key]
r3 <- rate_txt(mv("r_bsz")); r_jr <- rate_txt(mv("r_jr"))
r_lo <- pct(mv("r_min_jr"), 1); r_hi <- pct(mv("r_max_jr"), 1)
# The three largest steps averaged over every order (the Shapley check), on output (b) at each
# analysis's own horizon, and the three largest on the explorer's path. The introduction names
# both lists in words (the path's ranking depends on its order); stop if either changes.
b_own_b <- br[br$horizon_setting == "own" & br$output == "b", ]
shap3_ids <- b_own_b$step_id[order(-abs(b_own_b$shapley))][1:3]
path3_ids <- dp$step_id[order(-abs(dp$change))][1:3]
stopifnot(setequal(path3_ids, c("horizon", "litigation_survival", "confirmed_departures")),
setequal(shap3_ids, c("litigation_survival", "income_loss_share", "horizon")))
# A(H, r): what 1 a year for H years is worth today at the rate r
# A signed amount stays on one line: a browser may break a line between a minus sign and the
# dollar sign, so amounts are wrapped in a no-wrap span, in running text (usd()) and in the
# tables (show(), which also catches amounts inside the source texts).
nobr <- function(x) ifelse(nzchar(x), sprintf('<span style="white-space:nowrap">%s</span>', x), x)
nobr_amounts <- function(x) gsub("([\u2212+-]\\$[0-9][0-9,.]*B)", '<span style="white-space:nowrap">\\1</span>', x)
# Money in running text: a dollar sign in markdown starts a formula, so escape it.
usd <- function(x, d = 1, signed = FALSE) nobr(gsub("$", "\\$", money(x, d, signed), fixed = TRUE))
# The BSZ authors' rounded headline ("about $100 billion", the abstract's rounding of Table 5's
# $104 billion; the endpoint ggss_headline holds the same rounding), as the root page words it.
bsz_round_words <- nobr(sprintf("\\$%s billion", format(epv("ggss_headline"))))
# Two pandoc traps in raw HTML tables: a bare dollar sign can pair with the next one into
# a formula across cells, and "(a)" in a cell reads as a list marker that leaves the
# surrounding <div> unclosed. Every table writes both as HTML entities.
ent <- function(x) gsub("\\(([a-z])\\)", "(\\1)", gsub("$", "$", x, fixed = TRUE))
show <- function(df) {
df[] <- lapply(df, function(x) if (is.character(x)) ent(nobr_amounts(x)) else x)
names(df) <- ent(names(df))
knitr::kable(df, format = "html", escape = FALSE, row.names = FALSE, table.attr = 'class="table table-sm"')
}
# Blank lines around the table, so pandoc sees the closing </div> as its own block.
scroll <- function(x) cat('\n<div class="table-scroll">\n\n', x, "\n\n</div>\n\n", sep = "")
# The code a reader sees when unfolding an exhibit: the source of the R functions that
# compute it (read from comparison/R/ at render time, so it cannot drift) and of their
# Python twins in comparison/py/bridge.py (same names).
rdef <- function(fn) {
f <- get(fn, envir = globalenv()); sr <- attr(f, "srcref")
l <- as.character(sr); l[1] <- paste0(fn, " <- ", l[1])
c(sprintf("# comparison/R/%s, lines %d-%d", basename(attr(sr, "srcfile")$filename),
as.integer(sr)[1], as.integer(sr)[3]), l, "")
}
pydef <- function(fn) {
l <- readLines("../py/bridge.py", warn = FALSE); i <- grep(paste0("^def ", fn, "\\("), l)
if (length(i) != 1) return(NULL)
nxt <- which(seq_along(l) > i & grepl("^[^[:space:]#]", l))
j <- if (length(nxt)) nxt[1] - 1 else length(l)
while (j > i && (!nzchar(trimws(l[j])) || grepl("^#", l[j]))) j <- j - 1
c(sprintf("# comparison/py/bridge.py, lines %d-%d (read-only display)", i, j), l[i:j], "")
}
twin_html <- function(what, r, py) {
if (is.null(py)) {
cat(sprintf('\n<details class="code-fold"><summary>R code for %s</summary>\n\n', what),
"```r\n", paste(r, collapse = "\n"), "\n```\n\n</details>\n\n", sep = "")
return(invisible())
}
cat(sprintf('\n<details class="twin code-fold"><summary><a href="#" class="lang on" data-lang="r">R</a> or <a href="#" class="lang" data-lang="py">Python</a> code for %s</summary>\n\n', what),
"::: {.lang-r}\n```r\n", paste(r, collapse = "\n"), "\n```\n:::\n\n",
"::: {.lang-py}\n```python\n", paste(py, collapse = "\n"), "\n```\n:::\n\n</details>\n\n", sep = "")
}
# One fold, one line: "R or Python code for <exhibit>"; the R functions and their twins.
twin_fold <- function(exhibit, fns, py = fns) {
r <- unlist(lapply(fns, rdef))
p <- unlist(lapply(py, pydef))
if (!is.null(p)) p <- c("# The Python twin (comparison/py/bridge.py); it writes comparison/export/py/.", "", p)
twin_html(exhibit, r, p)
}# The page is built with R, so the Python twin has no setup here: it runs on its own.
# From the repository root (Python 3.11 with scipy):
#
# python3 comparison/py/bridge.py # writes comparison/export/py/
#
# export/py/ holds the same tables this report reads from export/r/, and
# comparison/tests/testthat/test-parity.R checks every number against R's to 1e-9.
import pandas as pd
ends = pd.read_csv("comparison/export/py/ends.csv") # the two ends of every bridgeProp 40 comparison: Overview · Open Output: Explorer · Open Analysis: Report (here) and Slides · Open Materials
Also in this project: BSZ analysis overview · JR analysis overview
OPA done by Fernando Hoces de la Guardia, using agentic coding tools
Conclusion
This is not the only attempt to make these two numbers inspectable. PolicyEngine built a calculator that applies each paper’s assumptions to one person-level roster of Forbes billionaires and bridges the gap between them, and Hoopes (2026) walked between the two estimates in a note. Neither knew of this project, and I learned of PolicyEngine’s work after launch. Their calculator and this comparison are built differently (settings inside one model, against reproductions of each paper’s own pipeline) and land within a few billion of each other: +$106.2B and −$37.2B there, $106.8B and −$39.0B here. Three independent efforts in one year to open the same two numbers say something about where policy analysis is heading. The tools now make transparency cheap enough that people build it unasked. The appendix sets both beside this bridge.
Appendix: two other comparisons
PolicyEngine’s calculator
PolicyEngine’s Proposition 40 calculator (repository PolicyEngine/california-wealth-tax, started March 2026) takes the opposite route to the same place. It builds one person-level model over Forbes’s California list, refreshed daily, and offers three starting points: the statute with no behavioural response (+$117.6B), “Berkeley assumptions” (+$106.2B) and “Hoover assumptions” (−$37.2B), each paper’s assumptions applied to the calculator’s own roster rather than a reproduction of the paper. Between any two settings it draws a Shapley bridge, the same order-free average this report uses as its check. This OPA reproduces each paper’s pipeline and bridges the two results; the calculator applies both papers’ assumptions inside one pipeline. The numbers were read from the app on 2026-10-19 and are cited, not recomputed here. That two constructions this different land within a few billion of each other ($106.8B and −$39.0B here) is itself a check on both.
Hoopes (2026)
Jeffrey Hoopes, “Galle v Rauh: Comparing Two Revenue Estimates for California Billionaire Taxation” (SSRN 6428578; SSRN is a preprint site), is a previous comparison of the two estimates. It compares their earlier versions: the February 2026 text of the GGSS expert report, on which the BSZ analysis builds, and the March 2026 version of the JR analysis. His Figure 2 (p.6) is a waterfall from the “Galle estimate” to “Rauh net fiscal effect”. It prints no values, so his column below is read off the chart with comparison/tools/digitize_hoopes_fig2.py. The script fits the chart’s dollar scale to its axis ticks, and the fitted scale misses a tick by at most $0.1B. Beside his column is the bridge read in his order, ending at the JR analysis’s September 2026 version, which postdates his note. The list below gives every difference between the two.
R or Python code for the Hoopes comparison
# comparison/R/build.R, lines 110-272
build_all <- function(k = read_contracts()) {
bi <- bridge_inputs(k); sup <- bi$sup; rauh <- bi$rauh
so <- k$bsz$outputs; oo <- k$rjkdc$outputs; d <- k$docs
oi <- k$rjkdc$inputs
ggss_printed <- pick(so, "ggss_headline", "printed_value")
bsz_row1 <- pick(so, "tab5_row1_wealth_tax_revenue")
# The JR analysis's own Monte Carlo (NPV_dist_v8.R), as the authors ship it.
nber_args <- list(wt_min = 0, wt_max = pick(oi, "nber_ceiling_hardcoded"),
f_min = pick(oi, "nber_mc_f_min"), f_max = pick(oi, "nber_mc_f_max"),
c_min = pick(oi, "rauh_mc_c_min"), c_max = pick(oi, "rauh_mc_c_max"),
r_min = pick(oi, "rauh_mc_r_min"), r_max = pick(oi, "rauh_mc_r_max"))
nber_own <- function(H) do.call(rauh_nber_expectation, c(nber_args, list(H = H)))
steps <- bridge_steps(); ids <- steps$step_id
settings <- c("own", paste0("common_", ifelse(is.infinite(HORIZONS), "Inf", HORIZONS)))
bridge <- list(); ends <- list(); board <- list()
for (st in settings) {
s2 <- sup; r2 <- rauh
players <- ids
if (st != "own") {
H <- as.numeric(sub("common_", "", st))
s2$H <- H; r2$H <- H
players <- setdiff(ids, "horizon")
}
Hr <- r2$H
v_sup <- score_common(s2); v_rauh <- score_common(r2)
own_n <- nber_own(Hr)
# bit i-1 of a switchboard mask = input i in the bridge's order (ids); with a common
# horizon the horizon is not a player, so only masks with its bit off are listed
full_bit <- 2^(match(players, ids) - 1)
for (out in c("a", "b")) {
v <- coalition_values(s2, r2, players, out)
sh <- shapley_bridge(s2, r2, players, out, v = v)
cmask <- 0:(2^length(players) - 1)
board[[length(board) + 1]] <- data.frame(
output = out, horizon_setting = st,
mask = vapply(cmask, function(m) sum(full_bit[bitwAnd(m, 2^(seq_along(players) - 1)) > 0]), 0),
value = v, stringsAsFactors = FALSE)
sq <- sequential_bridge(s2, r2, intersect(sequential_order(), players), out)
full_sh <- stats::setNames(rep(0, length(ids)), ids); full_sh[players] <- sh
full_sq <- stats::setNames(rep(0, length(ids)), ids); full_sq[names(sq)] <- sq
bridge[[length(bridge) + 1]] <- data.frame(
horizon_setting = st, output = out, step_id = ids,
shapley = unname(full_sh), sequential = unname(full_sq),
sequential_rank = match(ids, sequential_order()), stringsAsFactors = FALSE)
# jr_own is the JR analysis's own expectation (its rate drawn between r_min and
# r_max); jr_residual, what the common function at the JR inputs misses of it, is
# zero in perpetuity (the JR rate r_jr is defined so) and kept so the identity is
# tested, not assumed. At a finite common horizon it is what one rate misses of the
# annuity averaged over the draw, f0 C (annuity(r_jr, H) - E_r[annuity(r, H)]).
ends[[length(ends) + 1]] <- data.frame(
horizon_setting = st, output = out,
bsz_model = v_sup[[out]], jr_model = v_rauh[[out]],
jr_own = own_n[[out]], jr_residual = own_n[[out]] - v_rauh[[out]],
stringsAsFactors = FALSE)
}
}
bridge <- do.call(rbind, bridge); ends <- do.call(rbind, ends)
board <- do.call(rbind, board)
board <- board[order(board$output, match(board$horizon_setting, settings), board$mask), ]
board$mask <- as.integer(board$mask)
rownames(board) <- NULL
bridge <- merge(bridge, steps, by = "step_id", sort = FALSE)
bridge <- bridge[order(bridge$horizon_setting, bridge$output, match(bridge$step_id, ids)), ]
rownames(bridge) <- NULL
# ---- endpoint checks against printed numbers ---------------------------------
own <- ends[ends$horizon_setting == "own", ]
ea <- own[own$output == "a", ]; eb <- own[own$output == "b", ]
endpoints <- data.frame(
endpoint_id = c("ggss_headline", "ggss_scoring", "bsz_tab5_row1", "bsz_net_constructed",
"rauh_nber_revenue_mc", "rauh_nber_npv"),
description = c(
"GGSS headline: BSZ model + their rounding step",
"GGSS estimate before rounding (p.4, '$104 billion')",
"BSZ Table 5 row 1 wealth tax revenue = model at all-BSZ inputs",
"BSZ row 1 net of its own extra income tax and 5 years of losses (not printed by BSZ; constructed here)",
"JR expected revenue in the Monte Carlo, E[WT] = (0 + 72)/2 = model at all-JR inputs",
"JR mean NPV, analytic expectation of the Monte Carlo = model at all-JR inputs (the rate at 1 / E[1/r])"),
value = c(ea$bsz_model + (ggss_printed - ea$bsz_model),
ea$bsz_model, ea$bsz_model, eb$bsz_model,
ea$jr_own, eb$jr_own),
printed_value = c(ggss_printed, pick(so, "ggss_scoring", "printed_value"),
pick(so, "tab5_row1_wealth_tax_revenue", "printed_value"), NA,
NA, pick(oo, "npv_mc_nber_mean", "printed_value")),
printed_where = c("GGSS p.4", "GGSS p.4", "BSZ Table 5, PDF p.39", "",
"", "Jaros-Rauh NBER p.1, p.35"),
contract_value = c(pick(so, "ggss_headline"), pick(so, "ggss_scoring"), bsz_row1, NA,
NA, pick(oo, "npv_mc_nber_mean")),
stringsAsFactors = FALSE)
endpoints$abs_diff_printed <- abs(endpoints$value - endpoints$printed_value)
# ---- output (a): which JR revenue is "the" revenue? (decided: MC mean, PLAN.md) ------
anchors <- data.frame(
anchor_id = c("mc_expected", "preferred_about_30"),
label = c(sprintf("Expected WT in the Monte Carlo behind the %s mean NPV",
formatC(pick(oo, "npv_mc_nber_mean", "printed_value"), format = "f", digits = 1)),
"Preferred estimate, 'approximately $30 billion'"),
value = c(ea$jr_own, pick(d, "nber_headline_revenue")),
where = c("NBER p.35, NPV_dist_v8.R (WT uniform between 0 and the 72 ceiling)",
"NBER p.1 abstract, p.23 (0.5 x the recognized base, Zuckerberg excluded)"),
default = c(TRUE, FALSE), stringsAsFactors = FALSE)
anchors$residual_vs_model <- anchors$value - ea$jr_model
# ---- annual income tax loss: every source on one scale ------------------------
# 5-year values at the BSZ end's rate (3%), perpetuities at the JR end's (r_jr, 2.73%), so
# the JR row in perpetuity is the JR analysis's own present value
A5 <- annuity(sup$r, 5); Ainf <- annuity(rauh$r, Inf)
loss <- data.frame(
source_id = c("bsz_row1", "bsz_row3", "lao_upper", "rauh_nber", "walczak_low", "walczak_high"),
label = c("BSZ Table 5 row 1 (benchmark)", "BSZ Table 5 row 3 (four reported leavers counted)",
"LAO ballot analysis (upper bound, 'less than $1 billion')",
"JR, Jaros and Rauh NBER: E[f] x E[C]",
"Walczak / CalTax, low (incl. spillovers)", "Walczak / CalTax, high (incl. spillovers)"),
side = c("bsz", "bsz", "neutral", "rjkdc", "walczak (third party)", "walczak (third party)"),
annual_loss = c(-pick(so, "tab5_row1_annual_ca_inctax_loss"), -pick(so, "tab5_row3_annual_ca_inctax_loss"),
pick(d, "lao_loss_upper"),
(nber_args$f_min + nber_args$f_max) / 2 * (nber_args$c_min + nber_args$c_max) / 2,
pick(d, "walczak_loss_low"), pick(d, "walczak_loss_high")),
where = c("BSZ PDF p.39", "BSZ PDF p.39", "LAO p.2", "NBER p.35", "Walczak p.1", "Walczak p.1"),
stringsAsFactors = FALSE)
loss$pv_5yr <- loss$annual_loss * A5
loss$pv_perpetuity <- loss$annual_loss * Ainf
# Each loss netted against the JR analysis's expected revenue on (a), in perpetuity.
loss$net_at_nber_revenue_perpetuity <- ea$jr_own - loss$pv_perpetuity
# ---- Hoopes (2026) Figure 2 beside a Hoopes-shaped chain of ours ----------------
hz <- k$hoopes
seqa <- bridge[bridge$horizon_setting == "own" & bridge$output == "a", ]
sq <- function(ids) sum(seqa$sequential[seqa$step_id %in% ids])
ours <- c(galle_estimate = ggss_printed,
ggss_rounding = ea$bsz_model - ggss_printed,
residency_correction = sq(c("noncitizens", "base_list")),
confirmed_departures = sq("confirmed_departures"),
additional_movers = NA,
additional_behavioral_response = sq(c("avoidance", "litigation_survival")),
real_estate_exclusion = sq("real_estate"),
rauh_rounding = ea$jr_residual,
income_tax_loss = eb$jr_model - ea$jr_model)
hoopes <- data.frame(step_id = names(ours), ours = unname(ours), stringsAsFactors = FALSE)
hoopes$hoopes <- hz$delta[match(hoopes$step_id, hz$step_id)]
hoopes$hoopes_uncertainty <- hz$uncertainty[match(hoopes$step_id, hz$step_id)]
hoopes$our_steps <- c("GGSS printed headline", "GGSS's own rounding of their estimate",
"Non-US-citizen residents + base list and valuation date",
"Billionaires who moved out in time",
"(none: Rauh's Monte Carlo headline does not use the ten 'expanded' departures)",
"Avoidance allowance + chance the tax does not survive litigation (his bar is the March 2026 version's elasticity response, which the September version does not use)",
"Real-estate deduction", "(none: the common function returns the September version's expectation exactly)",
"The JR analysis's own NPV minus its own expected revenue (all income-tax steps together)")
list(inputs = bridge_input_table(bi), bridge = bridge, ends = ends, endpoints = endpoints,
anchors = anchors, loss = loss, hoopes = hoopes, switchboard = board,
slider_sample = slider_sample(bi),
dial_path = dial_path(bi),
meta = data.frame(key = c("A_5", "A_inf", "L_bsz_aggressive", "R0_rauh", "S_rauh",
"X_bsz", "PV_bsz_own", "f_rauh", "C_rauh",
"r_bsz", "r_jr", "r_min_jr", "r_max_jr", "A_inf_jr_draw"),
value = c(A5, Ainf, bi$ladder$L[2], score_common(rauh)[["R0"]],
score_common(rauh)[["S"]], score_common(sup)[["X"]],
score_common(sup)[["PV"]], score_common(rauh)[["f"]], rauh$C,
sup$r, rauh$r, nber_args$r_min, nber_args$r_max,
expected_annuity(Inf, nber_args$r_min, nber_args$r_max))))
}# The Python twin (comparison/py/bridge.py); it writes comparison/export/py/.
# comparison/py/bridge.py, lines 327-433 (read-only display)
def build_all(k):
sup, rauh, ladder = bridge_inputs(k)
so, oo, d = k["bsz"]["outputs"], k["rjkdc"]["outputs"], k["docs"]
oi = k["rjkdc"]["inputs"]
ggss_printed = pick(so, "ggss_headline", "printed_value")
# The JR analysis's own Monte Carlo (NPV_dist_v8.R), as the authors ship it.
na = dict(wt_min=0.0, wt_max=pick(oi, "nber_ceiling_hardcoded"),
f_min=pick(oi, "nber_mc_f_min"), f_max=pick(oi, "nber_mc_f_max"),
c_min=pick(oi, "rauh_mc_c_min"), c_max=pick(oi, "rauh_mc_c_max"),
r_min=pick(oi, "rauh_mc_r_min"), r_max=pick(oi, "rauh_mc_r_max"))
settings = ["own"] + [f"common_{'Inf' if math.isinf(h) else h}" for h in HORIZONS]
bridge, ends, board = [], [], []
for st in settings:
s2, r2, players = dict(sup), dict(rauh), list(IDS)
if st != "own":
H = math.inf if st.endswith("Inf") else float(st.split("_")[1])
s2["H"] = r2["H"] = H
players = [p for p in IDS if p != "horizon"]
v_sup, v_rauh = score_common(s2), score_common(r2)
own_n = rauh_nber_expectation(**na, H=r2["H"])
# bit i of a switchboard mask = input i in the bridge's order (IDS); with a common
# horizon the horizon is not a player, so only masks with its bit off are listed
full_bit = [1 << IDS.index(p) for p in players]
for out in ("a", "b"):
v = coalition_values(s2, r2, players, out)
sh = shapley_bridge(s2, r2, players, out, v=v)
for m, val in enumerate(v):
board.append({"output": out, "horizon_setting": st,
"mask": sum(b for i, b in enumerate(full_bit) if m & (1 << i)),
"value": val})
sq = sequential_bridge(s2, r2, [p for p in SEQUENTIAL if p in players], out)
for sid, row, title, kind in STEPS:
bridge.append({"step_id": sid, "horizon_setting": st, "output": out,
"shapley": sh.get(sid, 0.0), "sequential": sq.get(sid, 0.0),
"sequential_rank": SEQUENTIAL.index(sid) + 1,
"disputes_row": row, "title": title, "kind": kind})
ends.append({"horizon_setting": st, "output": out,
"bsz_model": v_sup[out], "jr_model": v_rauh[out],
"jr_own": own_n[out], "jr_residual": own_n[out] - v_rauh[out]})
bridge.sort(key=lambda r: (r["horizon_setting"], r["output"], IDS.index(r["step_id"])))
board.sort(key=lambda r: (r["output"], settings.index(r["horizon_setting"]), r["mask"]))
ea = [e for e in ends if e["horizon_setting"] == "own" and e["output"] == "a"][0]
eb = [e for e in ends if e["horizon_setting"] == "own" and e["output"] == "b"][0]
endpoints = []
vals = [("ggss_headline", ea["bsz_model"] + (ggss_printed - ea["bsz_model"]),
pick(so, "ggss_headline", "printed_value")),
("ggss_scoring", ea["bsz_model"], pick(so, "ggss_scoring", "printed_value")),
("bsz_tab5_row1", ea["bsz_model"], pick(so, "tab5_row1_wealth_tax_revenue", "printed_value")),
("bsz_net_constructed", eb["bsz_model"], math.nan),
("rauh_nber_revenue_mc", ea["jr_own"], math.nan),
("rauh_nber_npv", eb["jr_own"], pick(oo, "npv_mc_nber_mean", "printed_value"))]
for eid, v, p in vals:
endpoints.append({"endpoint_id": eid, "value": v, "printed_value": p,
"abs_diff_printed": abs(v - p)})
anchors = [{"anchor_id": a, "value": v, "residual_vs_model": v - ea["jr_model"]} for a, v in [
("mc_expected", ea["jr_own"]),
("preferred_about_30", pick(d, "nber_headline_revenue"))]]
# 5-year values at the BSZ end's rate, perpetuities at the JR end's (see R/build.R)
A5 = annuity(sup["r"], 5)
Ainf = annuity(rauh["r"], math.inf)
loss = []
for sid, al in [("bsz_row1", -pick(so, "tab5_row1_annual_ca_inctax_loss")),
("bsz_row3", -pick(so, "tab5_row3_annual_ca_inctax_loss")),
("lao_upper", pick(d, "lao_loss_upper")),
("rauh_nber", (na["f_min"] + na["f_max"]) / 2 * (na["c_min"] + na["c_max"]) / 2),
("walczak_low", pick(d, "walczak_loss_low")),
("walczak_high", pick(d, "walczak_loss_high"))]:
loss.append({"source_id": sid, "annual_loss": al, "pv_5yr": al * A5,
"pv_perpetuity": al * Ainf,
"net_at_nber_revenue_perpetuity": ea["jr_own"] - al * Ainf})
hz = {r["step_id"]: r for r in k["hoopes"]}
sqa = {r["step_id"]: r["sequential"] for r in bridge
if r["horizon_setting"] == "own" and r["output"] == "a"}
ours = [("galle_estimate", ggss_printed),
("ggss_rounding", ea["bsz_model"] - ggss_printed),
("residency_correction", sqa["noncitizens"] + sqa["base_list"]),
("confirmed_departures", sqa["confirmed_departures"]),
("additional_movers", math.nan),
("additional_behavioral_response",
sqa["avoidance"] + sqa["litigation_survival"]),
("real_estate_exclusion", sqa["real_estate"]),
("rauh_rounding", ea["jr_residual"]),
("income_tax_loss", eb["jr_model"] - ea["jr_model"])]
hoopes = [{"step_id": sid, "ours": v,
"hoopes": num(hz[sid]["delta"]) if sid in hz else math.nan,
"hoopes_uncertainty": num(hz[sid]["uncertainty"]) if sid in hz else math.nan}
for sid, v in ours]
s_r = score_common(rauh)
meta = [{"key": "A_5", "value": A5}, {"key": "A_inf", "value": Ainf},
{"key": "L_bsz_aggressive", "value": ladder["L"][1]},
{"key": "R0_rauh", "value": s_r["R0"]}, {"key": "S_rauh", "value": s_r["S"]},
{"key": "X_bsz", "value": score_common(sup)["X"]},
{"key": "PV_bsz_own", "value": score_common(sup)["PV"]},
{"key": "f_rauh", "value": s_r["f"]}, {"key": "C_rauh", "value": rauh["C"]},
{"key": "r_bsz", "value": sup["r"]}, {"key": "r_jr", "value": rauh["r"]},
{"key": "r_min_jr", "value": na["r_min"]}, {"key": "r_max_jr", "value": na["r_max"]},
{"key": "A_inf_jr_draw", "value": expected_annuity(math.inf, na["r_min"], na["r_max"])}]
return {"bridge": bridge, "ends": ends, "endpoints": endpoints, "anchors": anchors,
"loss": loss, "hoopes": hoopes, "meta": meta, "switchboard": board,
"slider_sample": slider_sample(sup, rauh, ladder),
"dial_path": dial_path(sup, rauh, ladder)}| Hoopes Figure 2 bar | Hoopes (read off the chart) | This OPA, one order | This OPA’s steps |
|---|---|---|---|
| Galle estimate | $100.0B ± 0.6 | $100.0B | GGSS printed headline |
| (no bar) | none | +$3.8B | GGSS’s own rounding of their estimate |
| Residency correction | −$12.6B ± 0.6 | −$12.6B | Non-US-citizen residents + base list and valuation date |
| Confirmed departures | −$17.0B ± 0.6 | −$26.0B | Billionaires who moved out in time |
| Additional movers | −$12.5B ± 0.6 | none | (none: Rauh’s Monte Carlo headline does not use the ten ‘expanded’ departures) |
| Additional behavioral response | −$9.2B ± 0.6 | −$29.0B | Avoidance allowance + chance the tax does not survive litigation (his bar is the March 2026 version’s elasticity response, which the September version does not use) |
| Real estate exclusion | −$1.1B ± 0.8 | −$0.2B | Real-estate deduction |
| (no bar) | none | −$0.0000B | (none: the common function returns the September version’s expectation exactly) |
| Income tax loss | −$71.5B ± 0.6 | −$75.0B | The JR analysis’s own NPV minus its own expected revenue (all income-tax steps together) |
This OPA’s column follows Hoopes’s construction. Its revenue rows are steps on output (a), starting from the BSZ wealth tax ($103.8B), and its last row adds the JR analysis’s whole income tax loss. The “One order” column of the (b) table in the order-free check reads the same order on the net fiscal effect, so its sizes differ.
The “Galle estimate” bar is clipped by the chart’s frame at $100B; its value is taken from the note’s text (p.3), so the reading uncertainty printed with it is the digitizer’s default for every bar, not a measurement.
The note discloses two things. Hoopes states his priors: “To lay my cards on the table, I believe that the world is complicated, and that when possible, empirical analysis should be done in as detailed a way as possible” (p.2). He also thanks “David Gamage for encouraging me to read both of these analyses carefully and compare the two” (p.1). Gamage is a GGSS co-author.
Every difference
- Vintage. Hoopes’s estimate uses the February GGSS text: 213 billionaires, $2,182B of wealth, $109B gross, $99B after 10% avoidance, rounded up to $100B (p.3 fn.1). The July GGSS text covers 250 billionaires and $2,307B: $103.8B after avoidance, rounded down to $100B. The same printed headline hides a rounding of +$1B in one vintage and of −$3.8B in the other.
- Residency. His bar is −$12.6B, close to Rauh’s own −$12.84B for dropping Ellison, Houston and Snyder and adding Sacks (Rauh p.5). By July the BSZ analysis has itself dropped Ellison, so this OPA’s step is a different quantity: the $150B of non-US-citizen residents Rauh omits plus the list and valuation-date difference, −$12.6B in one order.
- Confirmed departures. His bar is −$17.0B; Rauh’s own step is −$26.69B before avoidance (94.2 to 67.51, p.11), and this OPA’s is −$26.0B at that point of the order. This OPA could not trace his figure to a number in Rauh et al.
- Additional movers. His −$12.5B matches Rauh’s expanded ten-departure estimate (67.51 to 55.1, a step of −$12.41B, pp.12-13). The March 2026 version’s own −$24.7B mean NPV does not use that estimate, so this OPA’s bridge has no such step.
- Behaviour. His −$9.2B is close to the March 2026 version’s step from the expanded estimate to the literature calibration (55.1 to 45.59, −$9.51B, p.14), a mobility response from a European elasticity. The September 2026 version predicts no such departures; in their place it weights the revenue by the chance the tax survives litigation. This OPA’s, −$29.0B in one order, nets two labelled steps: dropping the 10.0% avoidance allowance (up) and the litigation weight (down).
- Real estate. His bar is drawn near zero (−$1.1B ± 0.8 read off the chart), while his text says the exclusion reduces the base by about $8B (p.4). The two are consistent: $8B of base is about $0.4B of revenue at 5%. This OPA’s is −$0.23B.
- Level before income tax. His revenue level before the income-tax bar is about $47.2B, near the March 2026 version’s preferred ‘about $40B’ (p.14). This OPA’s ends at $36.00B, the expected revenue in the Monte Carlo behind the September 2026 version’s mean NPV (its text prefers ‘approximately $30B’, p.1).
- Income tax. His single bar, −$71.5B, ends at −$24.3B, close to the March 2026 version’s −$24.7B. This OPA’s is −$74.98B, the September 2026 version’s present value of lost income tax against $36.00B of revenue, split into five labelled steps (the share lost, the income tax level, the horizon, the discount rate, asset-sale tax), which interact with the base steps.
- Order and labels. His is one sequential order, with no provenance labels. This OPA’s bridge moves the explorer’s dials in the order of the equation at the top (D, q, the loss box’s dials, then the rest), with each step labelled data, research, guesswork, scenario or derived (“A vs B” reads the BSZ value’s kind against the JR value’s). The Shapley average (a step’s effect averaged over every order the inputs could be switched in) checks it without choosing an order, and the column here reads this OPA’s steps in his order.
- Steps he does not have. The non-US-citizen residents, the BSZ analysis’s avoidance allowance being dropped, the asset-sale income tax, and the litigation weight of the September 2026 version (which postdates his note).
Footnotes
Hoopes, J. L. (2026), “Galle v Rauh: Comparing Two Revenue Estimates for California Billionaire Taxation”, SSRN Working Paper 6428578. The appendix sets its steps beside this bridge.↩︎