Prop 40, the comparison: the reproduction

The BSZ analysis and the JR analysis put on one common output, and the gap between them explained one disputed input at a time

Published

October 14, 2026

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
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()
# Money in running text: a dollar sign in markdown starts a formula, so escape it.
usd <- function(x, d = 1, signed = FALSE) gsub("$", "\\$", money(x, d, signed), fixed = TRUE)
# 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])\\)", "&#40;\\1)", gsub("$", "&#36;", x, fixed = TRUE))
show <- function(df) {
  df[] <- lapply(df, function(x) if (is.character(x)) ent(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 bridge

Introduction

Proposition 40, the Billionaire Tax Act, would tax people who lived in California on 1 January 2026 and are worth $1 billion or more: once, at 5% of their whole net worth as valued on 31 December 2026, paid in five equal yearly installments. Two analyses estimate it. This report cites each in its latest version. The BSZ analysis (Boll, Saez and Zucman, NBER WP 35218, August 2026; NBER is the National Bureau of Economic Research, which circulates working papers) estimates about $100 billion of wealth tax ($104B in its Table 5 row 1, reproduced here as $103.8B), and $106.8B once the income tax the tax changes is added in. The JR analysis (Jaros and Rauh, NBER chapter c15504, Tax Policy and the Economy vol. 41, September 2026, revising the March 2026 version by Rauh, Jaros, Kearney, Doran and Cosso) finds that the tax would collect about $30B by its own account but leave the state $38.9B poorer in today’s dollars (a net present value of −$38.9B), because billionaires who leave stop paying California income tax for ever. Today’s dollars (present value): what money received or lost in future years is worth now. The three inputs that separate the two most: the chance that the tax survives a court challenge (counted in full by BSZ, at one half by JR), whether the lost income tax is counted for five years (BSZ) or for ever (JR), and how much of billionaires’ income tax leaves with those who go. The two numbers answer different questions, so neither can be read against the other directly. This report puts both on one common output and walks from one to the other, one disputed input at a time.

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 of sliders, one per disputed input that moves the estimate, move each input in five steps from one analysis’s value to the other’s, and the browser computes each panel’s estimate with explorer/model.js, a copy of score_common() that a test checks against the R values. The step-by-step bridge is in this report only
  • Open Analysis: this document
  • Open Materials: the repository folder, every script, test and data file (guide: the materials page)

The two main estimates are each analysis’s own: the BSZ analysis’s net total over 5 years, $106.8B, and the JR analysis’s mean net present value in perpetuity, −$38.9B (NBER, September 2026). On the common output (b), the net fiscal effect, they are the bridge’s two ends. The BSZ end is the same $106.8B. Jaros and Rauh print −$38.9B from one seeded run of their Monte Carlo (a run of the random draws started from a fixed seed number, so it gives the same draws every time); the JR end, −$38.98B, is its exact expectation, the average computed by formula. 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. “Shapley value” is defined where it is first used, in The bridge.

The common output, and what each analysis reports

The BSZ report says how much the one-time tax collects. The JR report 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 report counts the losses for about the 5 years of payments (BSZ PDF p.27). The JR report counts them forever. Discounting uses the JR report’s own range, a rate drawn uniformly between 1.5% and 4.5%. The BSZ analysis takes no position on the rate; its overview discounts at 3%, the middle of that range.

Conventions. All amounts are nominal billions of dollars. 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 although the Act collects it in five yearly installments; both analyses do this, so the comparison follows them. Present values are at the assessment date. The discount-rate range is the one the JR code draws; its code labels the draw (r − g), the rate less the growth of the lost income tax, while its text uses both (r − g) and the plain rate r (the JR analysis’s mismatch 3).

R or Python code for the two analyses on the common output
# comparison/R/model.R, lines 42-55
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   <- expected_annuity(x$H, x$r_min, x$r_max)
  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 231-235
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))
}

# comparison/R/model.R, lines 35-40
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)
}
# The Python twin (comparison/py/bridge.py); it writes comparison/export/py/.

# comparison/py/bridge.py, lines 76-88 (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 = expected_annuity(x["H"], x["r_min"], x["r_max"])
    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 227-230 (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)}

# comparison/py/bridge.py, lines 67-73 (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)
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; 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. For the JR analysis, both outputs are the expectations of the authors’ own Monte Carlo, computed exactly rather than by drawing.

The two ends, estimated on it

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 JR end, the exact mean of the September 2026 version’s Monte Carlo. The JR values are those of that version, the latest (its code and person-level file are in the authors’ public repository).

In words, the function computes seven quantities in order. 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, t_cg and the discount-rate range are shared by both ends.

  1. R0 = tau * (W_core + W_noncit) * (1 - re): the revenue with no behavioural response. tau is the tax rate; W_core (the base) and W_noncit (the non-US-citizen residents) are in billions of dollars (“Forbes source: which list and valuation date”, “Non-US-citizen residents”); re is the share of the revenue the real-estate deduction removes (“Real-estate deduction”).
  2. S = 1 - d_conf: the share of the base still taxed. d_conf = D / Wn is the share of the base that had already left (“Billionaires already departed”): D is their wealth, valued on the Forbes list in use (lam picks it), and Wn the base net of real estate. The departures dial has three stops: none, the BSZ analysis’s aggressive scenario (Table 5 row 3) and the JR analysis’s seven. For the JR analysis, R0 * S is its ceiling, the most the tax can collect after the seven confirmed departures.
  3. WT = R0 * (1 - alpha) * S * q: output (a), the gross one-time revenue, after the avoidance allowance alpha, a share of the base (“Avoidance and evasion allowance”), weighted by q, the chance that the Act survives a court challenge (“Chance the tax survives litigation”). 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, its q.
  4. X = WT * s * g * t_cg: the extra income tax from assets sold to pay. s is the share of the tax paid by selling assets (“Extra income tax from selling assets to pay”), g the capital-gain share of a sale and t_cg the top California rate on realized gains.
  5. f = alpha*m + own * L / C + f0: the share of billionaire income tax lost (“Share of billionaire income tax lost”). The first term is the BSZ movers’ share (m is the part of the avoidance allowance that is people leaving California). Under the BSZ rule (own = 1) the leavers’ own income tax, L dollars a year as BSZ Table 5 counts it for the people at the departures stop, is lost. Under the JR rule (own = 0) a flat share f0 is lost whoever leaves (the JR analysis’s independent draw of 30% to 60%). At the aggressive stop the BSZ end reproduces Table 5 row 3 in all three columns.
  6. PV = f * C * E_r[annuity(r, H)]: the present value of the lost income tax. C is billionaires’ annual California income tax, in billions of dollars a year (“Billionaires’ annual CA income tax”), lost for H years (“How long the income tax loss lasts”; for ever at the JR end); annuity(r, H) is what 1 a year for H years is worth today at the discount rate r, averaged over the rate range.
  7. NET = WT + X - PV: output (b), the net fiscal effect.

The table checks each end against the number its authors print.

R or Python code for the endpoint checks
# comparison/R/build.R, lines 12-56
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 confirmed departures
  # 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; mismatch 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 JR seven 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"),
                 r_min = pick(oi, "rauh_mc_r_min"), r_max = pick(oi, "rauh_mc_r_max"))
  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"),
    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"), 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 42-55
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   <- expected_annuity(x$H, x$r_min, x$r_max)
  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 234-272 (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"),
              "r_min": pick(oi, "rauh_mc_r_min"), "r_max": pick(oi, "rauh_mc_r_max")}
    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"), 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"), 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 76-88 (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 = expected_annuity(x["H"], x["r_min"], x["r_max"])
    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 -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

Every difference is a rounding of the printed number. The NBER mean NPV is the expectation of the Monte Carlo (−$38.98B), while the authors print one run of it (−$38.9B).

The two main estimates

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, no departures before the tax and no mobility response to a one-time tax. 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 over the rate range: $106.796B, shown as $106.8B. The BSZ analysis overview discounts at 3% instead and gets the same $106.8B.

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 the income tax base that leaves between 0.3 and 0.6, the annual income tax between $3.3B and $5.8B, and the discount rate as above, all independently. The mean net present value is therefore $36.0B − 0.45 × $4.550B × 36.620 (the average of 1/r) = −$38.980B, the bridge’s JR end. The authors print −$38.9B from one seeded run of the same Monte Carlo, which the JR pipeline reproduces as −$38.886B. That run is the JR main estimate, shown as −$38.9B.

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 disputed input. The steps add up to −$145.776B, the gap read from the BSZ end, because the common function returns both ends exactly.

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, split the same way into the steps.

R or Python code for the two main estimates
# comparison/R/build.R, lines 12-56
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 confirmed departures
  # 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; mismatch 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 JR seven 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"),
                 r_min = pick(oi, "rauh_mc_r_min"), r_max = pick(oi, "rauh_mc_r_max"))
  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"),
    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"), 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 42-55
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   <- expected_annuity(x$H, x$r_min, x$r_max)
  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 231-235
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 234-272 (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"),
              "r_min": pick(oi, "rauh_mc_r_min"), "r_max": pick(oi, "rauh_mc_r_max")}
    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"), 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"), 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 76-88 (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 = expected_annuity(x["H"], x["r_min"], x["r_max"])
    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 227-230 (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

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). Because the inputs interact (a departure matters more when the base is larger), the size of a step depends on the order in which the inputs are switched. The report shows two answers:

  • Shapley values. Each step’s switch is averaged over all 3,628,800 orders of the 10 steps. The code computes this exactly, by estimating the model at every one of the 1,024 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 one Hoopes (2026) reads in (see below).
R or Python code for the bridge tables and the figure
# comparison/R/model.R, lines 98-102
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 199-213
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 215-225
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 114-119 (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 199-214 (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 217-224 (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 out

Output (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 $150B $0B −$5.40B −$6.99B
Forbes source: which list and valuation date 7 data Forbes 2026, $2,157B Forbes 2025 grown 7%, $2,027.4B −$4.08B −$6.04B
Real-estate deduction 7 data 0.0% 0.5% −$0.36B −$0.24B
Billionaires already departed 3 guesswork on data none the JR seven: $614B on the BSZ list, $578B on the JR list −$23.71B −$28.21B
Avoidance and evasion allowance (not a row) guesswork 10.0% 0.0% +$11.45B +$8.20B
Chance the tax survives litigation 9 guesswork 100% 50% −$45.00B −$37.52B
Share of billionaire income tax lost 6 data vs guesswork leavers’ own tax 45% flat −$33.47B −$5.04B
Billionaires’ annual CA income tax 5 research vs guesswork $3.06B/yr $4.55B/yr −$9.26B −$3.07B
How long the income tax loss lasts 2 scenario 5 years perpetuity −$33.55B −$65.60B
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

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.81B $103.81B
Non-US-citizen residents 8 data $150B $0B −$5.30B −$6.75B
Forbes source: which list and valuation date 7 data Forbes 2026, $2,157B Forbes 2025 grown 7%, $2,027.4B −$4.00B −$5.83B
Real-estate deduction 7 data 0.0% 0.5% −$0.36B −$0.23B
Billionaires already departed 3 guesswork on data none the JR seven: $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 survives litigation 9 guesswork 100% 50% −$44.17B −$36.23B
Share of billionaire income tax lost 6 data vs guesswork leavers’ own tax 45% flat +$0.00B +$0.00B
Billionaires’ annual CA income tax 5 research vs guesswork $3.06B/yr $4.55B/yr +$0.00B +$0.00B
How long the income tax loss lasts 2 scenario 5 years perpetuity +$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.81B −$67.81B
JR end, (a) $36.00B $36.00B

Reading the (b) table: the three largest Shapley steps are chance the tax survives litigation (−$45.0B); how long the income tax loss lasts (−$33.6B); share of billionaire income tax lost (−$33.5B). The horizon is the largest step read in Hoopes’s order (−$65.6B, switched last, when the loss is already JR-sized) but −$33.6B averaged over orders, because a perpetuity multiplies whatever annual loss the other inputs imply. One step raises the number: the JR analysis applies no avoidance allowance beyond migration, so dropping the BSZ analysis’s 10% adds +$11.5B on (b) and +$7.2B on (a).

Reading the (a) table: the three largest Shapley steps are chance the tax survives litigation (−$44.2B); billionaires already departed (−$21.2B); avoidance and evasion allowance (+$7.2B). Order matters most for chance the tax survives litigation: −$44.2B averaged over all orders, −$36.2B in the one order shown, because the revenue inputs multiply each other.

R code for the waterfall
# comparison/R/site.R, lines 112-148
build_series <- function(ex) {
  br <- ex$bridge; en <- ex$ends; steps <- bridge_steps()
  settings <- unique(en$horizon_setting)
  out <- list()
  for (st in settings) {
    Hlab <- if (st == "own") "" else {
      h <- sub("common_", "", st); if (h == "Inf") " (perpetuity)" else paste0(" (", h, "-year horizon)") }
    out[[st]] <- list()
    for (o in c("a", "b")) {
      e <- en[en$horizon_setting == st & en$output == o, ]
      out[[st]][[o]] <- list()
      for (m in c("shapley", "sequential")) {
        b <- br[br$horizon_setting == st & br$output == o, ]
        b <- if (m == "sequential") b[order(b$sequential_rank), ] else b[match(steps$step_id, b$step_id), ]
        keep <- abs(b$shapley) > 1e-12 | abs(b$sequential) > 1e-12
        b <- b[keep, ]
        L <- list()
        add <- function(id, type, value, label, kind = NULL)
          L[[length(L) + 1]] <<- list(id = id, type = type, value = value, label = label,
                                     short = unname(short_labels[id]), kind = kind)
        add("bsz", "total", e$bsz_model,
            if (o == "a") "BSZ analysis (August 2026): Table 5 row 1 wealth tax revenue"
            else paste0("BSZ analysis (August 2026): Table 5 row 1 net of its own extra income tax and lost income tax",
                        if (st == "own") " (5 years)" else Hlab))
        for (i in seq_len(nrow(b))) {
          rowlab <- if (grepl("^\\d", b$disputes_row[i])) paste0("DISPUTES row ", b$disputes_row[i], ", ") else "not a DISPUTES row, "
          add(b$step_id[i], "step", b[[m]][i], b$title[i], paste0(rowlab, b$kind[i]))
        }
        add("nber", "total", e$rauh_own,
            if (o == "a") "JR analysis (Jaros and Rauh, NBER, September 2026): expected revenue in the Monte Carlo"
            else paste0("JR analysis (Jaros and Rauh, NBER, September 2026): mean NPV", Hlab))
        out[[st]][[o]][[m]] <- L
      }
    }
  }
  out
}
Figure 1: Output (b), Shapley steps, each analysis’s own horizon.

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.

R or Python code for the candidates
# comparison/R/build.R, lines 106-260
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)
      # rauh_own is the JR analysis's own expectation; rauh_residual, what the common
      # function at the JR inputs misses of it, is zero by construction (the script's
      # ceiling literal is the input) and kept so the identity is tested, not assumed
      ends[[length(ends) + 1]] <- data.frame(
        horizon_setting = st, output = out,
        bsz_model = v_sup[[out]], rauh_model = v_rauh[[out]],
        rauh_own = own_n[[out]], rauh_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"),
    value = c(ea$bsz_model + (ggss_printed - ea$bsz_model),
              ea$bsz_model, ea$bsz_model, eb$bsz_model,
              ea$rauh_own, eb$rauh_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$rauh_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$rauh_model

  # ---- annual income tax loss: every source on one scale ------------------------
  A5 <- expected_annuity(5, sup$r_min, sup$r_max); Ainf <- expected_annuity(Inf, sup$r_min, sup$r_max)
  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 (aggressive leavers)",
              "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 (credited dial)", "walczak (credited dial)"),
    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$rauh_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$rauh_residual,
            income_tax_loss = eb$rauh_own - ea$rauh_own)
  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 already departed",
                        "(none: Rauh's Monte Carlo headline does not use the ten 'expanded' departures)",
                        "Avoidance allowance + chance the tax survives 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),
       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"),
                         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)))
}
# The Python twin (comparison/py/bridge.py); it writes comparison/export/py/.

# comparison/py/bridge.py, lines 275-376 (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], "rauh_model": v_rauh[out],
                         "rauh_own": own_n[out], "rauh_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["rauh_own"], math.nan),
            ("rauh_nber_npv", eb["rauh_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["rauh_model"]} for a, v in [
        ("mc_expected", ea["rauh_own"]),
        ("preferred_about_30", pick(d, "nber_headline_revenue"))]]

    A5 = expected_annuity(5, sup["r_min"], sup["r_max"])
    Ainf = expected_annuity(math.inf, sup["r_min"], sup["r_max"])
    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["rauh_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["rauh_residual"]),
            ("income_tax_loss", eb["rauh_own"] - ea["rauh_own"])]
    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"]}]
    return {"bridge": bridge, "ends": ends, "endpoints": endpoints, "anchors": anchors,
            "loss": loss, "hoopes": hoopes, "meta": meta, "switchboard": board,
            "slider_sample": slider_sample(sup, rauh, ladder)}
Candidate Value Where Extra step vs the bridge end
Expected WT 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 Galle, Gamage, Saez and Shanske expert report of July 2026, written in support of the measure.

R or Python code for the disputed inputs
# comparison/R/build.R, lines 12-56
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 confirmed departures
  # 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; mismatch 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 JR seven 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"),
                 r_min = pick(oi, "rauh_mc_r_min"), r_max = pick(oi, "rauh_mc_r_max"))
  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"),
    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"), 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 60-104
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", "asset_sales"),
    input = c("W_noncit", "W_core", "lam", "re", "dep_b", "dep_j", "L", "alpha", "q", "own", "f0", "C", "H", "s"),
    supporting_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$s),
    rauh_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$s),
    unit = c("$B", "$B", "list", "share", "$B", "$B", "$B/yr", "share", "probability", "rule", "share",
             "$B/yr", "years", "share"),
    supporting_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)",
      "benchmark taxes all 1 Jan 2026 residents (BSZ PDF p.23); the aggressive row 3 (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",
      "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')",
      "BSZ export sell_share (BSZ PDF p.24)"),
    rauh_source = c(
      "omitted: 28 international billionaires, $146.3B, excluded from the domestic base (JR export nber_international_net_worth; NBER chapter Sec. 3.5)",
      "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)",
      "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 seven confirmed departures (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",
      "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, mismatch 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: no avoidance allowance (NBER chapter Secs. 4-5)",
      "q = 0.50, the ASC 740-10 'more likely than not' threshold (NBER README assumptions table; the script draws WT ~ U[0, 72], whose mean is 0.5 x 72; mismatch 5)",
      "not used: the share lost does not follow who leaves",
      "midpoint of f ~ U[0.30, 0.60], drawn independently of the revenue (NPV_dist_v8.R; chapter Sec. 5.4): guesswork",
      "midpoint of C ~ U[3.3, 5.8] (NPV_dist_v8.R:21; chapter pp.29-31): guesswork, the K = 500 floor is the authors' judgement",
      "perpetuity (chapter Sec. 5.4, eq. 18-19)",
      "not modelled"),
    stringsAsFactors = FALSE)
}
# The Python twin (comparison/py/bridge.py); it writes comparison/export/py/.

# comparison/py/bridge.py, lines 234-272 (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"),
              "r_min": pick(oi, "rauh_mc_r_min"), "r_max": pick(oi, "rauh_mc_r_max")}
    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"), 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"), 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 $150B. GGSS 20 Jul 2026 p.2 fn.1 (24 people, about $150B); BSZ PDF p.54 ($132B at 1 Jan) $0B. omitted: 28 international billionaires, $146.3B, excluded from the domestic base (JR export nber_international_net_worth; NBER chapter Sec. 3.5)
7 Forbes source: which list and valuation date 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. 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 data 0.0%. no separate deduction (BSZ PDF p.24: 90% x 5% x $2,307B) 0.5%. 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)
3 Billionaires already departed guesswork on data none. benchmark taxes all 1 Jan 2026 residents (BSZ PDF p.23); the aggressive row 3 (Page, Thiel, Hankey, Kalanick, BSZ export pre2026_leavers_wealth) is the dial’s middle stop the JR seven: $614B on the BSZ list, $578B on the JR list. the seven confirmed departures (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; 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, mismatch 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: no avoidance allowance (NBER chapter Secs. 4-5)
9 Chance the tax survives litigation guesswork 100%. the Act as written, collected in full: neither BSZ nor GGSS discounts for litigation 50%. q = 0.50, the ASC 740-10 ‘more likely than not’ threshold (NBER README assumptions table; the script draws WT ~ U[0, 72], whose mean is 0.5 x 72; mismatch 5)
6 Share of billionaire income tax lost data vs guesswork leavers’ own tax. 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% flat. midpoint of f ~ U[0.30, 0.60], drawn independently of the revenue (NPV_dist_v8.R; chapter Sec. 5.4): guesswork; not used: the share lost does not follow who leaves
5 Billionaires’ annual CA 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] (NPV_dist_v8.R:21; chapter pp.29-31): guesswork, the K = 500 floor is the authors’ judgement
2 How long the income tax loss lasts scenario 5 years. BSZ PDF p.27 (‘If they last say only 5 years’) perpetuity. perpetuity (chapter Sec. 5.4, eq. 18-19)
(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

The Hoopes comparison

Jeffrey Hoopes, “Galle v Rauh: Comparing Two Revenue Estimates for California Billionaire Taxation” (SSRN 6428578; SSRN is a preprint site), is the one published 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 (comparison/tools/digitize_hoopes_fig2.py, tick fit within $0.1B). Beside it is the bridge read in his order, ending at the JR analysis’s September 2026 version, which postdates his note. The overview page lists every difference between the two in its Hoopes section.

R or Python code for the Hoopes comparison
# comparison/R/build.R, lines 106-260
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)
      # rauh_own is the JR analysis's own expectation; rauh_residual, what the common
      # function at the JR inputs misses of it, is zero by construction (the script's
      # ceiling literal is the input) and kept so the identity is tested, not assumed
      ends[[length(ends) + 1]] <- data.frame(
        horizon_setting = st, output = out,
        bsz_model = v_sup[[out]], rauh_model = v_rauh[[out]],
        rauh_own = own_n[[out]], rauh_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"),
    value = c(ea$bsz_model + (ggss_printed - ea$bsz_model),
              ea$bsz_model, ea$bsz_model, eb$bsz_model,
              ea$rauh_own, eb$rauh_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$rauh_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$rauh_model

  # ---- annual income tax loss: every source on one scale ------------------------
  A5 <- expected_annuity(5, sup$r_min, sup$r_max); Ainf <- expected_annuity(Inf, sup$r_min, sup$r_max)
  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 (aggressive leavers)",
              "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 (credited dial)", "walczak (credited dial)"),
    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$rauh_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$rauh_residual,
            income_tax_loss = eb$rauh_own - ea$rauh_own)
  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 already departed",
                        "(none: Rauh's Monte Carlo headline does not use the ten 'expanded' departures)",
                        "Avoidance allowance + chance the tax survives 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),
       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"),
                         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)))
}
# The Python twin (comparison/py/bridge.py); it writes comparison/export/py/.

# comparison/py/bridge.py, lines 275-376 (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], "rauh_model": v_rauh[out],
                         "rauh_own": own_n[out], "rauh_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["rauh_own"], math.nan),
            ("rauh_nber_npv", eb["rauh_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["rauh_model"]} for a, v in [
        ("mc_expected", ea["rauh_own"]),
        ("preferred_about_30", pick(d, "nber_headline_revenue"))]]

    A5 = expected_annuity(5, sup["r_min"], sup["r_max"])
    Ainf = expected_annuity(math.inf, sup["r_min"], sup["r_max"])
    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["rauh_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["rauh_residual"]),
            ("income_tax_loss", eb["rauh_own"] - ea["rauh_own"])]
    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"]}]
    return {"bridge": bridge, "ends": ends, "endpoints": endpoints, "anchors": anchors,
            "loss": loss, "hoopes": hoopes, "meta": meta, "switchboard": board,
            "slider_sample": slider_sample(sup, rauh, ladder)}
Hoopes Figure 2 bar Hoopes (read off the chart) Ours, one order Our 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 already departed
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 survives 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)

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.

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 (asserted without data or a study behind it) and scenario (a choice of which world is estimated). Each bridge step carries one of these kinds (the tables above). The values the comparison transcribes from the PDFs itself, because neither contract carries them, are in comparison/data/document-inputs.csv:

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 RJKDC 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 RJKDC NBER mean NPV (printed) -38.9 $B Jaros and Rauh NBER c15504 abstract and Sec 5.4
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
guesswork RJKDC 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)
guesswork RJKDC NBER preferred revenue estimate (range 28-36) 30 $B Jaros and Rauh NBER c15504 abstract
guesswork RJKDC Ongoing annual state tax loss incl. spillovers: low (credited alternative dial) 3.53 $B/yr Walczak, California Tax Foundation, 22 Apr 2026, p.1
guesswork RJKDC Ongoing annual state tax loss incl. spillovers: high (credited alternative dial) 4.49 $B/yr Walczak, California Tax Foundation, 22 Apr 2026, p.1
scenario BSZ Years the lost income tax is counted (losses ‘annual temporary’) 5 years BSZ Aug 2026 PDF p.27 (“If they last say only 5 years”) and Table 5 header p.39
scenario RJKDC Years the lost income tax is counted (perpetuity) Inf years Rauh et al. SSRN 6340778 eq.17-18 (perpetuity)

18 transcribed inputs: data 7, derived 4, guesswork 5, scenario 2.

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 Monte Carlo mean, −$38.980B on (b) and $36.000B on (a), against the printed −$38.9B from one seeded run. It uses the script’s ceiling literal, 72, rather than the 72.05 the person-level file sums to (the JR analysis’s mismatch 4), so that the two agree exactly.
  • The code does what the method says. Shapley values sum to the gap by construction; test-shapley.R checks that ours 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 and the endpoints is 5.0e-13 (test-parity.R requires 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.qmd