---
title: 'Prop 40, JR analysis: the reproduction <a class="ai-badge ai-badge-red" href="index.html#how-ai-was-used">AI generated, not reviewed yet</a>'
pagetitle: "Prop 40, JR analysis: the reproduction"
subtitle: "Jaros and Rauh (NBER, September 2026; first version by Rauh, Jaros, Kearney, Doran and Cosso, March 2026), reproduced step by step from the authors' own public code and data"
date: today
format:
html:
toc: true
toc-depth: 3
toc-location: left
code-fold: true
code-summary: "code"
code-tools: true
embed-resources: true
fig-align: center
tbl-cap-location: top
theme: [cosmo, ../../assets/paper.scss]
css: [repro.css, ../../assets/paper.css]
engine: knitr
execute:
echo: true
warning: false
message: false
---
::: {.site-nav}
JR analysis of California Proposition 40:
[Overview](index.html) · Open Output: [Explorer](explorer/) · Open Analysis: Report (here) and
[Slides](slides/slides.html) ·
[Open Materials](https://github.com/fhoces/opa-prop40/tree/main/rjkdc-analysis)
Also in this project: [Comparison overview](../../index.html) · [BSZ analysis overview](../../bsz-analysis/site/)
:::
```{r}
#| label: setup
#| code-summary: "R code for the setup"
suppressPackageStartupMessages({
library(targets); library(dplyr); library(knitr); library(ggplot2)
})
store <- "../_targets" # rjkdc-analysis/_targets
tr <- function(name) tar_read_raw(name, store = store)
for (f in list.files("../R", pattern = "\\.R$", full.names = TRUE)) source(f)
rc <- tr("revenue_chain")
pf <- tr("pareto_fit")
sch <- tr("pareto_income_schedule")
t8 <- tr("income_tax_table8")
itmc <- tr("income_tax_mc")
t9 <- tr("npv_table9")
t10 <- tr("npv_table10")
mcs <- tr("npv_mc_ssrn")
mcn <- tr("npv_mc_nber")
nc <- tr("nber_ceiling")
ncol <- tr("nber_collectible")
hl <- tr("site_headlines")
grid <- tr("site_grid")
# the explorer's dials that are the authors' Monte Carlo inputs; q and g are this OPA's settings
own_dials <- Filter(function(d) !d$key %in% c("q", "g"), site_dials(rc))
stopifnot(length(site_dials(rc)) - length(own_dials) == 2) # the prose names the two
inputs <- read.csv("../export/r/inputs.csv")
outputs <- read.csv("../export/r/outputs.csv")
printed <- read.csv("data/printed-tables.csv")
ss <- formals(compute_npv_mc_ssrn); nb <- formals(compute_npv_mc_nber)
# HTML tables: pandoc would read a pair of "$" across markdown table cells as math
options(knitr.table.format = "html")
# formatting: a real minus sign, fixed decimals
num <- function(x, d = 1) {
s <- formatC(abs(x), format = "f", digits = d, big.mark = ",")
ifelse(x < 0, paste0("−", s), s)
}
pct <- function(x, d = 1) paste0(num(x, d), "%")
# −$38.9B; a word joiner (U+2060) after the sign keeps a line from breaking between "−" and "$"
usdb <- function(x, d = 1) paste0(ifelse(x < 0, "−\u2060", ""), "\\$", num(abs(x), d), "B")
out <- function(id, col = "value") outputs[[col]][outputs$output_id == id]
inp <- function(id) inputs$value[inputs$input_id == id]
h <- function(v, q, col = "reproduced") hl[[col]][hl$version == v & hl$quantity == q]
# raw HTML block: pandoc must not read "$B ... $B" inside a table as a math span
scroll <- function(x) cat("\n\n```{=html}\n<div class=\"table-scroll\">\n",
as.character(x), "\n</div>\n```\n\n", sep = "")
```
## Introduction
::: {.report-intro}
{.opa-figure}
Jaros and Rauh estimate that Proposition 40 leaves California poorer: a mean net present value
of `r usdb(h("nber","mc_mean_npv","printed"))` (PDF p.1). The tax is collected once, but
billionaires who leave stop paying income tax for ever. At the authors' discount rates that lost
stream is worth about \$`r num(h("nber","pv_lost"), 0)`B today, against
\$`r num(h("nber","mc_expected_revenue"), 0)`B, the average revenue in the authors' simulation
(half of `r nb$wt_max`). The chapter's own revenue estimate is lower, about \$30B, and it sets that
against the \$104B benchmark of the [BSZ analysis](../../bsz-analysis/site/) (PDF p.3). This
report rebuilds the net present value step by step.
Following the [Open Policy Analysis](https://doi.org/10.1093/scipol/scaa067) framework:
- **Open Output**: [the explorer](explorer/), the mean net present value and share of
negative outcomes for every combination of seven dials: the five ranges of the authors' Monte
Carlo and two settings this OPA adds
- **Open Analysis**: this document, and [the slide deck](slides/slides.html)
- **Open Materials**: [the repository](https://github.com/fhoces/opa-prop40/tree/main/rjkdc-analysis), every script, test, CSV and
source (guide, with all `r tolower(c("One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve")[sum(grepl("^## [0-9]+[.]", readLines(file.path("..", "NOTES.md"))))])` reproduction notes kept along the way: [the materials page](materials.html#reproduction-notes))
:::
::: {.callout-note collapse="true" title="Sources and how they are read"}
**What is reproduced.** Jaros and Rauh, *The Net Present Value of the California Billionaire Tax Act*,
NBER chapter c15504 (Tax Policy and the Economy, vol. 41; 22 September 2026; NBER is the National Bureau of Economic Research, which
circulates working papers), the litigation-weighted revision of the first version by Rauh, Jaros,
Kearney, Doran and Cosso (SSRN 6340778, March 2026; SSRN is a preprint site). Its headline:
revenue of about \$30B (\$`r h("nber","revenue_range_low","printed")`B to
\$`r h("nber","revenue_range_high","printed")`B) and a mean NPV of
`r usdb(h("nber","mc_mean_npv","printed"))`, with `r h("nber","mc_pct_negative","printed")`%
of simulated outcomes negative (the Figure 3 note, PDF p.35; the text on p.1 says 85%). The
authors report the share below zero as the share of plausible parameter combinations with a
negative net present value (PDF p.35). Their draws are independent and uniform over ranges that
are themselves guesswork, so the share is not a probability that the tax loses money: it moves
with those ranges, as the explorer's Rate 1.5% and Rate 4.5% settings show. The mean
NPV is **the main estimate**, derived step by
step in [the next section](#the-main-estimate).^[The authors released a first version in March
2026: Rauh, Jaros, Kearney, Doran and Cosso, Hoover Institution, SSRN 6340778. It printed revenue
of about \$`r h("ssrn","revenue_headline","printed")`B and a mean NPV of
`r usdb(h("ssrn","mc_mean_npv","printed"))`, with `r h("ssrn","mc_pct_negative","printed")`%
of outcomes negative (its PDF p.1). The pipeline reproduces it too; the
[appendix](#appendix-first-version) at the end of this report sets out what it printed and
which checks concern it.]
The analysis is reproduced from the authors' own public, MIT-licensed code and data
([github.com/bjaros20/wealth_tax](https://github.com/bjaros20/wealth_tax), pinned at commit
`25e84dd`): their Excel workbook is read cell by cell, their R scripts are ported line by line
and also re-run unmodified, and the person-level file `final.csv` is summed directly. Every
number in this document is read from the project's `{targets}` pipeline at render time
(`{targets}` is an R package that runs the pipeline's steps in order and records which outputs are
current); the only numbers typed in are the paper's printed values this OPA compares against, each
with its PDF page (page numbers are PDF pages throughout).
:::
::: {.callout-note collapse="true" title="When the main estimate was chosen"}
The project's pre-specified output (`PLAN.md`, 23 September 2026) reported the March 2026 and
September 2026 versions side by side, with the March 2026 mean as the comparison's anchor. On
14 October 2026, after results, the September 2026 mean became the main estimate and the
comparison's anchor, because the pages follow each analysis's latest version (`PLAN.md`,
decisions 7 and 9).
:::
**The shape of the argument**, in one line: net present value = wealth tax collected once, minus
the present value of the income tax that departing billionaires would have paid in every future
year. Sections 4 and 5.1 of the paper estimate the two pieces; Sections 5.2 to 5.4 combine them.
This report follows that order.
### Notation, in one place
| Symbol | Meaning | Unit |
|---|---|---|
| $\tau$ | statutory wealth tax rate | share |
| $WT$ | one-time wealth tax revenue actually collected | \$B |
| $C$ | annual California income tax paid by the billionaires | \$B per year |
| $f$ | share of that income tax base that leaves the state | share |
| $r - g$ | the interest rate minus the lost tax's yearly growth (a real rate; the Monte Carlo's draw) | per year |
| $g$ | growth of the lost income tax stream; the explorer's further growth on top of the draw, $\Delta g$, is this OPA's illustration | per year |
| $\varepsilon$ | migration semi-elasticity: % of the base lost per percentage point of tax | |
| $q$ | probability the Act survives constitutional challenge | probability |
| NPV | $WT - f \cdot C/(r-g)$, the net fiscal effect today | \$B |
## The main estimate, step by step {#the-main-estimate}
The main estimate is the paper's mean net present value, printed as
**`r usdb(h("nber","mc_mean_npv","printed"))`** (PDF p.1 and p.35). The authors get it from a
Monte Carlo: they draw four inputs at random 100,000 times, compute
$NPV = WT - f \cdot C / (r - g)$ for each draw, and average. Because the four draws are independent,
the average has an exact value too, and this report builds it one input at a time. Every number below is
read from the pipeline (`R/npv_mc.R`, `R/nber_final.R`); the draw ranges are the defaults of
`compute_npv_mc_nber()`, which copies the authors' script `NPV_dist_v8.R`.
### Step 1: the wealth tax collected, $WT$ {#step-wt}
The script draws $WT \sim U[`r nb$wt_min`, `r nb$wt_max`]$ (\$B). The top, `r nb$wt_max`, is
the revenue if the Act survives in court, after removing the seven publicly reported moves. Summing
the authors' person-level file `final.csv` gives \$`r num(nc$ceiling_recomputed, 2)`B for the same
quantity. The script's 72 is that value rounded, as the chapter rounds it on PDF pp.24 and 33
([note 4](materials.html#reproduction-notes)). The bottom,
`r nb$wt_min`, is the Act struck down. The chapter also grounds the 0 in its stress test of
unreported departures, in which revenue falls to \$4.82B (fn.32, PDF p.34; Appendix B, PDF p.43).
The mean of the draw is
$$E[WT] = \frac{`r nb$wt_min` + `r nb$wt_max`}{2} = `r num((nb$wt_min + nb$wt_max) / 2, 2)`.$$
This is the paper's central revenue, $q \times 72$ with a survival chance $q$ = 0.50
(PDF p.33). The 0 lower bound is **guesswork**.
### Step 2: the share of the income tax base that leaves, $f$ {#step-f}
The script draws $f \sim U[`r nb$f_min`, `r nb$f_max`]$, on its own, unrelated to the revenue
draw. Its mean is $E[f]$ = `r num((nb$f_min + nb$f_max) / 2, 2)`: on average, 45 cents of every
dollar of billionaire income tax leaves the state. The range is **guesswork**, "centered
conservatively around the observed wealth-base departure fraction" (PDF p.31).
### Step 3: the income tax the billionaires pay each year, $C$ {#step-c}
The script draws $C \sim U[`r nb$c_min`, `r nb$c_max`]$ (\$B a year), the two ends of the income
tax Monte Carlo in [Section 5.1](#section-5.1-the-income-tax-the-billionaires-pay). Its mean is
$E[C]$ = `r num((nb$c_min + nb$c_max) / 2, 2)`.
### Step 4: the discount rate net of growth, $r - g$ {#step-r}
The script draws $r - g \sim U[`r nb$rg_min`, `r nb$rg_max`]$, the interest rate minus the lost
tax's yearly growth (a real rate), between `r pct(100 * nb$rg_min)` and `r pct(100 * nb$rg_max)` a year. A
stream of \$1 a year, growing at $g$ for ever, is worth $1/(r-g)$ today, so what enters is the
mean of $1/(r-g)$, not $1/E[r-g]$:
$$E\!\left[\tfrac{1}{r-g}\right] = \frac{\ln(`r nb$rg_max` / `r nb$rg_min`)}{`r nb$rg_max` - `r nb$rg_min`} = `r num(npv_e_inv_rate(nb$rg_min, nb$rg_max), 2)`.$$
In words: at these rates, \$1 lost this year, and growing every year for ever, is worth about
\$`r num(npv_e_inv_rate(nb$rg_min, nb$rg_max), 0)` today. The range is **guesswork**. Its
`r pct(100 * nb$rg_min)` anchor is a published market yield, but the width of the band, to
`r pct(100 * nb$rg_max)`, is the authors' allowance for illiquidity, mean reversion in
entrepreneurial returns and a baseline outflow of high earners of about 1% a year (PDF p.32), not
an estimate, and the width is what moves the result.^[The pipeline's input file (`export/r/inputs.csv`) labels the range research, because of
its published anchor. The comparison, which uses one rate worth the same as the draw, labels that
rate derived ([disputes row 10](https://github.com/fhoces/opa-prop40/blob/main/comparison/DISPUTES.md)).
This report labels the range guesswork, because the width of the band moves the result.]
### Step 5: the present value of the income tax lost
The three loss inputs are independent, so the mean of their product is the product of their means:
$$E\!\left[\frac{f\,C}{r-g}\right] = `r num((nb$f_min + nb$f_max) / 2, 2)` \times `r num((nb$c_min + nb$c_max) / 2, 2)` \times `r num(npv_e_inv_rate(nb$rg_min, nb$rg_max), 2)` = `r num(h("nber","pv_lost"), 2)`.$$
### Step 6: the net present value {#step-npv}
The revenue of Step 1 minus the loss of Step 5:
$$E[NPV] = E[WT] - E\!\left[\frac{f\,C}{r-g}\right]$$
$$= `r num(h("nber","mc_expected_revenue"), 2)` - `r num(h("nber","pv_lost"), 2)` = `r num(tr("npv_mc_nber_analytic_mean"), 2)`.$$
The authors do not print this exact expectation. They print the average of one seeded run of
100,000 draws. A seeded run starts the random draws from a fixed number (2026), so it gives the
same draws every time. This OPA's R port replays it draw for draw:
**`r usdb(mcn$summary$mean, 3)`**, printed as `r num(h("nber","mc_mean_npv","printed"))`. The
gap, `r num(abs(mcn$summary$mean - tr("npv_mc_nber_analytic_mean")), 2)`, is less than one Monte
Carlo standard error (`r num(mcn$summary$sd / sqrt(mcn$summary$n), 2)`). The overview and the explorer show the printed
`r num(h("nber","mc_mean_npv","printed"))`; the explorer's grid holds the exact value for every other
setting. The [explorer](explorer/) moves each of the four draws, and two settings the authors' code
does not have: a litigation multiplier on top of the draw, and growth of the lost income on top of
the drawn $r - g$ (this OPA's illustration).
```{r}
#| label: main-estimate-code
#| echo: false
#| results: asis
# The code behind this section, read from R/ and py/ at render time so it cannot drift.
grab <- function(path, start_pat) {
l <- readLines(path, warn = FALSE); i <- grep(start_pat, l)[1]
# R: up to the closing brace in column 1; Python: up to the next unindented line
nxt <- if (grepl("[.]R$", path)) which(seq_along(l) > i & l == "}") + 1
else 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]))) j <- j - 1
l[i:j]
}
fold <- function(summary, lang, code) {
cat('\n<details class="code-fold"><summary>', summary, '</summary>\n\n```', lang, '\n',
paste(code, collapse = "\n"), '\n```\n\n</details>\n\n', sep = "")
}
fold("R code for the main estimate", "r", c(
"# R/npv_mc.R: the seeded Monte Carlo (the printed number) and the exact expectation.",
grab("../R/npv_mc.R", "^compute_npv_mc_nber <- function"), "",
grab("../R/npv_mc.R", "^npv_mc_summary <- function"), "",
grab("../R/npv_mc.R", "^npv_e_inv_rate <- function"), "",
grab("../R/npv_mc.R", "^npv_expectation <- function"), "",
"# _targets.R: the targets this section reads",
grep("npv_mc_nber|nber_ceiling", readLines("../_targets.R"), value = TRUE)))
fold("Python code for the main estimate", "python", c(
"# py/npv_mc.py: the Python twin. numpy's random stream differs from R's, so its seeded",
"# mean agrees with R's within Monte Carlo error; the exact expectation agrees exactly.",
grab("../py/npv_mc.py", "^def compute_npv_mc_nber"), "",
grab("../py/npv_mc.py", "^def npv_mc_summary"), "",
grab("../py/npv_mc.py", "^def npv_mc_analytic_mean")))
```
## Section 4: the wealth tax revenue
This section builds the revenue. At `r pct(100*inp("tax_rate"),0)` of the base, it is
\$`r num(rc$baseline_precise, 2)`B before anyone leaves and \$`r num(nc$domestic_total_grown, 2)`B
at the valuation date. Removing the
seven publicly reported moves leaves \$`r num(nc$ceiling_recomputed, 2)`B. Leaving out three
pre-filing departures and Zuckerberg, and halving for the chance the Act is struck down, gives
the paper's about \$30B (p.23).
### 4.1 The baseline, before anyone leaves (eqs. 1 and 2)
Take each of the $N$ = `r inp("n_billionaires")` California billionaires on the 2025
Forbes list (with the authors' corrections), subtract their directly held residential real estate
(TRRE, total residential real estate; the Act exempts it), multiply what is left by the
`r pct(100*inp("tax_rate"),0)` rate, and add up. Their aggregate net worth is
\$`r num(inp("baseline_net_worth"))`B. The authors' workbook sums this row by row to
**\$`r num(rc$baseline_precise, 2)`B**; the paper prints \$`r num(out("revenue_baseline","printed_value"), 2)`B
(PDF p.19, eq. 2), a value typed into the workbook, `r pct(100 * (rc$baseline_precise / rc$baseline_literal - 1), 1)`
below its own sum. Every later number in the first version is built on the typed value, and each
reproduces ([note 6](materials.html#reproduction-notes)).
$$R_{\text{base}} = \sum_{i=1}^{N} \tau \times \text{TaxBase}_i, \qquad \text{TaxBase}_i = \text{NetWorth}_i - \text{TRRE}_i$$
The tax is assessed on net worth at 31 December 2026, so the authors grow every net worth by
`r pct(100 * inp("nber_growth_rate"), 0)` to that valuation date and rebuild the base person by
person in `final.csv`: `r nc$n_domestic` domestic rows, face value
**\$`r num(nc$domestic_total_grown, 2)`B** (printed \$`r out("nber_domestic_grown","printed_value")`B,
PDF p.19). That is exactly `r num(nc$domestic_total_grown / rc$baseline_precise, 4)` times the
workbook's \$`r num(rc$baseline_precise, 2)`B, so the person-level file and the workbook agree with
each other, and both differ from the printed \$`r num(out("revenue_baseline","printed_value"), 2)`B. The
`r nc$n_international` non-US-citizen residents (\$`r num(nc$international_net_worth, 1)`B of net
worth) stay outside the base (PDF p.17).
### 4.2 The collectibility principle (eq. 3)
The authors count revenue only when it is "more likely than not" to be collected.
$$E[R] = q \times R_{\text{recognized}}$$
They borrow this
test from the accounting rule for uncertain tax positions in company accounts (ASC 740-10,
PDF p.20). It works at two levels. For each taxpayer: publicly reported moves, pre-filing departures
and billionaires imputed from a Pareto curve are left out of the recognized base,
$R_{\text{recognized}}$. For the Act as a whole: $q$ is the probability that the Act survives
constitutional challenge, set at `r num(ncol$q, 2)` (PDF pp.19-20). That value comes from the accounting
standard's 50% threshold, not from a legal assessment of the challenges, so it is **guesswork**.
### 4.3 The seven publicly reported moves (Table 7, eq. 4)
The chapter calls them "publicly confirmed pre-snapshot departures" (Table 7, PDF p.21). This OPA
says "publicly reported moves", because whether each ended legal residence in time is disputed
([disputes row 3](https://github.com/fhoces/opa-prop40/blob/main/comparison/DISPUTES.md)).
Table 7 lists Page, Brin, Thiel, Hankey, Spielberg, Kalanick and Sacks as having moved out between
the initiative's filing and the 1 January 2026 residency date (PDF p.21). Removing their
\$`r num(nc$removed_departed_total, 2)`B leaves **\$`r num(nc$ceiling_recomputed, 2)`B** in
`final.csv`. The paper (eq. 4, p.22) and the authors' README print
\$`r out("nber_ceiling","printed_value")`B, the difference of two figures each rounded to two
decimals (`r num(nc$domestic_total_grown, 2)` − `r num(nc$removed_departed_total, 2)`), and the
script and the chapter's later pages use `r nb$wt_max` ([note 4](materials.html#reproduction-notes)): one
value at three roundings. The paper then asks how big a behavioural response that is.
It divides the share of revenue lost by the rate change, in percentage points (eq. 4, PDF p.22):
$$\varepsilon = \frac{\%\Delta \text{TaxBase}}{\Delta \text{TaxRate (p.p.)}}
= \frac{(100.9 - 72.06)/100.9}{5}$$
```{r}
#| label: semi-elasticity
#| code-summary: "R code for the implied semi-elasticity"
eps7 <- 100 * (1 - nc$ceiling_recomputed / nc$domestic_total_grown) / (100 * inp("tax_rate"))
eps7
```
This gives `r num(eps7, 2)`. The printed 5.73 is the same formula with the round ceiling,
(`r num(nc$domestic_total_grown, 1)` − `r nb$wt_max`)/`r num(nc$domestic_total_grown, 1)`/5 =
`r num(100 * (1 - nb$wt_max / round(nc$domestic_total_grown, 1)) / 5, 2)`, so the two differ only in
rounding ([note 4](materials.html#reproduction-notes)). A 1-point tax rate rise drives out about
`r num(eps7, 1)`% of the base. The authors treat this as a floor, because it counts only
departures that made the news (PDF pp.22-24).
### 4.4 The litigation discount and the collectible estimate (eqs. 5 to 8)
Removing three pre-filing departures as well (\$`r num(ncol$removed_prefiling, 2)`B) leaves
\$`r num(ncol$collectible_base, 2)`B (printed \$71.17B, PDF p.22). Mark Zuckerberg announced his
move in February 2026, after the residency date. The authors exclude his
\$`r num(ncol$zuckerberg, 2)`B, because collecting it depends on the retroactive date surviving in
court. That leaves a recognized base of **\$`r num(ncol$recognized, 2)`B** (eq. 5). Multiplying by
$q$ = `r num(ncol$q, 2)` gives
$$E[R] = q \times R_{\text{recognized}} = `r num(ncol$q, 2)` \times `r num(ncol$recognized, 2)` = `r num(ncol$central, 2)`$$
(printed \$`r h("nber","revenue_headline","printed")`B, eqs. 6 and 8, PDF p.23). Keeping
Zuckerberg gives \$`r num(ncol$upper, 2)`B (eq. 7). Moving the gray-zone names out gives
\$`r num(ncol$lower, 2)`B: these are the seven billionaires with departure signals short of
confirmation, plus Jan Koum, Reed Hastings and Andy Fang, reported as considering a move
(PDF p.23). Hence the printed range, \$`r h("nber","revenue_range_low","printed")`B to
\$`r h("nber","revenue_range_high","printed")`B, around "approximately \$30 billion".
The Monte Carlo does not use this point estimate. It draws revenue from 0 to 72, whose mean is the
paper's central \$`r num(h("nber","mc_expected_revenue"), 0)`B (Step 1 and
[Section 5.4](#monte-carlo)).
### 4.5 Validation against the literature (Table 8)
Brülhart et al. (2022) estimate that Swiss taxable wealth falls by about 43% per point of wealth
tax, about 24% of it through people moving.
$$\eta_{\text{mig}} = 0.24 \times 43 = 10.32, \qquad \text{base lost} = 1 - (1 - 0.05)^{10.32}$$
```{r}
#| label: log-form
#| code-summary: "R code for the literature-implied base loss"
lit_loss <- 1 - (1 - inp("tax_rate"))^inp("brulhart_semi_elasticity")
lit_loss
```
The authors keep only the moving part,
`r inp("brulhart_semi_elasticity")`, and apply it to the 5-point rate in log form: the base
shrinks by **`r pct(100 * lit_loss)`** (PDF p.24). Table 8 (PDF p.25) sets this beside the observed
28.6% from the seven publicly reported moves. This check does not enter the main estimate. Two
things to notice. First, the Swiss estimate is for a recurring annual tax. The paper argues that
taxpayers price the Act as the start of a recurring regime, so the recurring-tax estimate is the
right benchmark (PDF p.25). Second, the [BSZ analysis](../../bsz-analysis/site/) (Boll, Saez and
Zucman) disputes exactly this transfer ([disputes row 1](https://github.com/fhoces/opa-prop40/blob/main/comparison/DISPUTES.md)).
## Section 5.1: the income tax the billionaires pay
This section estimates $C$, the income tax the billionaires pay each year. It is at most
\$`r num(t8$mean_fy25[t8$K == 212], 1)`B if they are the 212 highest earners and about
\$`r num(t8$mean_fy25[t8$K == 500], 1)`B if they are spread among the top 500 filers. The Monte
Carlo draws between the two.
### The Pareto upper bound (eqs. 9 and 10)
The state publishes how many tax returns report income above \$1M, \$2M, \$3M, \$4M, \$5M and
\$10M (Franchise Tax Board, FTB below: Personal Income Tax Annual Report 2024, Table B-4A). If top incomes follow a Pareto distribution, the log
of those counts falls on a straight line against the log of the thresholds, with slope $-\alpha$.
The fitted line gives $\hat\alpha$ = **`r num(pf$alpha, 3)`** (printed `r out("pareto_alpha","printed_value")`,
$R^2$ = `r num(pf$r_squared, 4)`, PDF p.26).
$$N(y) = A\,y^{-\alpha}, \qquad S(k, n) = \left(\frac{k}{n}\right)^{(\alpha - 1)/\alpha}$$
```{r}
#| label: f-pareto
#| code-summary: "R code for the Pareto fit figure"
#| fig-cap: "The six published filer counts against their AGI thresholds, log-log, with the fitted Pareto line (paper Figure 1, PDF p.27)."
#| fig-width: 6
#| fig-height: 3.6
d <- data.frame(y = pf$thresholds, n = pf$filer_counts)
ggplot(d, aes(y, n)) +
geom_abline(intercept = log10(pf$A), slope = -pf$alpha, linetype = 2, colour = "grey50") +
geom_point(size = 2.5, colour = "#1f6b3b") +
scale_x_log10(labels = scales::label_dollar(scale = 1e-6, suffix = "M")) +
scale_y_log10(labels = scales::label_comma()) +
labs(x = "AGI threshold", y = "Returns above the threshold") +
theme_minimal(base_size = 11)
```
$S(k, n)$ is the share of the bracket's income that goes to its top $k$ of $n$ filers. With the
top `r inp("n_billionaires")` of the 4,729 filers above \$10M, the continuous formula gives
`r pct(100 * sch$continuous_top_share)` of the bracket's \$`r inp("bracket_tax_ty23")`B of tax.
The script then builds a rank-by-rank income schedule and rescales it by
`r num(sch$rescale_factor, 3)` so that its top 212 match the continuous share (footnote 28,
PDF p.28: the discrete sum undershoots by about 11%). Scaling Tax Year 2023 to FY 2024-25 collections
(\$`r inp("total_pit_fy25")`B in total) multiplies by `r num(attr(itmc, "scale_factor"), 3)`. If
the billionaires *are* the 212 highest earners, they pay **\$`r num(t8$mean_fy25[t8$K == 212], 2)`B
a year**. That is the upper bound, and the top of $C$'s range.
### The dispersion Monte Carlo (Table 9)
Billionaires are ranked by wealth, not income, and many realise little income in a given year. So
the script places the 212 billionaires at random among the top $K$ filers (without replacement,
100,000 draws for each $K$ from 212 to 2,000) and sums their tax. The wider the pool, the lower the
expected total. The authors take $K \approx 500$ ("roughly the top decile of the bracket", PDF p.28)
as a reasonable floor, which gives the bottom of $C$'s range, \$3.3B. The choice of $K$ = 500 is a
judgement, not an estimate, so the range $C \in [3.3, 5.8]$ is labelled **guesswork** below.
```{r}
#| label: t-table8
#| results: asis
#| code-summary: "R code for Table 9"
p8 <- printed[printed$table == "table8", ]
pv <- function(k, col) p8$printed[p8$row == k & p8$column == col]
tab8 <- t8 %>% transmute(
`Pool size K` = format(K, big.mark = ","),
`Mean` = num(mean_fy25, 3), `printed` = sapply(K, pv, "mean"),
`P5` = num(p05_fy25, 3), `printed ` = sapply(K, pv, "p05"),
`P95` = num(p95_fy25, 3), `printed ` = sapply(K, pv, "p95"))
m8 <- sum(round(t8$mean_fy25, 2) == sapply(t8$K, pv, "mean")) +
sum(round(t8$p05_fy25, 2) == sapply(t8$K, pv, "p05")) +
sum(round(t8$p95_fy25, 2) == sapply(t8$K, pv, "p95"))
scroll(kable(tab8, align = "rrrrrrr", caption = "Table 9, $B per year (PDF p.30)"))
```
`r m8` of the `r 3 * nrow(t8)` cells round to the printed value; the tests hold this at the printed rounding
(`tests/testthat/test-income-tax-mc.R`).
## Section 5.2: from an annual loss to a present value (eqs. 11 to 14) {#perpetuity}
This is the step that makes the result. The wealth tax is collected **once**. The income tax of
anyone who leaves is lost **every year, for ever**, and a perpetuity paying $C$ a year is worth
$C/r$ today.
$$PV = \frac{C}{r}, \qquad NPV = WT - f \cdot \frac{C}{r}$$
$$\text{or, with growth:}\qquad PV = \frac{C}{r - g}, \qquad NPV = WT - f \cdot \frac{C}{r - g}$$
At a rate of 1.5% a perpetuity is worth 1/0.015, about 67 years of payments; at 4.5%,
about 22. The paper's text then argues the lost stream grows with the billionaires' wealth, making
it a growing perpetuity, $C/(r-g)$ (PDF p.31). It calibrates $r - g$ at about 1.5% from the
S&P 500's dividend yield, and presents scenarios at 1.5%, 3.0% and 4.5% (PDF p.32).
Three things are worth knowing before reading the rest:
- **Which rate is drawn.** The chapter's eq. 18 and its code (`NPV_dist_v8.R`) draw the 1.5% to
4.5% rate as $r - g$, the discount rate net of growth, and the NBER README describes the draw
the same way. One sentence after eq. 19 writes $C/r$ (PDF p.35)
([note 3](materials.html#reproduction-notes)). The first version's code divides by plain $r$
([note 2](materials.html#reproduction-notes)). The explorer's dial for further growth on top of the
drawn $r - g$ is this OPA's illustration, not part of the authors' model.
- **The loss is not scaled by $q$.** People who left do not come back if the Act is struck down,
so the income tax loss stays even when the wealth tax collects nothing (PDF p.33).
- **The horizon.** The authors assume leavers do not come back, because the Act permanently lifts
the constitutional cap on taxes on intangible property and lets the Legislature change the Act by
a two-thirds vote (chapter PDF p.32). Treating the loss as permanent is one of the disputed
choices: the [BSZ analysis](../../bsz-analysis/site/) argues the loss should be temporary and
gives five years, the payment period, as an example (BSZ PDF p.27)
([disputes row 2](https://github.com/fhoces/opa-prop40/blob/main/comparison/DISPUTES.md)).
## Section 5.3: the break-even departure share (Table 10, eq. 15)
Set NPV to zero and solve for the departure share. The tax pays for itself only if less than
$x^*$ of the income tax base leaves.
$$x^* = \frac{WT \cdot (r - g)}{C}$$
The paper evaluates it at its central revenue,
\$`r num(0.5 * nb$wt_max, 0)`B ($q \times 72$), and the midpoint income tax, \$4.55B (PDF p.33).
```{r}
#| label: t-nber-table10
#| results: asis
#| code-summary: "R code for Table 10"
wt36 <- 0.5 * nb$wt_max
n10 <- compute_npv_table10(scenarios = list(central = list(label = "Central", WT = wt36, C = 4.55)))
pn <- printed[printed$table == "nber_table10", ]
mn10 <- sum(abs(round(100 * n10$break_even_pct, 1) - pn$printed[match(paste0("central@", n10$r), pn$row)]) < 1e-9)
scroll(kable(n10 %>% transmute(`WT ($B)` = num(WT, 0), `C ($B)` = num(C, 2), `r - g` = pct(100 * r),
`x*` = pct(100 * break_even_pct),
printed = pct(pn$printed[match(paste0("central@", r), pn$row)])),
align = "rrrrr", caption = "Table 10 (PDF p.34)"))
```
`r mn10` of the `r nrow(n10)` cells match the printed digit. The central departure share,
$E[f]$ = `r num((nb$f_min + nb$f_max) / 2, 2)`, is above every one of them, which is why the mean
NPV comes out negative (PDF p.34). A check that is not an input: plugging each $x^*$ back into the
NPV formula must give exactly zero (see [internal identities](#internal-identities)).
## Section 5.4: the Monte Carlo behind the headline (eqs. 16 to 19) {#monte-carlo}
Draw the four inputs independently and uniformly, 100,000 times, and average the NPV
(PDF p.35).
$$WT \sim U[\$0\text{B}, \$72\text{B}], \quad C \sim U[\$3.3\text{B}, \$5.8\text{B}], \quad
(r - g) \sim U[0.015, 0.045], \quad f \sim U[0.30, 0.60]$$
Two details matter:
1. **Revenue is drawn from zero.** The mean of $U[0, 72]$, \$`r num(h("nber","mc_expected_revenue"), 0)`B,
is $0.5 \times 72$, the paper's central estimate (PDF p.33). This is the only place $q$
appears in the code: there is no explicit multiplier ([note 5](materials.html#reproduction-notes)).
The script's comment says the draw "embeds litigation range", and the chapter also grounds the
0 in unreported departures (fn.32, PDF p.34). A uniform draw from 0 is a different assumption
from a survive-or-not coin flip on a fixed base. The two share a mean, not a distribution.
2. **The departure share is drawn on its own**, not tied to revenue. Revenue lost and income tax
lost do not move together in a draw.
```{r}
#| label: t-mc
#| results: asis
#| code-summary: "R code for Section 5.4"
s <- tr("npv_mc_nber_summary")
scroll(kable(data.frame(
` ` = c("reproduced (seed 2026)", "printed (PDF p.35)"),
`Mean` = c(num(s$mean, 2), num(h("nber", "mc_mean_npv", "printed"))),
`Median` = c(num(s$median, 2), num(h("nber", "mc_median_npv", "printed"))),
`SD` = c(num(s$sd, 2), num(h("nber", "mc_sd_npv", "printed"))),
`% negative` = c(num(s$pct_negative, 2), num(h("nber", "mc_pct_negative", "printed"))),
check.names = FALSE), align = "lrrrr",
caption = "NPV Monte Carlo, $B, seed 2026, 100,000 draws"))
```
This OPA's R port replays the authors' script exactly (same seed, same draw order) and agrees with an
unmodified run of `NPV_dist_v8.R` to machine precision (`tests/testthat/test-npv-mc.R`). The
printed summary reproduces to within Monte Carlo error, with no systematic gap. Its exact
expectation is `r usdb(tr("npv_mc_nber_analytic_mean"), 2)`, which rounds to
`r num(tr("npv_mc_nber_analytic_mean"), 1)` rather than the printed
`r num(h("nber","mc_mean_npv","printed"))`: the printed value is the seeded run's, not the
expectation. The explorer shows the exact expectations and says so.
```{r}
#| label: f-npv
#| code-summary: "R code for the NPV distribution figure"
#| fig-cap: "The distribution of simulated NPV (paper Figure 3, PDF p.35). Draws below zero in the lighter gray; the dashed line is the mean."
#| fig-width: 7
#| fig-height: 3.4
ggplot(data.frame(npv = mcn$draws$npv), aes(npv, fill = npv < 0)) +
geom_histogram(binwidth = 5, boundary = 0) +
geom_vline(xintercept = mean(mcn$draws$npv), linetype = 2) +
scale_fill_manual(values = c(`TRUE` = "#9a9a9a", `FALSE` = "#555555"), guide = "none") +
coord_cartesian(xlim = c(-250, 70)) +
labs(x = "NPV, $B", y = "draws") + theme_minimal(base_size = 11)
```
## Every input, by origin {#every-input-by-origin}
This section is not in the paper: it re-sorts every input by *how much evidence stands behind
it*, the question a reader who wants to argue with the result has to answer first.
**The headline:** `r sum(vapply(own_dials, function(d) d$origin == "guesswork", logical(1)))`
of the authors' `r length(own_dials)` inputs that move the result in the explorer are guesswork.
The explorer's other two dials (an extra litigation weight and further growth) are this OPA's
settings. The one labelled data, the revenue ceiling, rests on a legal judgement about who counts
as having left (disputes row 3). The data and research inputs (the Forbes base, the FTB brackets, the Pareto fit)
fix the *scale*. Whether the NPV is negative is decided by the guesswork: how wide the revenue draw
is, where the income tax range stops, which rate is used, and whether the lost income tax is
permanent.^[The pipeline's input file also lists `r sum(inputs$version == "ssrn")` rows sourced to
the authors' first version (March 2026): its revenue floor, its central scenario and the Swiss
semi-elasticity as its workbook applies it. The [appendix](#appendix-first-version) shows them.]
- **data**: read off a public dataset or the authors' own person-level file, and recomputable from it.
- **research**: an estimate taken from a published paper.
- **guesswork**: set by assumption. Some are bounded by evidence, but no dataset or estimate pins
the value itself.
- **scenario**: a choice about which policy or counterfactual is being estimated.
- **derived**: a value another row implies.
```{r}
#| label: origins
#| results: asis
#| code-summary: "R code for the input table"
dials <- site_dials(rc)
dial_rows <- do.call(rbind, lapply(dials, function(d) data.frame(
input = if (d$key == "g") d$sym else d$key, origin = d$origin, what = d$label, `value(s)` = paste(d$labels, collapse = "; "),
where = "explorer dial (R/site.R)", check.names = FALSE)))
# The rows only the first version uses (version "ssrn") go to the appendix.
first_only <- inputs$version == "ssrn"
inp_row <- function(x) data.frame(input = x$input_id, origin = x$label, what = x$description,
`value(s)` = ifelse(is.na(x$value), "1.5% to 4.5%", vapply(x$value, function(v) format(signif(v, 6)), "")),
where = x$source, check.names = FALSE)
odf <- rbind(inp_row(inputs[!first_only, ]), dial_rows)
scroll(kable(odf, caption = "Inputs by origin (export/r/inputs.csv, then the explorer's dials)"))
```
```{r}
#| label: origins-count
#| results: asis
#| code-summary: "R code for the input counts"
# The levels list every origin present, so no row falls out of the count.
origin_levels <- union(c("data", "research", "guesswork", "guesswork on data", "scenario", "derived"),
unique(odf$origin))
cnt <- as.data.frame(table(factor(odf$origin, levels = origin_levels)))
stopifnot(sum(cnt$Freq) == nrow(odf))
names(cnt) <- c("origin", "rows")
scroll(kable(cnt, caption = paste0("Rows of the table above (", nrow(odf), "), by origin. ",
"The last ", length(dials), " rows are the explorer's dials.")))
```
## What reproduces
```{r}
#| label: reproduction-summary
#| results: asis
#| code-summary: "R code for the output checks"
# The chapter's PDF page for each figure it prints (typed, like the printed values), and a
# plain label for each output id (outputs.csv has no description column).
page_nber <- c(revenue_baseline = "19", nber_domestic_grown = "19", nber_ceiling = "21",
nber_international = "17", pareto_alpha = "26", income_tax_mc_k212 = "30",
income_tax_mc_k300 = "30", income_tax_mc_k500 = "30", npv_mc_nber_mean = "35",
npv_mc_nber_median = "35", npv_mc_nber_pct_negative = "35")
what_nber <- c(revenue_baseline = "Baseline revenue, before anyone leaves ($B)",
nber_domestic_grown = "Baseline grown 7% to the valuation date ($B)",
nber_ceiling = "Revenue left after the seven publicly reported moves ($B)",
nber_international = "Net worth of the non-US-citizen residents left out ($B)",
pareto_alpha = "Pareto tail parameter, alpha",
income_tax_mc_k212 = "Yearly income tax C, billionaires among the top 212 filers ($B)",
income_tax_mc_k300 = "Yearly income tax C, billionaires among the top 300 filers ($B)",
income_tax_mc_k500 = "Yearly income tax C, billionaires among the top 500 filers ($B)",
npv_mc_nber_mean = "Mean NPV, seeded run ($B)",
npv_mc_nber_median = "Median NPV, seeded run ($B)",
npv_mc_nber_pct_negative = "Share of draws below zero (%)")
ob <- outputs[outputs$output_id %in% names(page_nber), ]
top_id <- ob$output_id[which.max(ob$abs_diff)]
stopifnot(top_id == "revenue_baseline") # the prose below names it as note 6
o <- ob %>% transmute(output = what_nber[output_id], reproduced = signif(value, 6),
printed = printed_value, `abs diff` = signif(abs_diff, 2), page = page_nber[output_id])
# The rows whose reproduced value does not round to the digits the paper prints.
digits_printed <- vapply(as.character(ob$printed_value), function(p) nchar(sub("^[^.]*[.]?", "", p)), 0)
off_print <- ob$output_id[abs(round(ob$value, digits_printed) - ob$printed_value) > 1e-9]
stopifnot(setequal(off_print, c("revenue_baseline", "nber_ceiling"))) # the prose names these two
# One cat() call: the prose, then the table, so the code shows in one fold.
top_rel <- 100 * max(ob$abs_diff) / ob$printed_value[ob$output_id == top_id]
cat("The code reproduces the chapter's published results (tests in `tests/testthat/`). ",
"The simulated figures lie within four Monte Carlo standard errors of the print. ",
"The deterministic figures round to the printed digit, apart from ",
c("one", "two", "three", "four")[length(off_print)], " where the paper prints a typed or ",
"rounded intermediate value: the baseline revenue, a typed 94.20 ",
"([note 6](materials.html#reproduction-notes)), and the ceiling, the difference of two rounded ",
"figures ([note 4](materials.html#reproduction-notes)). The larger gap is the baseline revenue's, ",
num(max(ob$abs_diff), 2), " (", num(top_rel, 1), "%). Table 9's 21 cells and Table 10's 3 cells, ",
"shown above, match as well.^[The first version's own outputs are in the ",
"[appendix](#appendix-first-version).]\n",
paste(capture.output(scroll(kable(o, row.names = FALSE))), collapse = "\n"), sep = "")
```
```{r}
#| label: reproduction-notes
#| results: asis
#| code-summary: "R code for the list of reproduction notes"
mm <- readLines("../NOTES.md")
mm_lines <- sub("^## ", "", grep("^## [0-9]+\\.", mm, value = TRUE))
mm_first <- grepl("^[0-9]+\\. First version: ", mm_lines)
# each note's kind, from the "**Kind:**" line under its heading
mm_kind <- sub("^[*][*]Kind:[*][*] ([^.]+)[.].*", "\\1", grep("^[*][*]Kind:[*][*] ", mm, value = TRUE))
stopifnot(length(mm_kind) == length(mm_lines),
all(mm_kind %in% c("rounded or typed input", "seed variation", "documentation")))
word <- function(n) c("One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten")[n]
kinds <- c(`rounded or typed input` = "rounded or typed input", `seed variation` = "seed variation",
documentation = "documentation note")
by_kind <- vapply(names(kinds), function(k) {
n <- sum(mm_kind == k)
paste0(tolower(word(n)), " ", kinds[[k]], if (n > 1) "s" else "")
}, "")
cat(paste0(
word(length(mm_lines)), " notes record where the authors' text, their READMEs and their code ",
"describe the same step differently. None is a failure to reproduce a printed result. By kind: ",
paste(by_kind[-length(by_kind)], collapse = ", "), " and ", by_kind[length(by_kind)], ". ",
word(sum(!mm_first)), " of them concern the chapter. This OPA records them rather than changing the code.",
"^[The other ", tolower(word(sum(mm_first))), " concern only the authors' first version: ",
paste(paste0(sub("First version: ", "", mm_lines[mm_first]), " (", mm_kind[mm_first], ")"), collapse = "; "), ".]\n\n",
paste0(mm_lines[!mm_first], " (", mm_kind[!mm_first], ")", collapse = "\n"),
"\n\nFull text: [the materials page](materials.html#reproduction-notes).\n"))
```
### Internal identities {#internal-identities}
These checks compare the pipeline with itself, not with the paper. Each value should be zero or small.
```{r}
#| label: identities
#| results: asis
#| code-summary: "R code for the consistency checks"
n_cell <- site_grid_cell(grid, as.list(site_named()$nber))
se_n <- mcn$summary$sd / sqrt(mcn$summary$n)
checks <- data.frame(
identity = c(
"Table 10's x* plugged into NPV = WT - x* C/(r - g) gives 0 (max |NPV|, $B)",
"The explorer's value at the main setting equals the pipeline's analytic mean (abs diff, $B)",
"Seeded MC mean vs exact expectation, in MC standard errors",
"Share negative: exact integration (quadrature) vs seeded MC (points)",
"Ceiling: domestic total minus the 7 departures, recomputed, minus the script's 72 ($B)"),
value = c(max(abs(with(n10, WT - break_even_pct * C / r))),
abs(n_cell$mean_npv - tr("npv_mc_nber_analytic_mean")),
(mcn$summary$mean - tr("npv_mc_nber_analytic_mean")) / se_n,
n_cell$pct_negative - mcn$summary$pct_negative,
nc$ceiling_recomputed - nb$wt_max))
checks$value <- vapply(checks$value, function(v) format(signif(v, 3)), "")
scroll(kable(checks))
```
The seeded mean sits within about one Monte Carlo standard error of the exact expectation. The
printed `r num(h("nber","mc_mean_npv","printed"))` equals the rounded seeded run, not the rounded
expectation, `r num(tr("npv_mc_nber_analytic_mean"), 1)`. Which one a printed digit matches is a
rounding coincidence.^[In the authors' first version it is the other way round: its printed mean
equals the rounded expectation (see the [appendix](#appendix-first-version)).]
## Reproducing this document
```sh
cd rjkdc-analysis
export RAUH_REPO_DIR=/path/to/wealth_tax # clone of github.com/bjaros20/wealth_tax at 25e84dd
Rscript -e 'targets::tar_make()' # writes the targets store, export/r/, site/data/, site/explorer/grid.js
Rscript tests/testthat.R # full suite
quarto render site/repro.qmd
```
The authors' repository is MIT licensed but not redistributed here; the tests that re-run the
authors' own scripts skip cleanly without it, and the rest of the suite does not need it.
## Appendix: the authors' first version (March 2026) {#appendix-first-version}
This appendix is not part of the account above. It records what the pipeline also reproduces:
the authors' first version, Rauh, Jaros, Kearney, Doran and Cosso, *The Net Present Value of
the Billionaire Tax Act*, Hoover Institution, SSRN 6340778 (17 March 2026). Page numbers in this
appendix are that version's PDF pages. Its Pareto fit and income tax Monte Carlo are the ones in
Section 5.1 above (its Table 8 is the chapter's Table 9). Its revenue part and its Monte Carlo
differ.
**Revenue.** It starts from the same \$`r num(out("revenue_baseline","printed_value"), 2)`B baseline,
without growth to a valuation date or a litigation discount. Six publicly reported moves leave
\$`r num(rc$confirmed6_ceiling, 2)`B (printed \$`r out("revenue_confirmed6","printed_value")`B,
p.11); ten reported departures leave \$`r num(rc$expanded10_estimate, 2)`B (printed
\$`r out("revenue_expanded10","printed_value")`B, p.13). The Swiss semi-elasticity, applied
linearly (10.32 × 5 = `r pct(100 * rc$migration_share)` of the base), gives
\$`r num(rc$literature_calibrated, 2)`B (printed \$`r out("revenue_literature_calibrated","printed_value")`B,
p.14). The preferred "approximately \$`r h("ssrn","revenue_headline","printed")` billion (range:
\$35-46 billion)" (p.14) is not computed anywhere in the authors' code. Every step after the
baseline uses a typed 94.2 rather than the workbook's 94.30, and each printed result built on it
reproduces (note 6).
**NPV by scenario and break-even (its Tables 9 and 10).** The departure share is tied to revenue,
$f = 1 - WT/94.2$, so revenue lost and income tax lost move together.
```{r}
#| label: t-table9
#| results: asis
#| code-summary: "R code for the first version's Table 9"
p9 <- printed[printed$table == "table9", ]
t9p <- t9 %>% mutate(key = paste0(scenario, "@", r)) %>% rowwise() %>% mutate(
pv_printed = p9$printed[p9$row == key & p9$column == "pv_lost"],
npv_printed = p9$printed[p9$row == key & p9$column == "npv"]) %>% ungroup()
tab9 <- t9p %>% transmute(Scenario = label, `WT ($B)` = num(WT, 2), `C ($B)` = num(C, 2),
`f` = pct(100 * f), `r - g` = pct(100 * r),
`PV lost` = num(pv_lost), `printed` = num(pv_printed),
`NPV` = num(npv), `printed ` = num(npv_printed))
m9 <- sum(abs(round(t9p$pv_lost, 1) - t9p$pv_printed) < 1e-9) + sum(abs(round(t9p$npv, 1) - t9p$npv_printed) < 1e-9)
scroll(kable(tab9, align = "lrrrrrrrr", caption = "First version, Table 9, $B (its PDF p.19)"))
```
```{r}
#| label: t-table10
#| results: asis
#| code-summary: "R code for the first version's Table 10"
p10 <- printed[printed$table == "table10", ]
tab10 <- t10 %>% mutate(key = paste0(scenario, "@", r)) %>% rowwise() %>%
mutate(printed = p10$printed[p10$row == key]) %>% ungroup() %>%
transmute(Scenario = label, `r - g` = pct(100 * r), `x*` = pct(100 * break_even_pct),
printed = pct(printed))
m10 <- sum(abs(round(100 * t10$break_even_pct, 1) -
sapply(paste0(t10$scenario, "@", t10$r), function(k) p10$printed[p10$row == k])) < 1e-9)
scroll(kable(tab10, align = "lrrr", caption = "First version, Table 10 (its PDF p.20)"))
```
`r m9` of the `r 2 * nrow(t9p)` Table 9 cells and `r m10` of the `r nrow(t10)` Table 10 cells
match the printed digit. The central scenario's \$`r inp("wt_central_scenario")`B is a typed
literal with no derivation (note 8). The headline is not a cell of Table 9: the central cell
at 1.5% is `r usdb(t9$npv[t9$scenario=="central" & t9$r==0.015])`.
**Monte Carlo (its Sec 5.5).** It draws $WT \sim U[`r ss$wt_min`, `r ss$wt_max`]$,
$C \sim U[3.3, 5.8]$ and $r \sim U[0.015, 0.045]$, with $f = 1 - WT/94.2$. The paper prints the
upper bound as 67.1, a typo for 67.51 (note 1). The text derives a growing perpetuity, but the
code divides by the plain rate (note 2). The draw's revenue has a mean of
\$`r num(h("ssrn","mc_expected_revenue"), 2)`B, above the preferred
\$`r h("ssrn","revenue_headline","printed")`B.
```{r}
#| label: t-mc-first
#| results: asis
#| code-summary: "R code for the first version's Monte Carlo"
s1 <- tr("npv_mc_ssrn_summary")
scroll(kable(data.frame(
` ` = c("reproduced (seed 2026)", "printed (its PDF p.20)"),
`Mean` = c(num(s1$mean, 2), num(h("ssrn", "mc_mean_npv", "printed"))),
`Median` = c(num(s1$median, 2), num(h("ssrn", "mc_median_npv", "printed"))),
`SD` = c(num(s1$sd, 2), num(h("ssrn", "mc_sd_npv", "printed"))),
`% negative` = c(num(s1$pct_negative, 2), num(h("ssrn", "mc_pct_negative", "printed"))),
check.names = FALSE), align = "lrrrr",
caption = "First version, NPV Monte Carlo, $B, seed 2026, 100,000 draws"))
```
This OPA's R port agrees with an unmodified run of `NPV_dist.R` to machine precision. The median and
share negative round to the printed values. The mean (`r num(mcs$summary$mean, 2)` against
`r num(h("ssrn","mc_mean_npv","printed"))`) and the standard deviation
(`r num(mcs$summary$sd, 2)` against `r h("ssrn","mc_sd_npv","printed")`) lie within Monte Carlo
error of the print, so the summary reproduces; the small gaps are seed variation (note 9). The
draws are independent, so the mean has a closed form:
$$E[NPV] = E[WT] - \left(1 - \frac{E[WT]}{94.20}\right) E[C]\, E\!\left[\tfrac{1}{r}\right]$$
It equals **`r usdb(tr("npv_mc_ssrn_analytic_mean"), 3)`**, which rounds to the printed
`r num(h("ssrn","mc_mean_npv","printed"))`. The SD has a closed form too
(`npv_mc_analytic_sd()`), **`r num(npv_mc_analytic_sd(ss$wt_min, ss$wt_max, ss$c_min, ss$c_max, ss$r_min, ss$r_max, f_mode = "ssrn", baseline_revenue = ss$baseline_revenue), 3)`**,
which rounds to the printed `r h("ssrn","mc_sd_npv","printed")`. So the printed mean and SD are
the model's exact values at the printed digit, and the printed summary is what the same code gives
on another run. The shipped seed gives `r num(mcs$summary$mean, 2)` and `r num(mcs$summary$sd, 2)`.
**Its outputs, inputs and checks.**
```{r}
#| label: first-version-tables
#| results: asis
#| code-summary: "R code for the first version's outputs, inputs and checks"
of <- outputs[!outputs$output_id %in% names(page_nber) & outputs$version != "nber", ]
scroll(kable(of %>% transmute(output = output_id, reproduced = signif(value, 6),
printed = printed_value, `abs diff` = signif(abs_diff, 2), `its page` = printed_page),
row.names = FALSE, caption = "First version: outputs"))
scroll(kable(inp_row(inputs[first_only, ]), row.names = FALSE, caption = "First version: inputs"))
x10 <- t10 %>% left_join(t9 %>% select(scenario, r, WT, C), by = c("scenario", "r", "WT", "C"))
s_cell <- site_grid_cell(grid, as.list(site_named()$ssrn))
se_s <- mcs$summary$sd / sqrt(mcs$summary$n)
checks1 <- data.frame(
identity = c(
"Table 10's x* plugged into Table 9's formula gives NPV = 0 (max |NPV|, $B)",
"Grid's first-version cell equals the pipeline's analytic mean (abs diff, $B)",
"Seeded MC mean vs exact expectation, in MC standard errors",
"Share negative: quadrature vs seeded MC (points)"),
value = c(max(abs(with(x10, WT - break_even_pct * C / r))),
abs(s_cell$mean_npv - tr("npv_mc_ssrn_analytic_mean")),
(mcs$summary$mean - tr("npv_mc_ssrn_analytic_mean")) / se_s,
s_cell$pct_negative - mcs$summary$pct_negative))
checks1$value <- vapply(checks1$value, function(v) format(signif(v, 3)), "")
scroll(kable(checks1, caption = "First version: internal identities"))
```
::: {.site-nav}
JR analysis of California Proposition 40:
[Overview](index.html) · Open Output: [Explorer](explorer/) · Open Analysis: Report (here) and
[Slides](slides/slides.html) ·
[Open Materials](https://github.com/fhoces/opa-prop40/tree/main/rjkdc-analysis)
Also in this project: [Comparison overview](../../index.html) · [BSZ analysis overview](../../bsz-analysis/site/)
:::
::: {.credit}
OPA done by [Fernando Hoces de la Guardia](https://fhoces.github.io), using [agentic coding tools](index.html#how-ai-was-used)
:::