diff --git a/.claude/hooks/gloss-variables-reminder.sh b/.claude/hooks/gloss-variables-reminder.sh new file mode 100755 index 0000000..0d8821c --- /dev/null +++ b/.claude/hooks/gloss-variables-reminder.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# UserPromptSubmit hook — standing writing-style rule for this repo. +# +# Injects a reminder that model symbols must be glossed with a short definition +# the first time they appear in prose. The model's variable names (n_inter_F, +# rdep_F, def_rate_ss, goods_mkt_D, ...) are opaque without one, and prose that +# leans on them is unreadable to anyone but the author. +# +# Emits JSON on stdout; `additionalContext` is injected into the model's context +# for the turn. Contains no double quotes so the quoted heredoc below is valid +# JSON verbatim — keep it that way when editing the text. +cat <<'JSON' +{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"Writing-style rule for this repo (prose only): the first time a model variable, parameter, coefficient or residual appears in a sentence, gloss it with a short definition in parentheses immediately after it — e.g. `n_inter_F` (F-bank net worth), `rdep_F` (F ex-ante real deposit rate), `def_rate_ss` (steady-state default probability), `goods_mkt_D` (D goods-market residual), `psi_lambda_B` (collateral-friction amplification dial), `size_F` (F size relative to D). Applies to prose and table cells; skip inside code blocks, file paths, diffs and pasted program output. Do not re-gloss the same symbol twice in one reply."}} +JSON diff --git a/.claude/settings.json b/.claude/settings.json index 86caa14..93d0280 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -13,6 +13,18 @@ } ] } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/gloss-variables-reminder.sh\" || true", + "timeout": 5, + "suppressOutput": true + } + ] + } ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 25d0a7e..23a75fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,11 +34,27 @@ equation change and inspect the printed residuals: > than importing `get_calibration()`, so it silently tested a *different* model than > `code/main.py` and its results were misleading. Recover from git history if needed. +**Fast unit tests** — the sticky-price/nominal-deposit blocks, ~1 s, no model solve. Run +these first; they catch a wiring mistake in a second rather than in twelve minutes: +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -v # 17 tests +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py code/test_eba_calibration.py experiments/ -v # 40 passed +``` + **Acceptance thresholds** (from `docs/verification_report.md`): -- `goods_mkt_D` ≤ 1e−14 -- `goods_mkt_F` ≤ 1e−7 -- `ca_res_D` ≤ 1e−7 +- `goods_mkt_D` ≤ 1e−6 — **corrected 2026-08-18.** The old `1e−14` was never met on this + calibration: the pre-refactor `main` (91ac778) prints `−4.2493e−07` and the current + branch prints `−4.2281e−07`. Measured directly against a clean worktree at 91ac778, so + this is a documentation fix, not a regression. The genuine machine-zero residual is + `ca_res_D`. +- `goods_mkt_F` ≤ 1e−6 (same story; `−4.18e−07`) +- `ca_res_D` ≤ 1e−13 (actually ~1e−16) - `deposit_mkt_D/F` ≤ 1e−13 +- `nkpc_p_res_D/F`, `tot_res`, `union_pi_res` — exactly `0.000000e+00` at SS +- **GK portfolio FOCs** — `report_gk_steady_state` raises if `nu_i/nu_K ≠ Delta_i_eff` on any + of the four legs (own legs to 1e−9, cross legs to 5e−4), or if any `Delta_*_eff` leaves + [0,1]. This runs on every solved SS; it is the check that the sovereign spread is coming + from intermediary optimality and not from a wedge. **Targeted audit scripts:** removed with `audit_artifacts/` (2026-07-30). The findings they produced are recorded in `docs/audit.md` and `docs/STATE.md`; the scripts themselves are in @@ -54,7 +70,7 @@ The model is implemented in the `sequence_jacobian` (SSJ) library. Blocks are de - `code/equations_D.py` — Country D (Greece): household EGM het block (`hh_D`), deposit return, bank steady-state and intermediation, production, capital, government fiscal, bond pricing/default - `code/equations_F.py` — Country F (Germany): symmetric analogues of all D blocks -- `code/equations_global.py` — global goods market, external account, bond clearing, portfolio adjustment costs, trade balance, bond yield formula +- `code/equations_global.py` — global goods market, external account, bond clearing, the cross-border GK portfolio FOC (`gk_cross_border_foc`), trade balance, bond yield formula, terms of trade, union inflation ### Production pipeline (run this) @@ -62,8 +78,37 @@ The model is implemented in the `sequence_jacobian` (SSJ) library. Blocks are de - `code/calibration.py`, `code/steady_state.py`, `code/ic_delta_calibration.py`, `code/depreciation_calibration.py`, `code/full_model.py` — the calibration/solve stages `main.py` calls. - `code/tpi.py`, `code/tpi_plots.py`, `code/irf_plots.py` — TPI experiment and figure generation. +**`full_model.build_block_list()` is the single model definition.** `full_model.py`, +`code/tpi.py` and `diagnostics/regimes/regime_model.py` all call it; the TPI layer supplies +its four `_tpi` swaps (`budget_residual_D/F`, `external_account_D`, `domestic_bond_clearing`) +through `tpi.tpi_overrides()`. Add a block once, there. The three used to hardcode their own +copies of the `sj.create_model([...])` list. + The legacy `code/model_v12.ipynb` has been removed; the modular pipeline above (added in PR #28) is the source of truth. `docs/equation_reconstruction.md` cites notebook cells 2–21 for historical provenance only. +### ⚠ `solve_jacobian_padded()` — never call `Block.solve_jacobian` directly + +SSJ 1.0.0's `CombinedBlock._jacobian` seeds from the shock list and ends with +`total_Js[original_outputs & total_Js.outputs, :]`, only visiting blocks whose inputs +intersect that list. **A target that is a pure function of the solver's own unknowns is +therefore silently dropped from H_Z.** All four sticky-price targets (`nkpc_p_res_D/F`, +`tot_res`, `union_pi_res`) are exactly that, so stock SSJ returns a 23-row H_Z against the +27×27 H_U and `np.linalg.solve` dies with `size 11500 is different from 13500`. + +`full_model.solve_jacobian_padded()` restores the missing rows as zeros. This is **exact, not +an approximation** — `dH/dZ` at fixed unknowns is identically zero when the shock symbol never +appears in the equation — and it otherwise mirrors `Block.solve_jacobian` line-for-line, +printing the padded row names on every solve so the padding can never go silent. + +**Every Jacobian call site in the repo routes through it.** The invariant: + +```bash +grep -rn "\.solve_jacobian(" --include="*.py" code experiments diagnostics | grep -v solve_jacobian_padded +``` + +must stay **empty**. A 25×25 rewrite folding the four targets into existing equations was +considered and rejected: it hits the identical defect with smaller numbers. + ### Policy experiments (`experiments/`, added 2026-08-03) The paper's standard results set. **`code/` is deliberately untouched by this package** so @@ -77,14 +122,23 @@ The paper's standard results set. **`code/` is deliberately untouched by this pa schedule, welfare labelled secondary. - `experiments/e2_dy_decomposition.py` — ΔY against the `market_clearing_D` identity; self-verifying, asserts closure at 1e−7. -- `experiments/e3_writeoff_s1.py` — the S-1 writeoff variants. +- `experiments/e3_writeoff_s1.py` — S-1 and the payoff specification. Rebased 2026-08-18: baseline is now `zeta_writeoff = 1`, so the variants are `e3a_realised_writeoff` (`writeoff_enabled = 1` — the pure S-1 test) and `e3b_coupon_only_pricing` (`zeta = 0` — the §12 Arm-3 diagnostic). Both are exactly SS-neutral; `zeta` is allocation-neutral but dynamically decisive. - `experiments/common.py` — cache access, `calibration_override`, unit helpers, provenance. +**`run_all.py` covers E1–E3 only. E4 is a separate entry point.** +`experiments/e4_distribution.py` (~4 min) builds `cache_e4_deciles.npz`, which feeds +`experiments/paper_outputs.py` → the eight tracked `experiments/paper/fig0*.png` and +`docs/paper_draft_results.md`. Regenerating E1–E3 does **not** regenerate any of that. Run +both, or you will ship paper artefacts built on the previous model — which is exactly what +nearly happened in the sticky-price workstream. + It runs on `diagnostics/regimes/regime_model.py`'s cached Jacobian response matrices, which are built from the production equation files — **no copy of the model or the calibration lives in this package**, which is the failure that made the retired `audit_artifacts/` harness silently test a different model for weeks. Rebuild the cache after any calibration -change: `/opt/anaconda3/envs/ssj/bin/python diagnostics/regimes/regime_model.py --force`. +change, and **rebuild it *before* running the experiments** (they never re-solve the model, +so the reverse order silently re-reports the old one): +`/opt/anaconda3/envs/ssj/bin/python diagnostics/regimes/regime_model.py --force`. **Two gotchas worth knowing before extending it.** `calibration_override` patches the *module attribute*, so a module-level `from calibration import get_calibration` binds the @@ -107,14 +161,76 @@ path; findings live in `docs/audit.md` and `docs/STATE.md`. These are deliberate design decisions — do not "fix" them without checking `docs/SPEC.md`: - **`Y = F(K_t)` (current-period capital):** production uses same-period capital stock; capital producer receives `mpk·(K−K(-1))` to close capital income accounting (W-1 fix). The alternative `K(-1)` timing eliminates this term but is equally valid. -- **Predetermined deposit rate:** `Rgross = (1+rdep(-1))·P(-1)/P`. Deposit contracts are non-contingent — the rate is locked at t−1. Using `rdep` (a period-t unknown) instead was T-2, the critical doom-loop sign inversion. -- **Hatchondo-Martinez perpetuity:** bond coupon decays at rate `1−delta_b`; duration ≈ 1/delta_b quarters. This is what generates MTM capital losses on bank balance sheets. +- **Predetermined deposit rate:** the rate is locked at t−1 and deposit contracts are non-contingent. Since 2026-08-06 the contracted rate is **nominal**: `i_dep_D/F` is the solver unknown, `rdep_D/F` keeps its name as the derived **ex-ante** real rate (t → t+1), and `rdep_expost_D/F` is the realised rate carrying the inflation surprise. Using a period-t rate instead was T-2, the critical doom-loop sign inversion. Note `rdep_expost` carries its own `(-1)` internally — writing `rdep_expost_D(-1)` double-lags it. +- **Hatchondo-Martinez perpetuity:** bond coupon decays at rate `1−delta_b`; duration ≈ 1/delta_b quarters. **Duration alone does NOT generate the MTM capital losses — duration *interacting with* `rho_s` (persistence of the latent default-risk factor) does.** Corrected 2026-08-28; the former wording ("This is what generates MTM capital losses") credited duration by itself and is wrong. Holding duration at the calibrated 17 quarters and varying persistence, the perpetuity and a one-quarter bill lose *exactly the same* on the reported impulse when `rho_s = 0` (both −1.01%), diverge mildly at `rho_s = 0.5` (−1.27% vs −1.01%), and separate only at the calibrated `rho_s = 0.95` (−5.58% vs −1.01%, a factor of 5.5). A transitory risk shock marks a long bond down no more than a short one. The loss is large because a persistent `s_t` keeps the hazard elevated across the claim's whole remaining life. Two corollaries: a short bond's *principal* is marked down too, so the distinction is never coupon-vs-principal; and any text describing the MTM channel must carry the persistence, not just the maturity. + +**Sovereign pricing is structural — no wedges (2026-08-18, `gk-structural-foc` stages 2–5).** +Read `docs/STATE.md` → *GK structural refactor* before touching the bank block. + +- **One source of truth for the bond payoff.** `bond_return_D/F` emits three objects: + `rb_exp_D` (expected payoff over the default distribution — the ONLY return the pricing + equations read), `rb_actual_D` (the realised return on the branch the IRF traces, gated by + `writeoff_enabled_D = 0`), and `EL_load_D` (a pure diagnostic, read by `code/tpi.py`'s CB + P&L and by nothing else). Never price off `rb_actual`. +- **`zeta_writeoff_D/F = 1`.** A default writes down the perpetuity's continuation value as + well as its coupon. Coupon-only pricing under-states the loss on a 12.9-quarter claim by + ~12.6×. `zeta_writeoff` governs what is PRICED; `writeoff_enabled` governs what is + REALISED. They are independent and both are needed. +- **`psi_spread_D/F` and `EL_price_D/F` are DELETED, not recalibrated.** So are + `divert_bond_foc_D/F`, `divert_portfolio_adj`, `domestic_bond_foc_D/F`, + `portfolio_adj_cost`, `bond_price_ss_D/F` and the `excess_return_*_ss` anchors. + `code/test_nkpc_blocks.py::test_no_ad_hoc_sovereign_spread_wedge_anywhere` AST-scans + `code/*.py` and fails if any of those names reappears in live code. There must be no + equation of the form `spread += parameter * default_probability`. +- **The spread comes from the GK portfolio FOC.** `gk_bond_foc_D/F` impose + `nu_own/nu_K = Delta_own_eff`, which with `intermediation_P1_D/F` is + `rb_exp(+1) − rdep = Delta_eff · (rk(+1) − rdep)`. `q_b_D` and `q_b_F` are **SS unknowns** + pinned by these two residuals (`rb_D_res`, `rb_F_res`), not by `bond_price_ss_*`. +- **Cross-border legs get the same FOC plus a portfolio adjustment cost.** + `gk_cross_border_foc` (in `equations_global.py`) states + `nu_cross/nu_K = Delta_cross_eff + psi·(b − b_ss)`, divided through by `SDF_banker·Omega_p1` + so `psi_bF_D`/`psi_bD_F` keep their calibrated units. Four proportionality conditions + cannot hold against two bond prices, so the own legs pin PRICES and the cross legs pin + QUANTITIES. The `psi` costs load on the bond stock, carry no `def_rate`, and are zero at + the calibrated position — they are not spread wedges. +- **All four `Delta` are 0.20.** Cross-border `Delta_bF_D`/`Delta_bD_F` moved 0.40 → 0.20 + because GK optimality at a riskless SS with `rk_D = rk_F` and `rdep_D = rdep_F = 0` forces + it. Holding 0.40 leaves an 80bp/yr constant cross-border wedge. +- **`psi_lambda_B_D/F` is SLATED FOR DELETION (decided 2026-08-24), not a diagnostic arm.** + It is 0 in the live calibration and will never again be set otherwise: the parameter and + the risk-sensitive branch of `collateral_quality_D/F` are to be removed outright, leaving + `Delta_*_eff = Delta_*` as constants. The Greek episode gives no independent observable for + a sovereign-specific haircut *elasticity*, and keeping a switched-off dial invites the + reader to ask what it does. This does not remove collateral: the IC still binds and + `Delta_bD_D = 0.20` still makes sovereign paper LESS divertable than capital — i.e. BETTER + collateral, which is why it earns a fifth of the capital premium. (The former wording here + said "worse collateral"; that contradicted the code comment in `intermediation_IC_D` and + the IC algebra `theta_tgt = value/lambda_gk + (1−Delta)·phi_b`, and is corrected.) + **The paper's introduction now states that the model contains no device making a + balance-sheet friction a function of the default probability.** Until the deletion lands, + `code/` and the paper disagree; the deletion is the fix, not a caveat in the text. The old + `psi_lambda_B = 3.01` counterfactual is void and must never be described as "the + non-fundamental share of the spread". +- **Do not write "x% fundamental / y% non-fundamental".** This is a linearised equilibrium + model; the channels operate jointly. Describe mechanisms: direct expected-default-loss + pricing, intermediary balance-sheet amplification, and (if enabled) risk-sensitive + collateral amplification. - **Walras redundancy:** `ca_res_D` and `goods_mkt_F` are *dropped* from the solver target system (not a bug). Post-fix they hold to machine tolerance; monitoring them is the primary regression check. - **p-conversion in F-bank returns:** F-bank's D-bond book is denominated in D-goods; returns must be converted via `p(-1)/p` to F-goods before entering the F-goods budget constraint (W-2 fix). Missing this causes `goods_mkt_F` to leak up to 2% of GDP. +**Nominal rigidities (added 2026-08-06 on `add-nkpc`; see `docs/SPEC.md` for the full +rationale and `docs/STATE.md` for numbers):** + +- **Rotemberg price NKPC, subsidy-neutralised.** `pi = beta*pi(+1) + kappa_p*(mu_p*mc − 1)` in both countries; markup wedge `w = mu_p*mc*(1−alpha)*Y/N` in `labor_demand_D/F`. **Wages stay flexible** — `labor_market_D/F` is untouched. `mc_ss = 1/mu_p` neutralises the markup, so `mu_p*mc = 1`, `profit_ss = 0`, `pi_ss = 0` and **the steady state is bit-identical to the flex-price model**. `kappa_p → ∞` recovers flexible prices exactly — the standing equivalence gate. +- **Markup rent proportional to `e`, not lump-sum.** `firm_profit_D/F` route `(1 − mu_p*mc)(1−alpha)Y` through `income_D/F` in proportion to productivity, which makes labour-plus-profit income per unit of `e` exactly `(1−alpha)Y·e` — identical to the flex-price model, so the wedge bites only on hiring, never on household income. A lump-sum rebate was rejected as countercyclical (it would manufacture a progressive incidence result as an artifact of the rebate rule). +- **No policy rate.** The union-inflation normalisation `omega_pi_D*pi_D + (1−omega_pi_D)*pi_F = 0` (`phi_pi → ∞` limit of an ECB rule on union PPI) pins the inflation level; `p/p(-1) = (1+pi_F)/(1+pi_D)` pins the differential off the existing unknown `p`. No contract in the model carries a policy rate, so no Fisher relation is needed. `omega_pi_D = 0.071` is the renormalised capital key, **not** GDP weights — GDP weights would erase the 93/7 Greek-deflation split. +- **Nominal deposits against REAL sovereign bonds** — a deliberate asymmetry that maximises banks' Fisher exposure (nominal debtors, real creditors). **Must be stated as a modelling choice in the paper.** Nominal sovereign bonds are a candidate extension, not a correction. +- **27×27 solver system** (was 23×23): `+mc_D, pi_D, mc_F, pi_F` unknowns (and `rdep_D/F → i_dep_D/F`), `+nkpc_p_res_D/F, tot_res, union_pi_res` targets. + ## Branch convention -- `main` — **use this for all new work**. Contains all six structural fixes (W-1, W-2, W-3, T-2, A-2, TPI-1, merged via PR #27) plus the modular-file reorganisation (PR #28). +- `add-nkpc` — **the current working branch.** Sticky prices + nominal deposit contracts (Tasks 1–16, 2026-08-05/06), to be merged to `main`. +- `main` — contains all six structural fixes (W-1, W-2, W-3, T-2, A-2, TPI-1, merged via PR #27) plus the modular-file reorganisation (PR #28). - `audit` / `AB-audit` — historical audit branches. `AB-audit` was merged into `main` (PR #27); `audit` (PR #26) was closed as superseded. Do not reuse. - `bank-cal` — old calibration branch predating structural fixes. **Do not merge.** Port calibration values only (see `docs/bank_cal_review.md`). @@ -125,19 +241,19 @@ See `docs/STATE.md` for the full calibration table. Key tensions: | Issue | Description | |-------|-------------| | **C-1** | **RESOLVED (2026-07-22).** Was: `Delta_cross=1.45>1`, back-solved divertable fraction exceeds 1, multi-asset IC degenerate. Fixed at its root: `steady_auxilliary_D/F` now solve `lambda_gk` from the multi-asset IC directly; `Delta_bD_D/F=0.2/0.4` are genuine hardcoded inputs, verified to bind exactly. See `docs/eba_calibration.md`. | -| **S-1** | **RESOLVED (author decision, 2026-08-04): `writeoff_enabled=0` stays.** The paper commits to the **pure risk-premium framing** — `def_rate` is a genuine probability, agents price the expected loss, and the IRF traces the no-default branch. This is a standard risk-premium-shock device, *not* "default is impossible", and must be stated as such. E3 (`experiments/e3_writeoff_s1.py`) quantified the alternative before the decision: `writeoff_enabled=1` alone is SS-neutral and negligible (loading 4.00→3.93), but adding `zeta_writeoff=1` takes `EL_price_D` 0.0561→0.7017 (12.5×) and collapses the loading to **0.37/0.28 — below 1**, inverting Live Claim 1. It also breaks the named-regime construction (peak spread stops being monotone in γ). Retained as an appendix robustness result: the over-compensation claim is **conditional on no realised principal writedown**, and that conditionality must be stated in the paper, not buried. `recovery_rate_D/F=0.30` (EL-1, Greek PSI NPV framing) stays live through `EL_price`. **`EL_price_D` is 0.056134 at the live calibration, not the 0.0717 previously recorded here** — that predates the EBA `delta_b=0.0777`/`q_b=0.969`. Re-derive it wherever quoted; it is the loading's denominator. | -| **GK-1** | **RESOLVED (2026-07-31) — collateral mapping.** The GK block is well-posed only if `f*theta > (1-Delta_own)*phi_own + (1-Delta_cross)*phi_cross`. At measured EBA moments `Delta_own=0.2` violated it by −1.26/−1.42, giving **negative** `lambda_gk`/`Omega` while the solver converged with machine-zero residuals (C-1's silent-degeneracy mode). Cause: `_ic_delta`'s hidden `ratio=Delta_cross/Delta_own=2.0` back-solve closure, which capped `Delta_own<=0.5` against a required `>~0.73`. Removed; `Delta` is now free and the IC **residual** is checked directly. `Delta=0.85/0.90` → `lambda_gk_D=+0.927` (pre-EBA: +0.923). Guarded by `steady_state.assert_gk_well_posed` on every solved SS. | -| **GK-2** | **RESOLVED (2026-07-31) — `n_inter` scope.** Three compounding amplifiers made the CT1-scope EBA calibration explosive. Fixed in order: the hidden `ratio=2.0` closure (GK-1); `omega_K` as a *fixed share* (new `fund_rule=1` → fund holds a fixed quantity, `dK/dN = theta` not `theta/omega_K`, steady state identical); and finally the **scope of `n_inter`** — CT1 is the stress-test sample, not the agent intermediating the whole capital stock. New **`BANK_SCOPE="broad"`**: `n_inter = (Q*K + sovereign)/theta`, `omega_K = 1`, fund device gone. Model is stable and on target: `b_gov_D[499]=1.4e-05`, spread 150.4bp, `Y_D[0]=-0.0149%` (Y-1 resolved), `rk_D=rk_F=0.010000` (RK-1 resolved), TPI loading 4.35/4.01/3.44 declining. | -| **EBA switch** | `EBA_CALIBRATION` in `code/calibration.py`, default **False** (pre-EBA values, bit-exact, solves). `True` turns on the rebuilt measured moment set; SS is then correct but dynamics are explosive (GK-2). The moment set itself (`code/eba_calibration.py` → `data/eba_moments.json`) is rebuilt, identified, and tested (10/10). | -| **Calibration** | **Reverted to pre-EBA values 2026-07-30** (`psi_lambda_B=3.0`, `n_inter=3.0`, `omega_K=1.0`, `phi_lamb=0.15`, `mv_rule=0`, cross-holdings `0.25`), keeping all structural fixes and the EL-1 `recovery_rate=0.30`. The EBA 2011 anchoring (`phi_bD_D_ss=2.39`, `psi_lambda_B=1.1793`) is **no longer live** — `docs/eba_calibration.md` is now historical. Spread response is **187.2bp annualised** per 1pp default shock vs the paper's 150bp target (~25% over). `delta_b=0.10` (2.5yr) is still empirically short; `0.036/0.038` (7yr/6.5yr) requires `mv_rule=1` (see F-1). **The `psi_lambda_B<1.5` breakdown warning was EBA-specific** (thin net worth `n_inter=0.408`); at pre-EBA `n_inter=3.0` the documented breakdown region is ~4-5 and 3.0 runs clean. | +| **S-1** | **SUPERSEDED 2026-08-18 by the GK structural refactor.** The *framing* decision stands — `writeoff_enabled = 0`, the IRF traces the no-default branch, `def_rate` is a genuine probability and agents price the expected loss. What changed is that **`zeta_writeoff = 1` is now the baseline**, so the priced loss covers the perpetuity's principal/continuation value and not just the current coupon. E3's old finding that this drives the TPI loading below 1 is therefore no longer a robustness variant — it *is* the baseline: **loading 0.520 / 0.504 / 0.482 at γ = 2/5/10**, and near-invariant across all three arms. Any paper text claiming ECB over-compensation must be rewritten, not caveated. `EL_price_D` is deleted; the diagnostic loading is the endogenous `EL_load_D = 0.7014` (was 0.056134 under coupon-only pricing). Full-writeoff no longer breaks the named-regime construction — peak spread is monotone in γ in the baseline. | +| **GK-1** | **RESOLVED (2026-07-31) — collateral mapping.** The GK block is well-posed only if `f*theta > (1-Delta_own)*phi_own + (1-Delta_cross)*phi_cross`. At measured EBA moments `Delta_own=0.2` violated it by −1.26/−1.42, giving **negative** `lambda_gk`/`Omega` while the solver converged with machine-zero residuals (C-1's silent-degeneracy mode). Cause: `_ic_delta`'s hidden `ratio=Delta_cross/Delta_own=2.0` back-solve closure, which capped `Delta_own<=0.5` against a required `>~0.73`. Removed; `Delta` is now free and the IC **residual** is checked directly. Guarded by `steady_state.assert_gk_well_posed` on every solved SS. **UPDATED 2026-08-18:** the cross-border `Delta` is no longer free — imposing the cross-border portfolio FOC forces `Delta_bF_D = Delta_bD_F = Delta_own = 0.20` at a riskless SS with `rk_D = rk_F` and `rdep_D = rdep_F = 0`. **Live: all four `Delta = 0.20`, `lambda_gk_D = 2.1087`, `Omega_D = 10.3462`.** Anyone computing the collateral channel from the old 0.85/0.90 or from `Delta_cross = 0.40` is wrong. | +| **GK-2** | **RESOLVED (2026-07-31) — `n_inter` scope.** Three compounding amplifiers made the CT1-scope EBA calibration explosive. Fixed in order: the hidden `ratio=2.0` closure (GK-1); `omega_K` as a *fixed share* (new `fund_rule=1` → fund holds a fixed quantity, `dK/dN = theta` not `theta/omega_K`, steady state identical); and finally the **scope of `n_inter`** — CT1 is the stress-test sample, not the agent intermediating the whole capital stock. New **`BANK_SCOPE="broad"`**: `n_inter = (Q*K + sovereign)/theta`, `omega_K = 1`, fund device gone. Model is stable and on target. *(The dynamic numbers originally recorded here — spread 150.4bp, `Y_D[0]=-0.0149%`, loading 4.35/4.01/3.44 — were flex-price; current values are in the Calibration row below.)* `rk_D=rk_F=0.010000` (RK-1 resolved), Y-1 resolved. | +| **EBA switch** | `EBA_CALIBRATION` in `code/calibration.py` is **`True` and LIVE** since 2026-07-31, with `BANK_SCOPE="broad"`. GK-2's explosive dynamics are fixed; the `False` branch keeps the pre-EBA values as a fallback. The moment set (`code/eba_calibration.py` → `data/eba_moments.json`) is rebuilt, identified, and tested (10/10). | +| **Calibration** | **LIVE: EBA + `BANK_SCOPE="broad"` + sticky prices + nominal deposits + the GK structural refactor (2026-08-18).** **`psi_lambda_B_D/F = 0`** — the 150bp-moment tuning history is void; it was conditional on a payoff that under-priced default 12.6×. `zeta_writeoff_D/F = 1`, `writeoff_enabled_D/F = 0`, all four `Delta = 0.20`, `recovery_rate = 0.30` (EL-1), `EL_load_D = 0.7014` (endogenous, replaces the deleted `EL_price_D`). `n_inter = 2.138/1.627`, `omega_K = 1.0`, `phi_lamb = 0.15`, `mv_rule = 0`, `delta_b = 0.0777/0.0568`. Solved SS: `q_b_D = 0.974906`, `q_b_F = 0.965974`, both yields 80.0bp annualised, **SS spread exactly 0**, `beta_D = 0.999534`, `rdep_D = 0`, `rk_D = rk_F = 0.010000`. Sticky-price block unchanged: `mu_p_D/F = 1.20`, `mc_D/F = 1/1.20`, `kappa_p_D/F = 0.0871`, `pi_D/F = 0.0`, `omega_pi_D = 0.071`, `i_dep_D/F = 0.000`. Dynamics on a 1pp default shock, **none of it tuned to a moment**: peak spread **205.87bp**, `Y_D[0] = −1.974%`, `C_D[0] = −2.511%`, `n_inter_D[0] = −11.407%`, German yield **−16.2bp** (flight to quality), `b_DD[0] = +2.116%` with `K_D[0] = −0.067%` (balance-sheet crowding out), `b_DF[0] = −2.101%` (German banks retrench). TPI loading **0.520 / 0.504 / 0.482**, declining. | | **F-1** | `mv_rule_D/F` **committed at 0 (par)**. The near-unit-root zone `phi_lamb≈0.15-0.18` that F-1 identified under `mv_rule=1` is **not mild — it is a hard break**, measured directly 2026-07-30: `mv_rule=1` at the pre-EBA `phi_lamb=0.15` gives `n_inter_D[0]=-1554%`, `Y_D[0]=+0.17%` (perverse sign), `b_gov_D[499]=1.6e-2`. It needs `phi_lamb=0.60` to stay healthy (`n_inter_D[0]=-5.89%`, `Y_D[0]=-0.024%`, `b_gov_D[499]=0.0`). **`mv_rule=1` and `phi_lamb=0.15` are not a usable pair** — porting empirical duration is a two-parameter move. See `docs/STATE.md` Finding F-1. | ## Typical iteration -1. Edit equation files (`equations_D.py`, `equations_F.py`, `equations_global.py`). -2. Re-run the pipeline: `/opt/anaconda3/envs/ssj/bin/python code/main.py` (calibration → steady state → Jacobian → IRFs → TPI). -3. Inspect residuals: `goods_mkt_D`, `goods_mkt_F`, `ca_res_D`, `deposit_mkt_D/F` — all ≤ 1e−7. -4. Verify default shock: `n_inter_D[0]` and `Y_D[0]` must both fall (positive = timing bug). +1. Edit equation files (`equations_D.py`, `equations_F.py`, `equations_global.py`). New blocks go in `full_model.build_block_list()` — one place, not three. +2. Run the fast tests first: `/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -v` (17 tests, ~1 s). Then re-run the pipeline: `/opt/anaconda3/envs/ssj/bin/python code/main.py` (calibration → steady state → Jacobian → IRFs → TPI). +3. Inspect residuals: `goods_mkt_D`, `goods_mkt_F`, `ca_res_D`, `deposit_mkt_D/F` — all ≤ 1e−7 — plus the four sticky-price targets `nkpc_p_res_D`, `nkpc_p_res_F`, `tot_res`, `union_pi_res`, which must be exactly `0.000000e+00` at SS (they are zero by construction at `mu_p*mc = 1`, `pi = 0`; anything else means the subsidy neutralisation broke). +4. Verify default shock: `n_inter_D[0]` and `Y_D[0]` must both fall (positive = timing bug). Current values: `−11.4073%` and `−1.9742%` of SS. Also check the GK FOC table `main.py` now prints — all four legs must read `OK`. 5. Confirm the IC-δ consistency check and Walras residuals printed by `main.py` are unchanged. 6. Update the living docs after any calibration or structural change — **STATE.md, PROGRESS.md (changelog entry), HANDOFF.md** (not just CLAUDE.md). This is **enforced** by two hooks that block the commit otherwise: - `.claude/hooks/require-docs-before-commit.sh` — PreToolUse gate, fires when Claude Code runs the commit. @@ -159,6 +275,9 @@ See `docs/STATE.md` for the full calibration table. Key tensions: | `docs/walras_forensics.md` | Analytical derivation of all three Walras leaks and their proofs | | `docs/bank_cal_review.md` | bank-cal branch analysis; calibration porting roadmap | | `docs/verification_report.md` | Post-fix numerical verification with residual tables | +| `docs/cb_mechanism.md` | **CANONICAL (2026-08-19).** What the TPI central bank is, how it transmits, the 2×2 sovereign-holdings matrix, and the reporting rules that follow. Supersedes ad-hoc CB descriptions elsewhere. Key results: the spread identity `rb_exp_D(+1) − rdep_D = 0.20·(rk_D(+1) − rdep_D)` means TPI's spread and investment effects are **one** effect; ~72% of the CB book is bought from **German** banks; concentration relief is a net-worth not a quantity effect; the "closed-loop pole" is a T=500 truncation artefact. Evidence in `diagnostics/cb_audit/` | | `docs/experiments_results.md` | **GENERATED — do not hand-edit.** Standard policy results: E1 backstop schedule, E2 ΔY decomposition, E3 S-1 writeoff. Regenerate with `experiments/run_all.py` (`--skip-e3` skips the two model re-solves, `--render-only` rebuilds from results on disk) | +| `docs/paper_draft_results.md` | **GENERATED — do not hand-edit.** First-draft tables and figures. Emitted by `experiments/paper_outputs.py`, which needs `experiments/e4_distribution.py`'s cache first. **Its figure captions are currently stale** — see `docs/STATE.md`'s open items. | +| `docs/superpowers/plans/2026-08-05-nominal-rigidities.md` | Implementation plan for the sticky-price / nominal-deposit workstream (Tasks 1–16) | | `docs/superpowers/specs/2026-08-01-policy-experiments-design.md` | Design spec for the `experiments/` package | | `docs/superpowers/plans/2026-08-03-policy-experiments.md` | Implementation plan for the same | diff --git a/Empirics/fiscal_limit.py b/Empirics/fiscal_limit.py new file mode 100644 index 0000000..ba68139 --- /dev/null +++ b/Empirics/fiscal_limit.py @@ -0,0 +1,126 @@ +"""Estimate the Greek fiscal limit and back out def_scale_D / def_curvature_D. + +Mirrors Bi-Foerster-Traum (FRBSF WP 2025-10) eq (3.1): they fit + log(P/(1-P)) = eta0 + eta_s * s_{t-1} + u +on PRE-CRISIS Italian CDS, then let the fiscal-limit shifter carry the crisis. +Our shock_def_D is the shifter analogue, so the same split applies. + +Data + debt Eurostat gov_10q_ggdebt, geo=EL, Maastricht GG consolidated gross debt, + % of GDP, quarterly (the same concept BFT use). + spread repo's Empirics/outputs/spreads_fred.csv, GRC 10y minus DEU 10y, monthly, + averaged to quarterly. + +Spread -> default probability uses the credit triangle at the MODEL's own +recovery_rate_D = 0.30, so the calibration is internally consistent: + lambda_annual = spread / (1 - recovery); P_quarterly = 1 - exp(-lambda/4) +These are RISK-NEUTRAL probabilities, as are BFT's CDS-implied ones — the +risk-premium wedge is inherited from their design, not introduced here. +""" +import json, urllib.request +import numpy as np +import pandas as pd + +RECOVERY = 0.30 # model recovery_rate_D +DEF_OFFSET = 0.05 # model def_offset_D, held fixed (2 moments, 2 params) +DR_SS = 1.152 # model b_gov_ss_D, quarterly-GDP units (bank-held) +EBA_DATE = "2010-Q4" # EBA moment base date, 2010-12-31 + +# ── data ───────────────────────────────────────────────────────────────────── +url = ("https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/gov_10q_ggdebt" + "?format=JSON&lang=EN&geo=EL&unit=PC_GDP§or=S13&na_item=GD") +with urllib.request.urlopen(url, timeout=60) as r: + d = json.load(r) +idx = d["dimension"]["time"]["category"]["index"] +inv = {v: k for k, v in idx.items()} +debt = pd.Series({inv[int(i)]: v for i, v in d["value"].items()}).sort_index() +debt.index = pd.PeriodIndex(debt.index.str.replace("-Q", "Q"), freq="Q") +debt = debt / 100.0 # -> share of annual GDP + +sp = pd.read_csv("/Users/Adam/Documents/uni/phd/research/QUANTITATIVE_MODEL/" + "Empirics/outputs/spreads_fred.csv", parse_dates=["date"]) +sp = sp.set_index("date")["GRC_spread"].dropna() / 100.0 # pp -> decimal +spq = sp.resample("QE").mean() +spq.index = pd.PeriodIndex(spq.index, freq="Q") + +df = pd.DataFrame({"spread": spq, "debt": debt}).dropna() +df["debt_lag"] = df["debt"].shift(1) +df = df.dropna() + +lam = df["spread"] / (1.0 - RECOVERY) # annual hazard +df["P_q"] = 1.0 - np.exp(-lam / 4.0) # quarterly default probability +df = df[(df["P_q"] > 1e-9) & (df["P_q"] < 1 - 1e-9)] +df["logodds"] = np.log(df["P_q"] / (1 - df["P_q"])) + +print(f"merged sample: {df.index.min()}..{df.index.max()} n={len(df)}") +print(f"debt range {df['debt_lag'].min():.3f}..{df['debt_lag'].max():.3f} of annual GDP") +print(f"P_q range {df['P_q'].min():.6f}..{df['P_q'].max():.4f}\n") + + +def ols(y, x): + X = np.column_stack([np.ones_like(x), x]) + b, *_ = np.linalg.lstsq(X, y, rcond=None) + res = y - X @ b + n, k = len(y), 2 + s2 = res @ res / (n - k) + se = np.sqrt(np.diag(s2 * np.linalg.inv(X.T @ X))) + r2 = 1 - (res @ res) / ((y - y.mean()) @ (y - y.mean())) + return b, se, r2, n + + +SAMPLES = { + "pre-crisis 2000Q1-2009Q3 (BFT design)": (None, "2009Q3"), + "pre-OMT 2000Q1-2012Q3 (preferred)": (None, "2012Q3"), + "full 2000Q1-2026Q1": (None, None), + "crisis 2009Q4-2013Q4": ("2009Q4", "2013Q4"), +} +fits = {} +for name, (lo, hi) in SAMPLES.items(): + s = df.copy() + if lo: s = s[s.index >= pd.Period(lo, "Q")] + if hi: s = s[s.index <= pd.Period(hi, "Q")] + if len(s) < 6: + print(f"{name}: n={len(s)}, skipped"); continue + b, se, r2, n = ols(s["logodds"].values, s["debt_lag"].values) + fits[name] = b + print(f"{name}\n eta0 = {b[0]:8.3f} ({se[0]:.3f}) eta_s = {b[1]:7.3f} ({se[1]:.3f})" + f" R2 = {r2:.3f} n = {n}") + print(f" [BFT Italian pre-crisis: eta0 = -10.70, eta_s = 5.25]\n") + + +# ── map the fitted logistic onto the model's power function ────────────────── +# Model: def_rate_D = def_scale * [ (dr + off)^curv - (dr_ss + off)^curv ], +# dr = b_gov_D(-1)/Y_ss_D in quarterly-GDP units of BANK-HELD debt. +# Scope: bank-held = sigma * total, so dr = 4 * sigma * s. sigma is pinned by +# requiring the model SS debt to be the EBA base-date debt ratio. +s_eba = float(debt.loc[pd.Period(EBA_DATE.replace("-Q", "Q"), "Q")]) +sigma = (DR_SS / 4.0) / s_eba +print(f"scope factor: model SS bank-held debt = {DR_SS/4:.4f} of annual GDP; " + f"total debt at {EBA_DATE} = {s_eba:.4f} -> sigma = {sigma:.4f}") +print(f" (model dr = 4*sigma*s, so dr_ss = {4*sigma*s_eba:.4f})\n") + + +def logistic_slope(b, s): + z = b[0] + b[1] * s + P = 1.0 / (1.0 + np.exp(-z)) + return b[1] * P * (1 - P) + + +for name, b in fits.items(): + # Two moments: the data slope dP/ds at two debt levels -> def_scale, curv. + s1, s2 = 1.05, s_eba # pre-crisis norm and EBA base date + m1, m2 = logistic_slope(b, s1), logistic_slope(b, s2) + if m1 <= 0 or m2 <= 0: + print(f"{name}: degenerate slopes, skipped"); continue + # data slope in MODEL units: dP/d(dr) = (dP/ds) / (4*sigma) + g1, g2 = m1 / (4 * sigma), m2 / (4 * sigma) + dr1, dr2 = 4 * sigma * s1, 4 * sigma * s2 + # model slope: def_scale*curv*(dr+off)^(curv-1); ratio kills def_scale + curv = 1.0 + np.log(g2 / g1) / np.log((dr2 + DEF_OFFSET) / (dr1 + DEF_OFFSET)) + scale = g2 / (curv * (dr2 + DEF_OFFSET) ** (curv - 1.0)) + print(f"{name}\n implied def_scale_D = {scale:.4f} def_curvature_D = {curv:.4f}" + f" [current 0.25 / 0.50]") + # what the model's CURRENT calibration implies for the same slope + cur = 0.25 * 0.5 * (dr2 + DEF_OFFSET) ** (0.5 - 1.0) + print(f" slope at EBA debt: data {g2:.5f} vs current calibration {cur:.5f} " + f"({g2/cur:.2f}x)\n") diff --git a/Empirics/motivation_figures.py b/Empirics/motivation_figures.py new file mode 100644 index 0000000..1e3adf7 --- /dev/null +++ b/Empirics/motivation_figures.py @@ -0,0 +1,256 @@ +"""Motivating evidence for the introduction: the Greek episode, and the +disappearance of euro-area sovereign yield decoupling. + +Two figures, both built from published data and cached to Empirics/outputs/: + + fig_greece_motivation 2x2 panel -- debt level, debt/GDP, yields, real + activity -- the BFT-style motivation panel, Greece only. + fig_euro_yield_decoupling 10-year yields for four periphery sovereigns against + the Bund, 1995-2026, with the ECB/OMT/TPI markers. + +Sources + Eurostat gov_10q_ggdebt general government consolidated gross debt (Maastricht), + sector S13, na_item GD, quarterly, geo EL + Eurostat namq_10_gdp B1GQ and P51G, chain-linked volumes (2010), seasonally + and calendar adjusted, quarterly, geo EL + FRED IRLTLT01xxM156N 10-year benchmark government bond yields, monthly, + cached by Empirics/graph_spreads.py to spreads_fred.csv + +Run: /opt/anaconda3/envs/ssj/bin/python Empirics/motivation_figures.py [--refresh] +""" + +import json +import os +import sys +import textwrap +import urllib.request + +import matplotlib.pyplot as plt +import pandas as pd +from matplotlib.ticker import FuncFormatter + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.join(HERE, "outputs") +MACRO = os.path.join(OUT, "greece_macro.csv") +SPREADS = os.path.join(OUT, "spreads_fred.csv") + +# Okabe-Ito, assigned in fixed order and never cycled. Validated for CVD +# separation (worst adjacent pair dE 11.0 deutan) by the dataviz validator. +# Germany is deliberately NOT a categorical hue: it is the benchmark, so it wears +# ink and a dashed stroke. +COL = {"ITA": "#0072B2", "ESP": "#E69F00", "GRC": "#D55E00", "PRT": "#009E73"} +INK = "#1a1a1a" +GRID = "#d8d8d8" +SHADE = "#c8c8c8" + +# The calibration window: EBA base date 2010-12-31 sits inside it, and it closes +# with the March 2012 PSI exchange. +CAL0, CAL1 = pd.Timestamp("2010-01-01"), pd.Timestamp("2012-06-30") + +CAPTIONS = { + "fig_greece_motivation": + "The Greek episode in four series: the March 2012 PSI cut the debt stock by " + "EUR 75bn and the debt ratio by 34 points, yet the ratio was back at its " + "pre-exchange level within five quarters and the stock not until 2021, while " + "the sovereign yield reached 29% and real investment fell 70% against a 27% " + "fall in GDP -- the asymmetry a model of intermediated sovereign risk has to " + "reproduce.", + "fig_euro_yield_decoupling": + "Ten-year sovereign yields converged to within a few tens of basis points of " + "the Bund after 1999, decoupled violently in 2010-12, and have been " + "re-compressed ever since OMT: the phenomenon TPI exists to prevent is one " + "the post-2012 data no longer contains, which is why the instrument's " + "effects cannot be estimated and have to be modelled.", +} + + +# ---------------------------------------------------------------- data ------ +def _eurostat(dataset, **kw): + url = ("https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/" + + dataset + "?format=JSON&lang=EN&" + + "&".join(f"{k}={v}" for k, v in kw.items())) + with urllib.request.urlopen(url, timeout=90) as r: + d = json.load(r) + inv = {v: k for k, v in d["dimension"]["time"]["category"]["index"].items()} + s = pd.Series({inv[int(i)]: v for i, v in d["value"].items()}).sort_index() + s.index = pd.PeriodIndex(s.index.str.replace("-Q", "Q"), freq="Q") + return s.astype(float) + + +def greek_macro(refresh=False): + if os.path.exists(MACRO) and not refresh: + df = pd.read_csv(MACRO, index_col=0) + df.index = pd.PeriodIndex(df.index, freq="Q") + return df + debt = dict(geo="EL", sector="S13", na_item="GD") + nat = dict(geo="EL", s_adj="SCA", unit="CLV10_MEUR") + df = pd.DataFrame({ + "debt_eurbn": _eurostat("gov_10q_ggdebt", unit="MIO_EUR", **debt) / 1e3, + "debt_pct_gdp": _eurostat("gov_10q_ggdebt", unit="PC_GDP", **debt), + "inv_real_eurbn": _eurostat("namq_10_gdp", na_item="P51G", **nat) / 1e3, + "gdp_real_eurbn": _eurostat("namq_10_gdp", na_item="B1GQ", **nat) / 1e3, + }) + df.index.name = "quarter" + df.to_csv(MACRO) + return df + + +def yields(): + y = pd.read_csv(SPREADS, parse_dates=["date"]).set_index("date") + return y[["ITA", "ESP", "GRC", "PRT", "DEU"]] + + +# ---------------------------------------------------------------- style ----- +def style(): + plt.rcParams.update({ + "font.family": "serif", + "font.serif": ["Times New Roman", "Times", "DejaVu Serif"], + "axes.labelsize": 10, "axes.titlesize": 11, + "xtick.labelsize": 9, "ytick.labelsize": 9, "legend.fontsize": 9, + "axes.edgecolor": "#999999", "axes.linewidth": 0.8, + "figure.dpi": 300, "savefig.dpi": 300, + }) + + +def tidy(ax, ylabel): + """Recessive grid and axes; the data is the only thing with weight.""" + ax.set_ylabel(ylabel) + ax.grid(True, color=GRID, lw=0.6, alpha=0.7) + ax.set_axisbelow(True) + for side in ("top", "right"): + ax.spines[side].set_visible(False) + + +def window(ax, label=False): + ax.axvspan(CAL0, CAL1, color=SHADE, alpha=0.45, lw=0, zorder=0) + if label: + ax.annotate("calibration\nwindow", xy=(CAL0 + (CAL1 - CAL0) / 2, 0.045), + xycoords=("data", "axes fraction"), ha="center", va="bottom", + fontsize=7.5, color="#5a5a5a") + + +def save(fig, name): + chars = int(fig.get_size_inches()[0] * 14) + fig.text(0.5, -0.015, textwrap.fill(CAPTIONS[name], width=chars), + ha="center", va="top", fontsize=8, style="italic", color="0.35") + path = os.path.join(OUT, f"{name}.png") + fig.savefig(path, bbox_inches="tight") + plt.close(fig) + print(f" wrote {path}") + + +# ---------------------------------------------------------------- fig 1 ----- +def fig_greece_motivation(df, y): + q = df.loc["2000Q1":"2019Q4"] + t = q.index.to_timestamp(how="end") + ym = y.loc["2000":"2019"] + + fig, axes = plt.subplots(2, 2, figsize=(9.2, 6.2)) + (a, b), (c, d) = axes + + # (a) debt level -- the PSI write-down is a level event, and it is visible + a.plot(t, q["debt_eurbn"], color=COL["GRC"], lw=1.8) + window(a, label=True) + psi = pd.Timestamp("2012-03-31") + a.annotate("PSI exchange\n$-$\\texteuro75bn".replace("\\texteuro", "€"), + xy=(psi, 281.5), xytext=(pd.Timestamp("2013-06-30"), 215), + fontsize=8, color=INK, + arrowprops=dict(arrowstyle="->", color="#5a5a5a", lw=0.9)) + a.set_title("(a) General government debt", loc="left") + tidy(a, "€ bn") + + # (b) debt ratio -- and its full retracement + b.plot(t, q["debt_pct_gdp"], color=COL["GRC"], lw=1.8) + window(b) + b.axhline(175.1, color="#5a5a5a", ls=":", lw=0.9) + b.annotate("pre-exchange level regained\nwithin five quarters", + xy=(pd.Timestamp("2013-06-30"), 175.5), + xytext=(pd.Timestamp("2014-03-31"), 152), fontsize=8, color=INK, + arrowprops=dict(arrowstyle="->", color="#5a5a5a", lw=0.9)) + b.set_title("(b) General government debt, per cent of GDP", loc="left") + tidy(b, "per cent") + + # (c) yields -- two series, one axis, gap shaded: the gap IS the spread + c.fill_between(ym.index, ym["DEU"], ym["GRC"], where=ym["GRC"] >= ym["DEU"], + color=COL["GRC"], alpha=0.13, lw=0) + c.plot(ym.index, ym["GRC"], color=COL["GRC"], lw=1.8) + c.plot(ym.index, ym["DEU"], color=INK, lw=1.3, ls="--") + window(c) + c.annotate("Greece", xy=(pd.Timestamp("2014-01-01"), 22.5), fontsize=8.5, + color=COL["GRC"], ha="center") + c.annotate("Germany", xy=(pd.Timestamp("2006-06-01"), 1.4), fontsize=8.5, + color=INK, ha="center") + c.set_title("(c) Ten-year sovereign yield; shaded area is the spread", loc="left") + tidy(c, "per cent") + + # (d) real activity, indexed -- investment against output, same units, one axis + base = q.loc["2007Q4"] + d.plot(t, 100 * q["gdp_real_eurbn"] / base["gdp_real_eurbn"], + color=INK, lw=1.3, ls="--") + d.plot(t, 100 * q["inv_real_eurbn"] / base["inv_real_eurbn"], + color=COL["ITA"], lw=1.8) + window(d) + d.axhline(100, color="#bbbbbb", lw=0.8, zorder=0) + d.annotate("real investment", xy=(pd.Timestamp("2015-06-01"), 27), fontsize=8.5, + color=COL["ITA"], ha="center") + d.annotate("real GDP", xy=(pd.Timestamp("2015-06-01"), 84), fontsize=8.5, + color=INK, ha="center") + d.set_title("(d) Real GDP and investment, 2007Q4 = 100", loc="left") + tidy(d, "index") + + for ax in (a, b, c, d): + ax.set_xlim(pd.Timestamp("2000-01-01"), pd.Timestamp("2019-12-31")) + fig.tight_layout(h_pad=2.0, w_pad=2.4) + save(fig, "fig_greece_motivation") + + +# ---------------------------------------------------------------- fig 2 ----- +def fig_euro_yield_decoupling(y): + ym = y.loc["1995":] + fig, ax = plt.subplots(figsize=(9.2, 4.3)) + + for c in ("ITA", "ESP", "PRT", "GRC"): + ax.plot(ym.index, ym[c], color=COL[c], lw=1.5, label=c) + ax.plot(ym.index, ym["DEU"], color=INK, lw=1.3, ls="--", label="DEU (benchmark)") + + for name, when, ha in (("ECB created", "1999-01-01", "left"), + ("OMT", "2012-09-06", "left"), + ("TPI", "2022-07-21", "right")): + x = pd.Timestamp(when) + ax.axvline(x, color="#8a8a8a", lw=0.9, ls=(0, (4, 3)), zorder=0) + ax.annotate(name, xy=(x, 0.97), xycoords=("data", "axes fraction"), + ha=ha, va="top", fontsize=8.5, color="#4a4a4a", + xytext=(3 if ha == "left" else -3, 0), textcoords="offset points") + + ax.annotate("convergence", xy=(pd.Timestamp("2003-06-01"), 6.1), fontsize=9, + color="#4a4a4a", ha="center") + ax.annotate("decoupling", xy=(pd.Timestamp("2010-01-01"), 22.0), fontsize=9, + color="#4a4a4a", ha="center") + ax.annotate("re-compression", xy=(pd.Timestamp("2018-06-01"), 8.0), fontsize=9, + color="#4a4a4a", ha="center") + + tidy(ax, "per cent") + ax.set_xlim(pd.Timestamp("1995-01-01"), ym.index.max()) + ax.set_ylim(0, 30) + ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v:.0f}")) + ax.legend(loc="upper left", bbox_to_anchor=(0.008, 0.86), frameon=False, ncol=1) + fig.tight_layout() + save(fig, "fig_euro_yield_decoupling") + + +def main(): + style() + df = greek_macro(refresh="--refresh" in sys.argv) + y = yields() + print("building motivation figures ...") + fig_greece_motivation(df, y) + fig_euro_yield_decoupling(y) + + emitted = {f[:-4] for f in os.listdir(OUT) + if f.startswith("fig_") and f.endswith(".png")} + assert emitted == set(CAPTIONS), (emitted - set(CAPTIONS), set(CAPTIONS) - emitted) + print(f"caption coverage OK: {len(emitted)} figures, {len(CAPTIONS)} captions") + + +if __name__ == "__main__": + main() diff --git a/code/calibration.py b/code/calibration.py index 412fa38..a05aa19 100644 --- a/code/calibration.py +++ b/code/calibration.py @@ -30,6 +30,50 @@ def load_eba_targets(path: str = _EBA_MOMENTS, scope: str | None = None) -> dict return json.load(fh)[key] +def load_eba_size_ratio(path: str = _EBA_MOMENTS) -> float: + """F/D annual-GDP ratio (Germany / Greece, Eurostat 2010) = 11.697. + + CROSS-BORDER UNITS FIX (2026-08-07). The model normalises Y_D_ss = Y_F_ss = 1, + i.e. the two countries are the SAME SIZE, while every EBA moment is measured as + a ratio to its own country's net worth or GDP. A cross-border stock built as + ``phi * n_holder / q`` therefore lands in the HOLDER's units, and planting it in + a model where both countries are size 1 rescales it by this ratio. + + Measured: German banks' Greek book is 1.21% of German quarterly GDP -- correct. + Dropped into the model unscaled it became 1.21% of a country the size of Greece, + so foreigners held 1.25% of the bank-held Greek stock against 12.72% in the data + (7,933.6 / 62,380.7 EURm). The Greek banks' Bund book was distorted the same way + in the opposite direction: 1.50% of the bank-held Bund stock against 0.13%. + + The invariant this restores: EVERY BOND STOCK IS MEASURED IN ITS ISSUER'S GDP + UNITS. Own-holdings (b_D_D, b_F_F) already satisfy it -- holder and issuer are + the same country. Only the two cross-border stocks need converting. + + Note the nominal side already carried the size asymmetry (``omega_pi_D = 0.071`` + is the capital key, not GDP weights); quantities never did. + """ + with open(path) as fh: + raw = json.load(fh)["raw_EURm"] + return float(raw["GDP_ann_F"]) / float(raw["GDP_ann_D"]) + + +def load_eba_foreign_shares(path: str = _EBA_MOMENTS) -> dict: + """Measured foreign-held share of each country's BANK-HELD sovereign stock. + + Pure ratios of EBA EURm figures, so they carry no GDP normalisation and are + immune to the defect ``load_eba_size_ratio`` corrects. That is exactly what + makes them the over-identifying check on it. Not calibration inputs -- the + targeting is driven by the phi moments, and these verify the result. + + D: 7,933.6 / 62,380.7 = 0.1272 F: 410.7 / 315,723.9 = 0.001301 + """ + with open(path) as fh: + raw = json.load(fh)["raw_EURm"] + b_D_D, b_D_F = float(raw["b_D_D"]), float(raw["b_D_F"]) + b_F_F, b_F_D = float(raw["b_F_F"]), float(raw["b_F_D"]) + return {"D": b_D_F / (b_D_D + b_D_F), "F": b_F_D / (b_F_F + b_F_D)} + + # ───────────────────────────────────────────────────────────────────────────── # EBA switch. True = the MEASURED EBA 2011 moment set (code/eba_calibration.py -> # data/eba_moments.json), read at the scope set by BANK_SCOPE above. @@ -67,7 +111,11 @@ def eba_or(key, pre_eba): 'eis_D': 0.5, 'eis_F': 0.5, # ── Rates & Asset Prices ────────────────────────────────────────────── - 'rdep_D': 0.000, 'rdep_F': 0.000, + # Nominal deposit rate. Deposits are nominal euro contracts; the derived + # real rates rdep_D/F (ex-ante) and rdep_expost_D/F (realised) come from + # deposit_rates_D/F. At SS pi = 0, so rdep = i_dep and the SS is + # unchanged from the real-deposit calibration. + 'i_dep_D': 0.000, 'i_dep_F': 0.000, 'q_b_D': 0.83, 'q_b_F': 0.83, 'Q_D': 1.0, 'Q_F': 1.0, @@ -76,6 +124,12 @@ def eba_or(key, pre_eba): 'delta_D': 0.025, 'delta_F': 0.025, 'ksi_D': 0.50, 'ksi_F': 0.50, + # Investment-flow adjustment cost S(I/I(-1)) = (omega_I/2)(I/I(-1)-1)^2. + # S(1) = S'(1) = 0, so exactly SS-neutral; omega_I = 0 reproduces the + # model without it. Bi-Foerster-Traum use 2. Left at 0 pending the + # path-shape comparison. + 'omega_I_D': 0.0, 'omega_I_F': 0.0, + # ── Long-term bonds ─────────────────────────────────────────────────── # EBA REBUILD (2026-07-31): delta_b is now MEASURED, from the sovereign # maturity ladder (EBA worksheet 5, MATURITY_CODE 125..155) repriced at @@ -120,8 +174,21 @@ def eba_or(key, pre_eba): # # Still UNIDENTIFIED: no EBA counterpart, no moment attached. The # feasibility inequality bounds them but does not pin a level. + # + # CROSS-BORDER Delta: 0.4 -> 0.2 on 2026-08-18 (structural GK refactor). + # NOT a fit exercise — it is forced. Once gk_cross_border_foc imposes the actual + # portfolio optimality condition nu_cross/nu_K = Delta_cross_eff, and the two + # own-sovereign FOCs pin q_b_D and q_b_F, the steady-state cross-border ratio is + # no longer free: + # nu_bF_D/nu_K_D = (rb_exp_F - rdep_D)/(rk_D - rdep_D) + # and at a riskless SS (def_rate_ss = 0) with rk_D = rk_F = 0.01 (RK-1) and + # rdep_D = rdep_F = 0 (i_dep_ss = 0, pi_ss = 0) that equals Delta_bF_F = 0.20. + # Symmetrically for D-in-F. Holding 0.4 would leave an 80bp/yr constant wedge in + # gk_cross_border_foc, i.e. an unexplained cross-border pricing gap of the exact + # kind this refactor exists to remove. steady_state.report_gk_steady_state + # verifies the resulting residual numerically on every solve. 'Delta_bD_D': 0.2, 'Delta_bF_F': 0.2, - 'Delta_bF_D': 0.4, 'Delta_bD_F': 0.4, + 'Delta_bF_D': 0.2, 'Delta_bD_F': 0.2, 'lambda_BD_D': 0.06, 'lambda_BF_F': 0.06, 'lambda_BF_D': 0.06, 'lambda_BD_F': 0.06, # EBA 2011 REBUILD (2026-07-31). Supersedes both the 2026-07-22 EBA build @@ -146,8 +213,80 @@ def eba_or(key, pre_eba): # _apply_ss_anchors, so a sweep MUST re-solve the SS per point. Patching # the flag on an already-solved SS leaves psi_spread stale and inverts the # apparent sign of the spread response. - 'psi_lambda_B_D': 8.5 if EBA_CALIBRATION else 3.0, - 'psi_lambda_B_F': 8.5 if EBA_CALIBRATION else 3.0, + # + # RETUNED 2026-08-06 (Task 14, add-nkpc): sticky prices (NKPC blocks) and + # nominal (non-state-contingent) deposit contracts both raise spread + # transmission, moving the old 8.5 -> 162.0 bp (was 150.4 bp pre-change, + # ~8% over target). Re-bisected against the same 150bp GR-DE peak-spread + # moment on a 1pp default shock, holding everything else fixed: + # psi_lambda_B = 8.5 -> peak spread 0.4053 pp -> 162.14 bp + # psi_lambda_B = 7.0 -> peak spread 0.3405 pp -> 136.21 bp + # psi_lambda_B = 7.8 -> peak spread 0.3729 pp -> 149.16 bp + # psi_lambda_B = 7.85 -> peak spread 0.3753 pp -> 150.14 bp <- was adopted + # b_gov_D[499] stayed in ~1e-5..1e-4 across the whole bracket (no + # instability); n_inter_D[0] and Y_D[0] both negative throughout + # (correct doom-loop sign). See docs/STATE.md for the full record. + # + # RETUNED AGAIN 2026-08-06 (rho_def 0.80 -> 0.9408, see "Shock processes" + # below). A more persistent sovereign-risk shock raises the peak spread + # for a given amplification: at psi_lambda_B = 7.85 the peak went + # 150.14 -> 470.62 bp. Re-bisected against the same 150bp moment, + # rho_def = 0.9408 throughout, FULL pipeline re-solve at every point: + # psi_lambda_B = 7.850 -> 470.62 bp + # psi_lambda_B = 2.730 -> 139.60 bp + # psi_lambda_B = 2.8909 -> 148.50 bp + # psi_lambda_B = 2.9181 -> 149.99 bp + # psi_lambda_B = 2.92 -> ADOPTED (rounded; slope ~55bp per unit, + # so 2.9181 -> 2.92 is ~+0.1bp) + # METHOD WARNING, learned the hard way here: you CANNOT sweep this dial + # by patching psi_lambda_B_D/F and psi_spread_D/F onto an already-solved + # SS and re-solving only the Jacobian. The SS really is psi-neutral + # (goods_mkt_D is bit-identical at every psi), but that shortcut still + # gave 150.33bp at psi=2.73 where the real pipeline gives 139.60 -- a + # 7% error, all in the same direction, because only the divert_bond_foc + # psi_spread channel picks the patch up and not the intermediation_IC + # Delta_b_eff collateral channel. Re-solve the pipeline per point. + # b_gov_D[499] FELL 4.63e-05 -> 2.04e-05 across the re-tune, i.e. the + # move is away from the high-psi_lambda_B breakdown region, not toward + # it. All four impact signs stay negative (Y, C, I, n_inter). + # PAPER CONSEQUENCE: psi_spread_D is linear in this dial, so it drops + # 1.604839 -> ~0.5970 and the default-loading split moves from + # 3.4% fundamental / 96.6% collateral friction to ~8.6% / ~91.4% -- + # a friction:fundamental ratio of ~10.6:1, down from ~28.6:1. The + # constrained-seller claim survives but is quantitatively weaker, and + # the paper's fig04 prose must be re-derived. See docs/STATE.md. + # RETUNED 2026-08-07 (country-size asymmetry, fix-cross-border-units). + # size_F = 11.697 makes a Greek shock a much smaller shock to F, which + # damps the cross-border amplification and took the peak spread to + # 145.20 bp at the incumbent 2.92. Re-bisected on the same 150.14 bp + # moment, re-solving the SS per point (the sweep caveat above): + # psi_lambda_B = 2.92 -> 145.20 bp (the incumbent, now off target) + # psi_lambda_B = 3.01 -> 149.93 bp <- ADOPTED + # Local secant slope 52.6 bp/unit. Residual 0.21 bp, comparable to the + # 0.15 bp at which 2.92 was adopted. + # ── SET TO 0 ON 2026-08-18 (structural GK refactor) ─────────────────── + # Everything above this line is the history of tuning psi_lambda_B to a 150bp + # peak-spread moment. That entire exercise was conditional on a MIS-SPECIFIED + # bond payoff: zeta_writeoff = 0 priced only the coupon, so the pledgeability + # dial (and the now-deleted psi_spread_D it fed) had to carry the missing + # principal loss. With the payoff corrected the dial is no longer needed to + # generate a spread, and the Greek 2010-12 episode provides NO independent + # observable that identifies a sovereign-specific haircut ELASTICITY -- only + # the level of the haircut, which Delta_bD_D already carries. + # + # The PREFERRED BASELINE is therefore 0: d(Delta_bD_eff)/d(def_rate) = 0. + # This does NOT remove collateral from the model. The bank still faces the GK + # incentive constraint, Greek bonds are still worse collateral than capital + # (Delta_bD_D = 0.20), and the transmission is + # def_rate up -> q_b_D down -> MTM loss on the bank's sovereign book + # -> IC tightens -> K_D contracts -> rk_D up -> q_b_D down. + # psi_lambda_B > 0 adds a SECOND channel on top (the haircut itself widening + # with risk). experiments/ Arm 2 re-runs the model at 3.01 as a DIAGNOSTIC of + # what that channel is worth; it is not the baseline and the difference is a + # model counterfactual, not an empirical decomposition of the spread. + # Do not retune this to recover 150bp -- see docs/STATE.md. + 'psi_lambda_B_D': 0.0, + 'psi_lambda_B_F': 0.0, # Bank net worth = Core Tier 1 / own quarterly nominal GDP. # GR 22,778/55,898 = 0.4075; DE 114,317/653,815 = 0.1748. 'n_inter_D': eba_or('n_inter_D', 0.75*4), 'n_inter_F': eba_or('n_inter_F', 0.75*4), @@ -212,17 +351,27 @@ def eba_or(key, pre_eba): # Fiscal-rule debt measure: 0 = par/face value (default), 1 = market value # (q_b·b_gov(-1)). mv_gov_ss is recomputed exactly from the solved SS in # build_and_solve; these are placeholders (unused when mv_rule=0). - # Market-value rule REQUIRED under the EBA calibration; par rule for the - # pre-EBA placeholder. Measured 2026-07-31: at mv_rule=0 with the measured - # concentration the debt path explodes even with the collateral friction - # switched off entirely (psi_lambda_B=0 gives b_gov_D[499]=6.4e+03 and a - # nonsense peak spread), so this is the DEBT/fiscal mode, not amplification. - # The driver is phi_own=2.39 (a ~10x stronger doom loop than the 0.25 - # placeholder), not duration — which is why the "measured delta_b is close - # to the old 0.10, so the par rule is fine" reasoning did not carry: it was - # about duration. mv_rule=1 + phi_lamb=0.60 is the pairing the 2026-07-22 - # EBA build verified stationary, and F-1's hard break at - # mv_rule=1 + phi_lamb=0.15 is far away. + # PAR RULE (mv_rule=0) IS LIVE AND CORRECT. Justification, added 2026-08-07: + # EU fiscal surveillance defines general government debt at NOMINAL FACE + # VALUE (Maastricht), explicitly not marked to market, so the par gap is what + # the framework Greece was actually subject to keys off. It is also the right + # rule economically: an issuer that must roll at the new yields gets no relief + # from its mark-to-market liability falling. + # + # The two gaps move in OPPOSITE directions in a crisis, because q_b_D falls + # ~4.5% while face value rises only ~1.5%: over 40q the par gap is positive + # 39/40 quarters (the rule TIGHTENS) while the market-value gap is negative + # 40/40 and 2.88x larger (the rule would CUT taxes by 0.75% of quarterly GDP + # at impact, reading a wider spread as a windfall). That perverse sign is why + # the mv variant needed phi_lamb=0.60 to stay stationary. + # + # RETIRED (was: "Market-value rule REQUIRED under the EBA calibration ... at + # mv_rule=0 the debt path explodes"). That was measured under BANK_SCOPE="ct1" + # where phi_bD_D=2.39; commit 988c213 moved to the broad scope where the same + # moment is 0.456, a 5.2x weaker doom loop, and the explosion does not occur — + # b_gov_D[499] ~ 7e-05 at the live calibration. F-1's near-unit-root zone + # [0.15,0.18] was likewise an mv_rule=1 measurement and has no established + # bearing on the par rule. See docs/STATE.md. 'mv_rule_D': 0.0, 'mv_rule_F': 0.0, 'mv_gov_ss_D': 0.6*4, 'mv_gov_ss_F': 0.6*4, @@ -238,15 +387,48 @@ def eba_or(key, pre_eba): # Gulati, PIIE WP13-8; 59-65% investor NPV loss). The pre-EBA value was 0.00, # i.e. 100% loss-given-default -- counterfactual for Greece, and the *harshest* # possible assumption rather than a neutral one. - # While writeoff_enabled=0 this is live ONLY through EL_price (the realized- - # haircut terms in bond_return/government_ss/budget_residual are gated by - # writeoff_enabled). EL_price = (1-rec)*delta_b/q_b: 0.1025 at rec=0.00 -> - # 0.0717 at rec=0.30. Against psi_spread=0.8385 that moves total default - # loading by only ~3.3%. 'recovery_rate_D': 0.30, 'recovery_rate_F': 0.30, - 'zeta_writeoff_D': 0.0, 'zeta_writeoff_F': 0.0, + # ── zeta_writeoff: 0 -> 1 on 2026-08-18 (structural GK refactor) ────── + # zeta_writeoff scales the haircut applied to the CONTINUATION VALUE of the + # perpetuity, alongside the coupon. At 0 a default wrote down only the current + # coupon, so the expected loss on a 12.9-quarter claim was + # h*delta_b/q_b = 0.7*0.0777/0.975 = 0.0558 + # per unit of default probability, against the true + # h*[delta_b + (1-delta_b) q_b]/q_b = 0.7014 -- 12.6x larger. + # That missing principal loss is what the free parameter psi_spread_D (0.615 at + # the last flex calibration) was silently standing in for, which is why the old + # decomposition read 8.4% "fundamental" / 91.6% "friction". With the payoff + # right, the loss is priced by bond_return_D/F -> rb_exp -> the GK Euler + # equation, and psi_spread_D is deleted rather than recalibrated. + # NOTE writeoff_enabled stays 0: the REALISED path still traces the no-default + # branch (S-1, the paper's pure risk-premium framing). zeta_writeoff governs what + # is PRICED; writeoff_enabled governs what is REALISED. They are independent. + 'zeta_writeoff_D': 1.0, 'zeta_writeoff_F': 1.0, 'writeoff_enabled_D': 0.0, 'writeoff_enabled_F': 0.0, + # ── Shock processes ─────────────────────────────────────────────────── + # Promoted out of code/full_model.py (was hardcoded at lines 217-221) + # on 2026-08-06 so the persistence of the crisis is a calibration + # decision with a source, not a magic number in the solve driver. + # + # rho_def: quarterly persistence of the sovereign-risk shock. Disciplined + # by the repo's own Markov-switching estimation rather than chosen: + # Empirics/outputs/ms_regime_GRC.npz fits three states to MONTHLY + # Greek-Bund spreads (348 obs, 1997-06..2026-06); the crisis state + # (mean 9.63pp) has monthly persistence 0.9798, i.e. an expected + # duration of 50 months, and the realised episode ran 2010-04 to + # 2017-12 (92 months). Quarterly equivalent: 0.9798^3 = 0.9408. The + # previous hardcoded 0.80 implied a 14-month crisis and was the binding + # constraint on how long the contraction lasted -- cumulative Y over 40q + # goes -0.049 (rho=0.80) -> -0.784 (0.90) -> -2.021 (0.95) holding peak + # spread fixed at 150bp, so this is persistence, not crisis size. See + # docs/STATE.md. + # + # rho_Z is the TFP shock and is deliberately LEFT at 0.80 -- the MS + # estimate speaks to sovereign spreads only. + 'rho_def_D': 0.9408, 'rho_def_F': 0.9408, + 'rho_Z_D': 0.80, 'rho_Z_F': 0.80, + # ── ECB balance sheet (TPI conduit) ─────────────────────────────────── # Capital-key split of the CB's D-bond programme cash flows between the # two treasuries. kappa_cb_F = F share of the two-country renormalised @@ -265,7 +447,17 @@ def eba_or(key, pre_eba): 'T1_D': 0.0, 'T1_F': 0.0, # ── Trade & Terms of Trade ──────────────────────────────────────────── - 'omega': 0.85, + # Home bias, country-specific since 2026-08-07. A single shared omega is + # inconsistent with size asymmetry: at omega_F = omega_D the larger + # country's imports from the smaller come out size_F times too large. + # Symmetric BILATERAL trade intensity pins the pair -- + # size_F * (1 - omega_F) * C_F = (1 - omega_D) * C_D, + # so with C_D ~ C_F, (1 - omega_F) = (1 - omega_D) / size_F. D keeps the + # 0.85 that has been the calibration throughout; F follows. + # Greece imports 15% of its consumption basket from the core; the core + # imports 1.28% of its basket from Greece. + 'omega_D': 0.85, + 'omega_F': 1.0 - (1.0 - 0.85) / (load_eba_size_ratio() if EBA_CALIBRATION else 1.0), 'epsilon_trade': 1.5, 'p': 0.50, @@ -276,12 +468,44 @@ def eba_or(key, pre_eba): # Own-holdings set in steady_state.py from the same moment file. 'phi_bF_D_ss': eba_or('phi_bF_D_ss', 0.25), 'phi_bD_F_ss': eba_or('phi_bD_F_ss', 0.25), 'psi_bF_D': 0.5, 'psi_bD_F': 0.5, + # F's size relative to D (Germany/Greece 2010 GDP = 11.697). Enters the + # four cross-country blocks in equations_global.py and nowhere else -- + # every F variable is per F capita. 1.0 on the pre-EBA branch keeps that + # calibration bit-exact. + 'size_F': load_eba_size_ratio() if EBA_CALIBRATION else 1.0, # ── Wage Markups ────────────────────────────────────────────────────── + # Unchanged: wages are flexible. mu_w = 1 is the SS-neutralising device + # in labor_ss_D/F; there is no wage Phillips curve. 'mu_w_D': 1.0, 'mu_w_F': 1.0, - # ── SS Real Variables ───────────────────────────────────────────────── - 'mc_D': 1.0, 'mc_F': 1.0, + # ── Price Rigidity (Rotemberg) ──────────────────────────────────────── + # mu_p: gross price markup, epsilon_p = 6. FREE TO FIRST ORDER under the + # subsidy neutralisation -- the gap (mu_p*mc - 1) linearises to mc_hat + # for any mu_p -- so this needs no defending unless live markups are + # ever adopted. + # mc: SS real marginal cost = 1/mu_p. The production subsidy + # tau_s = 1 - 1/mu_p makes labour demand collapse to the competitive + # w = (1-alpha)Y/N at this value, so the SS is bit-identical to flex. + # kappa_p: Calvo theta_p = 0.75 at beta = 0.985, slope + # (1-theta)(1-beta*theta)/theta = 0.0871. Euro-area IPN median price + # duration ~4 quarters (Alvarez et al. 2006; Dhyne et al. 2006). + # Agrees with Bi-Foerster-Traum's implied 0.0846 to within 3%. + # pi: SS producer-price inflation, exactly zero. + 'mu_p_D': 1.20, 'mu_p_F': 1.20, + 'mc_D': 1.0 / 1.20, 'mc_F': 1.0 / 1.20, + 'kappa_p_D': 0.0871, 'kappa_p_F': 0.0871, + 'pi_D': 0.0, 'pi_F': 0.0, + + # omega_pi_D: weight on D in the union producer-price aggregate that the + # ECB is assumed to stabilise. = 1 - kappa_cb_F, the renormalised + # two-country capital key (BuBa 26.1 / BoG 2.0 of the euro-area key). + # DO NOT use model GDP weights: the model normalises Y_D_ss ~ Y_F_ss ~ 1, + # so they would give ~0.5 and split the terms-of-trade adjustment evenly + # between Greek deflation and German inflation -- counterfactual for + # 2010-12. Load-bearing twice over once deposits are nominal, since it + # scales pi_D and hence the Fisher revaluation on bank balance sheets. + 'omega_pi_D': 0.071, # ── Idiosyncratic Income Process (Rouwenhorst) ──────────────────────── 'rho_z_D': 0.90, 'rho_z_F': 0.90, @@ -297,12 +521,21 @@ def eba_or(key, pre_eba): _B_D = calibration_start['B_supply_D'] _B_F = calibration_start['B_supply_F'] + # Each phi is a ratio to its HOLDER's net worth, so these come out in the + # holder's own per-capita units -- which is now exactly right: b_D_F is per F + # capita and domestic_bond_clearing aggregates it with size_F. No conversion + # here. (An interim fix on 2026-08-07 scaled these directly; superseded by + # the size_F weight, which matches both EBA moments instead of trading one + # for the other.) b_F_D = calibration_start['phi_bF_D_ss'] * _n_D / calibration_start['q_b_F'] b_D_F = calibration_start['phi_bD_F_ss'] * _n_F / calibration_start['q_b_D'] + # Residual own-holdings must clear at the same weights domestic_bond_clearing + # uses, or the initial guess is inconsistent with the block that enforces it. + _size_F = calibration_start['size_F'] calibration_start.update({ - 'b_F_D': b_F_D, 'b_D_F': b_D_F, - 'b_D_D': _B_D - b_D_F, 'b_F_F': _B_F - b_F_D, + 'b_F_D': b_F_D, 'b_D_F': b_D_F, + 'b_D_D': _B_D - _size_F * b_D_F, 'b_F_F': _B_F - b_F_D / _size_F, 'b_F_D_anchor': b_F_D, 'b_D_F_anchor': b_D_F, 'psi_bD_D': 0.0, 'psi_bF_F': 0.0, }) diff --git a/code/depreciation_calibration.py b/code/depreciation_calibration.py index d5ce6ab..74efaf7 100644 --- a/code/depreciation_calibration.py +++ b/code/depreciation_calibration.py @@ -7,7 +7,7 @@ """ import copy -from steady_state import _apply_ss_anchors +from steady_state import _apply_ss_anchors, report_gk_steady_state def calibrate_depreciation(ss_results): @@ -20,17 +20,29 @@ def calibrate_depreciation(ss_results): rk_D_target = 0.01 rk_F_target = 0.01 - K_D_cur = float(ss['K_D']); K_F_cur = float(ss['K_F']) - Y_D_cur = float(ss['Y_D']); Y_F_cur = float(ss['Y_F']) - - delta_D_cal = calibration_start['alpha_D'] * Y_D_cur / K_D_cur - rk_D_target - delta_F_cal = calibration_start['alpha_F'] * Y_F_cur / K_F_cur - rk_F_target - calibration_start.update({'delta_D': delta_D_cal, 'delta_F': delta_F_cal}) - - print(f"Depreciation calibration: delta_D = {delta_D_cal:.6f} delta_F = {delta_F_cal:.6f}") - print("Final SS re-solve with calibrated delta...") - - ss = ha.solve_steady_state(calibration_start, unknowns_ss, targets_ss, solver='broyden_custom') + # ITERATED to a fixed point (2026-08-18). delta = alpha*Y/K - rk_target is exact + # only at the K and Y the NEXT solve returns, and re-solving moves both. That was + # tolerable while q_b was pinned outside the solver by the banker's SDF; since + # q_b_D/q_b_F became SS unknowns under the GK portfolio FOC the feedback + # delta -> K -> rk -> q_b -> balance sheet -> K is strong enough that one pass + # leaves rk_D at 0.009981 instead of 0.010000, and the residual drift shows up in + # goods_mkt_D at ~4e-7 against its 1e-14 acceptance threshold. Each extra pass is a + # cheap SS solve, and the loop exits as soon as rk is on target. + MAXIT, TOL = 12, 1e-12 + for it in range(1, MAXIT + 1): + delta_D_cal = calibration_start['alpha_D'] * float(ss['Y_D']) / float(ss['K_D']) - rk_D_target + delta_F_cal = calibration_start['alpha_F'] * float(ss['Y_F']) / float(ss['K_F']) - rk_F_target + calibration_start.update({'delta_D': delta_D_cal, 'delta_F': delta_F_cal}) + warm = {k: float(ss[k]) for k in unknowns_ss} + ss = ha.solve_steady_state(calibration_start, warm, targets_ss, solver='broyden_custom') + err = max(abs(float(ss['rk_D']) - rk_D_target), abs(float(ss['rk_F']) - rk_F_target)) + print(f" depreciation iter {it}: delta_D = {delta_D_cal:.8f} " + f"delta_F = {delta_F_cal:.8f} max|rk - target| = {err:.3e}") + if err <= TOL: + break + else: + print(f" WARNING: depreciation calibration did not reach {TOL:.0e} in {MAXIT} passes; " + f"rk is off target by {err:.3e} and the SS goods residuals will show it.") _apply_ss_anchors(ss, calibration_start) print(f"Verified rk_D = {float(ss['rk_D']):.6f} (target {rk_D_target:.4f})") @@ -45,6 +57,10 @@ def calibrate_depreciation(ss_results): print(" goods_mkt_F =", ss['goods_mkt_F']) print(" ca_res_D =", ss['ca_res_D']) + # This is the SS the dynamic model linearises around, so the GK portfolio FOCs are + # re-verified HERE, not only after the portfolio re-solve. + report_gk_steady_state(ss, calibration_start) + cali_D = cali_F = ss ss_final = copy.deepcopy(ss) @@ -109,8 +125,11 @@ def _get(k): eta_c = _get(f'eta_{c}') theta_c = _get(f'theta_{c}') Omega_p1 = f_c + (1 - f_c) * lam * theta_c - rb_h = _get('rb_actual_D' if c == 'D' else 'rb_actual_F') - rb_x = _get('rb_actual_F' if c == 'D' else 'rb_actual_D') + # rb_exp, not rb_actual: intermediation_P1_D/F price the EXPECTED payoff. The + # two coincide at SS (def_rate_ss = 0) but this diagnostic should mirror the + # block it is checking, not a numerically equal cousin. + rb_h = _get('rb_exp_D' if c == 'D' else 'rb_exp_F') + rb_x = _get('rb_exp_F' if c == 'D' else 'rb_exp_D') nu_K_c = _get(f'nu_K_{c}') nu_bh_c = _get('nu_bD_D' if c == 'D' else 'nu_bF_F') nu_bx_c = _get('nu_bF_D' if c == 'D' else 'nu_bD_F') diff --git a/code/dump_irfs.py b/code/dump_irfs.py new file mode 100644 index 0000000..aefd25f --- /dev/null +++ b/code/dump_irfs.py @@ -0,0 +1,54 @@ +"""Run the pipeline through build_and_solve and save IRFs for comparison. + +Usage: + /opt/anaconda3/envs/ssj/bin/python code/dump_irfs.py OUT.npz +""" +import sys +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).parent)) + +from calibration import get_calibration +from steady_state import solve_steady_state +from ic_delta_calibration import calibrate_ic_delta +from depreciation_calibration import calibrate_depreciation +from full_model import build_and_solve + +KEYS = ('Y_D', 'C_D', 'I_D', 'n_inter_D', 'K_D', 'b_gov_D', 'w_D', 'N_D', + 'p', 'q_b_D', 'spread_rb', 'Y_F', 'C_F', 'I_F', 'n_inter_F') + + +def main(out_path): + r = calibrate_depreciation(calibrate_ic_delta( + solve_steady_state(get_calibration()))) + m = build_and_solve(r) + payload = {} + for tag in ('irfs_def_D', 'irfs_Z_D'): + for k in KEYS: + if k in m[tag]: + payload[f'{tag}__{k}'] = np.asarray(m[tag][k]) + # Store the SS level for EVERY dumped series. A missing ss__ entry tempts a + # consumer into a divisor of 1.0, which silently reports a level deviation + # as a percentage -- exactly the mislabelling CLAUDE.md records (n_inter_ss + # = 2.138 and K_ss = 10.8 are not ~1, and a past bug mislabelled those by + # 2.1x and 10x). I_D in particular has no SS entry in ss_final and must be + # reconstructed as delta*K. + ss = m['ss_final'] + for k in KEYS: + if k in ('I_D', 'I_F'): + suf = k[-1] + payload[f'ss__{k}'] = np.asarray( + float(ss[f'delta_{suf}']) * float(ss[f'K_{suf}'])) + else: + payload[f'ss__{k}'] = np.asarray(float(ss[k])) + missing = [k for k in KEYS if f'ss__{k}' not in payload] + assert not missing, f'no SS level stored for: {missing}' + np.savez(out_path, **payload) + print(f'wrote {out_path} with {len(payload)} arrays ' + f'({len(KEYS)} series, all with SS levels)') + + +if __name__ == '__main__': + main(sys.argv[1]) diff --git a/code/equations_D.py b/code/equations_D.py index d029be7..3b6955d 100644 --- a/code/equations_D.py +++ b/code/equations_D.py @@ -61,9 +61,13 @@ def make_grids_D(Depmax_D, nDep_D, nZ_D, rho_z_D, sigma_z_D): return dep_D_grid, e_grid_D, Pi_D -def income_D(e_grid_D, w_D, N_D, div_D, div_fund_D, tau_D, lamb_D, P_CES_D, T_ls_D): +def income_D(e_grid_D, w_D, N_D, div_D, div_fund_D, profit_D, tau_D, lamb_D, P_CES_D, T_ls_D): # div_fund_D: rebate from the passive capital fund (zero when omega_K_D=1). - y_pre_D = (w_D * N_D * e_grid_D + div_D + div_fund_D) / P_CES_D + # profit_D: markup rent, distributed in proportion to productivity e (see + # firm_profit_D). w_D*N_D*e + profit_D*e = (1-alpha)*Y_D*e exactly, so + # household income is identical to the flex model and the markup wedge acts + # only on the firm's hiring decision. Zero at SS. + y_pre_D = (w_D * N_D * e_grid_D + profit_D * e_grid_D + div_D + div_fund_D) / P_CES_D z_D = lamb_D * (y_pre_D ** (1 - tau_D)) - T_ls_D t_paid_D = y_pre_D - z_D return z_D, t_paid_D @@ -73,14 +77,41 @@ def income_D(e_grid_D, w_D, N_D, div_D, div_fund_D, tau_D, lamb_D, P_CES_D, T_ls @simple -def deposit_return_D(rdep_D, P_CES_D): - # Bundle-real gross deposit return: corrects for P_CES revaluation between t-1 and t. - # T-2 fix: deposits are one-period non-contingent contracts — the rate paid at t - # was locked at t-1 (rdep_D(-1)). Previously rdep_D (a period-t unknown) was paid - # on the t-1 deposit stock, making deposits state-contingent and generating a - # large bank windfall on impact of shocks (audit.md T-2). - # At SS P_CES_D(-1)/P_CES_D = 1, so Rgross_D = 1 + rdep_D identically. - Rgross_D = (1 + rdep_D(-1)) * P_CES_D(-1) / P_CES_D +def deposit_rates_D(i_dep_D, pi_D): + # Deposits are NOMINAL euro contracts. i_dep_D is the nominal rate and is the + # unknown that clears deposit_mkt_D -- there is no policy rate pinning it, so + # no absorber or cross-border claim is needed and external_account_D is + # untouched. + # + # rdep_D keeps its existing meaning: the EX-ANTE real rate for the t -> t+1 + # holding period, locked at t. That is exactly what intermediation_P1_D, + # divert_bond_foc_D and divert_portfolio_adj already mean by rdep_D, so those + # blocks need no changes. + # + # rdep_expost_D is the REALISED real rate at t on deposits placed at t-1. It + # contains the inflation surprise: a deflation raises the real value of the + # bank's nominal liabilities. Banks hold real assets against nominal + # liabilities, so they are net nominal debtors and this deepens the net-worth + # loss -- the Fisher-Bernanke channel. + # + # At SS pi_D = 0 and both equal i_dep_D, so the SS is bit-identical. + rdep_D = (1 + i_dep_D) / (1 + pi_D(+1)) - 1 + rdep_expost_D = (1 + i_dep_D(-1)) / (1 + pi_D) - 1 + return rdep_D, rdep_expost_D + + +@simple +def deposit_return_D(i_dep_D, P_CES_D, pi_D): + # Bundle-real gross deposit return on a NOMINAL contract. + # P_c_D = P_D * P_CES_D is the nominal CPI, so + # P_c_D(-1)/P_c_D = (P_CES_D(-1)/P_CES_D) / (1 + pi_D). + # + # T-2 is NOT reopened: the rate is still locked at t-1 (i_dep_D(-1)); only + # the deflator is period-t, which this block already did via P_CES. T-2 was + # about paying a period-t UNKNOWN rate on the t-1 deposit stock. + # + # At SS P_CES_D(-1)/P_CES_D = 1 and pi_D = 0, so Rgross_D = 1 + i_dep_D. + Rgross_D = (1 + i_dep_D(-1)) * P_CES_D(-1) / P_CES_D / (1 + pi_D) return Rgross_D @@ -144,21 +175,24 @@ def market_clearing_D(Y_D, C_D, I_D, G_D, NX_D, DEP_D, D_supply_D, P_CES_D, Phi_ @simple -def ces_price_D(omega, epsilon_trade, p): - P_CES_D = (omega + (1 - omega) * p ** (1 - epsilon_trade)) ** (1 / (1 - epsilon_trade)) +def ces_price_D(omega_D, epsilon_trade, p): + # omega was a SINGLE shared home-bias parameter until 2026-08-07. Under the + # country-size asymmetry that is untenable: a common import share with F + # 11.7x larger makes F's imports from D 11.7x too big. See equations_global. + P_CES_D = (omega_D + (1 - omega_D) * p ** (1 - epsilon_trade)) ** (1 / (1 - epsilon_trade)) return P_CES_D @simple -def import_demand_D(C_D, omega, epsilon_trade, p, P_CES_D): - IM_D = (1 - omega) * (P_CES_D / p) ** epsilon_trade * C_D +def import_demand_D(C_D, omega_D, epsilon_trade, p, P_CES_D): + IM_D = (1 - omega_D) * (P_CES_D / p) ** epsilon_trade * C_D return IM_D @simple def steady_auxilliary_D(theta_D, rk_D, rdep_D, delta_D, alpha_D, Y_D, K_D, N_D, beta_inter_D, ksi_D, rn_D, f_D, - rb_actual_D, rb_actual_F, + rb_exp_D, rb_exp_F, phi_bD_D, phi_bF_D, Delta_bD_D, Delta_bF_D): iota_D = delta_D mpk_D = alpha_D * (Y_D / K_D) @@ -174,9 +208,15 @@ def steady_auxilliary_D(theta_D, rk_D, rdep_D, delta_D, alpha_D, Y_D, K_D, N_D, D_target_D = theta_D - (1 - Delta_bD_D) * phi_bD_D - (1 - Delta_bF_D) * phi_bF_D lambda_gk_D = f_D / (D_target_D / (beta_inter_D * (1 + rn_D)) - (1 - f_D) * theta_D) Omega_D = f_D + (1 - f_D) * lambda_gk_D * theta_D - nu_K_D = beta_inter_D * Omega_D * (rk_D - rdep_D) - nu_bD_D = beta_inter_D * Omega_D * (rb_actual_D - rdep_D) - nu_bF_D = beta_inter_D * Omega_D * (rb_actual_F - rdep_D) + # STEADY-STATE ANALOGUE of intermediation_P1_D -- an IDENTITY (the envelope + # condition evaluated at constant states), NOT a substitute for the portfolio FOC. + # It maps returns into marginal values; what CONSTRAINS those returns is + # gk_bond_foc_D (nu_bD_D = Delta_bD_eff_D * nu_K_D), imposed separately as an SS + # target with q_b_D as its unknown. Pricing reads rb_exp (the expected payoff), the + # same object intermediation_P1_D reads; at SS def_rate = 0 so rb_exp == rb_actual. + nu_K_D = beta_inter_D * Omega_D * (rk_D - rdep_D) + nu_bD_D = beta_inter_D * Omega_D * (rb_exp_D - rdep_D) + nu_bF_D = beta_inter_D * Omega_D * (rb_exp_F - rdep_D) eta_D = beta_inter_D * Omega_D * (1 + rdep_D) gamma0_D = delta_D ** ksi_D / (1 - ksi_D) gamma1_D = -delta_D * ksi_D / (1 - ksi_D) @@ -239,26 +279,108 @@ def labor_ss_D(w_D, N_D, frisch_D, mu_w_D, P_CES_D): @simple def bond_return_D(def_rate_D, recovery_rate_D, q_b_D, delta_b_D, zeta_writeoff_D, writeoff_enabled_D): - # writeoff_enabled_D = 0: pure sovereign risk shock, no haircuts on cash flows. + """The D sovereign's payoff — the model's SINGLE SOURCE OF TRUTH for default loss. + + CONTRACT (Hatchondo–Martinez perpetuity). One unit bought at t-1 for ``q_b_D(-1)`` + pays coupon ``delta_b_D`` at t and survives as ``(1 - delta_b_D)`` units of the same + bond, worth ``(1 - delta_b_D) * q_b_D``. Macaulay duration ~ 1/delta_b_D quarters + (12.9q at the measured ``delta_b_D = 0.0777``). + + STATE-CONTINGENT PAYOFF at t, with haircut ``h = 1 - recovery_rate_D``: + + Pi(no default) = delta_b + (1 - delta_b) * q_b + Pi(default) = delta_b * (1 - h) + (1 - delta_b) * q_b * (1 - zeta_writeoff * h) + + ``zeta_writeoff_D = 1`` writes the PRINCIPAL / continuation value down alongside the + coupon. That is the only defensible convention for a 13-quarter claim: the + coupon-only loss ``h * delta_b`` misses the continuation leg and under-prices default + by a factor of ``[delta_b + (1-delta_b) q_b] / delta_b ≈ 12.6``. It was that + under-pricing — not any weakness of the Gertler–Karadi mechanism — that the retired + free parameter ``psi_spread_D`` was silently absorbing. + + THREE returns leave this block and they are NOT interchangeable. + + ``rb_exp_D`` E[Pi] / q_b(-1) - 1. The return that PRICES the bond. Ungated by + ``writeoff_enabled``: pricing is always over the full default + distribution. This is the only object the GK Euler equations read. + ``rb_actual_D`` The realised return on the branch the IRF traces. + ``writeoff_enabled_D = 0`` selects the no-default branch (the + paper's pure risk-premium framing, S-1), so realised cash flows in + bank net worth and the government budget carry no haircut. The gap + between the two IS the risk premium; it is a branch selection, not a + wedge, and there is no free coefficient anywhere in it. + ``EL_load_D`` DIAGNOSTIC ONLY — expected loss per unit of default probability. + Nothing in the model reads it. It exists so the central bank's P&L + accounting (``code/tpi.py``) and every reported decomposition are + computed off the SAME payoff the FOC prices, instead of a separately + anchored ``EL_price_D`` parameter (deleted 2026-08-18). + """ haircut_D = 1.0 - recovery_rate_D + # ── Expected (pricing) payoff. Never gated. ─────────────────────────────── + coupon_exp_D = delta_b_D * (1.0 - def_rate_D * haircut_D) + cont_exp_D = (1.0 - delta_b_D) * q_b_D * (1.0 - zeta_writeoff_D * def_rate_D * haircut_D) + rb_exp_D = (coupon_exp_D + cont_exp_D) / q_b_D(-1) - 1.0 + # ── Realised payoff on the traced branch. ───────────────────────────────── haircut_mult_D = writeoff_enabled_D current_payoff_D = delta_b_D * (1.0 - def_rate_D * haircut_D * haircut_mult_D) continuation_D = (1.0 - delta_b_D) * q_b_D * (1.0 - zeta_writeoff_D * def_rate_D * haircut_D * haircut_mult_D) rb_actual_D = (current_payoff_D + continuation_D) / q_b_D(-1) - 1.0 - return rb_actual_D + # ── Diagnostic. Zero at SS (def_rate_ss = 0 does not enter). ────────────── + EL_load_D = haircut_D * (delta_b_D + zeta_writeoff_D * (1.0 - delta_b_D) * q_b_D) / q_b_D(-1) + return rb_actual_D, rb_exp_D, EL_load_D # ── OFF STEADY STATE EQUATIONS ─── ############################################################################################# @simple -def capital_adj_D(K_D, Q_D, I_D, Z_D, N_D, alpha_D, delta_D, gamma0_D, gamma1_D, ksi_D): - iota_D = I_D / K_D(-1) +def capital_adj_D(K_D, Q_D, I_D, Z_D, N_D, alpha_D, delta_D, gamma0_D, gamma1_D, + ksi_D, omega_I_D, beta_D): + # Investment-flow adjustment cost S(I/I(-1)) = (omega_I/2)(I/I(-1) - 1)^2. + # S(1) = S'(1) = 0, so this is EXACTLY steady-state neutral and omega_I = 0 + # reproduces the previous model identically. + # + # Motivation: without it, investment drops on impact and snaps straight back + # into a boom that drags output positive from q2. Penalising the CHANGE in + # investment turns that V into a slow U. This is the standard device + # (Bi-Foerster-Traum use omega_I = 2); the pre-existing chi1 cost penalises + # capital GROWTH instead and was measured to make the spike worse. + g_D = I_D / I_D(-1) + g_p1 = I_D(+1) / I_D + S_D = (omega_I_D / 2.0) * (g_D - 1.0) ** 2 + Sp_D = omega_I_D * (g_D - 1.0) + S_p1 = (omega_I_D / 2.0) * (g_p1 - 1.0) ** 2 + Sp_p1 = omega_I_D * (g_p1 - 1.0) + + I_eff_D = (1.0 - S_D) * I_D + iota_D = I_eff_D / K_D(-1) # W-1 (author convention): mpk is the marginal product of current K_t, # consistent with labor_D. Banks receive mpk on their K(-1) holdings via rk; # the product of newly installed capital goes to the capital producer. - mpk_D = alpha_D * Z_D * K_D ** (alpha_D - 1) * N_D ** (1 - alpha_D) - rk_D = (mpk_D + (1 - delta_D) * Q_D) / Q_D(-1) - 1 - q_res_D = Q_D - 1 / (gamma0_D * (1 - ksi_D) * iota_D ** (-ksi_D)) + mpk_D = alpha_D * Z_D * K_D ** (alpha_D - 1) * N_D ** (1 - alpha_D) + rk_D = (mpk_D + (1 - delta_D) * Q_D) / Q_D(-1) - 1 + + # Marginal capital per unit of EFFECTIVE investment, this period and next. + mpi_D = gamma0_D * (1 - ksi_D) * iota_D ** (-ksi_D) + iota_p1 = ((1.0 - S_p1) * I_D(+1)) / K_D + mpi_p1 = gamma0_D * (1 - ksi_D) * iota_p1 ** (-ksi_D) + + # Investment FOC. At SS S = S' = 0 and this is Q*mpi - 1 = 0, i.e. today's + # q_res_D = Q - 1/mpi. Same root, so the SS is untouched. (The two forms + # differ by the factor mpi, an exact constant row scaling of the target at + # first order, so the linearised solution is invariant as well.) + # + # Discounted at constant beta rather than SDF_D, exactly as price_nkpc_D + # does and for the same two reasons. (i) It is first-order EXACT here: + # S'(1) = 0, so the SDF multiplies a term that is zero at SS, and only + # SDF_ss = beta survives linearisation -- the model is solved by linearised + # solve_jacobian. (ii) SDF_D is an output of sdf_D <- ghh_composite_D <- + # hh_D, and hh_D reads capital_fund_D which reads this block, so taking + # SDF_D here makes SSJ's topological sort fail with a cyclic dependency + # hh_D -> capital_fund_D -> capital_adj_D -> sdf_D -> ghh_composite_D. + q_res_D = (Q_D * mpi_D * ((1.0 - S_D) - Sp_D * g_D) + + beta_D * Q_D(+1) * mpi_p1 * Sp_p1 * g_p1 ** 2 + - 1.0) + capital_res_D = K_D - (1 - delta_D) * K_D(-1) - (gamma0_D * iota_D ** (1 - ksi_D) + gamma1_D) * K_D(-1) return iota_D, mpk_D, rk_D, q_res_D, capital_res_D @@ -290,17 +412,114 @@ def labor_market_D(w_D, N_D, vphi_D, frisch_D, P_CES_D): @simple -def labor_demand_D(w_D, Y_D, N_D, alpha_D): - w_res_D = w_D - (1 - alpha_D) * Y_D / N_D +def labor_demand_D(w_D, Y_D, N_D, alpha_D, mu_p_D, mc_D): + # Firm FOC with a price markup: w = mu_p*mc*(1-alpha)*Y/N. + # The mu_p factor IS the production subsidy tau_s = 1 - 1/mu_p: at the SS + # markup mc = 1/mu_p this collapses to the competitive w = (1-alpha)Y/N + # identically, so the steady state is unchanged. Off SS the wedge shifts + # labour demand, which is what makes N -- and hence output -- respond to + # demand rather than being pinned by Z, K and P_CES alone. + # The rent (1 - mu_p*mc)(1-alpha)Y is routed by firm_profit_D. + w_res_D = w_D - mu_p_D * mc_D * (1 - alpha_D) * Y_D / N_D return w_res_D +@simple +def firm_profit_D(Y_D, alpha_D, mu_p_D, mc_D): + # Markup rent. With sticky prices labour demand pays mu_p*mc*(1-alpha)*Y + # while capital still earns alpha*Y (capital_adj_D is unchanged), so off SS + # factor payments do not exhaust output. profit_D is that residual; leaving + # it unrouted is a Walras leak of the W-1/W-2 class. + # + # Distributed to households in proportion to productivity e (Auclert-Rognlie- + # Straub), NOT lump-sum: markups are countercyclical, so a lump-sum rebate + # would raise household income exactly when output falls. On the e rule, + # w*N*e + profit*e = (1-alpha)*Y*e -- identical to the flex model -- so the + # wedge affects the firm's hiring decision only, and because the share + # depends on type rather than hours the marginal wage is still w_D and + # labor_market_D is unchanged. + # + # Zero at SS, where mu_p*mc = 1. + profit_D = (1.0 - mu_p_D * mc_D) * (1.0 - alpha_D) * Y_D + return profit_D + + +@simple +def price_nkpc_D(pi_D, mc_D, mu_p_D, kappa_p_D, beta_D): + # Rotemberg NK Phillips curve in D producer-price inflation. + # + # The gap is a RATIO (mu_p*mc - 1), so it is unit-free and linearises to + # exactly mc_hat for any mu_p -- published Calvo slopes are directly usable + # for kappa_p with no SS rescaling, and mu_p is a free normalisation to + # first order under the subsidy neutralisation. + # + # Subsidy-neutralised: mc_ss = 1/mu_p, so the gap and pi are both exactly + # zero at the current SS and the SS is bit-identical to the flex model. + # kappa_p -> inf recovers flexible prices (mu_p*mc = 1). + # + # Discounted at constant beta rather than SDF_D: since pi_ss = 0 the SDF + # deviation multiplies a zero, so the two are identical to first order and + # the model is solved by linearised solve_jacobian. + nkpc_p_res_D = pi_D - beta_D * pi_D(+1) - kappa_p_D * (mu_p_D * mc_D - 1.0) + return nkpc_p_res_D + + +@simple +def collateral_quality_D(Delta_bD_D, Delta_bF_D, psi_lambda_B_D, + def_rate_D, def_rate_F): + """Bounded, forward-looking, asset-specific pledgeability of sovereign collateral. + + Replaces the unbounded linear map ``Delta_eff = Delta + psi_lambda_B*def_rate(+1)`` + that ``intermediation_IC_D`` used inline. That form leaves the economic domain at + ``def_rate(+1) > (1-Delta)/psi_lambda_B`` — 0.266 for the own-bond leg at + ``Delta_bD_D=0.2, psi_lambda_B_D=3.01`` — beyond which ``1-Delta_eff`` turns + negative and the bond becomes *worse than useless* as collateral, a region with no + pledgeability reading. + + z = psi_lambda_B * def_rate(+1) / (1 - Delta) + Delta_eff = Delta + (1 - Delta) * z/(1+z) + + Range ``[Delta, 1)`` for ``def_rate(+1) >= 0``; monotone increasing; and + + d Delta_eff / d def_rate(+1) |_{def_rate=0} = psi_lambda_B EXACTLY + + (verified against the SSJ Jacobian: 3.0100000000 at ``psi_lambda_B_D = 3.01``), + so the linearised model is unchanged and ``psi_lambda_B`` keeps its calibrated + meaning as the local slope. SS-neutral: ``def_rate_ss = 0`` gives ``Delta_eff = + Delta``. Exported (rather than computed inline) so the same object can price the + bond in the intermediary FOC and be reported in the diagnostics. + + WHY RATIONAL AND NOT EXPONENTIAL. The natural ``1 - exp(-z)`` saturation cannot be + used: SSJ differentiates ``@simple`` blocks with ``AccumulatedDerivative``, which + implements the arithmetic operators only, so ``np.exp`` raises + ``TypeError: loop of ufunc does not support argument 0 of type + AccumulatedDerivative``. ``z/(1+z)`` is built from ``* / +`` alone, differentiates + natively, and has the same three properties that mattered. + + REMAINING DOMAIN CAVEAT: there is a pole at ``def_rate(+1) = -(1-Delta)/psi_lambda_B`` + = -0.2658 at the live calibration. Only reachable at a default probability 26.6pp + BELOW steady state, which is impossible here (``def_rate_ss = 0`` and the shock is + positive), and the linearised solve never evaluates the nonlinear map anyway. It + would bind on a global/nonlinear solve — flagged for ``code/global/``. + + Interpretation is a MARKET haircut / pledgeability channel — the reduced-form + device Bi-Foerster-Traum use for cross-border interbank retrenchment — NOT a + Basel-II regulatory risk weight. + """ + slack_bD_D = 1.0 - Delta_bD_D + slack_bF_D = 1.0 - Delta_bF_D + z_bD_D = psi_lambda_B_D * def_rate_D(+1) / slack_bD_D + z_bF_D = psi_lambda_B_D * def_rate_F(+1) / slack_bF_D + Delta_bD_eff_D = Delta_bD_D + slack_bD_D * (z_bD_D / (1.0 + z_bD_D)) + Delta_bF_eff_D = Delta_bF_D + slack_bF_D * (z_bF_D / (1.0 + z_bF_D)) + return Delta_bD_eff_D, Delta_bF_eff_D + @simple def intermediation_IC_D(nu_K_D, nu_bD_D, nu_bF_D, eta_D, Q_D, K_D, q_b_D, q_b_F, b_D_D, b_F_D, n_inter_D, - lambda_gk_D, Delta_bD_D, Delta_bF_D, theta_D, - def_rate_D,def_rate_F, psi_lambda_B_D, omega_K_D, + lambda_gk_D, theta_D, + Delta_bD_eff_D, Delta_bF_eff_D, omega_K_D, fund_rule_D, K_fund_D): K_bank_D = ((1.0 - fund_rule_D) * omega_K_D * K_D + fund_rule_D * (K_D - K_fund_D)) @@ -311,9 +530,10 @@ def intermediation_IC_D(nu_K_D, nu_bD_D, nu_bF_D, eta_D, # bond class is weighted by its relative divertability Delta_i vs capital (=1). # theta_tgt = value/lambda_gk + (1-Delta_bD)·phi_bD + (1-Delta_bF)·phi_bF. # Delta=1 → single-lambda; Delta<1 → bond is better collateral → bank levers more. - # psi_lambda_B_D > 0: default risk raises bond divertability (worsens collateral). - Delta_bD_eff = Delta_bD_D + psi_lambda_B_D * def_rate_D(+1) - Delta_bF_eff = Delta_bF_D + psi_lambda_B_D * def_rate_F(+1) + # Delta_*_eff_D now arrive from collateral_quality_D (bounded map); default risk + # raising them is the pledgeability channel. + Delta_bD_eff = Delta_bD_eff_D + Delta_bF_eff = Delta_bF_eff_D value_D = nu_K_D * kappa_D + nu_bD_D * phi_bD_D + nu_bF_D * phi_bF_D + eta_D theta_tgt_D = (value_D / lambda_gk_D + (1 - Delta_bD_eff) * phi_bD_D @@ -323,42 +543,66 @@ def intermediation_IC_D(nu_K_D, nu_bD_D, nu_bF_D, eta_D, @simple -def bank_return_D(theta_D, rk_D, rdep_D, b_D_D, b_F_D, n_inter_D, +def bank_return_D(theta_D, rk_D, rdep_expost_D, b_D_D, b_F_D, n_inter_D, rb_actual_D, rb_actual_F, q_b_D, q_b_F): phi_bD_lag_D = q_b_D(-1) * b_D_D(-1) / n_inter_D(-1) phi_bF_lag_D = q_b_F(-1) * b_F_D(-1) / n_inter_D(-1) kappa_lag_D = theta_D(-1) - phi_bD_lag_D - phi_bF_lag_D # T-2 fix: funding cost on the t-1 balance sheet is the rate locked at t-1. - rn_D = (kappa_lag_D * (rk_D - rdep_D(-1)) - + phi_bD_lag_D * (rb_actual_D - rdep_D(-1)) - + phi_bF_lag_D * (rb_actual_F - rdep_D(-1)) - + rdep_D(-1)) + # Under nominal deposits that realised real cost is rdep_expost_D, which + # already carries the (-1) timing internally and contains the inflation + # surprise -- the Fisher revaluation on the bank's nominal liabilities. + rn_D = (kappa_lag_D * (rk_D - rdep_expost_D) + + phi_bD_lag_D * (rb_actual_D - rdep_expost_D) + + phi_bF_lag_D * (rb_actual_F - rdep_expost_D) + + rdep_expost_D) return rn_D @simple -def capital_fund_D(rk_D, rdep_D, Q_D, K_D, omega_K_D, fund_rule_D, K_fund_D): - # Passive capital fund funded by deposits; rebates its spread (rk - rdep) on the - # lagged capital value to households. Same predetermined-rate timing as - # bank_return_D (T-2). Zero when the fund is empty (omega_K_D=1, K_fund_D=0). +def capital_fund_D(rk_D, rdep_expost_D, Q_D, K_D, omega_K_D, fund_rule_D, K_fund_D): + # Passive capital fund funded by deposits; rebates its spread on the lagged + # capital value to households. Same predetermined-rate timing as + # bank_return_D (T-2); rdep_expost_D is the realised real funding cost under + # nominal deposits. Zero when the fund is empty (omega_K_D=1, K_fund_D=0). # fund_rule_D: 0 = fund holds (1-omega_K)·K, 1 = fund holds a constant K_fund. K_fnd_lag_D = ((1.0 - fund_rule_D) * (1.0 - omega_K_D) * K_D(-1) + fund_rule_D * K_fund_D) - div_fund_D = (rk_D - rdep_D(-1)) * Q_D(-1) * K_fnd_lag_D + div_fund_D = (rk_D - rdep_expost_D) * Q_D(-1) * K_fnd_lag_D return div_fund_D @simple -def intermediation_P1_D(rk_D, rb_actual_D, rb_actual_F, rdep_D, +def intermediation_P1_D(rk_D, rb_exp_D, rb_exp_F, rdep_D, nu_K_D, nu_bD_D, nu_bF_D, eta_D, lambda_gk_D, theta_D, SDF_banker_D, f_D): + """D bank's Bellman envelope: marginal value of each asset per unit of net worth. + + ``nu_i = E_t[ M^B_{t,t+1} Omega_{t+1} (r_{i,t+1} - r^dep_t) ]`` with the intermediary + SDF ``M^B = SDF_banker_D`` and franchise value ``Omega = f + (1-f) lambda_gk theta``. + + EXPECTED DEFAULT LOSS ENTERS HERE, AND ONLY HERE, on the D bank's two sovereign + legs — through ``rb_exp_D`` / ``rb_exp_F``, which ``bond_return_D/F`` compute from + the state-contingent payoff. There is no ``EL_price`` parameter, no additive spread + term, and no second netting of the same loss anywhere downstream (the cross-border + leg reads these same ``nu``s; see ``gk_cross_border_foc``). + + UNITS. ``q_b_D`` and ``q_b_F`` are both D-GOOD prices (``intermediation_P3_D`` adds + ``q_b_F * b_F_D`` straight onto ``Q_D * K_D``; ``external_account_D`` books the F-bond + receipt with no ``p``), so the D bank needs no terms-of-trade conversion on either + leg. The F bank does — see ``intermediation_P1_F``. + + T-2 fix: the deposit rate for the t -> t+1 holding period is ``rdep_D``, locked at t. + """ Omega_p1_D = f_D + (1 - f_D) * lambda_gk_D * theta_D(+1) - # T-2 fix: the deposit rate for the t->t+1 holding period is rdep_D (locked at t). - nu_K_res_D = nu_K_D - SDF_banker_D * Omega_p1_D * (rk_D(+1) - rdep_D) - nu_bD_res_D = nu_bD_D - SDF_banker_D * Omega_p1_D * (rb_actual_D(+1) - rdep_D) - nu_bF_res_D = nu_bF_D - SDF_banker_D * Omega_p1_D * (rb_actual_F(+1) - rdep_D) + nu_K_res_D = nu_K_D - SDF_banker_D * Omega_p1_D * (rk_D(+1) - rdep_D) + nu_bD_res_D = nu_bD_D - SDF_banker_D * Omega_p1_D * (rb_exp_D(+1) - rdep_D) + nu_bF_res_D = nu_bF_D - SDF_banker_D * Omega_p1_D * (rb_exp_F(+1) - rdep_D) eta_res_D = eta_D - SDF_banker_D * Omega_p1_D * (1 + rdep_D) - return nu_K_res_D, nu_bD_res_D, nu_bF_res_D, eta_res_D + # Omega_p1_D is EXPORTED so gk_cross_border_foc can divide the cross-border FOC back + # into return units without keeping a second copy of this line (a duplicated copy of + # model algebra is exactly how audit_artifacts/ drifted -- see CLAUDE.md). + return nu_K_res_D, nu_bD_res_D, nu_bF_res_D, eta_res_D, Omega_p1_D @simple @@ -409,28 +653,6 @@ def intermediation_P3_D(Q_D, K_D, n_inter_D, b_D_D, b_F_D, q_b_D, q_b_F): return D_supply_D -@simple -def bond_price_ss_D(SDF_banker_D, def_rate_D, recovery_rate_D, delta_b_D, zeta_writeoff_D, writeoff_enabled_D): - haircut_D = 1.0 - recovery_rate_D - haircut_mult_D = writeoff_enabled_D - surv_cont_D = 1.0 - zeta_writeoff_D * def_rate_D * haircut_D * haircut_mult_D - q_b_D = ( - SDF_banker_D * delta_b_D * (1.0 - def_rate_D * haircut_D * haircut_mult_D) - / (1.0 - SDF_banker_D * (1.0 - delta_b_D) * surv_cont_D) - ) - return q_b_D - - -@simple -def domestic_bond_foc_D(rb_actual_D, rdep_D, b_D_D, n_inter_D, q_b_D, - phi_bD_D_ss, psi_bD_D, excess_return_bD_D_ss, tau_mp_D): - phi_bD_D = q_b_D * b_D_D / n_inter_D - rb_D_res = (rb_actual_D(+1) - rdep_D(+1)) - excess_return_bD_D_ss \ - - psi_bD_D * (phi_bD_D - phi_bD_D_ss) \ - - tau_mp_D - return rb_D_res - - # ==> GOVERMENT EQUATIONS @simple def government_default_D(shock_def_D, b_gov_D, Y_ss_D, b_gov_ss_D, @@ -471,25 +693,64 @@ def budget_residual_D(b_gov_D, G_D, TAX_D, q_b_D, def_rate_D, recovery_rate_D, z @simple -def divert_bond_foc_D(rb_actual_D, rdep_D, b_D_D, n_inter_D, q_b_D, - phi_bD_D_ss, psi_bD_D, excess_return_bD_D_ss, tau_mp_D, - psi_spread_D, EL_price_D, def_rate_D): - phi_bD_D = q_b_D * b_D_D / n_inter_D - # IC-theory derived required spread: additive default loading independent of SS excess return. - # psi_spread_D = lambda_gk_D * psi_lambda_B_D / (beta_inter_D * Omega_D), computed in _apply_ss_anchors. - # macro-pru-fix: EL_price_D is the FUNDAMENTAL expected-loss loading (per unit default - # probability) priced by bondholders, INDEPENDENT of the psi_lambda_B collateral friction. - # It makes q_b_D fall on higher def_rate even when psi_lambda_B=0 (psi_spread=0); psi_lambda_B - # is now a pure amplifier on top. SS-neutral: multiplies def_rate(+1)=0 at SS. - # See diagnostics/recommended_fix.md. - req_spread = excess_return_bD_D_ss + (EL_price_D + psi_spread_D) * def_rate_D(+1) - # T-2 fix: compare t+1 bond return with rdep_D locked at t. - rb_D_res = (rb_actual_D(+1) - rdep_D) - req_spread \ - - psi_bD_D * (phi_bD_D - phi_bD_D_ss) \ - - tau_mp_D +def gk_bond_foc_D(nu_bD_D, nu_K_D, Delta_bD_eff_D): + """GK portfolio optimality for the D bank's OWN sovereign. This PINS ``q_b_D``. + + Renamed from ``divert_bond_foc_D``, which is deleted. That block was a hand-written + spread rule, + + (rb_actual_D(+1) - rdep_D) = excess_return_bD_D_ss + + (EL_price_D + psi_spread_D) * def_rate_D(+1) + - psi_bD_D * (phi_bD_D - phi_bD_D_ss) - tau_mp_D, + + referencing no endogenous GK object at all — ``psi_spread_D`` was a scalar frozen at + the steady state and ``excess_return_bD_D_ss`` an anchor read off the same solve. The + pledgeability channel entered only the incentive constraint and dead-ended in + ``theta_D``; it never reached the price. The sovereign spread was consequently a + calibrated object, not an equilibrium one. + + THE GENUINE CONDITION. The banker maximises + + V = nu_K * kappa + nu_bD * phi_bD + nu_bF * phi_bF + eta + + subject to the binding incentive constraint + + V = lambda_gk * (kappa + Delta_bD_eff * phi_bD + Delta_bF_eff * phi_bF). + + Both sides are linear in the portfolio shares, so the first-order conditions in + ``kappa`` and ``phi_bD`` give a pure proportionality — excess returns line up with + divertability weights: + + nu_bD_D / nu_K_D = Delta_bD_eff_D. + + Composed with ``intermediation_P1_D`` (divide through by SDF_banker * Omega, which is + strictly positive) this is + + rb_exp_D(+1) - rdep_D = Delta_bD_eff_D * (rk_D(+1) - rdep_D), + + i.e. the D bank's required expected return on its own sovereign is the capital + premium scaled by how much worse the bond is as collateral. Every object in it is + endogenous: ``nu_bD_D`` and ``nu_K_D`` are solved inside ``financial_solved_D``, and + ``Delta_bD_eff_D`` is the bounded map from ``collateral_quality_D`` (identically + ``Delta_bD_D`` at the preferred ``psi_lambda_B_D = 0``). + + HOW THE SPREAD IS NOW GENERATED. ``rb_exp_D`` is the expected payoff over + ``q_b_D``, so a rise in ``def_rate_D(+1)`` cuts the expected payoff and ``q_b_D`` has + to fall until the condition holds again. The measured yield + ``rb_D = delta_b_D (1/q_b_D - 1)`` rises by the expected loss; bank net worth then + takes a mark-to-market hit, the IC tightens, ``K_D`` contracts, ``rk_D`` rises, and + the right-hand side pushes ``q_b_D`` down further. Direct expected-loss pricing plus + balance-sheet amplification — no additive risk-premium term anywhere. + + NOT STEADY-STATE-NEUTRAL, by design: the pre-refactor SS satisfied + ``nu_bD_D/nu_K_D = 0.2491`` against ``Delta_bD_D = 0.20``. ``q_b_D`` is therefore an SS + UNKNOWN, with this residual as its SS target (``steady_state.solve_steady_state``). + """ + rb_D_res = nu_bD_D - Delta_bD_eff_D * nu_K_D return rb_D_res + @simple def welfare_agg_D(X_D, C_D_ss): # GHH utility composite normalised by SS consumption. diff --git a/code/equations_F.py b/code/equations_F.py index fea45e7..193e052 100644 --- a/code/equations_F.py +++ b/code/equations_F.py @@ -57,9 +57,9 @@ def make_grids_F(Depmax_F, nDep_F, nZ_F, rho_z_F, sigma_z_F): return dep_F_grid, e_grid_F, Pi_F -def income_F(e_grid_F, w_F, N_F, div_F, div_fund_F, tau_F, lamb_F, P_CES_F, T_ls_F): - # div_fund_F: rebate from the passive capital fund (zero when omega_K_F=1). - y_pre_F = (w_F * N_F * e_grid_F + div_F + div_fund_F) / P_CES_F +def income_F(e_grid_F, w_F, N_F, div_F, div_fund_F, profit_F, tau_F, lamb_F, P_CES_F, T_ls_F): + # See income_D. profit_F is the markup rent, distributed on e; zero at SS. + y_pre_F = (w_F * N_F * e_grid_F + profit_F * e_grid_F + div_F + div_fund_F) / P_CES_F z_F = lamb_F * (y_pre_F ** (1 - tau_F)) - T_ls_F t_paid_F = y_pre_F - z_F return z_F, t_paid_F @@ -67,11 +67,17 @@ def income_F(e_grid_F, w_F, N_F, div_F, div_fund_F, tau_F, lamb_F, P_CES_F, T_ls hh_extended_F = hh_F.add_hetinputs([make_grids_F, income_F]) @simple -def deposit_return_F(rdep_F, P_CES_F): - # Bundle-real gross deposit return: corrects for P_CES revaluation between t-1 and t. - # T-2 fix: rate paid at t was locked at t-1 — see deposit_return_D. - # At SS P_CES_F(-1)/P_CES_F = 1, so Rgross_F = 1 + rdep_F identically. - Rgross_F = (1 + rdep_F(-1)) * P_CES_F(-1) / P_CES_F +def deposit_rates_F(i_dep_F, pi_F): + # See deposit_rates_D. + rdep_F = (1 + i_dep_F) / (1 + pi_F(+1)) - 1 + rdep_expost_F = (1 + i_dep_F(-1)) / (1 + pi_F) - 1 + return rdep_F, rdep_expost_F + + +@simple +def deposit_return_F(i_dep_F, P_CES_F, pi_F): + # See deposit_return_D. Nominal contract; T-2 timing preserved. + Rgross_F = (1 + i_dep_F(-1)) * P_CES_F(-1) / P_CES_F / (1 + pi_F) return Rgross_F @@ -123,21 +129,24 @@ def market_clearing_F(Y_F, C_F, I_F, G_F, NX_F, DEP_F, D_supply_F, P_CES_F, Phi_ return goods_mkt_F, deposit_mkt_F @simple -def ces_price_F(omega, epsilon_trade, p): +def ces_price_F(omega_F, epsilon_trade, p): # F's domestic good price = 1 (in F-units); D-good import price = 1/p (in F-units). # So F's CES price index uses (1/p)^(1-eps), NOT p^(1-eps). - P_CES_F = (omega + (1 - omega) * (1 / p) ** (1 - epsilon_trade)) ** (1 / (1 - epsilon_trade)) + # omega_F > omega_D by construction: the larger country is more closed. See + # ces_price_D and equations_global's country-size note. + P_CES_F = (omega_F + (1 - omega_F) * (1 / p) ** (1 - epsilon_trade)) ** (1 / (1 - epsilon_trade)) return P_CES_F @simple -def import_demand_F(C_F, omega, epsilon_trade, p, P_CES_F): - IM_F = (1 - omega) * (P_CES_F * p) ** epsilon_trade * C_F +def import_demand_F(C_F, omega_F, epsilon_trade, p, P_CES_F): + # Per F capita, like every other F quantity. + IM_F = (1 - omega_F) * (P_CES_F * p) ** epsilon_trade * C_F return IM_F @simple def steady_auxilliary_F(theta_F, rk_F, rdep_F, delta_F, alpha_F, Y_F, K_F, N_F, beta_inter_F, ksi_F, rn_F, f_F, - rb_actual_F, rb_actual_D, + rb_exp_F, rb_exp_D, phi_bF_F, phi_bD_F, Delta_bF_F, Delta_bD_F): iota_F = delta_F mpk_F = alpha_F * (Y_F / K_F) @@ -147,9 +156,12 @@ def steady_auxilliary_F(theta_F, rk_F, rdep_F, delta_F, alpha_F, Y_F, K_F, N_F, D_target_F = theta_F - (1 - Delta_bF_F) * phi_bF_F - (1 - Delta_bD_F) * phi_bD_F lambda_gk_F = f_F / (D_target_F / (beta_inter_F * (1 + rn_F)) - (1 - f_F) * theta_F) Omega_F = f_F + (1 - f_F) * lambda_gk_F * theta_F - nu_K_F = beta_inter_F * Omega_F * (rk_F - rdep_F) - nu_bF_F = beta_inter_F * Omega_F * (rb_actual_F - rdep_F) - nu_bD_F = beta_inter_F * Omega_F * (rb_actual_D - rdep_F) + # SS analogue of intermediation_P1_F -- an identity, not the portfolio FOC. + # See steady_auxilliary_D. At SS p is constant, so P1_F's p/p(+1) conversion is 1 + # and no conversion is needed here. + nu_K_F = beta_inter_F * Omega_F * (rk_F - rdep_F) + nu_bF_F = beta_inter_F * Omega_F * (rb_exp_F - rdep_F) + nu_bD_F = beta_inter_F * Omega_F * (rb_exp_D - rdep_F) eta_F = beta_inter_F * Omega_F * (1 + rdep_F) gamma0_F = delta_F ** ksi_F / (1 - ksi_F) gamma1_F = -delta_F * ksi_F / (1 - ksi_F) @@ -209,23 +221,64 @@ def labor_ss_F(w_F, N_F, frisch_F, mu_w_F, P_CES_F): @simple def bond_return_F(def_rate_F, recovery_rate_F, q_b_F, delta_b_F, zeta_writeoff_F, writeoff_enabled_F): - # writeoff_enabled_F = 0: pure sovereign risk shock, no haircuts on cash flows. + """The F sovereign's payoff. Exact mirror of ``bond_return_D`` — see that block for + the contract, the state-contingent payoff and why ``rb_exp_F`` / ``rb_actual_F`` / + ``EL_load_F`` are not interchangeable. + + F is the SAFE asset: ``def_rate_F`` is driven only by ``shock_def_F`` (never shocked + in the baseline) and by F's own debt ratio through ``government_default_F``. Nothing + here gives F a spread wedge; its yield moves only through ``q_b_F``, which the F + bank's own GK portfolio FOC prices. Flight to quality is therefore free to raise + ``q_b_F`` and lower the German yield on a Greek shock. + """ haircut_F = 1.0 - recovery_rate_F + coupon_exp_F = delta_b_F * (1.0 - def_rate_F * haircut_F) + cont_exp_F = (1.0 - delta_b_F) * q_b_F * (1.0 - zeta_writeoff_F * def_rate_F * haircut_F) + rb_exp_F = (coupon_exp_F + cont_exp_F) / q_b_F(-1) - 1.0 haircut_mult_F = writeoff_enabled_F current_payoff_F = delta_b_F * (1.0 - def_rate_F * haircut_F * haircut_mult_F) continuation_F = (1.0 - delta_b_F) * q_b_F * (1.0 - zeta_writeoff_F * def_rate_F * haircut_F * haircut_mult_F) rb_actual_F = (current_payoff_F + continuation_F) / q_b_F(-1) - 1.0 - return rb_actual_F + EL_load_F = haircut_F * (delta_b_F + zeta_writeoff_F * (1.0 - delta_b_F) * q_b_F) / q_b_F(-1) + return rb_actual_F, rb_exp_F, EL_load_F # ── Off-steady-state blocks ─────────────────────────────────────────────────── @simple -def capital_adj_F(K_F, Q_F, I_F, Z_F, N_F, alpha_F, delta_F, gamma0_F, gamma1_F, ksi_F): - iota_F = I_F / K_F(-1) +def capital_adj_F(K_F, Q_F, I_F, Z_F, N_F, alpha_F, delta_F, gamma0_F, gamma1_F, + ksi_F, omega_I_F, beta_F): + # Exactly symmetric to capital_adj_D — see there for the full rationale. + # Investment-flow adjustment cost S(I/I(-1)) = (omega_I/2)(I/I(-1) - 1)^2, + # with S(1) = S'(1) = 0 so it is EXACTLY steady-state neutral. + # No terms-of-trade conversion enters here: F's capital, investment and Q + # are all denominated in F goods (the p(-1)/p conversion of W-2 applies to + # the F bank's D-bond book, in bank_return_F, not to this block). + g_F = I_F / I_F(-1) + g_p1 = I_F(+1) / I_F + S_F = (omega_I_F / 2.0) * (g_F - 1.0) ** 2 + Sp_F = omega_I_F * (g_F - 1.0) + S_p1 = (omega_I_F / 2.0) * (g_p1 - 1.0) ** 2 + Sp_p1 = omega_I_F * (g_p1 - 1.0) + + I_eff_F = (1.0 - S_F) * I_F + iota_F = I_eff_F / K_F(-1) # W-1 (author convention): mpk of current K_t — see capital_adj_D - mpk_F = alpha_F * Z_F * K_F ** (alpha_F - 1) * N_F ** (1 - alpha_F) - rk_F = (mpk_F + (1 - delta_F) * Q_F) / Q_F(-1) - 1 - q_res_F = Q_F - 1 / (gamma0_F * (1 - ksi_F) * iota_F ** (-ksi_F)) + mpk_F = alpha_F * Z_F * K_F ** (alpha_F - 1) * N_F ** (1 - alpha_F) + rk_F = (mpk_F + (1 - delta_F) * Q_F) / Q_F(-1) - 1 + + # Marginal capital per unit of EFFECTIVE investment, this period and next. + mpi_F = gamma0_F * (1 - ksi_F) * iota_F ** (-ksi_F) + iota_p1 = ((1.0 - S_p1) * I_F(+1)) / K_F + mpi_p1 = gamma0_F * (1 - ksi_F) * iota_p1 ** (-ksi_F) + + # Investment FOC. At SS S = S' = 0 and this is Q*mpi - 1 = 0, i.e. the old + # q_res_F = Q - 1/mpi. Same root, so the SS is untouched. Discounted at + # constant beta_F, not SDF_F — first-order exact because S'(1) = 0, and + # required to keep the DAG acyclic. See capital_adj_D. + q_res_F = (Q_F * mpi_F * ((1.0 - S_F) - Sp_F * g_F) + + beta_F * Q_F(+1) * mpi_p1 * Sp_p1 * g_p1 ** 2 + - 1.0) + capital_res_F = K_F - (1 - delta_F) * K_F(-1) - (gamma0_F * iota_F ** (1 - ksi_F) + gamma1_F) * K_F(-1) return iota_F, mpk_F, rk_F, q_res_F, capital_res_F @@ -243,19 +296,46 @@ def labor_market_F(w_F, N_F, vphi_F, frisch_F, P_CES_F): @simple -def labor_demand_F(w_F, Y_F, N_F, alpha_F): - # Firm FOC: w = (1−α)·Y/N. Pins the wage in ha_full (drop labor_mkt_res_F there). - w_res_F = w_F - (1 - alpha_F) * Y_F / N_F +def labor_demand_F(w_F, Y_F, N_F, alpha_F, mu_p_F, mc_F): + # See labor_demand_D. Pins the wage in ha_full (drop labor_mkt_res_F there). + w_res_F = w_F - mu_p_F * mc_F * (1 - alpha_F) * Y_F / N_F return w_res_F +@simple +def firm_profit_F(Y_F, alpha_F, mu_p_F, mc_F): + # See firm_profit_D. + profit_F = (1.0 - mu_p_F * mc_F) * (1.0 - alpha_F) * Y_F + return profit_F + + +@simple +def price_nkpc_F(pi_F, mc_F, mu_p_F, kappa_p_F, beta_F): + # See price_nkpc_D. + nkpc_p_res_F = pi_F - beta_F * pi_F(+1) - kappa_p_F * (mu_p_F * mc_F - 1.0) + return nkpc_p_res_F + + + +@simple +def collateral_quality_F(Delta_bF_F, Delta_bD_F, psi_lambda_B_F, + def_rate_F, def_rate_D): + """F-side mirror of collateral_quality_D — see that block for the derivation, + the domain argument and the exact local-slope property.""" + slack_bF_F = 1.0 - Delta_bF_F + slack_bD_F = 1.0 - Delta_bD_F + z_bF_F = psi_lambda_B_F * def_rate_F(+1) / slack_bF_F + z_bD_F = psi_lambda_B_F * def_rate_D(+1) / slack_bD_F + Delta_bF_eff_F = Delta_bF_F + slack_bF_F * (z_bF_F / (1.0 + z_bF_F)) + Delta_bD_eff_F = Delta_bD_F + slack_bD_F * (z_bD_F / (1.0 + z_bD_F)) + return Delta_bF_eff_F, Delta_bD_eff_F @simple def intermediation_IC_F(nu_K_F, nu_bF_F, nu_bD_F, eta_F, Q_F, K_F, q_b_F, q_b_D, b_F_F, b_D_F, n_inter_F, - lambda_gk_F, Delta_bF_F, Delta_bD_F, theta_F, p, - def_rate_F, def_rate_D, psi_lambda_B_F, omega_K_F, + lambda_gk_F, theta_F, p, + Delta_bF_eff_F, Delta_bD_eff_F, omega_K_F, fund_rule_F, K_fund_F): K_bank_F = ((1.0 - fund_rule_F) * omega_K_F * K_F + fund_rule_F * (K_F - K_fund_F)) @@ -263,8 +343,9 @@ def intermediation_IC_F(nu_K_F, nu_bF_F, nu_bD_F, eta_F, phi_bF_F = q_b_F * b_F_F / (p * n_inter_F) phi_bD_F = q_b_D * b_D_F / (p * n_inter_F) # GK multi-asset IC — see intermediation_IC_D for derivation. - Delta_bF_eff = Delta_bF_F + psi_lambda_B_F * def_rate_F(+1) - Delta_bD_eff = Delta_bD_F + psi_lambda_B_F * def_rate_D(+1) + # Delta_*_eff_F now arrive from collateral_quality_F (bounded map). + Delta_bF_eff = Delta_bF_eff_F + Delta_bD_eff = Delta_bD_eff_F value_F = nu_K_F * kappa_F + nu_bF_F * phi_bF_F + nu_bD_F * phi_bD_F + eta_F theta_tgt_F = (value_F / lambda_gk_F + (1 - Delta_bF_eff) * phi_bF_F @@ -273,7 +354,7 @@ def intermediation_IC_F(nu_K_F, nu_bF_F, nu_bD_F, eta_F, return ic_res_F @simple -def bank_return_F(theta_F, rk_F, rdep_F, b_F_F, b_D_F, n_inter_F, +def bank_return_F(theta_F, rk_F, rdep_expost_F, b_F_F, b_D_F, n_inter_F, rb_actual_F, rb_actual_D, q_b_F, q_b_D, p): phi_bF_lag_F = q_b_F(-1) * b_F_F(-1) / (p(-1) * n_inter_F(-1)) phi_bD_lag_F = q_b_D(-1) * b_D_F(-1) / (p(-1) * n_inter_F(-1)) @@ -284,34 +365,59 @@ def bank_return_F(theta_F, rk_F, rdep_F, b_F_F, b_D_F, n_inter_F, rb_F_fg = (1 + rb_actual_F) * p(-1) / p - 1 rb_D_fg = (1 + rb_actual_D) * p(-1) / p - 1 # T-2 fix: funding cost on the t-1 balance sheet is the rate locked at t-1. - rn_F = (kappa_lag_F * (rk_F - rdep_F(-1)) - + phi_bF_lag_F * (rb_F_fg - rdep_F(-1)) - + phi_bD_lag_F * (rb_D_fg - rdep_F(-1)) - + rdep_F(-1)) + # Under nominal deposits that realised real cost is rdep_expost_F, which + # already carries the (-1) timing internally and contains the inflation + # surprise -- the Fisher revaluation on the bank's nominal liabilities. + rn_F = (kappa_lag_F * (rk_F - rdep_expost_F) + + phi_bF_lag_F * (rb_F_fg - rdep_expost_F) + + phi_bD_lag_F * (rb_D_fg - rdep_expost_F) + + rdep_expost_F) return rn_F @simple -def capital_fund_F(rk_F, rdep_F, Q_F, K_F, omega_K_F, fund_rule_F, K_fund_F): - # Passive capital fund funded by deposits; rebates its spread (rk - rdep) on the - # lagged capital value to households (F-goods). Zero when the fund is empty. +def capital_fund_F(rk_F, rdep_expost_F, Q_F, K_F, omega_K_F, fund_rule_F, K_fund_F): + # Passive capital fund funded by deposits; rebates its spread on the lagged + # capital value to households (F-goods). Same predetermined-rate timing as + # bank_return_F (T-2); rdep_expost_F is the realised real funding cost under + # nominal deposits. Zero when the fund is empty. # fund_rule_F: 0 = fund holds (1-omega_K)·K, 1 = fund holds a constant K_fund. K_fnd_lag_F = ((1.0 - fund_rule_F) * (1.0 - omega_K_F) * K_F(-1) + fund_rule_F * K_fund_F) - div_fund_F = (rk_F - rdep_F(-1)) * Q_F(-1) * K_fnd_lag_F + div_fund_F = (rk_F - rdep_expost_F) * Q_F(-1) * K_fnd_lag_F return div_fund_F @simple -def intermediation_P1_F(rk_F, rb_actual_F, rb_actual_D, rdep_F, +def intermediation_P1_F(rk_F, rb_exp_F, rb_exp_D, rdep_F, nu_K_F, nu_bF_F, nu_bD_F, eta_F, - lambda_gk_F, theta_F, SDF_banker_F, f_F): + lambda_gk_F, theta_F, SDF_banker_F, f_F, p): + """F bank's Bellman envelope. Mirror of ``intermediation_P1_D``. + + Expected default loss enters through ``rb_exp_F`` / ``rb_exp_D`` and nowhere else. + + UNITS FIX (2026-08-18). Both bond books are D-GOOD claims (``intermediation_P3_F`` + and ``k_balance_sheet_F`` divide the whole bond block by ``p``; ``government_ss_F`` + divides F's own coupon and issuance by ``p``), while ``rk_F``, ``rdep_F`` and the F + bank's net worth are F goods. The expected D-good return therefore has to be + converted, exactly as ``bank_return_F`` converts the realised one with ``p(-1)/p`` + (the W-2 fix). Forward, that conversion is ``p / p(+1)``. + + Before this fix ``intermediation_P1_F`` compared an unconverted ``rb_actual_F(+1)`` + with ``rdep_F`` while the old cross-border block applied ``p/p(+1)`` to the same + return — the two sides of the F bank's problem were on different unit conventions, + and every downstream condition stated on ``nu_bF_F`` inherited the discrepancy. SS is + untouched: ``p`` is constant at SS, so both conversions are exactly 1. + """ Omega_p1_F = f_F + (1 - f_F) * lambda_gk_F * theta_F(+1) + rb_F_fg = (1.0 + rb_exp_F(+1)) * p / p(+1) - 1.0 + rb_D_fg = (1.0 + rb_exp_D(+1)) * p / p(+1) - 1.0 # T-2 fix: the deposit rate for the t->t+1 holding period is rdep_F (locked at t). - nu_K_res_F = nu_K_F - SDF_banker_F * Omega_p1_F * (rk_F(+1) - rdep_F) - nu_bF_res_F = nu_bF_F - SDF_banker_F * Omega_p1_F * (rb_actual_F(+1) - rdep_F) - nu_bD_res_F = nu_bD_F - SDF_banker_F * Omega_p1_F * (rb_actual_D(+1) - rdep_F) + nu_K_res_F = nu_K_F - SDF_banker_F * Omega_p1_F * (rk_F(+1) - rdep_F) + nu_bF_res_F = nu_bF_F - SDF_banker_F * Omega_p1_F * (rb_F_fg - rdep_F) + nu_bD_res_F = nu_bD_F - SDF_banker_F * Omega_p1_F * (rb_D_fg - rdep_F) eta_res_F = eta_F - SDF_banker_F * Omega_p1_F * (1 + rdep_F) - return nu_K_res_F, nu_bF_res_F, nu_bD_res_F, eta_res_F + # Exported for gk_cross_border_foc -- see intermediation_P1_D. + return nu_K_res_F, nu_bF_res_F, nu_bD_res_F, eta_res_F, Omega_p1_F @simple def k_balance_sheet_F(Q_F, theta_F, n_inter_F, K_F, b_F_F, b_D_F, q_b_F, q_b_D, p, omega_K_F, @@ -357,30 +463,6 @@ def intermediation_P3_F(Q_F, K_F, n_inter_F, b_F_F, b_D_F, q_b_F, q_b_D, p): D_supply_F = Q_F * K_F + (q_b_F * b_F_F + q_b_D * b_D_F) / p - n_inter_F return D_supply_F -@simple -def bond_price_ss_F(SDF_banker_F, def_rate_F, recovery_rate_F, delta_b_F, zeta_writeoff_F, writeoff_enabled_F): - haircut_F = 1.0 - recovery_rate_F - haircut_mult_F = writeoff_enabled_F - surv_cont_F = 1.0 - zeta_writeoff_F * def_rate_F * haircut_F * haircut_mult_F - q_b_F = ( - SDF_banker_F * delta_b_F * (1.0 - def_rate_F * haircut_F * haircut_mult_F) - / (1.0 - SDF_banker_F * (1.0 - delta_b_F) * surv_cont_F) - ) - return q_b_F - - -@simple -def domestic_bond_foc_F(rb_actual_F, rdep_F, b_F_F, n_inter_F, q_b_F, - phi_bF_F_ss, psi_bF_F, excess_return_bF_F_ss, tau_mp_F, p): - phi_bF_F = q_b_F * b_F_F / (p * n_inter_F) - # Expected F-good return on D-good bond: (1+rb)·p/p(+1) − 1 - rb_F_fg_next = (1 + rb_actual_F(+1)) * p / p(+1) - 1 - rb_F_res = (rb_F_fg_next - rdep_F(+1)) - excess_return_bF_F_ss \ - - psi_bF_F * (phi_bF_F - phi_bF_F_ss) \ - - tau_mp_F - return rb_F_res - - @simple def government_default_F(shock_def_F, b_gov_F, Y_ss_F, b_gov_ss_F, def_scale_F, def_curvature_F, def_offset_F): @@ -422,25 +504,28 @@ def budget_residual_F(b_gov_F, G_F, TAX_F, q_b_F, def_rate_F, recovery_rate_F, z @simple -def divert_bond_foc_F(rb_actual_F, rdep_F, b_F_F, n_inter_F, q_b_F, - phi_bF_F_ss, psi_bF_F, excess_return_bF_F_ss, tau_mp_F, p, - psi_spread_F, EL_price_F, def_rate_F): - phi_bF_F = q_b_F * b_F_F / (p * n_inter_F) - # IC-theory derived required spread: additive default loading independent of SS excess return. - # psi_spread_F = lambda_gk_F * psi_lambda_B_F / (beta_inter_F * Omega_F), computed in _apply_ss_anchors. - # macro-pru-fix: EL_price_F is the fundamental expected-loss loading, independent of - # psi_lambda_B (see divert_bond_foc_D). SS-neutral. - req_spread = excess_return_bF_F_ss + (EL_price_F + psi_spread_F) * def_rate_F(+1) - # W-3 fix: expected F-good return on the D-good-denominated bond converts with - # p/p(+1), as in domestic_bond_foc_F and divert_portfolio_adj. - rb_F_fg_next = (1 + rb_actual_F(+1)) * p / p(+1) - 1 - # T-2 fix: compare t+1 bond return with rdep_F locked at t. - rb_F_res = (rb_F_fg_next - rdep_F) - req_spread \ - - psi_bF_F * (phi_bF_F - phi_bF_F_ss) \ - - tau_mp_F +def gk_bond_foc_F(nu_bF_F, nu_K_F, Delta_bF_eff_F): + """GK portfolio optimality for the F bank's OWN sovereign. This PINS ``q_b_F``. + + Mirror of ``gk_bond_foc_D`` — see that block for the derivation. Renamed from + ``divert_bond_foc_F``, which is deleted along with its ``psi_spread_F`` / + ``excess_return_bF_F_ss`` / ``tau_mp_F`` wedges. + + Stating the condition on the marginal values rather than on returns also removes a + unit inconsistency: the old block converted ``rb_actual_F`` with ``p/p(+1)`` while + ``intermediation_P1_F`` defined ``nu_bF_F`` off an UNCONVERTED return. The conversion + now lives in exactly one place, ``intermediation_P1_F``, and everything stated on the + ``nu``s inherits it. + + German bond pricing stays fully structural. There is no F-side spread wedge of any + kind, so a Greek shock reaches ``q_b_F`` only through the F bank's own optimality — + flight to quality, not an assumed safe-haven premium. + """ + rb_F_res = nu_bF_F - Delta_bF_eff_F * nu_K_F return rb_F_res + @simple def welfare_agg_F(X_F, C_F_ss): # GHH utility composite normalised by SS consumption. diff --git a/code/equations_global.py b/code/equations_global.py index aedb1e6..bd611c8 100644 --- a/code/equations_global.py +++ b/code/equations_global.py @@ -1,34 +1,61 @@ from sequence_jacobian import simple +# ── COUNTRY SIZE (2026-08-07) ──────────────────────────────────────────────── +# `size_F` is F's size relative to D (Germany/Greece 2010 GDP = 11.697). Every +# F-side variable in the model is PER F CAPITA and stays O(1); D-side variables +# are aggregates (size_D == 1). The four blocks below are the only places the two +# countries meet, so they are the only places the weight appears. +# +# Why this exists: with both countries normalised to Y_ss = 1 the model could not +# match the EBA portfolio-composition moment (DE banks' Greek book / DE bank net +# worth = 0.0075) and the market-structure moment (foreigners hold 12.7% of the +# bank-held Greek stock) at the same time — joint consistency needs n_F/n_D = 8.85 +# against the model's 0.761, a gap that is exactly the GDP ratio. Under per-capita +# F variables plus this weight, both hold simultaneously. +# +# Watch the units: b_D_F and IM_F and b_gov_F are PER F CAPITA, so they carry a +# `* size_F` when they meet a D aggregate; b_F_D and IM_D are D aggregates, so +# they carry a `/ size_F` when they meet an F per-capita quantity. + + @simple -def trade_balance(p, IM_D, IM_F): - NX_D = IM_F - p * IM_D - NX_F = IM_D - IM_F / p +def trade_balance(p, IM_D, IM_F, size_F): + # D's exports = what F imports, aggregated over F: size_F * IM_F (D goods). + # D's imports = IM_D (F goods), valued at p. + NX_D = size_F * IM_F - p * IM_D + # F's exports per F capita = IM_D / size_F (D goods), valued at 1/p in F goods + # -- but IM_D is already in D goods, so the conversion is the /p on imports. + NX_F = IM_D / size_F - IM_F / p return NX_D, NX_F @simple -def external_account_D(NX_D, q_b_D, q_b_F, b_F_D, b_D_F,rb_actual_F, rb_actual_D): +def external_account_D(NX_D, q_b_D, q_b_F, b_F_D, b_D_F, rb_actual_F, rb_actual_D, + size_F): + # b_F_D is a D aggregate; b_D_F is per F capita and must be aggregated. receipts_from_F_bonds = (1 + rb_actual_F) * q_b_F(-1) * b_F_D(-1) - payments_on_D_bonds = (1 + rb_actual_D) * q_b_D(-1) * b_D_F(-1) - nfa_D = q_b_F * b_F_D - q_b_D * b_D_F - ca_res_D = (NX_D + receipts_from_F_bonds - payments_on_D_bonds- nfa_D) + payments_on_D_bonds = (1 + rb_actual_D) * q_b_D(-1) * size_F * b_D_F(-1) + nfa_D = q_b_F * b_F_D - q_b_D * size_F * b_D_F + ca_res_D = (NX_D + receipts_from_F_bonds - payments_on_D_bonds - nfa_D) return nfa_D, ca_res_D @simple -def global_goods_mkt(goods_mkt_D, goods_mkt_F, p): - global_goods_res = goods_mkt_D + p * goods_mkt_F +def global_goods_mkt(goods_mkt_D, goods_mkt_F, p, size_F): + # goods_mkt_F is per F capita; the union-wide residual needs it aggregated. + global_goods_res = goods_mkt_D + p * size_F * goods_mkt_F return global_goods_res @simple -def domestic_bond_clearing(b_gov_D, b_gov_F, b_D_F, b_F_D): - b_D_D = b_gov_D - b_D_F - b_F_F = b_gov_F - b_F_D +def domestic_bond_clearing(b_gov_D, b_gov_F, b_D_F, b_F_D, size_F): + # D debt (aggregate) is held by D banks plus size_F F-banks' per-capita books. + b_D_D = b_gov_D - size_F * b_D_F + # F debt is per F capita; the D aggregate holding is spread over size_F. + b_F_F = b_gov_F - b_F_D / size_F return b_D_D, b_F_F @@ -44,6 +71,33 @@ def bond_yield(q_b_D, q_b_F, delta_b_D, delta_b_F): return rb_D, rb_F, spread_rb +@simple +def terms_of_trade(p, pi_D, pi_F): + # p = P_F/P_D in euro producer prices. In a monetary union the nominal + # exchange rate is fixed at 1, so terms-of-trade movement IS the inflation + # differential. This pins pi_D - pi_F off an unknown that already exists. + # Zero at SS: p/p(-1) = 1 and pi_D = pi_F = 0. + tot_res = p / p(-1) - (1.0 + pi_F) / (1.0 + pi_D) + return tot_res + + +@simple +def union_inflation(pi_D, pi_F, omega_pi_D): + # The ECB stabilises union-wide producer-price inflation -- the phi_pi -> inf + # limit of a Taylor rule, stated as an abstraction and NOT a modelled rule. + # Financial contracts carry no policy rate, so no Fisher relation is needed + # to close the nominal side. + # + # With terms_of_trade this gives pi_D = -(1 - omega_pi_D)*dlog p. At the + # capital-key omega_pi_D = 0.071, 93% of any terms-of-trade adjustment is D + # producer-price deflation and 7% is F inflation -- the 2010-12 internal- + # devaluation pattern. Do NOT use model GDP weights: the model normalises + # Y_D_ss ~ Y_F_ss ~ 1, so those would give ~0.5 and split it evenly. + pi_U = omega_pi_D * pi_D + (1.0 - omega_pi_D) * pi_F + union_pi_res = pi_U + return pi_U, union_pi_res + + @simple def portfolio_level_anchors(b_F_D_anchor, b_D_F_anchor): b_F_D_ss = b_F_D_anchor @@ -52,49 +106,62 @@ def portfolio_level_anchors(b_F_D_anchor, b_D_F_anchor): @simple -def portfolio_adj_cost(rb_actual_F, rb_actual_D, rdep_D, rdep_F, - b_F_D, b_D_F, - b_F_D_ss, b_D_F_ss, - psi_bF_D, psi_bD_F, - excess_return_F_D_ss, excess_return_D_F_ss, - tau_mp_D, tau_mp_F, p): - # Level penalty on face-value bond stocks anchors the external position level, - # not only its composition relative to net worth. - # Expected D-good return on F-bonds: (1+rb_F)·p(+1)/p − 1 - rb_F_dg_next = (1 + rb_actual_F(+1)) * p(+1) / p - 1 - b_F_D_res = (rb_F_dg_next - rdep_D(+1)) - excess_return_F_D_ss \ - - psi_bF_D * (b_F_D - b_F_D_ss) \ - - tau_mp_D - - # Expected F-good return on D-bonds: (1+rb_D)·p/p(+1) − 1 - rb_D_fg_next = (1 + rb_actual_D(+1)) * p / p(+1) - 1 - b_D_F_res = (rb_D_fg_next - rdep_F(+1)) - excess_return_D_F_ss \ - - psi_bD_F * (b_D_F - b_D_F_ss) \ - - tau_mp_F - - return b_F_D_res, b_D_F_res - +def gk_cross_border_foc(nu_bF_D, nu_K_D, Delta_bF_eff_D, SDF_banker_D, Omega_p1_D, + nu_bD_F, nu_K_F, Delta_bD_eff_F, SDF_banker_F, Omega_p1_F, + b_F_D, b_D_F, b_F_D_ss, b_D_F_ss, psi_bF_D, psi_bD_F, + gk_wedge_F_D_ss, gk_wedge_D_F_ss): + """GK portfolio optimality on the two CROSS-BORDER sovereign legs. + + Replaces ``divert_portfolio_adj`` (and the never-wired ``portfolio_adj_cost``), which + required + + (r_cross(+1) - rdep) = excess_return_*_ss + + (EL_price_issuer + psi_spread_holder) * def_rate_issuer(+1) + - psi * (b - b_ss) - tau_mp. + + THE DOUBLE-COUNT THAT IS NOW GONE. From 2026-08-17 the expected loss also sat inside + ``intermediation_P1_D/F``, so ``b_F_D`` and ``b_D_F`` had ``EL * def_rate`` netted off + TWICE — once in the ``nu`` that values the position and again in the block that chose + it — while the two own-sovereign legs netted it once. Both cross-border legs now read + the same ``nu``s the own legs do, so the loss enters exactly once on all four legs. + ``psi_spread_D/F`` and the ``excess_return_*_ss`` anchors are deleted outright. + + THE CONDITION. Same first-order condition as ``gk_bond_foc_D``, plus a quadratic + portfolio adjustment cost on the cross-border stock: + + nu_bF_D / nu_K_D = Delta_bF_eff_D + (adjustment cost). + + Why the cross legs need the cost and the own legs do not: under a binding IC with + linear payoffs the banker's problem is linear in portfolio shares, so four + proportionality conditions cannot hold simultaneously against only two bond prices. + The own-sovereign legs pin ``q_b_D`` and ``q_b_F``; the cross-border legs then pin + QUANTITIES, with ``psi_bF_D``/``psi_bD_F`` as the frictions that make an interior + cross-border position optimal at all. This is the standard portfolio-cost device and + it is NOT a sovereign-spread wedge: it loads on the bond STOCK gap, carries no + ``def_rate`` term, and is identically zero at the calibrated position. + + UNITS. The residual is divided through by ``SDF_banker * Omega`` so it is stated in + RETURN units, which is what ``psi_bF_D``/``psi_bD_F`` were calibrated against; the + division is exact and changes no root (both factors are strictly positive — see + ``steady_state.assert_gk_well_posed``). Ordering the terms this way also keeps + ``psi_bF_D`` comparable with its pre-refactor value. + + ``gk_wedge_*_ss`` is the CONSTANT steady-state shadow cost of holding the + EBA-measured cross-border position — the level at which the adjustment cost sits at + the calibrated stock. It is a scalar set once in ``_apply_ss_anchors``, it does not + move with ``def_rate``, and at the preferred calibration it is ~0 because GK + optimality at a riskless symmetric steady state forces the cross divertability to + equal the own one. ``steady_state`` prints it and asserts it stays small precisely so + an inconsistent ``Delta`` cannot hide inside it. + """ + # D bank holding F bonds. No terms-of-trade conversion: q_b_F is a D-GOOD price + # (intermediation_P3_D, external_account_D), so nu_bF_D is already in D goods. + foc_F_D = (nu_bF_D - Delta_bF_eff_D * nu_K_D) / (SDF_banker_D * Omega_p1_D) + b_F_D_res = foc_F_D - gk_wedge_F_D_ss - psi_bF_D * (b_F_D - b_F_D_ss) + + # F bank holding D bonds. The p/p(+1) conversion into F goods already happened + # inside intermediation_P1_F, so nu_bD_F and nu_K_F are both F-good objects here. + foc_D_F = (nu_bD_F - Delta_bD_eff_F * nu_K_F) / (SDF_banker_F * Omega_p1_F) + b_D_F_res = foc_D_F - gk_wedge_D_F_ss - psi_bD_F * (b_D_F - b_D_F_ss) -@simple -def divert_portfolio_adj(rb_actual_F, rb_actual_D, rdep_D, rdep_F, p, - b_F_D, b_D_F, b_F_D_ss, b_D_F_ss, psi_bF_D, psi_bD_F, - excess_return_F_D_ss, excess_return_D_F_ss, tau_mp_D, tau_mp_F, - psi_spread_D, psi_spread_F, EL_price_D, EL_price_F, def_rate_D, def_rate_F): - # D holds F-bonds (F-good claim -> convert with p); issuer = F - rb_F_dg_next = (1 + rb_actual_F(+1)) * p(+1) / p - 1 - # IC-theory derived required premium: D-bank IC parameters govern D-bank's FOC on F-bonds - # macro-pru-fix: EL_price_F = fundamental expected-loss loading on F-bonds (issuer=F), - # independent of psi_lambda_B. See divert_bond_foc_D. - prem_FD = excess_return_F_D_ss + (EL_price_F + psi_spread_D) * def_rate_F(+1) - # T-2 fix: deposit rate for the t->t+1 holding period is locked at t (rdep, not rdep(+1)). - b_F_D_res = (rb_F_dg_next - rdep_D) - prem_FD \ - - psi_bF_D * (b_F_D - b_F_D_ss) - tau_mp_D - # F holds D-bonds (D-good claim -> convert with p); issuer = D - rb_D_fg_next = (1 + rb_actual_D(+1)) * p / p(+1) - 1 - # IC-theory derived required premium: F-bank IC parameters govern F-bank's FOC on D-bonds - # macro-pru-fix: EL_price_D = fundamental expected-loss loading on D-bonds (issuer=D), - # independent of psi_lambda_B. See divert_bond_foc_D. - prem_DF = excess_return_D_F_ss + (EL_price_D + psi_spread_F) * def_rate_D(+1) - b_D_F_res = (rb_D_fg_next - rdep_F) - prem_DF \ - - psi_bD_F * (b_D_F - b_D_F_ss) - tau_mp_F return b_F_D_res, b_D_F_res diff --git a/code/full_model.py b/code/full_model.py index bc16a4e..11f14c5 100644 --- a/code/full_model.py +++ b/code/full_model.py @@ -9,30 +9,139 @@ intermediation_IC_D, bank_return_D, capital_fund_D, intermediation_P1_D, k_balance_sheet_D, cap_adj_cost_inter_D, macro_pru_tax_D, intermediation_P2_D, banker_div_res_D, intermediation_P3_D, - government_default_D, divert_bond_foc_D, + government_default_D, gk_bond_foc_D, collateral_quality_D, tax_rule_D, capital_producer_profit_D, budget_residual_D, - ces_price_D, import_demand_D, deposit_return_D, + ces_price_D, import_demand_D, deposit_rates_D, deposit_return_D, bond_return_D, sdf_D, sdf_banker_ss_D, sdf_banker_D, ghh_composite_D, - welfare_agg_D, market_clearing_D, + welfare_agg_D, market_clearing_D, hh_extended_D, + price_nkpc_D, firm_profit_D, ) from equations_F import ( capital_adj_F, labor_F, labor_market_F, labor_demand_F, intermediation_IC_F, bank_return_F, capital_fund_F, intermediation_P1_F, k_balance_sheet_F, cap_adj_cost_inter_F, macro_pru_tax_F, intermediation_P2_F, banker_div_res_F, intermediation_P3_F, - government_default_F, divert_bond_foc_F, + government_default_F, gk_bond_foc_F, collateral_quality_F, tax_rule_F, capital_producer_profit_F, budget_residual_F, - ces_price_F, import_demand_F, deposit_return_F, + ces_price_F, import_demand_F, deposit_rates_F, deposit_return_F, bond_return_F, sdf_F, sdf_banker_ss_F, sdf_banker_F, ghh_composite_F, - welfare_agg_F, market_clearing_F, + welfare_agg_F, market_clearing_F, hh_extended_F, + price_nkpc_F, firm_profit_F, ) from equations_global import ( trade_balance, domestic_bond_clearing, - portfolio_level_anchors, divert_portfolio_adj, bond_yield, + portfolio_level_anchors, gk_cross_border_foc, bond_yield, global_goods_mkt, external_account_D, + terms_of_trade, union_inflation, ) +def build_block_list(financial_solved_D, financial_solved_F, *, + hh_D=None, hh_F=None, overrides=None): + """The single definition of the model's block list. + + Every consumer (full_model, tpi, diagnostics/regimes) calls this. A second + copy of the list is how the retired audit_artifacts/ harness drifted into + silently testing a different model — see CLAUDE.md. + + financial_solved_D/F : the runtime-constructed GK solved blocks. + hh_D/hh_F : optionally REPLACE the household blocks with versions + carrying extra hetoutputs (experiments/e4_distribution + adds per-decile consumption). + overrides : {name: block} used by the TPI layer to swap in its + _tpi variants without keeping a second list. + """ + o = overrides or {} + hh_D = hh_extended_D if hh_D is None else hh_D + hh_F = hh_extended_F if hh_F is None else hh_F + + def pick(name, default): + return o.get(name, default) + + return [ + # Country D + deposit_rates_D, deposit_return_D, tax_rule_D, hh_D, ghh_composite_D, + sdf_D, sdf_banker_D, government_default_D, + collateral_quality_D, financial_solved_D, + bond_return_D, bank_return_D, capital_fund_D, cap_adj_cost_inter_D, macro_pru_tax_D, + intermediation_P2_D, intermediation_P3_D, k_balance_sheet_D, + capital_adj_D, capital_producer_profit_D, + pick('budget_residual_D', budget_residual_D), + labor_D, labor_market_D, labor_demand_D, banker_div_res_D, + firm_profit_D, price_nkpc_D, + market_clearing_D, welfare_agg_D, + # Country F + deposit_rates_F, deposit_return_F, tax_rule_F, hh_F, ghh_composite_F, + sdf_F, sdf_banker_F, government_default_F, + collateral_quality_F, financial_solved_F, + bond_return_F, bank_return_F, capital_fund_F, cap_adj_cost_inter_F, macro_pru_tax_F, + intermediation_P2_F, intermediation_P3_F, k_balance_sheet_F, + capital_adj_F, capital_producer_profit_F, + pick('budget_residual_F', budget_residual_F), + labor_F, labor_market_F, labor_demand_F, banker_div_res_F, + firm_profit_F, price_nkpc_F, + market_clearing_F, welfare_agg_F, + # Global + ces_price_D, import_demand_D, ces_price_F, import_demand_F, + trade_balance, + pick('external_account_D', external_account_D), + pick('domestic_bond_clearing', domestic_bond_clearing), + bond_yield, portfolio_level_anchors, gk_cross_border_foc, + gk_bond_foc_D, gk_bond_foc_F, global_goods_mkt, + terms_of_trade, union_inflation, + ] + + +def solve_jacobian_padded(model, ss, unknowns, targets, inputs, T, + Js=None, options=None): + """SSJ 1.0.0's ``Block.solve_jacobian`` with the missing H_Z rows restored. + + ``CombinedBlock._jacobian`` ends with + ``total_Js[original_outputs & total_Js.outputs, :]`` and only visits a block + whose inputs intersect the shock list. A target that is a pure function of + the solver's *own unknowns* is therefore never reached, and SSJ silently + returns an H_Z with fewer rows than H_U — numpy then raises a core-dimension + mismatch inside ``np.linalg.solve``. + + Four of the 27 sticky-price targets are exactly that case: ``nkpc_p_res_D/F`` + (functions of pi and mc), ``tot_res`` (p, pi_D, pi_F) and ``union_pi_res`` + (pi_D, pi_F) contain no ``Z_*`` or ``shock_def_*`` symbol anywhere. Their + H_Z rows are *identically* zero — dH/dZ at fixed unknowns is zero because + the shock never appears in the equation — so restoring them as zeros is + exact, not an approximation, and the flex-price limit is unaffected. + + Everything else below is SSJ's own algorithm, kept line-for-line in step + with ``Block.solve_jacobian`` so the two cannot drift. + """ + from sequence_jacobian import combine + from sequence_jacobian.classes.jacobian_dict import JacobianDict + + Js = {} if Js is None else Js + options = {} if options is None else options + + inputs = model.make_ordered_set(inputs) + unknowns = model.make_ordered_set(unknowns) + targets = model.make_ordered_set(targets) + actual_outputs, unknowns_as_outputs = model.process_outputs(ss, unknowns, None) + + Js = model.partial_jacobians(ss, inputs | unknowns, + (actual_outputs | targets) - unknowns, + T, Js, options) + H_Z = model.jacobian(ss, inputs, targets, T, Js, options) + H_U = model.jacobian(ss, unknowns, targets, T, Js, options) + + missing = [t for t in targets if t not in H_Z.outputs] + if missing: + print(f" [H_Z zero-pad] no direct shock loading, rows restored as zero: {missing}") + H_Z = JacobianDict({t: H_Z.nesteddict.get(t, {}) for t in targets}, + outputs=targets, inputs=inputs, T=T) + + U_Z = JacobianDict.unpack( + -np.linalg.solve(H_U.pack(T), H_Z.pack(T)), unknowns, inputs, T) + return combine([U_Z, model]).jacobian( + ss, inputs, unknowns_as_outputs | actual_outputs, T, Js, options) + + def build_and_solve(ss_results): sys.setrecursionlimit(5000) @@ -51,6 +160,8 @@ def build_and_solve(ss_results): 'eta_D': float(cali_D['eta_D']), 'theta_D': float(cali_D['theta_D'])}, targets=['nu_K_res_D', 'nu_bD_res_D', 'nu_bF_res_D', 'eta_res_D', 'ic_res_D'], + # NB Omega_p1_D is an extra OUTPUT of intermediation_P1_D (not an unknown); it is + # consumed by gk_cross_border_foc. solver='broyden_custom' ) financial_solved_F = combine([ @@ -66,44 +177,31 @@ def build_and_solve(ss_results): ) # ── Full dynamic model ──────────────────────────────────────────────────── - ha_full = sj.create_model([ - # Country D - deposit_return_D, tax_rule_D, hh_extended_D, ghh_composite_D, - sdf_D, sdf_banker_D, government_default_D, financial_solved_D, - bond_return_D, bank_return_D, capital_fund_D, cap_adj_cost_inter_D, macro_pru_tax_D, - intermediation_P2_D, intermediation_P3_D, k_balance_sheet_D, - capital_adj_D, capital_producer_profit_D, budget_residual_D, - labor_D, labor_market_D, labor_demand_D, banker_div_res_D, - market_clearing_D, welfare_agg_D, - # Country F - deposit_return_F, tax_rule_F, hh_extended_F, ghh_composite_F, - sdf_F, sdf_banker_F, government_default_F, financial_solved_F, - bond_return_F, bank_return_F, capital_fund_F, cap_adj_cost_inter_F, macro_pru_tax_F, - intermediation_P2_F, intermediation_P3_F, k_balance_sheet_F, - capital_adj_F, capital_producer_profit_F, budget_residual_F, - labor_F, labor_market_F, labor_demand_F, banker_div_res_F, - market_clearing_F, welfare_agg_F, - # Global - ces_price_D, import_demand_D, ces_price_F, import_demand_F, - trade_balance, external_account_D, domestic_bond_clearing, - bond_yield, portfolio_level_anchors, divert_portfolio_adj, - divert_bond_foc_D, divert_bond_foc_F, global_goods_mkt, - ], name="Full 2-Country MU HANK — GHH Preferences, Flex Price & Wage, No CB") + ha_full = sj.create_model( + build_block_list(financial_solved_D, financial_solved_F), + name="Full 2-Country MU HANK — GHH Preferences, Sticky Price, Flex Wage, No CB", + ) - # ── 23×23 system ────────────────────────────────────────────────────────── + # ── 27×27 system ────────────────────────────────────────────────────────── + # +4 vs the flex model: mc and pi per country. mc is pinned by the price + # NKPC, pi jointly by the terms-of-trade identity and the union-inflation + # normalisation. No targets are renamed or removed -- labor_mkt_res_D/F is + # unchanged because wages stay flexible. unknowns_tp = [ - 'K_D', 'n_inter_D', 'div_D', 'I_D', 'Q_D', 'b_gov_D', 'N_D', 'b_F_D', 'w_D', 'rdep_D', - 'K_F', 'n_inter_F', 'div_F', 'I_F', 'Q_F', 'b_gov_F', 'N_F', 'b_D_F', 'w_F', 'rdep_F', + 'K_D', 'n_inter_D', 'div_D', 'I_D', 'Q_D', 'b_gov_D', 'N_D', 'b_F_D', 'w_D', 'i_dep_D', + 'mc_D', 'pi_D', + 'K_F', 'n_inter_F', 'div_F', 'I_F', 'Q_F', 'b_gov_F', 'N_F', 'b_D_F', 'w_F', 'i_dep_F', + 'mc_F', 'pi_F', 'p', 'q_b_D', 'q_b_F', ] targets_tp = [ 'deposit_mkt_D', 'K_res_D', 'n_inter_val_D', 'div_res_D', 'capital_res_D', 'q_res_D', 'b_gov_res_D', 'b_F_D_res', - 'labor_mkt_res_D', 'w_res_D', + 'labor_mkt_res_D', 'w_res_D', 'nkpc_p_res_D', 'deposit_mkt_F', 'K_res_F', 'n_inter_val_F', 'div_res_F', 'capital_res_F', 'q_res_F', 'b_gov_res_F', 'b_D_F_res', - 'labor_mkt_res_F', 'w_res_F', - 'goods_mkt_D', 'rb_D_res', 'rb_F_res', + 'labor_mkt_res_F', 'w_res_F', 'nkpc_p_res_F', + 'goods_mkt_D', 'rb_D_res', 'rb_F_res', 'tot_res', 'union_pi_res', ] T = 500 @@ -116,15 +214,20 @@ def build_and_solve(ss_results): # ── Jacobian ────────────────────────────────────────────────────────────── exogenous = ['Z_D', 'shock_def_D', 'Z_F', 'shock_def_F'] print(f"Computing Jacobian G (T={T}, {len(exogenous)} exogenous inputs)...") - G = ha_full.solve_jacobian(ss_final, unknowns=unknowns_tp, targets=targets_tp, - inputs=exogenous, T=T) + G = solve_jacobian_padded(ha_full, ss_final, unknowns=unknowns_tp, + targets=targets_tp, inputs=exogenous, T=T) print("G computed successfully.") # ── Shocks ──────────────────────────────────────────────────────────────── - rho_Z_D = 0.8 + # Persistences come from the calibration (code/calibration.py, "Shock + # processes"). The literals below are the pre-2026-08-06 hardcoded values + # and are kept only as a fallback so any caller that hands build_and_solve + # an older calibration dict still runs. + rho_Z_D = float(calibration_start.get('rho_Z_D', 0.8)) dZ_D = 0.01 * rho_Z_D ** np.arange(T) - rho_def_D = 0.8 + rho_def_D = float(calibration_start.get('rho_def_D', 0.8)) dShock_def_D = 0.01 * rho_def_D ** np.arange(T) + print(f" shock persistences: rho_Z_D={rho_Z_D:.4f}, rho_def_D={rho_def_D:.4f}") irfs_Z_D = G @ { 'Z_D': dZ_D, 'Z_F': np.zeros(T), @@ -139,9 +242,16 @@ def build_and_solve(ss_results): print("\n=== Stability check: debt level at t=499 (should be near 0) ===") print(f" irfs_Z_D ['b_gov_D'][499] = {irfs_Z_D['b_gov_D'][499]:.6f}") print(f" irfs_def_D['b_gov_D'][499] = {irfs_def_D['b_gov_D'][499]:.6f}") + # ρ_b is PARTIAL-EQUILIBRIUM and DOES NOT decide stationarity. It omits + # def_scale_D — the debt->default feedback that does the amplifying — so it is + # systematically optimistic. Measured 2026-08-07 by sweeping phi_lamb_D on the + # live par rule: the true boundary is between 0.10 and 0.07, where this formula + # says 0.05. Do NOT pick phi_lamb_D from it; it lands you on a divergent + # calibration. b_gov_D[499] above is the check that means something. phi_lamb = calibration_start['phi_lamb_D'] - print(f" ρ_b (partial-eq.) = {round((0.953 * 0.95 + 0.05 - phi_lamb) / 0.953, 4)}" - " [target < 0.95]") + print(f" ρ_b (partial-eq., NOT the stationarity test) = " + f"{round((0.953 * 0.95 + 0.05 - phi_lamb) / 0.953, 4)}" + " [measured boundary: phi_lamb_D in (0.07, 0.10)]") # SSJ returns LEVEL deviations. Y_D_ss ~ 1 so Y_D*100 happens to read as a # percent, but n_inter_D_ss is not 1 (2.138 under BANK_SCOPE="broad", 3.0 # pre-EBA, 0.408 under CT1), so the raw x100 was NOT a percentage and was not @@ -149,6 +259,58 @@ def build_and_solve(ss_results): # the level deviation; the true impact is -3.38% of SS net worth. Both are # printed now: the level for continuity with the historical logs, the # percent-of-SS as the number to quote. + # ── Pledgeability stays in [0,1] along the SIMULATED PATH ───────────────── + # steady_state.report_gk_steady_state checks the SS level; this checks the + # deviations, which is where an unbounded collateral map would actually bite. + # collateral_quality_D/F's rational form is bounded in [Delta, 1) for + # def_rate(+1) >= 0, but the model is solved by LINEARISATION, so the path can + # leave that range even though the nonlinear map cannot. At psi_lambda_B = 0 the + # map is the identity and these are flat by construction. + print("\n=== Pledgeability along the default-shock path ===") + for k in ('Delta_bD_eff_D', 'Delta_bF_eff_D', 'Delta_bF_eff_F', 'Delta_bD_eff_F'): + if k not in irfs_def_D: + print(f" {k}: not in G.outputs (constant at this calibration)") + continue + path = float(ss_final[k]) + np.asarray(irfs_def_D[k]) + lo, hi = float(path.min()), float(path.max()) + print(f" {k}: [{lo:.6f}, {hi:.6f}] (SS {float(ss_final[k]):.6f})") + if not (0.0 <= lo and hi <= 1.0): + raise ValueError( + f"{k} leaves [0,1] on the simulated path: [{lo:.6f}, {hi:.6f}]. The " + f"collateral map is not interpretable as a pledgeable share there. " + f"Lower psi_lambda_B or shrink the shock — do not clip.") + + # ── Impact signs on BOTH shocks ─────────────────────────────────────────── + # The TFP row is new (2026-08-18), added because fig_irf_overview_macro.png LOOKS + # like Y_D collapses on impact under TFP. It does not — that plunge is the default + # shock's red dashed line, and the two are easy to confuse at that scale. Measured: + # Y_D -0.073% on a +1% TFP shock, i.e. flat, with N_D -3.71%, w_D -7.26% and + # I_D +5.00%. + # + # That is the STANDARD sticky-price contractionary-technology result (Gali 1999), + # not a defect: with prices sticky and the union-inflation normalisation being the + # phi_pi -> infinity limit (no accommodation whatever), firms meet unchanged demand + # with fewer hours. GHH preferences make labour supply very wage-elastic, which is + # why N and w move so much while Y barely does. The threshold below is set at 0.5% + # so it flags a genuinely perverse output response rather than this near-zero one. + print("\n=== Impact signs (t=0, % of own SS) ===") + print(f" {'variable':<12} {'+1% TFP':>12} {'+1pp default':>14}") + for k in ('Y_D', 'C_D', 'I_D', 'N_D', 'w_D', 'n_inter_D', 'K_D', 'q_b_D'): + if k not in irfs_Z_D or k not in irfs_def_D: + continue + ss_k = float(ss_final[k]) + print(f" {k:<12} {irfs_Z_D[k][0]/ss_k*100:>11.4f}% " + f"{irfs_def_D[k][0]/ss_k*100:>13.4f}%") + _y_tfp = irfs_Z_D['Y_D'][0] / float(ss_final['Y_D']) * 100 + if _y_tfp < -0.5: + print(f" ⚠ Y_D falls {_y_tfp:.4f}% on a POSITIVE TFP shock — beyond the " + f"near-zero impact the sticky-price mechanism explains. Investigate before " + f"quoting any impact-quarter number.") + elif _y_tfp < 0: + print(f" Y_D {_y_tfp:+.4f}% on a +1% TFP shock: flat on impact, hours-driven " + f"(N_D {irfs_Z_D['N_D'][0]/float(ss_final['N_D'])*100:+.2f}%). Expected " + f"under sticky prices with no monetary accommodation; not a defect.") + n0, Y0 = irfs_def_D['n_inter_D'][0], irfs_def_D['Y_D'][0] n_ss, Y_ss = float(ss_final['n_inter_D']), float(ss_final['Y_D']) print(f" n_inter_D[0] on default shock = {n0/n_ss*100:+.4f}% of SS" @@ -172,7 +334,3 @@ def build_and_solve(ss_results): 'irfs_def_D': irfs_def_D, } - -# Needed by full_model.py and tpi.py: import the hh_extended blocks -from equations_D import hh_extended_D # noqa: F401 (re-export for tpi.py) -from equations_F import hh_extended_F # noqa: F401 diff --git a/code/steady_state.py b/code/steady_state.py index 204e2d5..e5c16b8 100644 --- a/code/steady_state.py +++ b/code/steady_state.py @@ -7,27 +7,30 @@ hh_init_D, hh_D, make_grids_D, income_D, hh_extended_D, smart_steady_D, market_clearing_D, steady_auxilliary_D, banker_div_D, sdf_D, sdf_ss_D, sdf_banker_ss_D, government_ss_D, labor_ss_D, - government_default_D, bond_price_ss_D, bond_return_D, - ces_price_D, import_demand_D, deposit_return_D, + government_default_D, bond_return_D, collateral_quality_D, gk_bond_foc_D, + ces_price_D, import_demand_D, deposit_rates_D, deposit_return_D, + firm_profit_D, price_nkpc_D, ) from equations_F import ( hh_init_F, hh_F, make_grids_F, income_F, hh_extended_F, smart_steady_F, market_clearing_F, steady_auxilliary_F, banker_div_F, sdf_F, sdf_ss_F, sdf_banker_ss_F, government_ss_F, labor_ss_F, - government_default_F, bond_price_ss_F, bond_return_F, - ces_price_F, import_demand_F, deposit_return_F, + government_default_F, bond_return_F, collateral_quality_F, gk_bond_foc_F, + ces_price_F, import_demand_F, deposit_rates_F, deposit_return_F, + firm_profit_F, price_nkpc_F, ) from equations_global import ( trade_balance, domestic_bond_clearing, - portfolio_level_anchors, portfolio_adj_cost, bond_yield, + portfolio_level_anchors, bond_yield, global_goods_mkt, external_account_D, + terms_of_trade, union_inflation, ) # NB: import the MODULE, not the flag. `from calibration import EBA_CALIBRATION` # binds the value at import time, so a sweep that flips the switch afterwards # would silently keep the old portfolio targets — the same stale-binding trap as # the regimes cache key and PSILAM_MAIN. import calibration -from calibration import load_eba_targets +from calibration import load_eba_targets, load_eba_foreign_shares def assert_gk_well_posed(ss_in): @@ -82,34 +85,147 @@ def min_Delta_own(theta, f, phi_own, phi_cross, Delta_cross, margin=0.0): return 1.0 - (f * theta - (1.0 - Delta_cross) * phi_cross - margin) / phi_own +def gk_cross_wedges(ss_in): + """SS shadow cost of the calibrated cross-border sovereign positions, in return units. + + ``(nu_cross/nu_K - Delta_cross_eff) * (rk - rdep)``, i.e. how far the EBA-measured + cross-border book sits from GK proportionality at the solved steady state. Enters + ``gk_cross_border_foc`` as a CONSTANT — it carries no ``def_rate`` and is not a spread + loading; it is the level at which the portfolio adjustment cost sits so that the + measured position is optimal. + + At a riskless steady state (``def_rate_ss = 0``) with ``rk_D = rk_F`` and + ``rdep_D = rdep_F``, GK optimality forces the cross divertability to equal the own + one, and these wedges vanish. They are printed and range-checked on every solve so an + inconsistent ``Delta_bF_D``/``Delta_bD_F`` cannot hide inside them — which is exactly + what the pre-refactor ``excess_return_F_D_ss`` / ``excess_return_D_F_ss`` anchors did, + since those absorbed the FULL excess return rather than its deviation from the FOC. + """ + exc_D = float(ss_in['rk_D']) - float(ss_in['rdep_D']) + exc_F = float(ss_in['rk_F']) - float(ss_in['rdep_F']) + w_FD = (float(ss_in['nu_bF_D']) / float(ss_in['nu_K_D']) + - float(ss_in['Delta_bF_eff_D'])) * exc_D + w_DF = (float(ss_in['nu_bD_F']) / float(ss_in['nu_K_F']) + - float(ss_in['Delta_bD_eff_F'])) * exc_F + return w_FD, w_DF + + +def report_gk_steady_state(ss_in, cal, tol_own=1e-9, tol_cross=5e-4): + """Print the GK/sovereign steady state and NUMERICALLY VERIFY the portfolio FOCs. + + The own-sovereign conditions ``nu_bD_D/nu_K_D = Delta_bD_eff_D`` and + ``nu_bF_F/nu_K_F = Delta_bF_eff_F`` are SS TARGETS, so they must hold to solver + tolerance; a failure means ``gk_bond_foc_D/F`` is no longer wired to ``q_b_D/q_b_F``. + The cross-border conditions are pinned by quantities the calibration takes from EBA, + so they hold only up to ``gk_wedge_*_ss``; that wedge is reported and range-checked + (``tol_cross``, on the dimensionless ``nu_cross/nu_K - Delta_cross_eff``) so an + inconsistent cross divertability cannot hide inside it. At the live calibration all + four Delta are 0.20 and the cross residual is ~2e-13. + """ + # Quarterly fraction -> annualised basis points. NOT 400: that is the *percentage-point* + # scaling, and mixing the two is how a 205bp spread gets reported as 2bp. + BP_ANN = 4.0e4 + g = lambda k: float(ss_in[k]) + rows = [] + for c, own, cross in (("D", "bD", "bF"), ("F", "bF", "bD")): + r_own = g(f"nu_{own}_{c}") / g(f"nu_K_{c}") + r_cr = g(f"nu_{cross}_{c}") / g(f"nu_K_{c}") + rows.append((c, r_own, g(f"Delta_{own}_eff_{c}"), r_cr, g(f"Delta_{cross}_eff_{c}"))) + + print("\n=== GK steady state: sovereign block ===") + print(f" {'':4} {'q_b':>9} {'yield q/q':>10} {'yield ann':>11} {'def_rate':>9} " + f"{'recovery':>9} {'EL_load':>9} {'delta_b':>8} {'duration':>9}") + for c in ("D", "F"): + q, db = g(f"q_b_{c}"), g(f"delta_b_{c}") + y = db * (1.0 / q - 1.0) + print(f" {c:4} {q:>9.6f} {y:>10.6f} {y*BP_ANN:>9.2f}bp {g(f'def_rate_{c}'):>9.4f} " + f"{cal[f'recovery_rate_{c}']:>9.2f} {g(f'EL_load_{c}'):>9.6f} " + f"{db:>8.4f} {1.0/db:>8.1f}q") + sp = g("delta_b_D") * (1.0 / g("q_b_D") - 1.0) - g("delta_b_F") * (1.0 / g("q_b_F") - 1.0) + print(f" SS spread (D-F) = {sp*BP_ANN:+.4f} bp annualised " + f"(zero is correct: def_rate_ss = 0 in both countries)") + + print(f"\n {'':4} {'lambda_gk':>10} {'Omega':>9} {'theta':>8} {'n_inter':>9} " + f"{'K':>9} {'q_b*b_own':>10} {'q_b*b_cross':>12}") + for c in ("D", "F"): + own_b = "b_D_D" if c == "D" else "b_F_F" + cr_b = "b_F_D" if c == "D" else "b_D_F" + own_q = "q_b_D" if c == "D" else "q_b_F" + cr_q = "q_b_F" if c == "D" else "q_b_D" + print(f" {c:4} {g(f'lambda_gk_{c}'):>10.4f} {g(f'Omega_{c}'):>9.4f} " + f"{g(f'theta_{c}'):>8.4f} {g(f'n_inter_{c}'):>9.4f} {g(f'K_{c}'):>9.4f} " + f"{g(own_q)*g(own_b):>10.4f} {g(cr_q)*g(cr_b):>12.4f}") + + print("\n Portfolio FOC check nu_i/nu_K vs Delta_i_eff") + print(f" {'bank':5} {'leg':7} {'nu_i/nu_K':>11} {'Delta_eff':>11} {'residual':>12} {'status':>8}") + ok = True + for c, r_own, D_own, r_cr, D_cr in rows: + for leg, r, D, tol in (("own", r_own, D_own, tol_own), ("cross", r_cr, D_cr, tol_cross)): + res = r - D + good = abs(res) <= tol + ok &= good + print(f" {c:5} {leg:7} {r:>11.6f} {D:>11.6f} {res:>12.3e} " + f"{'OK' if good else 'FAIL':>8}") + # Over-identifying check the spec asks for explicitly: nu_own/nu_cross must equal + # Delta_own/Delta_cross. Implied by the two rows above, printed because it is the + # form the portfolio condition is usually stated in. + for c, own, cross in (("D", "bD", "bF"), ("F", "bF", "bD")): + nu_r = g(f"nu_{own}_{c}") / g(f"nu_{cross}_{c}") + D_r = g(f"Delta_{own}_eff_{c}") / g(f"Delta_{cross}_eff_{c}") + print(f" {c:5} {'ratio':7} {nu_r:>11.6f} {D_r:>11.6f} {nu_r - D_r:>12.3e}") + w_FD, w_DF = gk_cross_wedges(ss_in) + print(f" cross-border SS wedges (constant, def_rate-free):" + f" F-in-D = {w_FD*BP_ANN:+.3e} bp/yr D-in-F = {w_DF*BP_ANN:+.3e} bp/yr") + for c in ("D", "F"): + for leg in ("bD", "bF"): + v = float(ss_in[f"Delta_{leg}_eff_{c}"]) + if not (0.0 <= v <= 1.0): + raise ValueError(f"Delta_{leg}_eff_{c} = {v} outside [0,1] at the steady state.") + if not ok: + raise ValueError( + "GK portfolio FOC violated at the steady state. The own-sovereign legs are " + "SS targets and must hold to solver tolerance; a cross-border failure means " + "the cross divertability Delta is inconsistent with the calibrated " + "cross-border position by more than gk_cross_wedges can legitimately absorb. " + "Do NOT paper over this with a spread parameter — fix Delta or the position.") + print(" psi_lambda_B = " + f"{cal['psi_lambda_B_D']:.4f}/{cal['psi_lambda_B_F']:.4f} " + f"zeta_writeoff = {cal['zeta_writeoff_D']:.1f}/{cal['zeta_writeoff_F']:.1f} " + f"writeoff_enabled = {cal['writeoff_enabled_D']:.1f}/{cal['writeoff_enabled_F']:.1f}") + + def _apply_ss_anchors(ss_in, cal): # Fires on every solved SS (this is the common path for steady_state.py and - # depreciation_calibration.py). Note psi_spread below divides by Omega, so a - # negative Omega would silently flip the sign of the collateral channel. + # depreciation_calibration.py). + # + # DELETED 2026-08-18 (structural GK refactor): + # psi_spread_D/F -- the free sovereign-spread loading. Deleted, not + # recalibrated: it existed only to absorb the + # principal/continuation loss that zeta_writeoff = 0 + # left out of the bond payoff. + # EL_price_D/F -- separate expected-loss PRICING wedge. The loss is now + # inside the payoff itself (bond_return_D/F -> rb_exp). + # The diagnostic loading survives as the endogenous + # EL_load_D/F, an OUTPUT of bond_return_D/F. + # excess_return_bD_D_ss -- own-sovereign SS excess-return anchors, consumed only + # excess_return_bF_F_ss by the deleted domestic_bond_foc_D/F. + # excess_return_F_D_ss -- cross-border anchors that absorbed the whole SS excess + # excess_return_D_F_ss return. Replaced by gk_cross_wedges, which absorbs + # only the DEVIATION from the GK portfolio FOC. assert_gk_well_posed(ss_in) + w_FD, w_DF = gk_cross_wedges(ss_in) anchors = { 'phi_bD_D_ss': float(ss_in['q_b_D']) * float(ss_in['b_D_D']) / float(ss_in['n_inter_D']), 'phi_bF_F_ss': float(ss_in['q_b_F']) * float(ss_in['b_F_F']) / (float(ss_in['p']) * float(ss_in['n_inter_F'])), 'b_F_D_anchor': float(ss_in['b_F_D']), 'b_D_F_anchor': float(ss_in['b_D_F']), - 'excess_return_bD_D_ss': float(ss_in['rb_actual_D']) - float(ss_in['rdep_D']) - cal['T0_D'], - 'excess_return_bF_F_ss': float(ss_in['rb_actual_F']) - float(ss_in['rdep_F']) - cal['T0_F'], - 'excess_return_F_D_ss': float(ss_in['rb_actual_F']) - float(ss_in['rdep_D']) - cal['T0_D'], - 'excess_return_D_F_ss': float(ss_in['rb_actual_D']) - float(ss_in['rdep_F']) - cal['T0_F'], - 'psi_spread_F': float(ss_in['lambda_gk_F']) * cal['psi_lambda_B_F'] - / (float(ss_in['beta_inter_F']) * float(ss_in['Omega_F'])), - 'psi_spread_D': float(ss_in['lambda_gk_D']) * cal['psi_lambda_B_D'] - / (float(ss_in['beta_inter_D']) * float(ss_in['Omega_D'])), - # macro-pru-fix: fundamental expected-loss loading per unit default probability, - # priced by bondholders independent of the psi_lambda_B collateral friction (so it - # survives psi_lambda_B=0). EL = (1-recovery)*[delta_b + zeta*(1-delta_b)*q_b]/q_b - # (SS q_b(-1)=q_b). Enters only the bond FOCs (∝ def_rate(+1)=0 at SS) → SS-neutral. - 'EL_price_D': (1.0 - cal['recovery_rate_D']) - * (cal['delta_b_D'] + cal['zeta_writeoff_D'] * (1.0 - cal['delta_b_D']) * float(ss_in['q_b_D'])) - / float(ss_in['q_b_D']), - 'EL_price_F': (1.0 - cal['recovery_rate_F']) - * (cal['delta_b_F'] + cal['zeta_writeoff_F'] * (1.0 - cal['delta_b_F']) * float(ss_in['q_b_F'])) - / float(ss_in['q_b_F']), + 'gk_wedge_F_D_ss': w_FD, + 'gk_wedge_D_F_ss': w_DF, + # Omega_{t+1} at SS. intermediation_P1_D/F export the dynamic Omega_p1; the SS + # model builds Omega in steady_auxilliary_D/F instead, and theta is constant at + # SS, so the two coincide exactly. gk_cross_border_foc needs it in ss_final. + 'Omega_p1_D': float(ss_in['Omega_D']), + 'Omega_p1_F': float(ss_in['Omega_F']), 'q_b_D': float(ss_in['q_b_D']), 'q_b_F': float(ss_in['q_b_F']), 'p': float(ss_in['p']), @@ -151,20 +267,36 @@ def _apply_ss_anchors(ss_in, cal): def solve_steady_state(calibration_start): ha = sj.create_model([ - sdf_ss_D, sdf_banker_ss_D, government_default_D, bond_price_ss_D, bond_return_D, - sdf_ss_F, sdf_banker_ss_F, government_default_F, bond_price_ss_F, bond_return_F, + sdf_ss_D, sdf_banker_ss_D, government_default_D, bond_return_D, gk_bond_foc_D, + sdf_ss_F, sdf_banker_ss_F, government_default_F, bond_return_F, gk_bond_foc_F, + # Pledgeability map. Nothing in the SS consumes Delta_*_eff_* (the SS uses + # steady_auxilliary_D/F, not intermediation_IC_D/F), but the DYNAMIC model does, + # so ss_final must carry them. Listed here rather than injected in + # _apply_ss_anchors so there is exactly one copy of the algebra -- a second copy + # is how audit_artifacts/ drifted (CLAUDE.md). SS-neutral: def_rate_ss = 0. + collateral_quality_D, collateral_quality_F, hh_extended_D, smart_steady_D, market_clearing_D, steady_auxilliary_D, - banker_div_D, government_ss_D, labor_ss_D, + banker_div_D, government_ss_D, labor_ss_D, firm_profit_D, price_nkpc_D, hh_extended_F, smart_steady_F, market_clearing_F, steady_auxilliary_F, - banker_div_F, government_ss_F, labor_ss_F, + banker_div_F, government_ss_F, labor_ss_F, firm_profit_F, price_nkpc_F, ces_price_D, import_demand_D, ces_price_F, import_demand_F, + deposit_rates_D, deposit_rates_F, deposit_return_D, deposit_return_F, bond_yield, trade_balance, external_account_D, global_goods_mkt, + terms_of_trade, union_inflation, ], name='MU HA Model 2 Country') - unknowns_ss = {'beta_D': 0.9850, 'beta_F': 0.9850, 'p': 0.99} - targets_ss = ['deposit_mkt_D', 'deposit_mkt_F', 'ca_res_D'] + # STAGE 2/3 (2026-08-17): q_b_D/q_b_F are now SS UNKNOWNS pinned by the GK + # portfolio FOC (rb_D_res / rb_F_res from divert_bond_foc_D/F), replacing + # bond_price_ss_D/F which priced off the banker's SDF alone and took no view on + # collateral quality -- the reason the old SS violated nu_bD_D/nu_K_D = Delta_bD_D + # (0.2491 vs 0.20). Cannot be a @simple block: q_b_D -> balance sheet -> K_D -> + # rk_D -> q_b_D is a cycle in the SS DAG, so it has to go through the solver. + unknowns_ss = {'beta_D': 0.9850, 'beta_F': 0.9850, 'p': 0.99, + 'q_b_D': 0.9749, 'q_b_F': 0.9663} + targets_ss = ['deposit_mkt_D', 'deposit_mkt_F', 'ca_res_D', + 'rb_D_res', 'rb_F_res'] # ── Initial SS solve ────────────────────────────────────────────────────── print("Solving initial steady state...") @@ -175,10 +307,11 @@ def solve_steady_state(calibration_start): 'phi_bF_F_ss': float(ss['q_b_F']) * float(ss['b_F_F']) / (float(ss['p']) * float(ss['n_inter_F'])), 'b_F_D_anchor': float(ss['b_F_D']), 'b_D_F_anchor': float(ss['b_D_F']), - 'excess_return_bD_D_ss': float(ss['rb_actual_D']) - float(ss['rdep_D']) - calibration_start['T0_D'], - 'excess_return_bF_F_ss': float(ss['rb_actual_F']) - float(ss['rdep_F']) - calibration_start['T0_F'], - 'excess_return_F_D_ss': float(ss['rb_actual_F']) - float(ss['rdep_D']) - calibration_start['T0_D'], - 'excess_return_D_F_ss': float(ss['rb_actual_D']) - float(ss['rdep_F']) - calibration_start['T0_F'], + # No excess_return_*_ss / psi_spread / EL_price anchors -- see _apply_ss_anchors. + 'gk_wedge_F_D_ss': gk_cross_wedges(ss)[0], + 'gk_wedge_D_F_ss': gk_cross_wedges(ss)[1], + 'Omega_p1_D': float(ss['Omega_D']), + 'Omega_p1_F': float(ss['Omega_F']), 'q_b_D': float(ss['q_b_D']), 'q_b_F': float(ss['q_b_F']), 'p': float(ss['p']), @@ -243,12 +376,39 @@ def solve_steady_state(calibration_start): q_D = float(ss['q_b_D']) q_F = float(ss['q_b_F']) - b_D_D_new = target_phi_bD_D * n_D / q_D - b_F_D_new = target_phi_bF_D * n_D / q_F - b_D_F_new = target_phi_bD_F * n_F / q_D - b_F_F_new = target_phi_bF_F * n_F / q_F - B_D_new = b_D_D_new + b_D_F_new - B_F_new = b_F_D_new + b_F_F_new + # COUNTRY SIZE (2026-08-07). Each phi is a ratio to its HOLDER's net worth, so + # every stock below is in its holder's own per-capita units -- which is exactly + # the convention the model now uses. The aggregation happens in + # domestic_bond_clearing via size_F, not here. + size_F = float(calibration_start['size_F']) + + b_D_D_new = target_phi_bD_D * n_D / q_D # D aggregate + b_F_D_new = target_phi_bF_D * n_D / q_F # D aggregate + b_D_F_new = target_phi_bD_F * n_F / q_D # per F capita + b_F_F_new = target_phi_bF_F * n_F / q_F # per F capita + + # Government stocks, each in ITS OWN country's units: + # D debt is a D aggregate; F debt is per F capita. + B_D_new = b_D_D_new + size_F * b_D_F_new + B_F_new = b_F_F_new + b_F_D_new / size_F + + # Over-identifying checks. The two EBA moments that the pre-size-asymmetry + # calibration could not satisfy jointly -- portfolio composition (the phi's, + # matched by construction above) and market structure (the foreign shares + # below). Both should now hold. B_supply_*_qgdp and the foreign shares are + # measured directly and were never used by the targeting, which is why the + # inconsistency went unnoticed for so long. + _eba_all = load_eba_targets() + _fs_eba = load_eba_foreign_shares() + fs_D_new = size_F * b_D_F_new / B_D_new + fs_F_new = (b_F_D_new / size_F) / B_F_new + print(f" size_F = {size_F:.4f} (F/D GDP; every F variable is per F capita)") + print(f" foreign-held share of the bank-held sovereign stock:" + f" D = {fs_D_new:.4f} (EBA {_fs_eba['D']:.4f})" + f" F = {fs_F_new:.6f} (EBA {_fs_eba['F']:.6f})") + print(f" bank-held stock in own-country quarterly GDP:" + f" B_D = {B_D_new:.4f} (EBA {_eba_all['B_supply_D_qgdp']:.4f})" + f" B_F = {B_F_new:.4f} (EBA {_eba_all['B_supply_F_qgdp']:.4f})") # omega_K is MEASURED (corporate+CRE EAD / K), not back-solved. It stays at # its calibration value; K is then an OUTPUT of the balance sheet, @@ -262,8 +422,16 @@ def solve_steady_state(calibration_start): omega_K_F_new = float(calibration_start['omega_K_F']) _fr = float(calibration_start['fund_rule_D']) - _bank_D = (float(calibration_start['theta_D']) - target_phi_bD_D - target_phi_bF_D) * n_D - _bank_F = (float(calibration_start['theta_F']) - target_phi_bF_F - target_phi_bD_F) * n_F + # Built from the REALISED stocks, not `target_phi * n`. Post-units-fix the two + # differ for the cross-border legs, and using the targets would compute this + # over-identifying check against the pre-fix bond book — overstating K_implied_F + # by ~0.13 and quietly making a broken balance sheet look like it validated. + # Each bank's own balance sheet, in its own per-capita units -- no size_F here: + # the F bank holds b_D_F per F capita and funds it per F capita. + _bank_D = (float(calibration_start['theta_D']) * n_D + - (q_D * b_D_D_new + q_F * b_F_D_new)) + _bank_F = (float(calibration_start['theta_F']) * n_F + - (q_F * b_F_F_new + q_D * b_D_F_new)) K_implied_D = ((1 - _fr) * _bank_D / omega_K_D_new + _fr * (_bank_D + float(calibration_start['K_fund_D']))) K_implied_F = ((1 - _fr) * _bank_F / omega_K_F_new @@ -287,10 +455,17 @@ def solve_steady_state(calibration_start): }) print("Re-solving SS with new portfolio allocation...") - _unknowns_warm = {'beta_D': float(ss['beta_D']), 'beta_F': float(ss['beta_F']), 'p': float(ss['p'])} + # Must carry EVERY unknown in unknowns_ss, warm-started at the first solve's + # values. q_b_D/q_b_F joined the unknown set in the stage-2/3 refactor; omitting + # them here left 3 unknowns against 5 targets and broyden_solver ran to its + # 100-iteration cap without converging. + _unknowns_warm = {'beta_D': float(ss['beta_D']), 'beta_F': float(ss['beta_F']), + 'p': float(ss['p']), + 'q_b_D': float(ss['q_b_D']), 'q_b_F': float(ss['q_b_F'])} ss = ha.solve_steady_state(calibration_start, _unknowns_warm, targets_ss, solver='broyden_custom') _apply_ss_anchors(ss, calibration_start) print(f"SS re-solved. beta_D={float(ss['beta_D']):.8f} p={float(ss['p']):.6f}") + report_gk_steady_state(ss, calibration_start) return { 'ss': ss, diff --git a/code/test_cross_border_units.py b/code/test_cross_border_units.py new file mode 100644 index 0000000..63f9181 --- /dev/null +++ b/code/test_cross_border_units.py @@ -0,0 +1,121 @@ +"""Regression checks for the country-size asymmetry — run standalone: + + /opt/anaconda3/envs/ssj/bin/python -m pytest code/test_cross_border_units.py -v + +THE CONVENTION: every F-side variable is PER F CAPITA and O(1); every D-side +variable is a D aggregate (size_D == 1). `size_F` (= 11.697, Germany/Greece 2010 +GDP) appears in exactly four blocks — the only places the two countries meet: +domestic_bond_clearing, trade_balance, external_account_D, global_goods_mkt. + +WHY: with both countries normalised to Y_ss = 1 the model could not match the EBA +portfolio-composition moment (phi_bD_F = 0.0075) and the market-structure moment +(foreigners hold 12.7% of the bank-held Greek stock) at the same time. Joint +consistency needs n_F/n_D = 8.85 against the model's 0.761 — a gap that is exactly +the GDP ratio. Matching one forced a ~11x error in the other. + +These tests are cheap (no model solve) and guard the arithmetic only. The pipeline's +printed over-identifying check (steady_state._apply_portfolio_targets) is what +confirms both moments hold in a SOLVED steady state. +""" +import json +import os + +import calibration as C + +_MOMENTS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "data", "eba_moments.json") + + +def approx(a, b, tol): + assert abs(a - b) <= tol, f"{a} != {b} (tol {tol})" + + +def _raw(): + with open(_MOMENTS) as fh: + return json.load(fh)["raw_EURm"] + + +def test_size_ratio_matches_eurostat(): + # Germany / Greece 2010 nominal GDP: 2,615,260 / 223,590.5 + approx(C.load_eba_size_ratio(), 11.697, 1e-3) + approx(C.get_calibration()["size_F"], 11.697, 1e-3) + + +def test_foreign_shares_are_pure_ratios(): + """Immune to the GDP normalisation — that is what makes them the check.""" + fs = C.load_eba_foreign_shares() + approx(fs["D"], 7_933.604 / 62_380.699, 1e-6) # 12.72% of the Greek bank-held stock + approx(fs["F"], 410.741 / 315_723.883, 1e-8) # 0.13% of the Bund bank-held stock + assert fs["D"] > 0.12, "Greek stock should be substantially foreign-held" + assert fs["F"] < 0.01, "Bund stock should be almost entirely domestically held" + + +def test_both_eba_moments_hold_jointly(): + """The whole point of size_F: composition AND market structure, together. + + Pre-size-asymmetry no calibration could do both — matching phi_bD_F forced the + foreign share to 1.25%, and matching the foreign share forced phi_bD_F to ~0.086. + + NOTE this runs on the INITIAL GUESS, where q_b_D = 0.83 rather than its solved + 0.969. Since b_D_F = phi*n_F/q, the low q inflates the face value and the + foreign share reads ~15% here against 12.7% in the data. So the composition + moment is asserted exactly (it is exact by construction at any q) while market + structure is asserted only to be in the right regime. The precise match is + checked on the SOLVED steady state by the pipeline's printed over-identifying + check in steady_state._apply_portfolio_targets — that is the real gate. + """ + cal = C.get_calibration() + size_F = cal["size_F"] + eba = C.load_eba_targets() + fs = C.load_eba_foreign_shares() + + # Composition: b_D_F is per F capita, so phi is a per-capita ratio. Exact. + phi_bD_F = cal["q_b_D"] * cal["b_D_F"] / cal["n_inter_F"] + approx(phi_bD_F, eba["phi_bD_F_ss"], 1e-12) + + # Market structure: aggregate the F holding before comparing to the D stock. + # The defect's signature was 1.25%; anything in double digits means size_F is + # doing its job. Bracketed generously because q_b_D is a guess at this stage. + share = size_F * cal["b_D_F"] / (cal["b_D_D"] + size_F * cal["b_D_F"]) + assert 0.08 < share < 0.20, f"foreign share {share:.4f} outside the plausible band" + assert share > 8.0 * 0.0125, "size_F is not aggregating — this is the old defect" + # And it must bracket the measured moment from the correct side of the old error. + assert share > fs["D"] * 0.8 + + +def test_bond_clearing_weights_are_consistent(): + """calibration's residual own-holdings must clear at the weights the model uses.""" + cal = C.get_calibration() + size_F = cal["size_F"] + # domestic_bond_clearing: b_D_D = b_gov_D - size_F*b_D_F, b_F_F = b_gov_F - b_F_D/size_F + approx(cal["b_D_D"] + size_F * cal["b_D_F"], cal["B_supply_D"], 1e-10) + approx(cal["b_F_F"] + cal["b_F_D"] / size_F, cal["B_supply_F"], 1e-10) + + +def test_home_bias_balances_bilateral_trade(): + """A single shared omega is inconsistent with size asymmetry. + + trade_balance uses NX_D = size_F*IM_F - p*IM_D. At p = 1 and C_D ~ C_F this + is zero only if size_F*(1-omega_F) = (1-omega_D). At the old shared + omega = 0.85 the D-good export leg came out size_F times too large. + """ + cal = C.get_calibration() + size_F = cal["size_F"] + approx(size_F * (1.0 - cal["omega_F"]), 1.0 - cal["omega_D"], 1e-12) + # The larger country is the more closed one. + assert cal["omega_F"] > cal["omega_D"] + approx(cal["omega_D"], 0.85, 1e-12) # unchanged from the long-standing value + + +def test_pre_eba_branch_is_unweighted(): + """size_F = 1 keeps the pre-EBA placeholder calibration bit-exact.""" + live = C.EBA_CALIBRATION + try: + # get_calibration reads the module attribute at call time, so patching it + # here is enough — see the calibration_override note in CLAUDE.md. + C.EBA_CALIBRATION = False + cal = C.get_calibration() + assert cal["size_F"] == 1.0 + approx(cal["omega_F"], cal["omega_D"], 1e-12) + finally: + C.EBA_CALIBRATION = live diff --git a/code/test_nkpc_blocks.py b/code/test_nkpc_blocks.py new file mode 100644 index 0000000..e4186f4 --- /dev/null +++ b/code/test_nkpc_blocks.py @@ -0,0 +1,375 @@ +"""Fast algebraic tests for the nominal-rigidity blocks. + +These evaluate SSJ @simple blocks directly via .steady_state(), which is just +"evaluate at constant values" -- lags and leads collapse to the same constant. +That makes it usable for deliberately OFF-steady-state identity checks too. +""" +import os +import sys + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + + +# ── Markup rent ─────────────────────────────────────────────────────────────── + +def test_firm_profit_is_zero_at_steady_state(): + from equations_D import firm_profit_D + mu_p = 1.20 + ss = firm_profit_D.steady_state({ + 'Y_D': 1.0, 'N_D': 0.8, 'alpha_D': 0.33, + 'mu_p_D': mu_p, 'mc_D': 1.0 / mu_p, + }) + assert ss['profit_D'] == pytest.approx(0.0, abs=1e-15) + + +def test_firm_profit_restores_factor_exhaustion_off_steady_state(): + """w*N + profit must equal (1-alpha)*Y for ANY mc, so that adding the + capital share alpha*Y exhausts output exactly.""" + from equations_D import firm_profit_D, labor_demand_D + mu_p, mc, Y, N, alpha = 1.20, 0.79, 1.03, 0.81, 0.33 + + # w from labour demand at this mc (w_res_D == 0 defines w) + w = mu_p * mc * (1 - alpha) * Y / N + + ss = firm_profit_D.steady_state({ + 'Y_D': Y, 'N_D': N, 'alpha_D': alpha, 'mu_p_D': mu_p, 'mc_D': mc, + }) + assert w * N + ss['profit_D'] == pytest.approx((1 - alpha) * Y, rel=1e-14) + + # and the wage we assumed really is the one labor_demand_D implies + ld = labor_demand_D.steady_state({ + 'w_D': w, 'Y_D': Y, 'N_D': N, 'alpha_D': alpha, + 'mu_p_D': mu_p, 'mc_D': mc, + }) + assert ld['w_res_D'] == pytest.approx(0.0, abs=1e-14) + + +def test_firm_profit_F_matches_D(): + from equations_D import firm_profit_D + from equations_F import firm_profit_F + args = dict(Y=1.03, N=0.81, alpha=0.33, mu_p=1.20, mc=0.79) + d = firm_profit_D.steady_state({ + 'Y_D': args['Y'], 'N_D': args['N'], 'alpha_D': args['alpha'], + 'mu_p_D': args['mu_p'], 'mc_D': args['mc'], + }) + f = firm_profit_F.steady_state({ + 'Y_F': args['Y'], 'N_F': args['N'], 'alpha_F': args['alpha'], + 'mu_p_F': args['mu_p'], 'mc_F': args['mc'], + }) + assert d['profit_D'] == pytest.approx(f['profit_F'], rel=1e-15) + + +# ── Price Phillips curve ────────────────────────────────────────────────────── + +def test_price_nkpc_is_zero_at_steady_state(): + from equations_D import price_nkpc_D + mu_p = 1.20 + ss = price_nkpc_D.steady_state({ + 'pi_D': 0.0, 'mc_D': 1.0 / mu_p, 'mu_p_D': mu_p, + 'kappa_p_D': 0.0871, 'beta_D': 0.985, + }) + assert ss['nkpc_p_res_D'] == pytest.approx(0.0, abs=1e-15) + + +def test_price_nkpc_flex_limit_forces_mc_to_one_over_mu_p(): + """As kappa_p -> inf the residual/kappa_p -> -(mu_p*mc - 1), so setting the + residual to zero drives mu_p*mc -> 1, which is the competitive condition.""" + from equations_D import price_nkpc_D + mu_p = 1.20 + base = {'pi_D': 0.0, 'mu_p_D': mu_p, 'beta_D': 0.985} + off_mc = 0.79 # != 1/mu_p = 0.8333... + for kappa in (1e2, 1e4, 1e6): + ss = price_nkpc_D.steady_state({**base, 'mc_D': off_mc, 'kappa_p_D': kappa}) + implied_gap = -ss['nkpc_p_res_D'] / kappa + assert implied_gap == pytest.approx(mu_p * off_mc - 1.0, rel=1e-12) + + +def test_price_nkpc_gap_linearises_to_mc_hat(): + """d(mu_p*mc - 1)/d(mc/mc_ss) evaluated at mc_ss = 1/mu_p equals 1 for ANY + mu_p -- which is why mu_p is a free normalisation to first order.""" + from equations_D import price_nkpc_D + for mu_p in (1.05, 1.20, 1.50): + mc_ss = 1.0 / mu_p + h = 1e-7 + base = {'pi_D': 0.0, 'mu_p_D': mu_p, 'kappa_p_D': 1.0, 'beta_D': 0.985} + up = price_nkpc_D.steady_state({**base, 'mc_D': mc_ss * (1 + h)}) + dn = price_nkpc_D.steady_state({**base, 'mc_D': mc_ss * (1 - h)}) + # residual = -kappa*(gap), kappa = 1 -> d(gap)/d(mc_hat) = -d(res)/d(mc_hat) + d_gap = -(up['nkpc_p_res_D'] - dn['nkpc_p_res_D']) / (2 * h) + assert d_gap == pytest.approx(1.0, rel=1e-6) + + +def test_price_nkpc_F_matches_D(): + from equations_D import price_nkpc_D + from equations_F import price_nkpc_F + args = dict(pi=0.001, mc=0.79, mu_p=1.20, kappa=0.0871, beta=0.985) + d = price_nkpc_D.steady_state({ + 'pi_D': args['pi'], 'mc_D': args['mc'], 'mu_p_D': args['mu_p'], + 'kappa_p_D': args['kappa'], 'beta_D': args['beta'], + }) + f = price_nkpc_F.steady_state({ + 'pi_F': args['pi'], 'mc_F': args['mc'], 'mu_p_F': args['mu_p'], + 'kappa_p_F': args['kappa'], 'beta_F': args['beta'], + }) + assert d['nkpc_p_res_D'] == pytest.approx(f['nkpc_p_res_F'], rel=1e-15) + + +def test_labor_demand_collapses_to_competitive_at_ss_markup(): + """At mc = 1/mu_p the condition must be exactly w = (1-alpha)Y/N, which is + what makes the steady state bit-identical to the flex model.""" + from equations_D import labor_demand_D + mu_p, Y, N, alpha = 1.20, 1.03, 0.81, 0.33 + w_competitive = (1 - alpha) * Y / N + ss = labor_demand_D.steady_state({ + 'w_D': w_competitive, 'Y_D': Y, 'N_D': N, 'alpha_D': alpha, + 'mu_p_D': mu_p, 'mc_D': 1.0 / mu_p, + }) + assert ss['w_res_D'] == pytest.approx(0.0, abs=1e-15) + + +# ── Global closure ──────────────────────────────────────────────────────────── + +def test_global_residuals_zero_at_steady_state(): + from equations_global import terms_of_trade, union_inflation + tot = terms_of_trade.steady_state({'p': 0.99, 'pi_D': 0.0, 'pi_F': 0.0}) + assert tot['tot_res'] == pytest.approx(0.0, abs=1e-15) + uni = union_inflation.steady_state({'pi_D': 0.0, 'pi_F': 0.0, 'omega_pi_D': 0.071}) + assert uni['union_pi_res'] == pytest.approx(0.0, abs=1e-15) + + +def test_closure_puts_93pct_of_tot_move_into_D_deflation(): + """Solving tot_res = 0 and union_pi_res = 0 together gives + pi_D = -(1 - omega)*dlog p and pi_F = omega*dlog p, so at the capital-key + omega = 0.071 the terms-of-trade adjustment splits 93/7 between D deflation + and F inflation -- the internal-devaluation pattern. + + Asserted on the net rates, which is exact arithmetic. Do NOT assert + log((1+pi_F)/(1+pi_D)) == dlog_p: that holds only to first order, and the + O(dlog_p^2) truncation is 0.429*dlog_p in relative terms, which swamps any + tight tolerance. + """ + from equations_global import union_inflation + omega = 0.071 + dlog_p = 1e-4 + pi_D = -(1 - omega) * dlog_p + pi_F = omega * dlog_p + + # the normalisation holds exactly + uni = union_inflation.steady_state({'pi_D': pi_D, 'pi_F': pi_F, + 'omega_pi_D': omega}) + assert uni['union_pi_res'] == pytest.approx(0.0, abs=1e-18) + + # the differential is exactly the terms-of-trade move + assert pi_F - pi_D == pytest.approx(dlog_p, rel=1e-15) + + # and D bears 1 - omega of it: 92.9% here + share_D = abs(pi_D) / (abs(pi_D) + abs(pi_F)) + assert share_D == pytest.approx(1 - omega, rel=1e-15) + assert share_D > 0.92 + + +def test_omega_one_half_splits_evenly(): + """Guards the calibration argument: at omega = 0.5 the adjustment splits + 50/50, which is counterfactual for GR/DE. See the spec.""" + omega = 0.5 + dlog_p = 1e-4 + assert -(1 - omega) * dlog_p == pytest.approx(-0.5 * dlog_p, rel=1e-15) + assert omega * dlog_p == pytest.approx(0.5 * dlog_p, rel=1e-15) + + +# ── Nominal deposits ────────────────────────────────────────────────────────── + +def test_deposit_rates_collapse_at_zero_inflation(): + """At pi = 0 both derived real rates must equal the nominal rate exactly -- + this is what keeps the steady state bit-identical.""" + from equations_D import deposit_rates_D + ss = deposit_rates_D.steady_state({'i_dep_D': 0.0125, 'pi_D': 0.0}) + assert ss['rdep_D'] == pytest.approx(0.0125, rel=1e-15) + assert ss['rdep_expost_D'] == pytest.approx(0.0125, rel=1e-15) + + +def test_deflation_raises_the_realised_real_deposit_rate(): + """Deflation is a windfall to depositors and a loss to banks, which hold + real assets against nominal liabilities. This is the Fisher channel; if the + sign flips, bank_return_D will amplify in the wrong direction.""" + from equations_D import deposit_rates_D + i = 0.0125 + base = deposit_rates_D.steady_state({'i_dep_D': i, 'pi_D': 0.0}) + defl = deposit_rates_D.steady_state({'i_dep_D': i, 'pi_D': -0.001}) + assert defl['rdep_expost_D'] > base['rdep_expost_D'] + assert defl['rdep_expost_D'] == pytest.approx((1 + i) / (1 - 0.001) - 1, rel=1e-14) + + +def test_deposit_return_is_unchanged_at_zero_inflation(): + """Rgross must be exactly 1 + i_dep when pi = 0 and P_CES is flat.""" + from equations_D import deposit_return_D + ss = deposit_return_D.steady_state({'i_dep_D': 0.0125, 'P_CES_D': 1.3, 'pi_D': 0.0}) + assert ss['Rgross_D'] == pytest.approx(1.0125, rel=1e-15) + + +def test_deposit_rates_F_matches_D(): + from equations_D import deposit_rates_D + from equations_F import deposit_rates_F + d = deposit_rates_D.steady_state({'i_dep_D': 0.0125, 'pi_D': -0.001}) + f = deposit_rates_F.steady_state({'i_dep_F': 0.0125, 'pi_F': -0.001}) + assert d['rdep_D'] == pytest.approx(f['rdep_F'], rel=1e-15) + assert d['rdep_expost_D'] == pytest.approx(f['rdep_expost_F'], rel=1e-15) + + +def test_bank_return_uses_the_expost_rate(): + """Signature check: bank_return_D must take rdep_expost_D and must NOT take + rdep_D. Getting this backwards silently reverses the Fisher channel.""" + from equations_D import bank_return_D, capital_fund_D + for blk in (bank_return_D, capital_fund_D): + assert 'rdep_expost_D' in blk.inputs, (blk.name, sorted(blk.inputs)) + assert 'rdep_D' not in blk.inputs, (blk.name, sorted(blk.inputs)) + + +def test_forward_looking_blocks_still_use_rdep(): + """intermediation_P1_D is ex-ante: rdep_D still means the t -> t+1 real rate. + + divert_bond_foc_D used to be checked here too. Since the 2026-08-17/18 structural + refactor its replacement gk_bond_foc_D states GK portfolio optimality on the marginal + values (nu_bD_D = Delta_bD_eff_D * nu_K_D) and carries NO interest rate at all, so the + T-2 invariant now lives entirely in intermediation_P1_D. Asserted below, which is + strictly stronger than the old signature check. + """ + from equations_D import intermediation_P1_D + assert 'rdep_D' in intermediation_P1_D.inputs, sorted(intermediation_P1_D.inputs) + assert 'rdep_expost_D' not in intermediation_P1_D.inputs, sorted(intermediation_P1_D.inputs) + + +def test_own_sovereign_foc_is_pure_gk(): + """gk_bond_foc_D/F must be a function of ENDOGENOUS GK objects only. + + Guards the refactor against regression to a reduced-form spread rule: no frozen + psi_spread, no excess_return_*_ss anchor, no tau_mp wedge, no interest rate. + """ + from equations_D import gk_bond_foc_D + from equations_F import gk_bond_foc_F + for blk, want in ((gk_bond_foc_D, {'nu_bD_D', 'nu_K_D', 'Delta_bD_eff_D'}), + (gk_bond_foc_F, {'nu_bF_F', 'nu_K_F', 'Delta_bF_eff_F'})): + assert set(blk.inputs) == want, (blk.name, sorted(blk.inputs)) + + +def test_no_ad_hoc_sovereign_spread_wedge_anywhere(): + """The governing invariant of the 2026-08-18 refactor. + + psi_spread_D/F and EL_price_D/F are DELETED. No symbol of either name may reappear + in code/, in any form — parameter, anchor, renamed twin or hard-coded literal. The + sovereign spread must come from the state-contingent payoff plus the GK portfolio + FOC, never from `spread += coefficient * default_probability`. + """ + import ast + import pathlib + import re + root = pathlib.Path(__file__).resolve().parent + banned = ('psi_spread', 'EL_price', 'divert_bond_foc', 'divert_portfolio_adj', + 'excess_return_') + ident = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') + + def symbols(tree): + """Every identifier the module actually USES. Prose in docstrings and comments + is exempt — the deleted objects are documented at length in the blocks that + replaced them, and that history is worth keeping.""" + for n in ast.walk(tree): + if isinstance(n, ast.Name): + yield n.id + elif isinstance(n, ast.arg): + yield n.arg + elif isinstance(n, ast.Attribute): + yield n.attr + elif isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + yield n.name + elif isinstance(n, ast.alias): + yield n.name + if n.asname: + yield n.asname + elif isinstance(n, ast.Constant) and isinstance(n.value, str): + # Dict keys / ss lookups such as ss['psi_spread_D'] land here. Docstrings + # do too, but they never match the identifier pattern. + if ident.match(n.value): + yield n.value + + hits = [] + for f in sorted(root.glob('*.py')): + if f.name == pathlib.Path(__file__).name: + continue + for s in symbols(ast.parse(f.read_text())): + for b in banned: + if b in s: + hits.append(f"{f.name}: {s}") + assert not hits, ("deleted objects still referenced in live code:\n" + + "\n".join(sorted(set(hits)))) + + +def test_expected_loss_enters_each_sovereign_leg_exactly_once(): + """Double-counting audit, as a test. + + Expected default loss lives in bond_return_D/F's rb_exp and reaches the banks only + through intermediation_P1_D/F. Every downstream portfolio condition must be stated on + the resulting nu's, so no block may take BOTH a nu and a def_rate/EL object. + """ + from equations_D import intermediation_P1_D, gk_bond_foc_D + from equations_F import intermediation_P1_F, gk_bond_foc_F + from equations_global import gk_cross_border_foc + + # The Euler equations price rb_exp, never rb_actual and never def_rate directly. + for blk, c, o in ((intermediation_P1_D, 'D', 'F'), (intermediation_P1_F, 'F', 'D')): + ins = set(blk.inputs) + assert f'rb_exp_{c}' in ins and f'rb_exp_{o}' in ins, (blk.name, sorted(ins)) + assert not (ins & {f'rb_actual_{c}', f'rb_actual_{o}', + f'def_rate_{c}', f'def_rate_{o}'}), (blk.name, sorted(ins)) + + # The four portfolio conditions carry no default object of their own. + for blk in (gk_bond_foc_D, gk_bond_foc_F, gk_cross_border_foc): + ins = set(blk.inputs) + assert not any(k in i for i in ins + for k in ('def_rate', 'EL_price', 'rb_exp', 'rb_actual')), \ + (blk.name, sorted(ins)) + assert any(i.startswith('nu_') for i in ins), (blk.name, sorted(ins)) + + +# ── Investment-flow adjustment cost ─────────────────────────────────────────── + +def test_flow_adjustment_cost_vanishes_at_steady_state(): + """S(1) = S'(1) = 0 is what makes this SS-neutral. Check the block's own + residual is unchanged when investment is flat, for ANY omega_I.""" + from equations_D import capital_adj_D + from equations_F import capital_adj_F + # SSJ's .steady_state() silently ignores dict keys the block does not take, + # so without this the rest of the test is vacuously green on the OLD block. + for blk, suf in ((capital_adj_D, 'D'), (capital_adj_F, 'F')): + assert f'omega_I_{suf}' in blk.inputs, (blk.name, sorted(blk.inputs)) + # Discounted at constant beta, NOT SDF: first-order exact because + # S'(1) = 0, and taking SDF here makes SSJ's topological sort fail + # (hh -> capital_fund -> capital_adj -> sdf -> ghh_composite -> hh). + assert f'beta_{suf}' in blk.inputs, (blk.name, sorted(blk.inputs)) + assert f'SDF_{suf}' not in blk.inputs, (blk.name, sorted(blk.inputs)) + base = dict(K_D=10.8, Q_D=1.0, I_D=0.242, Z_D=1.0, N_D=0.8, alpha_D=0.33, + delta_D=0.022407, gamma0_D=0.15, gamma1_D=-0.0053, ksi_D=0.5, + beta_D=0.9995) + ref = capital_adj_D.steady_state({**base, 'omega_I_D': 0.0}) + for w in (0.0, 2.0, 10.0): + ss = capital_adj_D.steady_state({**base, 'omega_I_D': w}) + assert ss['q_res_D'] == pytest.approx(ref['q_res_D'], rel=1e-14), w + assert ss['iota_D'] == pytest.approx(ref['iota_D'], rel=1e-14), w + assert ss['capital_res_D'] == pytest.approx(ref['capital_res_D'], rel=1e-14), w + + +def test_flow_adjustment_cost_bites_off_steady_state(): + """With investment falling, the cost must be strictly positive and must + scale with omega_I -- otherwise the parameter is doing nothing.""" + from equations_D import capital_adj_D + base = dict(K_D=10.8, Q_D=1.0, Z_D=1.0, N_D=0.8, alpha_D=0.33, + delta_D=0.022407, gamma0_D=0.15, gamma1_D=-0.0053, ksi_D=0.5, + beta_D=0.9995) + # steady_state() collapses lags, so probe the S(x) algebra directly instead + for w in (2.0, 10.0): + x = 0.9 # investment 10% below last period + S = (w / 2.0) * (x - 1.0) ** 2 + assert S > 0 + assert S == pytest.approx(w * 0.005, rel=1e-14) diff --git a/code/tpi.py b/code/tpi.py index fae2d25..45b7c81 100644 --- a/code/tpi.py +++ b/code/tpi.py @@ -18,7 +18,7 @@ capital_adj_D, capital_producer_profit_D, labor_D, labor_market_D, labor_demand_D, banker_div_res_D, market_clearing_D, welfare_agg_D, ces_price_D, import_demand_D, - divert_bond_foc_D, + gk_bond_foc_D, ) from equations_F import ( deposit_return_F, tax_rule_F, hh_extended_F, ghh_composite_F, @@ -28,11 +28,11 @@ capital_adj_F, capital_producer_profit_F, labor_F, labor_market_F, labor_demand_F, banker_div_res_F, market_clearing_F, welfare_agg_F, ces_price_F, import_demand_F, - divert_bond_foc_F, + gk_bond_foc_F, ) from equations_global import ( trade_balance, bond_yield, - portfolio_level_anchors, divert_portfolio_adj, global_goods_mkt, + portfolio_level_anchors, gk_cross_border_foc, global_goods_mkt, ) BLUE = '#002147' @@ -43,9 +43,12 @@ # ── TPI-1: CB bond clearing + budget constraint (audit fix) ────────────────── @simple -def domestic_bond_clearing_tpi(b_gov_D, b_gov_F, b_D_F, b_F_D, cb_buy_D): - b_D_D = b_gov_D - b_D_F - cb_buy_D - b_F_F = b_gov_F - b_F_D +def domestic_bond_clearing_tpi(b_gov_D, b_gov_F, b_D_F, b_F_D, cb_buy_D, size_F): + # size_F: see equations_global.domestic_bond_clearing. cb_buy_D is the CB's + # book measured as a D aggregate (the ECB buys a quantity of D debt), so it + # is NOT per-capita and takes no weight. + b_D_D = b_gov_D - size_F * b_D_F - cb_buy_D + b_F_F = b_gov_F - b_F_D / size_F return b_D_D, b_F_F @@ -78,29 +81,47 @@ def budget_residual_D_tpi(b_gov_D, G_D, TAX_D, q_b_D, def_rate_D, recovery_rate_ @simple def budget_residual_F_tpi(b_gov_F, G_F, TAX_F, q_b_F, def_rate_F, recovery_rate_F, zeta_writeoff_F, p, P_CES_F, delta_b_F, writeoff_enabled_F, - cb_flow_D, kappa_cb_F): + cb_flow_D, kappa_cb_F, size_F): haircut_F = 1.0 - recovery_rate_F haircut_mult_F = writeoff_enabled_F surv_cont_F = 1.0 - zeta_writeoff_F * def_rate_F * haircut_F * haircut_mult_F coupon_F = delta_b_F * (1.0 - def_rate_F * haircut_F * haircut_mult_F) * b_gov_F(-1) net_issuance_F = q_b_F * (b_gov_F - surv_cont_F * (1.0 - delta_b_F) * b_gov_F(-1)) - rem_cb_F = kappa_cb_F * cb_flow_D / p + # cb_flow_D is a D AGGREGATE cash flow in D goods; this budget is PER F CAPITA + # in F goods. So the remittance takes both conversions: /p for the good, and + # /size_F to spread the aggregate over F's population. Omitting the second + # leaked up to 2e-2 of F GDP through goods_mkt_F at gamma=10, while gamma=0 + # stayed clean at 2e-10 — the signature of a CB-conduit-only units error. + rem_cb_F = kappa_cb_F * cb_flow_D / p / size_F b_gov_res_F = (coupon_F - net_issuance_F) / p + G_F - P_CES_F * TAX_F - rem_cb_F return b_gov_res_F, rem_cb_F @simple def external_account_D_tpi(NX_D, q_b_D, q_b_F, b_F_D, b_D_F, rb_actual_F, rb_actual_D, - cb_buy_D, kappa_cb_F): + cb_buy_D, kappa_cb_F, size_F): # The F share of the CB book is an F claim on D: it enters D's external # account exactly like b_D_F. The D share stays domestic (like b_D_D). + # b_D_F is per F capita and takes size_F; cb_buy_D is already a D aggregate + # and does not. See equations_global.external_account_D. receipts_from_F_bonds = (1 + rb_actual_F) * q_b_F(-1) * b_F_D(-1) - payments_on_D_bonds = (1 + rb_actual_D) * q_b_D(-1) * (b_D_F(-1) + kappa_cb_F * cb_buy_D(-1)) - nfa_D = q_b_F * b_F_D - q_b_D * (b_D_F + kappa_cb_F * cb_buy_D) + payments_on_D_bonds = (1 + rb_actual_D) * q_b_D(-1) * (size_F * b_D_F(-1) + + kappa_cb_F * cb_buy_D(-1)) + nfa_D = q_b_F * b_F_D - q_b_D * (size_F * b_D_F + kappa_cb_F * cb_buy_D) ca_res_D = (NX_D + receipts_from_F_bonds - payments_on_D_bonds - nfa_D) return nfa_D, ca_res_D +def tpi_overrides(): + """The four blocks the TPI layer swaps into the shared block list.""" + return { + 'budget_residual_D': budget_residual_D_tpi, + 'budget_residual_F': budget_residual_F_tpi, + 'external_account_D': external_account_D_tpi, + 'domestic_bond_clearing': domestic_bond_clearing_tpi, + } + + def compute_tpi_irfs(G_tpi, shock_def, gamma_tpi, T): _has_spread = 'spread_rb' in G_tpi.outputs if _has_spread: @@ -142,26 +163,12 @@ def run_tpi(model_results): irfs_def_D = model_results['irfs_def_D'] # ── Build TPI model ─────────────────────────────────────────────────────── - ha_full_tpi = sj.create_model([ - deposit_return_D, tax_rule_D, hh_extended_D, ghh_composite_D, - sdf_D, sdf_banker_D, government_default_D, financial_solved_D, - bond_return_D, bank_return_D, capital_fund_D, cap_adj_cost_inter_D, macro_pru_tax_D, - intermediation_P2_D, intermediation_P3_D, k_balance_sheet_D, - capital_adj_D, capital_producer_profit_D, budget_residual_D_tpi, - labor_D, labor_market_D, labor_demand_D, banker_div_res_D, - market_clearing_D, welfare_agg_D, - deposit_return_F, tax_rule_F, hh_extended_F, ghh_composite_F, - sdf_F, sdf_banker_F, government_default_F, financial_solved_F, - bond_return_F, bank_return_F, capital_fund_F, cap_adj_cost_inter_F, macro_pru_tax_F, - intermediation_P2_F, intermediation_P3_F, k_balance_sheet_F, - capital_adj_F, capital_producer_profit_F, budget_residual_F_tpi, - labor_F, labor_market_F, labor_demand_F, banker_div_res_F, - market_clearing_F, welfare_agg_F, - ces_price_D, import_demand_D, ces_price_F, import_demand_F, - trade_balance, external_account_D_tpi, domestic_bond_clearing_tpi, - bond_yield, portfolio_level_anchors, divert_portfolio_adj, - divert_bond_foc_D, divert_bond_foc_F, global_goods_mkt, - ], name="Full 2-Country MU HANK — TPI Extension") + from full_model import build_block_list + ha_full_tpi = sj.create_model( + build_block_list(financial_solved_D, financial_solved_F, + overrides=tpi_overrides()), + name="Full 2-Country MU HANK — TPI Extension", + ) ss_tpi = copy.deepcopy(ss_final) ss_tpi.toplevel['cb_buy_D'] = 0.0 @@ -178,8 +185,9 @@ def run_tpi(model_results): # ── Jacobian ────────────────────────────────────────────────────────────── exogenous_tpi = ['Z_D', 'shock_def_D', 'Z_F', 'shock_def_F', 'cb_buy_D'] print(f"Computing G_tpi (T={T}, {len(exogenous_tpi)} exogenous inputs)...") - G_tpi = ha_full_tpi.solve_jacobian( - ss_tpi, unknowns=unknowns_tp, targets=targets_tp, + from full_model import solve_jacobian_padded + G_tpi = solve_jacobian_padded( + ha_full_tpi, ss_tpi, unknowns=unknowns_tp, targets=targets_tp, inputs=exogenous_tpi, T=T, ) print("G_tpi computed.") @@ -250,7 +258,12 @@ def run_tpi(model_results): q_b_F_ss = float(ss_final['q_b_F']) delta_b_D_v = float(ss_final['delta_b_D']) delta_b_F_v = float(ss_final['delta_b_F']) - EL_price_D_v = float(ss_final['EL_price_D']) + # Expected loss per unit of default probability, read off the ENDOGENOUS payoff + # (bond_return_D's EL_load_D), not a separately anchored EL_price_D parameter — that + # was deleted on 2026-08-18. This is accounting for the CB's book only; it is never + # fed back into any price. Same object the GK Euler equation prices, so the P&L and + # the model cannot disagree about what a default costs. + EL_load_D_v = float(ss_final['EL_load_D']) Y_D_ss = float(ss_final['Y_D']) rb_D_ss = delta_b_D_v * (1.0 / q_b_D_ss - 1.0) rb_F_ss = delta_b_F_v * (1.0 / q_b_F_ss - 1.0) @@ -276,7 +289,7 @@ def cb_pnl(irf, T_pnl=100): return { 'peak_exposure': float(np.max(q_b_D_ss * cb)), 'purchases_pv': float((disc * q_b_D_ss * purchases).sum()), - 'el_pv': float((disc * EL_price_D_v * defr * q_b_D_ss * cb).sum()), + 'el_pv': float((disc * EL_load_D_v * defr * q_b_D_ss * cb).sum()), 'prem_pv': float((disc * dspr * q_b_D_ss * cb_l).sum()), 'carry_ss_pv': float((disc * spread_ss * q_b_D_ss * cb_l).sum()), 'mtm_pv': float((disc * (1.0 - delta_b_D_v) * cb_l * (dq - dq_l)).sum()), @@ -297,8 +310,12 @@ def cb_pnl(irf, T_pnl=100): f"{pct(d['el_pv']):>9.4f}% {pct(d['prem_pv']):>9.4f}% " f"{pct(d['carry_ss_pv']):>11.4f}% {pct(d['mtm_pv']):>9.4f}% {loading:>8.2f}") print("─" * 82) - print("loading = prem PV / EL PV (theory: ≈ 1 + psi_spread/EL_price at small γ, " - "declining in γ — the self-extinguishing premium)") + # The old note here read "theory: ~ 1 + psi_spread/EL_price at small gamma". That + # closed form only existed because the spread was a hand-written affine function of + # def_rate. With the spread generated by the GK portfolio FOC there is no such + # formula and none is substituted: the loading is MEASURED off the run, full stop. + print("loading = prem PV / EL PV — premium income the CB earns per unit of expected " + "loss it absorbs. Measured from this run; no closed form, no target.") for g in gamma_values[1:]: d = pnl_by_gamma[g] print(f" γ={g:<2}: F bears EL PV = {pct(kappa_cb_F*d['el_pv']):.4f}% Y_D, " @@ -312,7 +329,30 @@ def _peak_spread(irf): return sp[:100].max() peak_no_tpi = _peak_spread(irfs_tpi[0]) - gammas_fine = np.concatenate([np.linspace(0, 5, 25), np.linspace(5, 30, 26)[1:]]) + # THE GRID MUST STOP BELOW THE CLOSED-LOOP POLE (2026-08-18). compute_tpi_irfs + # inverts (I - gamma*A_cb), which is singular where 1/gamma hits an eigenvalue of + # A_cb -- gamma ~ 27.3 on the post-GK-refactor calibration. The old fixed grid ran to + # 30 and straight through it, putting a spurious spike and a different solution + # branch on the effectiveness curve. Located by CONDITION NUMBER: a coarse scan steps + # over a pole this narrow and sees nothing. Same guard as + # diagnostics/regimes/lottery_math.closed_loop_pole; kept local because code/ does + # not import from diagnostics/. + # 0.75, matching diagnostics/regimes/lottery_math.POLE_SAFETY_FRACTION: the loading + # curve is monotone in gamma only up to ~0.85*pole, so the last stretch before the + # singularity is already dominated by it. Duplicated as a literal rather than + # imported because code/ must not depend on diagnostics/ -- keep the two in step. + _POLE_SAFETY = 0.75 + _gmax = 30.0 + _Acb = np.array(G_tpi['spread_rb']['cb_buy_D']) + for _g in np.linspace(0.25, 60.0, 240): + if np.linalg.cond(np.eye(T) - _g * _Acb) > 1.0e4: + _gmax = min(_gmax, _POLE_SAFETY * _g) + print(f" closed-loop pole at gamma = {_g:.2f}; capping the effectiveness " + f"curve at gamma = {_gmax:.2f} ({_POLE_SAFETY:g} x pole — beyond the " + f"pole is a different branch, and the approach to it is dominated by it)") + break + gammas_fine = np.concatenate([np.linspace(0, 5, 25), + np.linspace(5, _gmax, 26)[1:]]) peak_arr = np.empty(len(gammas_fine)) cost_arr = np.empty(len(gammas_fine)) el_pv_arr = np.empty(len(gammas_fine)) diff --git a/code/tpi_plots.py b/code/tpi_plots.py index 37e5589..5863500 100644 --- a/code/tpi_plots.py +++ b/code/tpi_plots.py @@ -50,11 +50,15 @@ 'fig_tpi_utility': "Household utility with and without intervention: D's crisis trough is shallower " "under TPI; F forgoes part of its crisis gain.", + # FALLBACK ONLY — plot_loading_schedule always passes a derived caption, which is + # what actually gets baked in. Kept deliberately directionless: the schedule sat + # above ℓ=1 before the 2026-08-18 payoff repair and sits below it after, so any + # hard-coded claim about which side it is on will go stale again. 'fig_tpi_loading_schedule': "The backstop's compensation self-extinguishes as it deploys: premium income per " - "unit of expected tail loss declines monotonically toward the fair-insurance limit " - "ℓ=1 (illustrative calibration; the declining shape, not the level, is the " - "model's prediction).", + "unit of expected tail loss declines as deployment rises. Whether the schedule " + "sits above or below the fair-insurance benchmark ℓ=1 is calibration-dependent " + "and is stated in the figure's own derived caption — do not assert it here.", } @@ -342,12 +346,19 @@ def plot_loading_schedule(tpi_results, output_dir): Y_D_ss = float(tpi_results['Y_D_ss']) kappa_cb_F = float(tpi_results['kappa_cb_F']) gamma_values = list(tpi_results['gamma_values']) + # Read live rather than written into the caption as a literal: the caption used to + # say "rho=0.8", which stopped being true when rho_def was disciplined by the MS + # regime estimate to 0.9408 on 2026-08-06 and nobody noticed for three months. + try: + rho_def = float(tpi_results['ss_final']['rho_def_D']) + except Exception: + rho_def = float(tpi_results.get('rho_def_D', float('nan'))) # persist the schedule so the key figure is regenerable without a full solve np.savez(output_dir / 'tpi_loading_schedule.npz', gammas_fine=gammas_fine, loading_arr=loading_arr, prem_pv_arr=prem_pv_arr, el_pv_arr=el_pv_arr, expos_arr=expos_arr, - Y_D_ss=Y_D_ss, kappa_cb_F=kappa_cb_F, + Y_D_ss=Y_D_ss, kappa_cb_F=kappa_cb_F, rho_def_D=rho_def, gamma_values=np.array(gamma_values, dtype=float)) dep = 100.0 * expos_arr / (4.0 * Y_D_ss) # peak holdings, % of annual GDP @@ -359,8 +370,10 @@ def plot_loading_schedule(tpi_results, output_dir): # Panel A — the schedule itself (single series: no legend, title names it) axA.plot(dep[m], loading_arr[m], color=BLUE, linewidth=2) axA.axhline(1.0, color='#888888', linewidth=1.0, linestyle='--') - axA.text(dep[m].min(), 1.07, 'actuarially fair (ℓ = 1)', - ha='left', va='bottom', fontsize=8, color='#666666') + # BELOW the line, not above it: at 1.07 this label sat in the same band as the + # subplot title and the two overprinted each other. + axA.text(dep[m].max(), 0.975, 'actuarially fair (ℓ = 1)', + ha='right', va='top', fontsize=8, color='#666666') for j, g in enumerate(gamma_values): if g == 0: continue @@ -369,12 +382,27 @@ def plot_loading_schedule(tpi_results, output_dir): color=BLUE, edgecolors='white', linewidths=1.5) axA.annotate(f'γ={g:g}: {loading_arr[i]:.2f}×', (dep[i], loading_arr[i]), textcoords='offset points', xytext=(8, 8), fontsize=8) - axA.text(0.98, 0.90, 'timid intervention → high loading\n(SMP-type regime)', - transform=axA.transAxes, fontsize=7.5, color='#666666', - ha='right', va='top') - axA.text(0.98, 0.21, 'at scale → fair insurance\n(PSPP-type regime)', - transform=axA.transAxes, fontsize=7.5, color='#666666', - ha='right', va='bottom') + # Annotations are chosen from WHERE THE SCHEDULE ACTUALLY SITS. The old pair + # ("timid intervention -> high loading (SMP-type)" above, "at scale -> fair + # insurance (PSPP-type)" below) hard-coded a schedule that starts above 1 and falls + # toward it. Since the 2026-08-18 payoff repair the whole schedule sits near 0.5, so + # those labels described a picture that is no longer on the axis. + _lo, _hi = float(np.nanmin(loading_arr[m])), float(np.nanmax(loading_arr[m])) + if _hi < 1.0: + axA.text(0.98, 0.90, + 'entire schedule below ℓ = 1:\nthe CB is UNDER-compensated\nat every ' + 'deployment', transform=axA.transAxes, fontsize=7.5, color='#666666', + ha='right', va='top') + axA.text(0.98, 0.06, 'more deployment → less compensation per unit of risk borne', + transform=axA.transAxes, fontsize=7.5, color='#666666', + ha='right', va='bottom') + else: + axA.text(0.98, 0.90, 'timid intervention → high loading\n(SMP-type regime)', + transform=axA.transAxes, fontsize=7.5, color='#666666', + ha='right', va='top') + axA.text(0.98, 0.21, 'at scale → fair insurance\n(PSPP-type regime)', + transform=axA.transAxes, fontsize=7.5, color='#666666', + ha='right', va='bottom') axA.set_xlabel('Deployment: peak CB holdings of D bonds (% of annual GDP)', fontsize=9) axA.set_ylabel('Loading ℓ = premium PV ÷ expected-loss PV', fontsize=9) axA.set_title('The Loading Schedule — Compensation Self-Extinguishes', fontsize=10, pad=6) @@ -393,9 +421,16 @@ def plot_loading_schedule(tpi_results, output_dir): axB.axhline(0, color='#888888', linewidth=0.8, linestyle=':') axB.set_xlabel('Deployment: peak CB holdings of D bonds (% of annual GDP)', fontsize=9) axB.set_ylabel('PV over 100q, β_F-discounted (% of quarterly SS GDP)', fontsize=9) - _i_pk = int(np.argmax(prem_pv_arr)) - axB.set_title(f'The Two Legs — Premium Peaks (γ≈{gammas_fine[_i_pk]:.0f}), ' - 'Tail Keeps Growing', fontsize=10, pad=6) + # Title derived, not asserted. "Premium Peaks (γ≈26), Tail Keeps Growing" was a + # literal claim about a shape that only held on the old calibration; on the current + # one both legs rise monotonically to the end of the (pole-capped) grid. + _i_pk = int(np.nanargmax(prem_pv_arr)) + _prem_peaks_inside = _i_pk < len(gammas_fine) - 1 + axB.set_title( + (f'The Two Legs — Premium Peaks (γ≈{gammas_fine[_i_pk]:.0f}), Tail Keeps Growing' + if _prem_peaks_inside else + 'The Two Legs — Expected Loss Outgrows Premium Throughout'), + fontsize=10, pad=6) axB.legend(fontsize=8, frameon=False, loc='center right') axB.spines[['top', 'right']].set_visible(False); axB.tick_params(labelsize=8) @@ -405,11 +440,23 @@ def plot_loading_schedule(tpi_results, output_dir): 'off-path accounting, never read off the linear DAG', fontsize=11, y=1.04) fig.tight_layout() + # Every clause below is DERIVED. The previous version asserted the schedule falls + # "toward the fair-insurance limit ℓ=1" and quoted the endpoints at one decimal, + # which on the current calibration printed "from 0.5× to 0.5×" while describing a + # descent toward a benchmark the curve is moving AWAY from. + _mono = bool(np.all(np.diff(loading_arr[m]) < 0)) + _dir = ("falls monotonically" if _mono else + "falls on net" if _l1 < _l0 else "does NOT fall") + _rel = ("toward the fair-insurance limit ℓ=1" if _l1 > 1.0 else + "AWAY from the fair-insurance benchmark ℓ=1, which the whole schedule sits " + "below — the CB is under-compensated at every deployment, so this figure " + "must not be captioned as over-compensation") + _rho = float(rho_def) if rho_def is not None else float('nan') caption = (f"The backstop's compensation self-extinguishes as it deploys: premium " - f"income per unit of expected tail loss falls monotonically from " - f"{_l0:.1f}× at minimal deployment to {_l1:.1f}× at the top of the grid, " - f"toward the fair-insurance limit ℓ=1 — because purchases relieve the " + f"income per unit of expected tail loss {_dir} from " + f"{_l0:.3f}× at minimal deployment to {_l1:.3f}× at the top of the " + f"(pole-capped) grid, {_rel} — because purchases relieve the " f"constrained marginal holder whose rent generates the premium. " - f"Illustrative calibration (1pp default-rate shock, ρ=0.8); the declining " + f"1pp default-rate shock, ρ_def={_rho:.4f}; the declining " f"shape, not the level, is the model's prediction.") _save(fig, 'fig_tpi_loading_schedule', output_dir, caption=caption) diff --git a/diagnostics/cb_audit/.gitignore b/diagnostics/cb_audit/.gitignore new file mode 100644 index 0000000..71a0bf3 --- /dev/null +++ b/diagnostics/cb_audit/.gitignore @@ -0,0 +1,3 @@ +# 38MB Jacobian/IRF dump from probe_pipeline.py — regenerate, do not commit. +probe_pipeline.npz +__pycache__/ diff --git a/diagnostics/cb_audit/VERDICT.md b/diagnostics/cb_audit/VERDICT.md new file mode 100644 index 0000000..31c4839 --- /dev/null +++ b/diagnostics/cb_audit/VERDICT.md @@ -0,0 +1,231 @@ +# Central Bank block audit — VERDICT + +**Date:** 2026-08-19 · **Branch:** `gk-structural-foc` · **HEAD:** `ea23e94` **plus +uncommitted working-tree changes** to ten files under `code/` (773 insertions / 239 +deletions). The audit is of the working tree. + +**Method:** static trace of every CB code path plus a live pipeline solve +(`diagnostics/cb_audit/probe_pipeline.py` — `main.py` stages 1–5 and `run_tpi()`, +figures skipped), a stability/Prony analysis (`probe_stability.py`), and a modal +decomposition of the closed-loop operator. No model source was edited. Full evidence +in `run_log.md`; proposed fixes in `recommended_fix.md`, none implemented. + +--- + +## BASE AUDITED — the well-posed base. No inadmissibility caveat applies. + +The brief anticipates that this might sit on the structurally inadmissible EBA +concentration regime (`phi_bD ≈ 2.39` against a well-posedness ceiling of 0.336) and +asks for a prominent flag if so. **It does not.** `EBA_CALIBRATION = True` and +`BANK_SCOPE = "broad"`: + +| | D | F | required | +|---|---|---|---| +| `lambda_gk` (GK incentive-constraint multiplier) | **+2.108746** | **+0.641387** | > 0 ✅ | +| `Omega` (banker's marginal value of net worth) | **+10.346180** | **+4.037867** | > 0 ✅ | +| `nu_K` (marginal value of capital per unit net worth) | **+0.103205** | **+0.040278** | > 0 ✅ | +| `phi_own` (own-sovereign book / net worth) | **0.452489** | 0.294078 | < 0.8232 ✅ | +| GK feasibility margin | **+0.296567** | **+0.591774** | > 0 ✅ | + +`phi_bD_D = 0.4525`, not 2.39. The 2.39 figure is the retired `BANK_SCOPE="ct1"` +(stress-test-sample) reading that GK-2 replaced on 2026-07-31; at the live broad scope +the own-sovereign concentration sits at 55% of its well-posedness ceiling. All four GK +portfolio FOC legs verify to ≤ 2.1e−13, `assert_gk_well_posed` passes, and the solved +SS reproduces the CLAUDE.md calibration row exactly (`q_b_D = 0.974906`, +`q_b_F = 0.965974`, both yields 80.00 bp, SS spread −0.0000 bp, `EL_load_D = 0.7014`, +peak spread 205.87 bp, `n_inter_D[0] = −11.4073%`, `Y_D[0] = −1.9742%`). + +**This audit's results transfer.** There is nothing to revert first. + +### One prior correction, because two audit steps are framed on it + +The brief describes the refactor as *"`EL_price` is now integrated into the GK +incentive constraint itself"*. That is not what happened. `EL_price_D/F` was +**deleted**, and expected default loss now enters through `rb_exp_D/F` — the +state-contingent expected payoff from `bond_return_D/F` — read by +`intermediation_P1_D/F`, the banker's **Bellman envelope**, not by the IC. The IC does +carry a default channel, but it is a different one: `collateral_quality_D`'s bounded +pledgeability map, whose slope `psi_lambda_B_D/F` is **0** at the preferred baseline, +so that channel is switched off and `Delta_eff ≡ Delta = 0.20` on all four legs. The +brief also anticipates no CB balance sheet and no capital key; both exist +(`kappa_cb_F = 0.929`). + +--- + +## Findings, most severe first + +### F-1 — HIGH (interpretation, not a code defect). What Germany actually pays is not the number the P&L table reports it bears. + +Answering the brief's decisive question directly: **CB income does NOT remit back to +the Greek treasury, the intervention is NOT self-financing, and a creditor side IS +representable and correctly signed.** The conduit is real: + +* **(a) a CB balance sheet holding `cb_buy_D`** — yes. `budget_residual_D_tpi` + (`code/tpi.py:62-78`) computes `cb_flow_D`, the full net cash flow on the CB's + D-bond book, in the same coupon/survival form as `bond_return_D`. +* **(b) a capital-key split of CB P&L** — yes. `kappa_cb_F = 0.929` to F, + `1 − kappa_cb_F = 0.071` to D. +* **(c) remittances to *both* treasuries** — yes. `rem_cb_D` enters `b_gov_res_D`, + `rem_cb_F` enters `b_gov_res_F`. + +Measured aggregate split at impact (F converted at `p` and scaled by +`size_F = 11.6967`): F/D = **13.059** against the capital key's 13.085, the 0.2% gap +being the endogenous terms of trade. Both remittances are **negative on impact** — a +capital call, funded by each treasury through its own fiscal rule at its own sovereign +terms. Downstream, German cumulative taxes rise monotonically with γ +(Σ₁₀₀ `TAX_F`: −1.4470e−03 → −1.2724e−03 from γ=0 to γ=10) and German welfare falls +monotonically (ΔW_F = −0.0195 / −0.0482 / −0.0944 at γ = 2/5/10). + +**The finding is what sits on top of that.** `writeoff_enabled_D = 0` (the S-1 +framing), so `haircut_mult_D = 0` in `cb_flow_D` and **no default loss ever flows +through the conduit**. On the branch the IRF traces, the German treasury books a pure +gain: at γ=10 it receives `kappa_cb_F × prem_pv = 0.1211%` of quarterly SS `Y_D` plus +its share of `mtm_pv = 0.0869%`, and pays nothing for credit risk. Meanwhile +`run_tpi` prints "F bears EL PV = 0.2513% Y_D" — an **off-path** expectation computed +by hand in `cb_pnl` from `EL_load_D × def_rate_D`, which appears in no budget +constraint anywhere in the model. + +Both objects are correct and both are needed. They are not commensurable, and the +paper must never net them, sum them, or present the realised transfer and the expected +loss in one column. Concretely: the loading 0.52 / 0.50 / 0.48 says the CB earns ~50 +cents of premium per euro of *expected* loss absorbed — under-compensated in +expectation — while the traced path shows the same position ending in profit. Any +German-tail or burden-sharing sentence has to say which of the two it is quantifying. + +### F-2 — MEDIUM. The closed-loop pole guard is unsound, and the pole it documents does not exist. + +`code/tpi.py:332-353` locates a "closed-loop pole" by scanning +`np.linspace(0.25, 60.0, 240)` for `cond(I − γ·A_cb) > 1e4`, reports γ = 26.50, and +caps the effectiveness curve at `0.75 × 26.50 = 19.88`. `CLAUDE.md` and `code/tpi.py` +record this as "γ ~ 27.3 on the post-GK-refactor calibration". +`diagnostics/regimes/lottery_math.closed_loop_pole` has the same construction. + +Two things are wrong. + +**The scan misses a nearer singularity.** `A_cb` has a real eigenvalue +0.452155, so +`I − γ·A_cb` is exactly singular at **γ = 2.2116** — inside the intended range. The +0.25-step grid evaluates 2.00 (cond 6.5e2) and 2.25 (cond 4.5e3), both under +threshold, and steps over it; `sign(det)` flips +1 → −1 across the gap. This is +exactly the failure the guard's own comment warns about. + +**But neither pole is economic — both are T=500 terminal-truncation artefacts.** The +resonant eigenvector carries **0.0000 of its mass in the first 100 quarters and 0.9922 +in t = 400–499**, peaking at index 499. The terminal columns of `A_cb` are +pathological while every interior column is clean (`||A[:,499]|| = 3.86` vs ~0.0065 +for interior columns; `A[499,499] = +1.080`, the only positive diagonal and the only +entry exceeding 1 in magnitude). Dropping the last five rows and columns collapses the +worst conditioning over γ ∈ [0.1, 30] from 5.45e+05 to 8.51e+01, removes every pole +below γ = 36, and **changes the reported peak spread by nothing at γ = 2, 5, 10** and +by 0.03 bp at γ = 20. + +**No reported number is wrong** — every TPI statistic is computed on `[:100]`, where +the artefact has no mass. What is wrong is the guard (it would equally miss a genuine +pole falling between grid points), the γ = 19.88 cap (imposed for a spurious reason), +and the documented claim of a pole at γ ≈ 27.3. + +### F-3 — MEDIUM (structural interpretation). The bonds the CB buys come off *German* balance sheets, not Greek ones. + +Differentiating the clearing identity `b_D_D = b_gov_D − size_F·b_D_F − cb_buy_D`: + +| horizon | `d b_D_D / d cb_buy` | `d b_gov_D / d cb_buy` | `size_F · d b_D_F / d cb_buy` | +|---|---|---|---| +| 0 | −0.1268 | +0.0325 | **−0.8408** | +| 1 | −0.0671 | −0.0539 | **−0.9867** | +| 12 | −0.0253 | −0.0202 | **−0.9949** | + +84% of a purchase at impact and ~99% from t=1 onward is absorbed by German banks +shedding their Greek book — governed by `psi_bD_F = 0.5`, the cross-border portfolio +adjustment cost in `gk_cross_border_foc` — not by Greek banks. Only 13% at impact and +2–6% thereafter comes off Greek balance sheets. + +The spread still compresses, and the reason is worth stating precisely rather than as +"the CB relieves Greek banks": + +``` + d b_D_D /d cb_buy [0,0] = -0.1268 modest quantity relief on D banks + d theta_D /d cb_buy [0,0] = -1.1777 required leverage falls, IC slackens + d n_inter_D/d cb_buy [0,0] = +0.5258 D bank net worth recovers (MTM on q_b_D) + d K_D /d cb_buy [0,0] = +0.0170 capital crowded back in + d q_b_D /d cb_buy [0,0] = +0.0373 bond price up -> spread compresses +``` + +The channel is **price support → mark-to-market recapitalisation of Greek banks**, +with quantity relief a minor contributor. This is not a defect; it is a result the +paper should own rather than describe as balance-sheet relief. + +### F-4 — LOW. Three `diagnostics/` scripts are wired to the deleted `psi_spread`. The CB block is clean. + +Answering Step 3 directly: **zero live `psi_spread` references in the CB block, the +clearing condition, or the residual equations.** The only occurrence in `code/tpi.py` +is a comment at line 313 recording that the old `loading ≈ 1 + psi_spread/EL_price` +closed form no longer exists and is deliberately not replaced. Every `psi_spread` +string in `code/` and `experiments/` is prose. `code/test_nkpc_blocks.py`'s AST +scanner over `code/*.py` passes (22/22 tests). + +Outside that scanner's reach, three scripts still execute against the deleted symbol: + +* `diagnostics/psilam_breakdown_sweep.py:70` and + `diagnostics/psilam_moment_sweep.py:59` — bare `float(ss["psi_spread_D"])`; these + **raise** on the current model. Loud, therefore harmless. +* `diagnostics/solve_configs.py:130/167/169/173` — the read at 130 is inside + `try/except` and degrades to `MISSING`, but line 169 then **writes** + `ss0.toplevel["psi_spread_D"] = 0.0`, inventing a symbol no block reads, and line + 173 reads it back successfully. The script does not crash; it silently produces a + "`psi_lambda_B = 0`" arm identical to its own baseline, since `psi_lambda_B` is + already 0 at the live calibration. **Silent, therefore worse.** + +### F-5 — LOW (cosmetic). Stale hardcoded parameter in a CB figure caption. + +`code/tpi_plots.py:243` hardcodes the subtitle `[δ_b = 0.10 → insensitive to q_b_D]`. +Live values are `delta_b_D = 0.0777` and `delta_b_F = 0.0568`. No number is computed +from it. + +### F-6 — NOTE. `cb_pnl` is duplicated. + +`experiments/e1_backstop_schedule.py:55-99` carries a second copy of `code/tpi.py`'s +`cb_pnl`. It currently agrees (both read `EL_load_D` from the solved model rather than +from an anchor), but duplicated model algebra is the exact drift mechanism that made +the retired `audit_artifacts/` harness test a different model for weeks. + +### F-7 — NOTE. What the "central bank" is, stated plainly for the paper. + +It is a **capital-key conduit with full per-period pass-through**, not a central bank +with a balance sheet in the institutional sense: + +* **no CB capital or retained earnings** — the entire net cash flow is remitted every + period, so there is no loss-absorption buffer and no delay between a loss and its + fiscal incidence; +* **no reserve liability and no policy rate** — the asset purchase is funded by a + same-period capital call on the two treasuries, not by creating remunerated + reserves. This is consistent with the model having no policy rate at all (the + union-inflation normalisation is the `phi_pi → ∞` limit), but it means the + seigniorage/reserve-remuneration leg of a real APP/TPI is absent by construction. + +Both are defensible modelling choices and both should be stated as such. + +--- + +## Step-by-step results + +| Step | Question | Result | +|---|---|---| +| **0** | Which base? | **Well-posed.** EBA + `BANK_SCOPE="broad"`; `lambda_gk`, `Omega`, `nu_K` all > 0 in both countries; `phi_bD_D = 0.4525` with feasibility margin +0.2966. No caveat. | +| **1** | Where do CB coupons and P&L go? | **Both treasuries, by capital key 0.929/0.071**, measured ratio 13.059 vs 13.085 implied. Not self-financing; creditor side representable and correctly signed (German taxes up, German welfare down, monotone in γ). **But** see F-1: with `writeoff_enabled = 0` no credit loss flows through the conduit, so the realised transfer and the reported expected loss are different objects. | +| **2** | Does the CB mark to the endogenous `q_b`? | **Yes, everywhere.** All four CB equations use endogenous `q_b_D` / `q_b_D(-1)`, and `cb_flow_D` matches `bond_return_D`'s payoff term for term including `zeta_writeoff_D` and `writeoff_enabled_D`. Established by diff as well as inspection: the refactor did not touch these four blocks at all. The SS-price sites in `cb_pnl` are the documented linearisation convention (`cb_buy_ss = 0`, so `q_b_D_ss × cb` is the correct first-order value and `mtm_pv` restores the revaluation separately), not stale prices. **No stale-price reference found.** | +| **3** | Dangling `psi_spread`? | **None in the CB block, clearing, or residual equations.** Three `diagnostics/` scripts are dangling (F-4). CB transmission confirmed to run through `intermediation_IC_D`'s `phi_bD_D` and `k_balance_sheet_D` and through nothing else — no additive spread term anywhere on the path. | +| **4** | Feedback sign and stability | **`A_cb[0,0] = −4.397e−03 < 0`** — purchases compress. Peak spread monotone: 205.87 → 193.25 → 176.75 → 154.36 bp at γ = 0/2/5/10. Prony moduli all < 1 (spread 0.940 → 0.947); `b_gov_D[499]` ≤ 3.7e−04; Walras residuals stay clean and `max|ca_res_D|` actually *falls* with γ. **No breakdown region in [0, 30]** once the terminal-truncation artefact is removed (F-2). | +| **5** | SS neutrality | **Exact.** `cb_buy_ss = 0`; `cb_flow_D`, `rem_cb_D`, `rem_cb_F` all identically 0 at SS; every TPI block output **bit-identical** (difference 0.000e+00) to its non-TPI counterpart — `b_gov_res_D`, `nfa_D`, `ca_res_D`, `b_D_D`, `b_F_F`. Confirmed dynamically by `G_tpi[cb=0]` vs baseline `G`: `max|err| = 0.00e+00`. The refactor did not move the SS through the CB block; prior SS-invariance arguments survive. | + +--- + +## What this audit did not do + +* No global/nonlinear check. Every statement here is about the linearised model. +* `psi_lambda_B_D/F = 3.01` (the diagnostic arm) was not audited; at the preferred + baseline the pledgeability channel is off, and the CB's interaction with it when + switched on is untested. +* No sensitivity of F-3's 84/99% split to `psi_bD_F`; the number is reported at the + calibrated `psi_bD_F = 0.5` only. +* The Prony estimator that `docs/STATE.md:2317` refers to is not in the working tree + (it went with `audit_artifacts/`); a self-contained order-selected reimplementation + with a passing synthetic self-test is at `diagnostics/cb_audit/prony.py`. diff --git a/diagnostics/cb_audit/lottery_math_shim.py b/diagnostics/cb_audit/lottery_math_shim.py new file mode 100644 index 0000000..f053737 --- /dev/null +++ b/diagnostics/cb_audit/lottery_math_shim.py @@ -0,0 +1,19 @@ +"""Thin shim so the audit probe can reuse diagnostics/regimes/lottery_math's +pole scan without importing the whole regimes package (which pulls the model).""" +import numpy as np + +POLE_SAFETY_FRACTION = 0.75 + + +def pole_scan(A_cb, T, lo=0.25, hi=60.0, n=240, cond_max=1.0e4): + """First gamma at which (I - gamma A_cb) becomes ill-conditioned. + + Same construction as diagnostics/regimes/lottery_math.closed_loop_pole and + code/tpi.py's inline guard. Returns None when no pole is found on [lo, hi]. + """ + I = np.eye(T) + for g in np.linspace(lo, hi, n): + if np.linalg.cond(I - g * A_cb) > cond_max: + return {"gamma_pole": float(g), + "gamma_safe_max": float(POLE_SAFETY_FRACTION * g)} + return None diff --git a/diagnostics/cb_audit/portfolio_matrix.json b/diagnostics/cb_audit/portfolio_matrix.json new file mode 100644 index 0000000..b628188 --- /dev/null +++ b/diagnostics/cb_audit/portfolio_matrix.json @@ -0,0 +1,96 @@ +{ + "size_F": 11.696650796880904, + "SS": { + "b_D_D": 0.9922837152315063, + "b_F_D": 0.007538986896364331, + "b_D_F": 0.012375326275958878, + "b_F_F": 0.49535011596549516, + "b_gov_D": 1.137033585178862, + "b_gov_F": 0.495994658324439, + "q_b_D": 0.9749060725100298, + "q_b_F": 0.9659744865875028, + "n_inter_D": 2.137914330014035, + "n_inter_F": 1.6271070113120254, + "cb_buy_D": 0.0, + "spread_rb": -2.175343238874916e-15, + "Y_D": 1.0, + "K_D": 10.806732428292566, + "theta_D": 5.510697108636639, + "theta_F": 6.941400199766871 + }, + "checks": { + "SS D paper": 1.1102230246251565e-16, + "SS F paper": -3.226585665316861e-16, + "g0 t0 D paper": 5.551115123125783e-17, + "g0 t0 F paper": -5.022024462952857e-16, + "g0 t4 D paper": 8.326672684688674e-17, + "g0 t4 F paper": -5.290906601729262e-16, + "g0 t20 D paper": 2.220446049250313e-16, + "g0 t20 F paper": -5.316927453868914e-16, + "g2 t0 D paper": 1.5265566588595902e-16, + "g2 t0 F paper": -5.499073418846478e-16, + "g2 t4 D paper": -8.500145032286355e-17, + "g2 t4 F paper": 7.28583859910259e-17, + "g2 t20 D paper": -4.206704429243757e-17, + "g2 t20 F paper": -6.7914424084492e-16, + "g5 t0 D paper": 4.85722573273506e-17, + "g5 t0 F paper": -8.864436962241484e-16, + "g5 t4 D paper": 1.1796119636642288e-16, + "g5 t4 F paper": 8.673617379884035e-17, + "g5 t20 D paper": 1.1449174941446927e-16, + "g5 t20 F paper": -1.1301723445988898e-15, + "g10 t0 D paper": 1.8735013540549517e-16, + "g10 t0 F paper": -1.1796119636642288e-16, + "g10 t4 D paper": 1.249000902703301e-16, + "g10 t4 F paper": -3.694961003830599e-16, + "g10 t20 D paper": 2.42861286636753e-17, + "g10 t20 F paper": -7.7021722333370235e-16 + }, + "matrix_mv": { + "ss": { + "b_D_D": 0.9673834196320086, + "b_F_D": 0.007282468996605446, + "b_D_F": 0.14111752720671406, + "b_F_F": 5.596795636355952, + "b_gov_D": 1.1085009468387228, + "b_gov_F": 5.604078105352557, + "cb_buy_D": 0.0 + }, + "g0": { + "b_D_D": 0.9302915042719381, + "b_F_D": 0.0075838060034397435, + "b_D_F": 0.1297560311678284, + "b_F_F": 5.625570810700258, + "b_gov_D": 1.0600475354397667, + "b_gov_F": 5.633154616703697, + "cb_buy_D": 0.0 + }, + "g2": { + "b_D_D": 0.9293022150902394, + "b_F_D": 0.008183980554254795, + "b_D_F": 0.12290749228749802, + "b_F_F": 5.626666464677898, + "b_gov_D": 1.0616295688365684, + "b_gov_F": 5.634850445232153, + "cb_buy_D": 0.009419861458831004 + }, + "g5": { + "b_D_D": 0.9280302749423718, + "b_F_D": 0.008954329674767184, + "b_D_F": 0.11412284454178649, + "b_F_F": 5.6279388029451285, + "b_gov_D": 1.0636924588792056, + "b_gov_F": 5.636893132619896, + "cb_buy_D": 0.021539339395047236 + }, + "g10": { + "b_D_D": 0.9263409819223036, + "b_F_D": 0.009972772287259431, + "b_D_F": 0.10251993035330598, + "b_F_F": 5.629347349271624, + "b_gov_D": 1.0664827402134724, + "b_gov_F": 5.6393201215588835, + "cb_buy_D": 0.037621827937862735 + } + } +} \ No newline at end of file diff --git a/diagnostics/cb_audit/portfolio_matrix.md b/diagnostics/cb_audit/portfolio_matrix.md new file mode 100644 index 0000000..ee52f9d --- /dev/null +++ b/diagnostics/cb_audit/portfolio_matrix.md @@ -0,0 +1,99 @@ +# The 2x2 sovereign-holdings matrix, before and after TPI + +Generated 2026-08-19 10:09:49 by `diagnostics/cb_audit/probe_portfolio.py`. `size_F` = 11.696651. + +All entries are **aggregate market value in D goods**, `q_b * quantity`, with per-F-capita legs (`b_D_F`, `b_F_F`, `b_gov_F`) scaled by `size_F`. `q_b_D` and `q_b_F` are both D-good prices, so no terms-of-trade conversion enters. + +## Clearing identities (must be ~0) + +| point | D paper residual | F paper residual | +|---|---|---| +| steady state | +1.110e-16 | -3.227e-16 | +| gamma=0, t=0 | +5.551e-17 | -5.022e-16 | +| gamma=0, t=4 | +8.327e-17 | -5.291e-16 | +| gamma=0, t=20 | +2.220e-16 | -5.317e-16 | +| gamma=2, t=0 | +1.527e-16 | -5.499e-16 | +| gamma=2, t=4 | -8.500e-17 | +7.286e-17 | +| gamma=2, t=20 | -4.207e-17 | -6.791e-16 | +| gamma=5, t=0 | +4.857e-17 | -8.864e-16 | +| gamma=5, t=4 | +1.180e-16 | +8.674e-17 | +| gamma=5, t=20 | +1.145e-16 | -1.130e-15 | +| gamma=10, t=0 | +1.874e-16 | -1.180e-16 | +| gamma=10, t=4 | +1.249e-16 | -3.695e-16 | +| gamma=10, t=20 | +2.429e-17 | -7.702e-16 | + +### Steady state (TPI dormant, `cb_buy_ss = 0`) + +| holder | D paper | F paper | total | D paper, % of D issue | +|---|---|---|---|---| +| D banks | +0.967383 | +0.007282 | +0.974666 | 87.27% | +| F banks | +0.141118 | +5.596796 | +5.737913 | 12.73% | +| **CB** | +0.000000 | 0.000000 | +0.000000 | 0.00% | +| **total held** | +1.108501 | +5.604078 | +6.712579 | | +| **issued** | +1.108501 | +5.604078 | +6.712579 | | + +### Impact of the 1pp default shock, t=0, gamma=0 + +| holder | D paper | F paper | total | D paper, % of D issue | +|---|---|---|---|---| +| D banks | +0.930292 | +0.007584 | +0.937875 | 87.76% | +| F banks | +0.129756 | +5.625571 | +5.755327 | 12.24% | +| **CB** | +0.000000 | 0.000000 | +0.000000 | 0.00% | +| **total held** | +1.060048 | +5.633155 | +6.693202 | | +| **issued** | +1.060048 | +5.633155 | +6.693202 | | + +### Impact of the 1pp default shock, t=0, gamma=2 + +| holder | D paper | F paper | total | D paper, % of D issue | +|---|---|---|---|---| +| D banks | +0.929302 | +0.008184 | +0.937486 | 87.54% | +| F banks | +0.122907 | +5.626666 | +5.749574 | 11.58% | +| **CB** | +0.009420 | 0.000000 | +0.009420 | 0.89% | +| **total held** | +1.061630 | +5.634850 | +6.696480 | | +| **issued** | +1.061630 | +5.634850 | +6.696480 | | + +### Impact of the 1pp default shock, t=0, gamma=5 + +| holder | D paper | F paper | total | D paper, % of D issue | +|---|---|---|---|---| +| D banks | +0.928030 | +0.008954 | +0.936985 | 87.25% | +| F banks | +0.114123 | +5.627939 | +5.742062 | 10.73% | +| **CB** | +0.021539 | 0.000000 | +0.021539 | 2.02% | +| **total held** | +1.063692 | +5.636893 | +6.700586 | | +| **issued** | +1.063692 | +5.636893 | +6.700586 | | + +### Impact of the 1pp default shock, t=0, gamma=10 + +| holder | D paper | F paper | total | D paper, % of D issue | +|---|---|---|---|---| +| D banks | +0.926341 | +0.009973 | +0.936314 | 86.86% | +| F banks | +0.102520 | +5.629347 | +5.731867 | 9.61% | +| **CB** | +0.037622 | 0.000000 | +0.037622 | 3.53% | +| **total held** | +1.066483 | +5.639320 | +6.705803 | | +| **issued** | +1.066483 | +5.639320 | +6.705803 | | + +## What TPI moves: holdings at t=0 relative to gamma=0 (same shock) + +Aggregate market value in D goods, and as a % of the D-paper stock at SS. + +| leg | SS level | g=0 | g=2 | g=5 | g=10 | d(g=10 - g=0) | as % of SS D issue | +|---|---|---|---|---|---|---|---| +| D banks / D paper | +0.967383 | +0.930292 | +0.929302 | +0.928030 | +0.926341 | -0.003951 | -0.356% | +| F banks / D paper | +0.141118 | +0.129756 | +0.122907 | +0.114123 | +0.102520 | -0.027236 | -2.457% | +| CB / D paper | +0.000000 | +0.000000 | +0.009420 | +0.021539 | +0.037622 | +0.037622 | +3.394% | +| D banks / F paper | +0.007282 | +0.007584 | +0.008184 | +0.008954 | +0.009973 | +0.002389 | +0.216% | +| F banks / F paper | +5.596796 | +5.625571 | +5.626666 | +5.627939 | +5.629347 | +0.003777 | +0.341% | +| D govt issue | +1.108501 | +1.060048 | +1.061630 | +1.063692 | +1.066483 | +0.006435 | +0.581% | +| F govt issue | +5.604078 | +5.633155 | +5.634850 | +5.636893 | +5.639320 | +0.006166 | +0.556% | + +## Sovereign concentration `phi = q_b*b / n_inter` — what the IC sees + +This is the object `intermediation_IC_D/F` reads, so it is where the portfolio shift becomes a constraint effect. + +| ratio | SS | g=0 t0 | g=2 t0 | g=5 t0 | g=10 t0 | +|---|---|---|---|---|---| +| phi_bD_D (D bank, own paper) | 0.452489 | 0.486757 | 0.479736 | 0.470614 | 0.458345 | +| phi_bF_D (D bank, F paper) | 0.003406 | 0.003936 | 0.004167 | 0.004463 | 0.004853 | +| phi_bD_F (F bank, D paper) | 0.007415 | 0.006774 | 0.006428 | 0.005983 | 0.005397 | +| phi_bF_F (F bank, own paper) | 0.294078 | 0.293864 | 0.294447 | 0.295200 | 0.296203 | + diff --git a/diagnostics/cb_audit/portfolio_matrix.npz b/diagnostics/cb_audit/portfolio_matrix.npz new file mode 100644 index 0000000..d1bbc7e Binary files /dev/null and b/diagnostics/cb_audit/portfolio_matrix.npz differ diff --git a/diagnostics/cb_audit/probe_pipeline.json b/diagnostics/cb_audit/probe_pipeline.json new file mode 100644 index 0000000..ecf406d --- /dev/null +++ b/diagnostics/cb_audit/probe_pipeline.json @@ -0,0 +1,293 @@ +{ + "timestamp": "2026-08-19 08:24:05", + "git_head": "ea23e94285b3d95b6119c0c2ea70a999d1c52e4f", + "EBA_CALIBRATION": true, + "BANK_SCOPE": "broad", + "cal.psi_lambda_B_D": 0.0, + "cal.psi_lambda_B_F": 0.0, + "cal.Delta_bD_D": 0.2, + "cal.Delta_bF_D": 0.2, + "cal.Delta_bD_F": 0.2, + "cal.Delta_bF_F": 0.2, + "cal.zeta_writeoff_D": 1.0, + "cal.zeta_writeoff_F": 1.0, + "cal.writeoff_enabled_D": 0.0, + "cal.writeoff_enabled_F": 0.0, + "cal.recovery_rate_D": 0.3, + "cal.kappa_cb_F": 0.929, + "cal.size_F": 11.696650796880904, + "cal.phi_bF_D_ss": 0.003437036133302237, + "cal.phi_bD_F_ss": 0.007457607889382024, + "cal.f_D": 0.12, + "cal.f_F": 0.12, + "cal.mv_rule_D": 0.0, + "cal.phi_lamb_D": 0.15, + "cal.psi_bF_D": 0.5, + "cal.psi_bD_F": 0.5, + "ss.lambda_gk_D": 2.108745763674537, + "ss.lambda_gk_F": 0.641386691778482, + "ss.Omega_D": 10.346180080803306, + "ss.Omega_F": 4.037867105186291, + "ss.nu_K_D": 0.10320475087383638, + "ss.nu_K_F": 0.04027835059879345, + "ss.nu_bD_D": 0.0206409501747656, + "ss.nu_bF_D": 0.020640950174788515, + "ss.nu_bD_F": 0.008055670119749557, + "ss.nu_bF_F": 0.0080556701197585, + "ss.eta_D": 10.320475087438934, + "ss.eta_F": 4.027835059896686, + "ss.theta_D": 5.510697108636639, + "ss.theta_F": 6.941400199766871, + "ss.n_inter_D": 2.137914330014035, + "ss.n_inter_F": 1.6271070113120254, + "ss.q_b_D": 0.9749060725100298, + "ss.q_b_F": 0.9659744865875028, + "ss.b_D_D": 0.9922837152315063, + "ss.b_D_F": 0.012375326275958878, + "ss.b_F_D": 0.007538986896364331, + "ss.b_F_F": 0.49535011596549516, + "ss.b_gov_D": 1.137033585178862, + "ss.b_gov_F": 0.495994658324439, + "ss.K_D": 10.806732428292566, + "ss.K_F": 10.804805449535872, + "ss.Q_D": 1.0, + "ss.Q_F": 1.0, + "ss.Delta_bD_eff_D": 0.2, + "ss.Delta_bF_eff_D": 0.2, + "ss.Delta_bD_eff_F": 0.2, + "ss.Delta_bF_eff_F": 0.2, + "ss.def_rate_D": 0.0, + "ss.def_rate_F": 0.0, + "ss.EL_load_D": 0.7013999999999925, + "ss.EL_load_F": 0.701399999999994, + "ss.rk_D": 0.00999999999994642, + "ss.rk_F": 0.009999999999956946, + "ss.rdep_D": 0.0, + "ss.rdep_F": 0.0, + "ss.rb_exp_D": 0.0019999999999891216, + "ss.rb_exp_F": 0.001999999999991342, + "ss.rb_actual_D": 0.0019999999999891216, + "ss.rb_actual_F": 0.001999999999991342, + "ss.delta_b_D": 0.07770055706859433, + "ss.delta_b_F": 0.056779421657665585, + "ss.Y_D": 1.0, + "ss.Y_F": 1.0, + "ss.beta_D": 0.9995343359540136, + "ss.beta_F": 0.9995353731289037, + "ss.beta_inter_D": 0.9975155088, + "ss.gk_wedge_F_D_ss": 2.0577983761317018e-15, + "ss.gk_wedge_D_F_ss": -2.2676305277871194e-15, + "ss.kappa_cb_F": 0.929, + "ss.cb_buy_D": null, + "ss.cb_flow_D": null, + "ss.rem_cb_D": null, + "ss.rem_cb_F": null, + "ss.goods_mkt_D": -4.22811451933347e-07, + "ss.goods_mkt_F": -4.181785814207778e-07, + "ss.ca_res_D": 1.6653345369377348e-16, + "ss.ic_res_D": null, + "ss.ic_res_F": null, + "ss.phi_own_D": 0.45248932852499185, + "ss.phi_cross_D": 0.0034063427586257106, + "ss.phi_own_F": 0.29407750727162807, + "ss.phi_cross_F": 0.007414866171584345, + "ss.gk_margin_D": 0.29656711600950253, + "ss.gk_margin_F": 0.5917741252174545, + "step5_ss_blocks": { + "budget_residual_D_tpi": { + "b_gov_D": 1.137033585178862, + "G_D": 0.08214336332079797, + "TAX_D": 0.08433546585861022, + "q_b_D": 0.9749060725100298, + "def_rate_D": 0.0, + "recovery_rate_D": 0.3, + "zeta_writeoff_D": 1.0, + "P_CES_D": 1.0002952418131537, + "delta_b_D": 0.07770055706859433, + "writeoff_enabled_D": 0.0, + "cb_buy_D": 0.0, + "kappa_cb_F": 0.929, + "b_gov_res_D": -4.163336342344337e-17, + "rem_cb_D": 0.0, + "cb_flow_D": 0.0 + }, + "budget_residual_F_tpi": { + "b_gov_F": 0.495994658324439, + "G_F": 0.08318277792578103, + "TAX_F": 0.08414125500366162, + "q_b_F": 0.9659744865875028, + "def_rate_F": 0.0, + "recovery_rate_F": 0.3, + "zeta_writeoff_F": 1.0, + "p": 1.001970751142378, + "P_CES_F": 0.9999747396432228, + "delta_b_F": 0.056779421657665585, + "writeoff_enabled_F": 0.0, + "cb_flow_D": 0.0, + "kappa_cb_F": 0.929, + "size_F": 11.696650796880904, + "b_gov_res_F": -2.7755575615628914e-17, + "rem_cb_F": 0.0 + }, + "external_account_D_tpi": { + "NX_D": 0.00026767011641889993, + "q_b_D": 0.9749060725100298, + "q_b_F": 0.9659744865875028, + "b_F_D": 0.007538986896364331, + "b_D_F": 0.012375326275958878, + "rb_actual_F": 0.001999999999991342, + "rb_actual_D": 0.0019999999999891216, + "cb_buy_D": 0.0, + "kappa_cb_F": 0.929, + "size_F": 11.696650796880904, + "nfa_D": -0.1338350582101086, + "ca_res_D": 1.6653345369377348e-16 + }, + "domestic_bond_clearing_tpi": { + "b_gov_D": 1.137033585178862, + "b_gov_F": 0.495994658324439, + "b_D_F": 0.012375326275958878, + "b_F_D": 0.007538986896364331, + "cb_buy_D": 0.0, + "size_F": 11.696650796880904, + "b_D_D": 0.9922837152315064, + "b_F_F": 0.49535011596549516 + }, + "budget_residual_D": { + "b_gov_D": 1.137033585178862, + "G_D": 0.08214336332079797, + "TAX_D": 0.08433546585861022, + "q_b_D": 0.9749060725100298, + "def_rate_D": 0.0, + "recovery_rate_D": 0.3, + "zeta_writeoff_D": 1.0, + "P_CES_D": 1.0002952418131537, + "delta_b_D": 0.07770055706859433, + "writeoff_enabled_D": 0.0, + "b_gov_res_D": -4.163336342344337e-17 + }, + "external_account_D": { + "NX_D": 0.00026767011641889993, + "q_b_D": 0.9749060725100298, + "q_b_F": 0.9659744865875028, + "b_F_D": 0.007538986896364331, + "b_D_F": 0.012375326275958878, + "rb_actual_F": 0.001999999999991342, + "rb_actual_D": 0.0019999999999891216, + "size_F": 11.696650796880904, + "nfa_D": -0.1338350582101086, + "ca_res_D": 1.6653345369377348e-16 + }, + "domestic_bond_clearing": { + "b_gov_D": 1.137033585178862, + "b_gov_F": 0.495994658324439, + "b_D_F": 0.012375326275958878, + "b_F_D": 0.007538986896364331, + "size_F": 11.696650796880904, + "b_D_D": 0.9922837152315064, + "b_F_F": 0.49535011596549516 + } + }, + "G_missing_columns": [ + "rem_cb_D__shock_def_D", + "rem_cb_F__shock_def_D", + "cb_flow_D__shock_def_D" + ], + "G_outputs_has_rem_cb_D": true, + "G_outputs_has_rem_cb_F": true, + "G_outputs_has_cb_flow_D": true, + "A_cb_00": -0.004397083388715885, + "A_cb_diag_first10": [ + -0.004397083388715885, + -0.004169005036349769, + -0.00394446614612201, + -0.0037241295825284717, + -0.0035190050199874587, + -0.0033363550514977714, + -0.0031786588778886897, + -0.003045239930470768, + -0.0029338007091644115, + -0.0028414243359102145 + ], + "A_cb_col0_first10": [ + -0.004397083388715885, + -0.0015330002319185391, + -0.0015189253182967032, + -0.0014370219822512898, + -0.0013250456093436042, + -0.0012023717829491587, + -0.001079358007570425, + -0.0009616160183760956, + -0.0008520402558895332, + -0.0007519110735255431 + ], + "A_cb_max_real_eig": 0.4521548333959231, + "A_cb_spectral_radius": 0.4521548333959231, + "closed_loop_pole": { + "gamma_pole": 26.5, + "gamma_safe_max": 19.875 + }, + "gamma0.peak_spread_bp": 205.87042801449053, + "gamma0.b_gov_D_499": 3.0241007608083614e-05, + "gamma0.n_inter_D_0": -0.24387904880273753, + "gamma0.Y_D_0": -0.01974176280665687, + "gamma0.max_ca_res_D": 7.209923837445797e-08, + "gamma0.max_goods_mkt_F": 1.8926010521989922e-10, + "gamma0.max_goods_mkt_D": 5.86287873845039e-17, + "gamma2.peak_spread_bp": 193.2465439378845, + "gamma2.b_gov_D_499": 0.00036938575061924917, + "gamma2.n_inter_D_0": -0.2128936630928301, + "gamma2.Y_D_0": -0.015701995385842823, + "gamma2.max_ca_res_D": 5.551801203956557e-08, + "gamma2.max_goods_mkt_F": 1.9583355105242241e-10, + "gamma2.max_goods_mkt_D": 5.466913991030388e-17, + "gamma5.peak_spread_bp": 176.75006856479007, + "gamma5.b_gov_D_499": -5.6147461127718146e-05, + "gamma5.n_inter_D_0": -0.17260395384245092, + "gamma5.Y_D_0": -0.010428064856718844, + "gamma5.max_ca_res_D": 3.380682231503987e-08, + "gamma5.max_goods_mkt_F": 2.0426061037494086e-10, + "gamma5.max_goods_mkt_D": 5.594013231165761e-17, + "gamma10.peak_spread_bp": 154.36083125833923, + "gamma10.b_gov_D_499": -0.00015062524644589222, + "gamma10.n_inter_D_0": -0.11836934386179232, + "gamma10.Y_D_0": -0.0032740777893073628, + "gamma10.max_ca_res_D": 1.4705839329346948e-08, + "gamma10.max_goods_mkt_F": 2.1541245060298922e-10, + "gamma10.max_goods_mkt_D": 5.842350210178911e-17, + "pnl_by_gamma": { + "0": { + "peak_exposure": 0.0, + "purchases_pv": 0.0, + "el_pv": 0.0, + "prem_pv": 0.0, + "carry_ss_pv": 0.0, + "mtm_pv": 0.0 + }, + "2": { + "peak_exposure": 0.009419861458831004, + "purchases_pv": 0.013428625910990293, + "el_pv": 0.0007187938265653781, + "prem_pv": 0.0003736894661592609, + "carry_ss_pv": -3.675421592125373e-16, + "mtm_pv": 0.000245384012886644 + }, + "5": { + "peak_exposure": 0.021539339395047243, + "purchases_pv": 0.03217648122021603, + "el_pv": 0.0016027567843440618, + "prem_pv": 0.0008083839565238563, + "carry_ss_pv": -8.774122509859276e-16, + "mtm_pv": 0.000533987944283508 + }, + "10": { + "peak_exposure": 0.03762182793786274, + "purchases_pv": 0.06062856747787709, + "el_pv": 0.0027055586873747316, + "prem_pv": 0.0013030182430280827, + "carry_ss_pv": -1.6424913880642992e-15, + "mtm_pv": 0.0008685260397475581 + } + }, + "kappa_cb_F_used": 0.929 +} \ No newline at end of file diff --git a/diagnostics/cb_audit/probe_pipeline.py b/diagnostics/cb_audit/probe_pipeline.py new file mode 100644 index 0000000..9701978 --- /dev/null +++ b/diagnostics/cb_audit/probe_pipeline.py @@ -0,0 +1,208 @@ +"""CB-block audit — live pipeline probe. + +Runs main.py's stages 1-5 plus run_tpi (NO figure generation, no source edits), +then dumps everything the audit's Steps 0/4/5 need to +diagnostics/cb_audit/probe_pipeline.npz + .json. + +Audit-only. Reads the production modules; writes nothing outside cb_audit/. +""" +import os, sys, json, datetime +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +sys.path.insert(0, os.path.join(ROOT, "code")) +sys.path.insert(0, HERE) + + +def ts(): + return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def log(m): + print(f"[{ts()}] {m}", flush=True) + + +def main(): + from calibration import get_calibration, EBA_CALIBRATION, BANK_SCOPE + from steady_state import solve_steady_state, gk_feasibility_margin + from ic_delta_calibration import calibrate_ic_delta + from depreciation_calibration import calibrate_depreciation + from full_model import build_and_solve + import tpi as tpi_mod + + out = {"timestamp": ts(), "git_head": os.popen("git -C %s rev-parse HEAD" % ROOT).read().strip()} + arrays = {} + + # ── Step 0: which base? ─────────────────────────────────────────────────── + log("STEP 0: calibration flags") + cal = get_calibration() + out["EBA_CALIBRATION"] = bool(EBA_CALIBRATION) + out["BANK_SCOPE"] = BANK_SCOPE + for k in ("psi_lambda_B_D", "psi_lambda_B_F", "Delta_bD_D", "Delta_bF_D", + "Delta_bD_F", "Delta_bF_F", "zeta_writeoff_D", "zeta_writeoff_F", + "writeoff_enabled_D", "writeoff_enabled_F", "recovery_rate_D", + "kappa_cb_F", "size_F", "phi_bD_D_ss", "phi_bF_D_ss", + "phi_bD_F_ss", "phi_bF_F_ss", "f_D", "f_F", "mv_rule_D", + "phi_lamb_D", "psi_bF_D", "psi_bD_F"): + if k in cal: + out[f"cal.{k}"] = float(cal[k]) + log(f" EBA_CALIBRATION={EBA_CALIBRATION} BANK_SCOPE={BANK_SCOPE}") + + log("solving steady state (stages 2-4)...") + ssr = solve_steady_state(cal) + ssr = calibrate_ic_delta(ssr) + ssr = calibrate_depreciation(ssr) + ss = ssr["ss_final"] if "ss_final" in ssr else ssr + log("steady state solved.") + + def g(k, default=None): + try: + return float(ss[k]) + except Exception: + try: + return float(ss.toplevel[k]) + except Exception: + return default + + for k in ("lambda_gk_D", "lambda_gk_F", "Omega_D", "Omega_F", + "nu_K_D", "nu_K_F", "nu_bD_D", "nu_bF_D", "nu_bD_F", "nu_bF_F", + "eta_D", "eta_F", "theta_D", "theta_F", + "n_inter_D", "n_inter_F", "q_b_D", "q_b_F", "b_D_D", "b_D_F", + "b_F_D", "b_F_F", "b_gov_D", "b_gov_F", "K_D", "K_F", "Q_D", "Q_F", + "Delta_bD_eff_D", "Delta_bF_eff_D", "Delta_bD_eff_F", "Delta_bF_eff_F", + "def_rate_D", "def_rate_F", "EL_load_D", "EL_load_F", + "rk_D", "rk_F", "rdep_D", "rdep_F", "rb_exp_D", "rb_exp_F", + "rb_actual_D", "rb_actual_F", "delta_b_D", "delta_b_F", + "Y_D", "Y_F", "beta_D", "beta_F", "beta_inter_D", + "gk_wedge_F_D_ss", "gk_wedge_D_F_ss", "kappa_cb_F", "cb_buy_D", + "cb_flow_D", "rem_cb_D", "rem_cb_F", + "goods_mkt_D", "goods_mkt_F", "ca_res_D", "ic_res_D", "ic_res_F"): + out[f"ss.{k}"] = g(k) + + # concentration ratios actually in force + for c, own, cross in (("D", "b_D_D", "b_F_D"), ("F", "b_F_F", "b_D_F")): + qo = "q_b_D" if c == "D" else "q_b_F" + qc = "q_b_F" if c == "D" else "q_b_D" + n = g(f"n_inter_{c}") + out[f"ss.phi_own_{c}"] = g(qo) * g(own) / n + out[f"ss.phi_cross_{c}"] = g(qc) * g(cross) / n + out["ss.gk_margin_D"] = gk_feasibility_margin( + g("theta_D"), out["cal.f_D"], out["ss.phi_own_D"], out["ss.phi_cross_D"], + g("Delta_bD_eff_D"), g("Delta_bF_eff_D")) + out["ss.gk_margin_F"] = gk_feasibility_margin( + g("theta_F"), out["cal.f_F"], out["ss.phi_own_F"], out["ss.phi_cross_F"], + g("Delta_bF_eff_F"), g("Delta_bD_eff_F")) + + log("STEP 0 key numbers:") + for k in ("ss.lambda_gk_D", "ss.lambda_gk_F", "ss.Omega_D", "ss.Omega_F", + "ss.nu_K_D", "ss.nu_K_F", "ss.phi_own_D", "ss.phi_own_F", + "ss.gk_margin_D", "ss.gk_margin_F"): + log(f" {k:22s} = {out[k]}") + + # ── Step 5 (SS part): CB block SS-neutrality, evaluated directly ───────── + log("STEP 5: CB block SS neutrality") + import copy + ss_tpi = copy.deepcopy(ss) + ss_tpi.toplevel["cb_buy_D"] = 0.0 + ss_tpi.toplevel["cb_flow_D"] = 0.0 + ss_tpi.toplevel["kappa_cb_F"] = float(cal["kappa_cb_F"]) + + def _blk_ss(blk, ssobj): + """Evaluate a @simple block at a steady state, return its outputs.""" + args = {k: ssobj[k] for k in blk.inputs} + return blk.steady_state(args) + + step5 = {} + for name, blk in (("budget_residual_D_tpi", tpi_mod.budget_residual_D_tpi), + ("budget_residual_F_tpi", tpi_mod.budget_residual_F_tpi), + ("external_account_D_tpi", tpi_mod.external_account_D_tpi), + ("domestic_bond_clearing_tpi", tpi_mod.domestic_bond_clearing_tpi)): + try: + r = _blk_ss(blk, ss_tpi) + step5[name] = {k: float(v) for k, v in r.items()} + except Exception as e: # noqa: BLE001 - audit probe + step5[name] = {"ERROR": repr(e)} + log(f" {name}: {step5[name]}") + # and the NON-TPI counterparts, for the difference + from equations_D import budget_residual_D + from equations_global import external_account_D, domestic_bond_clearing + for name, blk in (("budget_residual_D", budget_residual_D), + ("external_account_D", external_account_D), + ("domestic_bond_clearing", domestic_bond_clearing)): + try: + r = _blk_ss(blk, ss_tpi) + step5[name] = {k: float(v) for k, v in r.items()} + except Exception as e: # noqa: BLE001 + step5[name] = {"ERROR": repr(e)} + log(f" {name}: {step5[name]}") + out["step5_ss_blocks"] = step5 + + # ── Steps 2/4: full TPI solve ──────────────────────────────────────────── + log("building dynamic model + baseline Jacobian (this is the ~6 min leg)...") + mr = build_and_solve(ssr) + log("baseline done. running TPI...") + tr = tpi_mod.run_tpi(mr) + log("TPI done.") + + G = tr["G_tpi"] + T = tr["T"] + A_cb = np.array(G["spread_rb"]["cb_buy_D"]) + A_def = np.array(G["spread_rb"]["shock_def_D"]) + arrays["A_cb"] = A_cb + arrays["A_def"] = A_def + for o in ("q_b_D", "n_inter_D", "b_D_D", "K_D", "Y_D", "theta_D", + "b_gov_D", "rem_cb_D", "rem_cb_F", "cb_flow_D", "TAX_D", "TAX_F"): + if o in G.outputs: + for inp in ("cb_buy_D", "shock_def_D"): + # A pure-CB object (rem_cb_D, cb_flow_D) has no shock_def_D column: + # it responds to nothing but the CB's own purchases. Recorded, not + # assumed away. + try: + arrays[f"G_{o}__{inp}"] = np.array(G[o][inp]) + except KeyError: + out.setdefault("G_missing_columns", []).append(f"{o}__{inp}") + out["G_outputs_has_rem_cb_D"] = "rem_cb_D" in G.outputs + out["G_outputs_has_rem_cb_F"] = "rem_cb_F" in G.outputs + out["G_outputs_has_cb_flow_D"] = "cb_flow_D" in G.outputs + + out["A_cb_00"] = float(A_cb[0, 0]) + out["A_cb_diag_first10"] = [float(A_cb[i, i]) for i in range(10)] + out["A_cb_col0_first10"] = [float(A_cb[i, 0]) for i in range(10)] + ev = np.linalg.eigvals(A_cb) + out["A_cb_max_real_eig"] = float(np.max(ev.real)) + out["A_cb_spectral_radius"] = float(np.max(np.abs(ev))) + # closed-loop pole + from lottery_math_shim import pole_scan + out["closed_loop_pole"] = pole_scan(A_cb, T) + + for g_ in tr["gamma_values"]: + irf = tr["irfs_tpi"][g_] + out[f"gamma{g_}.peak_spread_bp"] = float(irf["spread_rb"][:100].max() * 4e4) + out[f"gamma{g_}.b_gov_D_499"] = float(irf["b_gov_D"][min(499, T - 1)]) + out[f"gamma{g_}.n_inter_D_0"] = float(irf["n_inter_D"][0]) + out[f"gamma{g_}.Y_D_0"] = float(irf["Y_D"][0]) + out[f"gamma{g_}.max_ca_res_D"] = float(np.max(np.abs(irf["ca_res_D"]))) + out[f"gamma{g_}.max_goods_mkt_F"] = float(np.max(np.abs(irf["goods_mkt_F"]))) + out[f"gamma{g_}.max_goods_mkt_D"] = float(np.max(np.abs(irf["goods_mkt_D"]))) + for o in ("spread_rb", "n_inter_D", "q_b_D", "Y_D", "b_gov_D", "b_D_D", + "K_D", "cb_buy_D", "TAX_D", "TAX_F", "C_D", "C_F"): + if o in irf: + arrays[f"irf_g{g_}_{o}"] = np.array(irf[o]) + for o in ("rem_cb_D", "rem_cb_F", "cb_flow_D"): + if o in irf: + arrays[f"irf_g{g_}_{o}"] = np.array(irf[o]) + out["pnl_by_gamma"] = {str(k): v for k, v in tr["pnl_by_gamma"].items()} + out["kappa_cb_F_used"] = float(tr["kappa_cb_F"]) + arrays["gammas_fine"] = tr["gammas_fine"] + arrays["peak_arr"] = tr["peak_arr"] + arrays["loading_arr"] = tr["loading_arr"] + + np.savez_compressed(os.path.join(HERE, "probe_pipeline.npz"), **arrays) + with open(os.path.join(HERE, "probe_pipeline.json"), "w") as f: + json.dump(out, f, indent=2, default=str) + log("WROTE probe_pipeline.npz / .json") + + +if __name__ == "__main__": + main() diff --git a/diagnostics/cb_audit/probe_pipeline_stdout.txt b/diagnostics/cb_audit/probe_pipeline_stdout.txt new file mode 100644 index 0000000..4a1980d --- /dev/null +++ b/diagnostics/cb_audit/probe_pipeline_stdout.txt @@ -0,0 +1,178 @@ +[2026-08-19 08:24:05] STEP 0: calibration flags +[2026-08-19 08:24:05] EBA_CALIBRATION=True BANK_SCOPE=broad +[2026-08-19 08:24:05] solving steady state (stages 2-4)... +Solving initial steady state... +Targeting portfolio shares (EBA 2011, 31 Dec 2010)... + size_F = 11.6967 (F/D GDP; every F variable is per F capita) + foreign-held share of the bank-held sovereign stock: D = 0.1273 (EBA 0.1272) F = 0.001299 (EBA 0.001301) + bank-held stock in own-country quarterly GDP: B_D = 1.1370 (EBA 1.1160) B_F = 0.4960 (EBA 0.4829) + D-bank: phi_bD_D = 0.456 phi_bF_D = 0.003 omega_K_D = 1.0000 + F-bank: phi_bD_F = 0.007 phi_bF_F = 0.296 omega_K_F = 1.0000 + over-identifying check (K from the measured balance sheet, target 10.8): K_D = 10.800 K_F = 10.812 +Re-solving SS with new portfolio allocation... +SS re-solved. beta_D=0.99948801 p=1.001508 + +=== GK steady state: sovereign block === + q_b yield q/q yield ann def_rate recovery EL_load delta_b duration + D 0.981293 0.001481 59.25bp 0.0000 0.30 0.701037 0.0777 12.9q + F 0.974574 0.001481 59.25bp 0.0000 0.30 0.701037 0.0568 17.6q + SS spread (D-F) = -0.0020 bp annualised (zero is correct: def_rate_ss = 0 in both countries) + + lambda_gk Omega theta n_inter K q_b*b_own q_b*b_cross + D 1.0191 5.0618 5.5107 2.1379 10.8003 0.9737 0.0073 + F 0.4148 2.6539 6.9414 1.6271 10.8002 0.4828 0.0121 + + Portfolio FOC check nu_i/nu_K vs Delta_i_eff + bank leg nu_i/nu_K Delta_eff residual status + D own 0.200000 0.200000 -7.133e-15 OK + D cross 0.200007 0.200000 6.740e-06 OK + F own 0.200000 0.200000 -4.663e-15 OK + F cross 0.199993 0.200000 -6.740e-06 OK + D ratio 0.999966 1.000000 -3.370e-05 + F ratio 1.000034 1.000000 3.370e-05 + cross-border SS wedges (constant, def_rate-free): F-in-D = +1.997e-03 bp/yr D-in-F = -1.997e-03 bp/yr + psi_lambda_B = 0.0000/0.0000 zeta_writeoff = 1.0/1.0 writeoff_enabled = 0.0/0.0 +IC residual check (SS satisfies the multi-asset IC at the calibrated Delta): + D-bank: Delta_own = 0.2000 Delta_cross = 0.2000 value = 5.241623 divertable_lev = 5.143585 residual = +1.776e-15 + F-bank: Delta_own = 0.2000 Delta_cross = 0.2000 value = 2.778661 divertable_lev = 6.698440 residual = +0.000e+00 + depreciation iter 1: delta_D = 0.02240642 delta_F = 0.02240667 max|rk - target| = 1.906e-05 + depreciation iter 2: delta_D = 0.02238736 delta_F = 0.02239312 max|rk - target| = 1.387e-07 + depreciation iter 3: delta_D = 0.02238722 delta_F = 0.02239299 max|rk - target| = 1.010e-09 + depreciation iter 4: delta_D = 0.02238722 delta_F = 0.02239299 max|rk - target| = 7.355e-12 + depreciation iter 5: delta_D = 0.02238722 delta_F = 0.02239299 max|rk - target| = 5.358e-14 +Verified rk_D = 0.010000 (target 0.0100) +Verified rk_F = 0.010000 (target 0.0100) +Final beta_D = 0.9995343360 +Final beta_F = 0.9995353731 + +beta_D=0.9995343360 beta_F=0.9995353731 p=1.001971 +rb_D=0.002000 rb_F=0.002000 rdep_D=0.000000 rdep_F=0.000000 +q_b_D=0.974906 q_b_F=0.965974 +SS goods residuals: + goods_mkt_D = -4.22811451933347e-07 + goods_mkt_F = -4.181785814207778e-07 + ca_res_D = 1.6653345369377348e-16 + +=== GK steady state: sovereign block === + q_b yield q/q yield ann def_rate recovery EL_load delta_b duration + D 0.974906 0.002000 80.00bp 0.0000 0.30 0.701400 0.0777 12.9q + F 0.965974 0.002000 80.00bp 0.0000 0.30 0.701400 0.0568 17.6q + SS spread (D-F) = -0.0000 bp annualised (zero is correct: def_rate_ss = 0 in both countries) + + lambda_gk Omega theta n_inter K q_b*b_own q_b*b_cross + D 2.1087 10.3462 5.5107 2.1379 10.8067 0.9674 0.0073 + F 0.6414 4.0379 6.9414 1.6271 10.8048 0.4785 0.0121 + + Portfolio FOC check nu_i/nu_K vs Delta_i_eff + bank leg nu_i/nu_K Delta_eff residual status + D own 0.200000 0.200000 -1.624e-14 OK + D cross 0.200000 0.200000 2.058e-13 OK + F own 0.200000 0.200000 -4.746e-15 OK + F cross 0.200000 0.200000 -2.268e-13 OK + D ratio 1.000000 1.000000 -1.110e-12 + F ratio 1.000000 1.000000 1.110e-12 + cross-border SS wedges (constant, def_rate-free): F-in-D = +8.231e-11 bp/yr D-in-F = -9.071e-11 bp/yr + psi_lambda_B = 0.0000/0.0000 zeta_writeoff = 1.0/1.0 writeoff_enabled = 0.0/0.0 + +Block residual Value Status +------------------------------------------------------------------------------------- + IC_D: θ − θ_tgt 0.000000e+00 OK + IC_F: θ − θ_tgt 0.000000e+00 OK + P1_D: nu_K_res 0.000000e+00 OK + P1_D: nu_bh_res 0.000000e+00 OK + P1_D: nu_bx_res 0.000000e+00 OK + P1_D: eta_res 0.000000e+00 OK + P1_F: nu_K_res 0.000000e+00 OK + P1_F: nu_bh_res 0.000000e+00 OK + P1_F: nu_bx_res 0.000000e+00 OK + P1_F: eta_res 0.000000e+00 OK + ca_res_D 1.665335e-16 OK +------------------------------------------------------------------------------------- +All residuals < 1e-8 ✓ +[2026-08-19 08:25:50] steady state solved. +[2026-08-19 08:25:50] STEP 0 key numbers: +[2026-08-19 08:25:50] ss.lambda_gk_D = 2.108745763674537 +[2026-08-19 08:25:50] ss.lambda_gk_F = 0.641386691778482 +[2026-08-19 08:25:50] ss.Omega_D = 10.346180080803306 +[2026-08-19 08:25:50] ss.Omega_F = 4.037867105186291 +[2026-08-19 08:25:50] ss.nu_K_D = 0.10320475087383638 +[2026-08-19 08:25:50] ss.nu_K_F = 0.04027835059879345 +[2026-08-19 08:25:50] ss.phi_own_D = 0.45248932852499185 +[2026-08-19 08:25:50] ss.phi_own_F = 0.29407750727162807 +[2026-08-19 08:25:50] ss.gk_margin_D = 0.29656711600950253 +[2026-08-19 08:25:50] ss.gk_margin_F = 0.5917741252174545 +[2026-08-19 08:25:50] STEP 5: CB block SS neutrality +[2026-08-19 08:25:50] budget_residual_D_tpi: {'b_gov_D': 1.137033585178862, 'G_D': 0.08214336332079797, 'TAX_D': 0.08433546585861022, 'q_b_D': 0.9749060725100298, 'def_rate_D': 0.0, 'recovery_rate_D': 0.3, 'zeta_writeoff_D': 1.0, 'P_CES_D': 1.0002952418131537, 'delta_b_D': 0.07770055706859433, 'writeoff_enabled_D': 0.0, 'cb_buy_D': 0.0, 'kappa_cb_F': 0.929, 'b_gov_res_D': -4.163336342344337e-17, 'rem_cb_D': 0.0, 'cb_flow_D': 0.0} +[2026-08-19 08:25:50] budget_residual_F_tpi: {'b_gov_F': 0.495994658324439, 'G_F': 0.08318277792578103, 'TAX_F': 0.08414125500366162, 'q_b_F': 0.9659744865875028, 'def_rate_F': 0.0, 'recovery_rate_F': 0.3, 'zeta_writeoff_F': 1.0, 'p': 1.001970751142378, 'P_CES_F': 0.9999747396432228, 'delta_b_F': 0.056779421657665585, 'writeoff_enabled_F': 0.0, 'cb_flow_D': 0.0, 'kappa_cb_F': 0.929, 'size_F': 11.696650796880904, 'b_gov_res_F': -2.7755575615628914e-17, 'rem_cb_F': 0.0} +[2026-08-19 08:25:50] external_account_D_tpi: {'NX_D': 0.00026767011641889993, 'q_b_D': 0.9749060725100298, 'q_b_F': 0.9659744865875028, 'b_F_D': 0.007538986896364331, 'b_D_F': 0.012375326275958878, 'rb_actual_F': 0.001999999999991342, 'rb_actual_D': 0.0019999999999891216, 'cb_buy_D': 0.0, 'kappa_cb_F': 0.929, 'size_F': 11.696650796880904, 'nfa_D': -0.1338350582101086, 'ca_res_D': 1.6653345369377348e-16} +[2026-08-19 08:25:50] domestic_bond_clearing_tpi: {'b_gov_D': 1.137033585178862, 'b_gov_F': 0.495994658324439, 'b_D_F': 0.012375326275958878, 'b_F_D': 0.007538986896364331, 'cb_buy_D': 0.0, 'size_F': 11.696650796880904, 'b_D_D': 0.9922837152315064, 'b_F_F': 0.49535011596549516} +[2026-08-19 08:25:50] budget_residual_D: {'b_gov_D': 1.137033585178862, 'G_D': 0.08214336332079797, 'TAX_D': 0.08433546585861022, 'q_b_D': 0.9749060725100298, 'def_rate_D': 0.0, 'recovery_rate_D': 0.3, 'zeta_writeoff_D': 1.0, 'P_CES_D': 1.0002952418131537, 'delta_b_D': 0.07770055706859433, 'writeoff_enabled_D': 0.0, 'b_gov_res_D': -4.163336342344337e-17} +[2026-08-19 08:25:50] external_account_D: {'NX_D': 0.00026767011641889993, 'q_b_D': 0.9749060725100298, 'q_b_F': 0.9659744865875028, 'b_F_D': 0.007538986896364331, 'b_D_F': 0.012375326275958878, 'rb_actual_F': 0.001999999999991342, 'rb_actual_D': 0.0019999999999891216, 'size_F': 11.696650796880904, 'nfa_D': -0.1338350582101086, 'ca_res_D': 1.6653345369377348e-16} +[2026-08-19 08:25:50] domestic_bond_clearing: {'b_gov_D': 1.137033585178862, 'b_gov_F': 0.495994658324439, 'b_D_F': 0.012375326275958878, 'b_F_D': 0.007538986896364331, 'size_F': 11.696650796880904, 'b_D_D': 0.9922837152315064, 'b_F_F': 0.49535011596549516} +[2026-08-19 08:25:50] building dynamic model + baseline Jacobian (this is the ~6 min leg)... +Computing Jacobian G (T=500, 4 exogenous inputs)... + [H_Z zero-pad] no direct shock loading, rows restored as zero: ['nkpc_p_res_D', 'nkpc_p_res_F', 'tot_res', 'union_pi_res'] +G computed successfully. + shock persistences: rho_Z_D=0.8000, rho_def_D=0.9408 + +=== Stability check: debt level at t=499 (should be near 0) === + irfs_Z_D ['b_gov_D'][499] = -0.000028 + irfs_def_D['b_gov_D'][499] = 0.000030 + ρ_b (partial-eq., NOT the stationarity test) = 0.8451 [measured boundary: phi_lamb_D in (0.07, 0.10)] + +=== Pledgeability along the default-shock path === + Delta_bD_eff_D: [0.200000, 0.200000] (SS 0.200000) + Delta_bF_eff_D: [0.200000, 0.200000] (SS 0.200000) + Delta_bF_eff_F: [0.200000, 0.200000] (SS 0.200000) + Delta_bD_eff_F: [0.200000, 0.200000] (SS 0.200000) + +=== Impact signs (t=0, % of own SS) === + variable +1% TFP +1pp default + Y_D -0.0732% -1.9742% + C_D -2.8287% -2.5108% + I_D 5.0004% -3.0046% + N_D -3.7118% -3.0010% + w_D -7.2599% -5.9102% + n_inter_D 8.2670% -11.4073% + K_D 0.1119% -0.0673% + q_b_D 3.2248% -5.9498% + Y_D -0.0732% on a +1% TFP shock: flat on impact, hours-driven (N_D -3.71%). Expected under sticky prices with no monetary accommodation; not a defect. + n_inter_D[0] on default shock = -11.4073% of SS (level dev -24.3879; negative = doom loop correct sign) + Y_D[0] on default shock = -1.9742% of SS (level dev -1.9742; negative = correct sign) +[2026-08-19 08:26:15] baseline done. running TPI... +Computing G_tpi (T=500, 5 exogenous inputs)... + [H_Z zero-pad] no direct shock loading, rows restored as zero: ['nkpc_p_res_D', 'nkpc_p_res_F', 'tot_res', 'union_pi_res'] +G_tpi computed. +Sanity check G_tpi[cb=0] vs baseline G: max |err| = 0.00e+00 (expect < 1e-8) + gamma = 0 ... peak spread = +0.515 pp max|ca_res_D| = 7.21e-08 max|goods_mkt_F| = 1.89e-10 + gamma = 2 ... peak spread = +0.483 pp max|ca_res_D| = 5.55e-08 max|goods_mkt_F| = 1.96e-10 + gamma = 5 ... peak spread = +0.442 pp max|ca_res_D| = 3.38e-08 max|goods_mkt_F| = 2.04e-10 + gamma = 10 ... peak spread = +0.386 pp max|ca_res_D| = 1.47e-08 max|goods_mkt_F| = 2.15e-10 +Sanity check gamma=0 vs irfs_def_D: max |err| = 0.00e+00 (expect < 1e-8) +Sign check (gamma=0): n_inter_D[0] = -2.439e-01, Y_D[0] = -1.974e-02 (both must be negative) + + γ W_D W_F ΔW_D vs γ=0 ΔW_F vs γ=0 +──────────────────────────────────────────────────────────── + 0 -2.6844 0.1722 +0.0000 +0.0000 + 2 -2.4887 0.1526 +0.1956 -0.0195 + 5 -2.1689 0.1240 +0.5155 -0.0482 + 10 -1.5554 0.0778 +1.1290 -0.0944 +──────────────────────────────────────────────────────────── +(Units: % of quarterly SS consumption, discounted over 100 quarters) + +ECB balance-sheet P&L (PV at beta_F over 100q; % of quarterly SS Y_D) +Capital-key conduit: F share 0.929, D share 0.071 (two-country renormalised euro-area key) + γ peak expos. purch. PV EL PV prem PV SS-carry PV MTM PV loading +────────────────────────────────────────────────────────────────────────────────── + 0 0.000% 0.000% 0.0000% 0.0000% 0.0000% 0.0000% nan + 2 0.942% 1.343% 0.0719% 0.0374% -0.0000% 0.0245% 0.52 + 5 2.154% 3.218% 0.1603% 0.0808% -0.0000% 0.0534% 0.50 + 10 3.762% 6.063% 0.2706% 0.1303% -0.0000% 0.0869% 0.48 +────────────────────────────────────────────────────────────────────────────────── +loading = prem PV / EL PV — premium income the CB earns per unit of expected loss it absorbs. Measured from this run; no closed form, no target. + γ=2 : F bears EL PV = 0.0668% Y_D, receives prem PV = 0.0347% (memo at full euro-area key 26.1%: EL 0.0188%) + γ=5 : F bears EL PV = 0.1489% Y_D, receives prem PV = 0.0751% (memo at full euro-area key 26.1%: EL 0.0418%) + γ=10: F bears EL PV = 0.2513% Y_D, receives prem PV = 0.1211% (memo at full euro-area key 26.1%: EL 0.0706%) + closed-loop pole at gamma = 26.50; capping the effectiveness curve at gamma = 19.88 (0.75 x pole — beyond the pole is a different branch, and the approach to it is dominated by it) +[2026-08-19 08:26:50] TPI done. +[2026-08-19 08:26:54] WROTE probe_pipeline.npz / .json diff --git a/diagnostics/cb_audit/probe_pipeline_stdout_run1.txt b/diagnostics/cb_audit/probe_pipeline_stdout_run1.txt new file mode 100644 index 0000000..a7d8593 --- /dev/null +++ b/diagnostics/cb_audit/probe_pipeline_stdout_run1.txt @@ -0,0 +1,184 @@ +[2026-08-19 08:20:41] STEP 0: calibration flags +[2026-08-19 08:20:41] EBA_CALIBRATION=True BANK_SCOPE=broad +[2026-08-19 08:20:41] solving steady state (stages 2-4)... +Solving initial steady state... +Targeting portfolio shares (EBA 2011, 31 Dec 2010)... + size_F = 11.6967 (F/D GDP; every F variable is per F capita) + foreign-held share of the bank-held sovereign stock: D = 0.1273 (EBA 0.1272) F = 0.001299 (EBA 0.001301) + bank-held stock in own-country quarterly GDP: B_D = 1.1370 (EBA 1.1160) B_F = 0.4960 (EBA 0.4829) + D-bank: phi_bD_D = 0.456 phi_bF_D = 0.003 omega_K_D = 1.0000 + F-bank: phi_bD_F = 0.007 phi_bF_F = 0.296 omega_K_F = 1.0000 + over-identifying check (K from the measured balance sheet, target 10.8): K_D = 10.800 K_F = 10.812 +Re-solving SS with new portfolio allocation... +SS re-solved. beta_D=0.99948801 p=1.001508 + +=== GK steady state: sovereign block === + q_b yield q/q yield ann def_rate recovery EL_load delta_b duration + D 0.981293 0.001481 59.25bp 0.0000 0.30 0.701037 0.0777 12.9q + F 0.974574 0.001481 59.25bp 0.0000 0.30 0.701037 0.0568 17.6q + SS spread (D-F) = -0.0020 bp annualised (zero is correct: def_rate_ss = 0 in both countries) + + lambda_gk Omega theta n_inter K q_b*b_own q_b*b_cross + D 1.0191 5.0618 5.5107 2.1379 10.8003 0.9737 0.0073 + F 0.4148 2.6539 6.9414 1.6271 10.8002 0.4828 0.0121 + + Portfolio FOC check nu_i/nu_K vs Delta_i_eff + bank leg nu_i/nu_K Delta_eff residual status + D own 0.200000 0.200000 -7.133e-15 OK + D cross 0.200007 0.200000 6.740e-06 OK + F own 0.200000 0.200000 -4.663e-15 OK + F cross 0.199993 0.200000 -6.740e-06 OK + D ratio 0.999966 1.000000 -3.370e-05 + F ratio 1.000034 1.000000 3.370e-05 + cross-border SS wedges (constant, def_rate-free): F-in-D = +1.997e-03 bp/yr D-in-F = -1.997e-03 bp/yr + psi_lambda_B = 0.0000/0.0000 zeta_writeoff = 1.0/1.0 writeoff_enabled = 0.0/0.0 +IC residual check (SS satisfies the multi-asset IC at the calibrated Delta): + D-bank: Delta_own = 0.2000 Delta_cross = 0.2000 value = 5.241623 divertable_lev = 5.143585 residual = +1.776e-15 + F-bank: Delta_own = 0.2000 Delta_cross = 0.2000 value = 2.778661 divertable_lev = 6.698440 residual = +0.000e+00 + depreciation iter 1: delta_D = 0.02240642 delta_F = 0.02240667 max|rk - target| = 1.906e-05 + depreciation iter 2: delta_D = 0.02238736 delta_F = 0.02239312 max|rk - target| = 1.387e-07 + depreciation iter 3: delta_D = 0.02238722 delta_F = 0.02239299 max|rk - target| = 1.010e-09 + depreciation iter 4: delta_D = 0.02238722 delta_F = 0.02239299 max|rk - target| = 7.355e-12 + depreciation iter 5: delta_D = 0.02238722 delta_F = 0.02239299 max|rk - target| = 5.358e-14 +Verified rk_D = 0.010000 (target 0.0100) +Verified rk_F = 0.010000 (target 0.0100) +Final beta_D = 0.9995343360 +Final beta_F = 0.9995353731 + +beta_D=0.9995343360 beta_F=0.9995353731 p=1.001971 +rb_D=0.002000 rb_F=0.002000 rdep_D=0.000000 rdep_F=0.000000 +q_b_D=0.974906 q_b_F=0.965974 +SS goods residuals: + goods_mkt_D = -4.22811451933347e-07 + goods_mkt_F = -4.181785814207778e-07 + ca_res_D = 1.6653345369377348e-16 + +=== GK steady state: sovereign block === + q_b yield q/q yield ann def_rate recovery EL_load delta_b duration + D 0.974906 0.002000 80.00bp 0.0000 0.30 0.701400 0.0777 12.9q + F 0.965974 0.002000 80.00bp 0.0000 0.30 0.701400 0.0568 17.6q + SS spread (D-F) = -0.0000 bp annualised (zero is correct: def_rate_ss = 0 in both countries) + + lambda_gk Omega theta n_inter K q_b*b_own q_b*b_cross + D 2.1087 10.3462 5.5107 2.1379 10.8067 0.9674 0.0073 + F 0.6414 4.0379 6.9414 1.6271 10.8048 0.4785 0.0121 + + Portfolio FOC check nu_i/nu_K vs Delta_i_eff + bank leg nu_i/nu_K Delta_eff residual status + D own 0.200000 0.200000 -1.624e-14 OK + D cross 0.200000 0.200000 2.058e-13 OK + F own 0.200000 0.200000 -4.746e-15 OK + F cross 0.200000 0.200000 -2.268e-13 OK + D ratio 1.000000 1.000000 -1.110e-12 + F ratio 1.000000 1.000000 1.110e-12 + cross-border SS wedges (constant, def_rate-free): F-in-D = +8.231e-11 bp/yr D-in-F = -9.071e-11 bp/yr + psi_lambda_B = 0.0000/0.0000 zeta_writeoff = 1.0/1.0 writeoff_enabled = 0.0/0.0 + +Block residual Value Status +------------------------------------------------------------------------------------- + IC_D: θ − θ_tgt 0.000000e+00 OK + IC_F: θ − θ_tgt 0.000000e+00 OK + P1_D: nu_K_res 0.000000e+00 OK + P1_D: nu_bh_res 0.000000e+00 OK + P1_D: nu_bx_res 0.000000e+00 OK + P1_D: eta_res 0.000000e+00 OK + P1_F: nu_K_res 0.000000e+00 OK + P1_F: nu_bh_res 0.000000e+00 OK + P1_F: nu_bx_res 0.000000e+00 OK + P1_F: eta_res 0.000000e+00 OK + ca_res_D 1.665335e-16 OK +------------------------------------------------------------------------------------- +All residuals < 1e-8 ✓ +[2026-08-19 08:22:24] steady state solved. +[2026-08-19 08:22:24] STEP 0 key numbers: +[2026-08-19 08:22:24] ss.lambda_gk_D = 2.108745763674537 +[2026-08-19 08:22:24] ss.lambda_gk_F = 0.641386691778482 +[2026-08-19 08:22:24] ss.Omega_D = 10.346180080803306 +[2026-08-19 08:22:24] ss.Omega_F = 4.037867105186291 +[2026-08-19 08:22:24] ss.nu_K_D = 0.10320475087383638 +[2026-08-19 08:22:24] ss.nu_K_F = 0.04027835059879345 +[2026-08-19 08:22:24] ss.phi_own_D = 0.45248932852499185 +[2026-08-19 08:22:24] ss.phi_own_F = 0.29407750727162807 +[2026-08-19 08:22:24] ss.gk_margin_D = 0.29656711600950253 +[2026-08-19 08:22:24] ss.gk_margin_F = 0.5917741252174545 +[2026-08-19 08:22:24] STEP 5: CB block SS neutrality +[2026-08-19 08:22:24] budget_residual_D_tpi: {'b_gov_D': 1.137033585178862, 'G_D': 0.08214336332079797, 'TAX_D': 0.08433546585861022, 'q_b_D': 0.9749060725100298, 'def_rate_D': 0.0, 'recovery_rate_D': 0.3, 'zeta_writeoff_D': 1.0, 'P_CES_D': 1.0002952418131537, 'delta_b_D': 0.07770055706859433, 'writeoff_enabled_D': 0.0, 'cb_buy_D': 0.0, 'kappa_cb_F': 0.929, 'b_gov_res_D': -4.163336342344337e-17, 'rem_cb_D': 0.0, 'cb_flow_D': 0.0} +[2026-08-19 08:22:24] budget_residual_F_tpi: {'b_gov_F': 0.495994658324439, 'G_F': 0.08318277792578103, 'TAX_F': 0.08414125500366162, 'q_b_F': 0.9659744865875028, 'def_rate_F': 0.0, 'recovery_rate_F': 0.3, 'zeta_writeoff_F': 1.0, 'p': 1.001970751142378, 'P_CES_F': 0.9999747396432228, 'delta_b_F': 0.056779421657665585, 'writeoff_enabled_F': 0.0, 'cb_flow_D': 0.0, 'kappa_cb_F': 0.929, 'size_F': 11.696650796880904, 'b_gov_res_F': -2.7755575615628914e-17, 'rem_cb_F': 0.0} +[2026-08-19 08:22:24] external_account_D_tpi: {'NX_D': 0.00026767011641889993, 'q_b_D': 0.9749060725100298, 'q_b_F': 0.9659744865875028, 'b_F_D': 0.007538986896364331, 'b_D_F': 0.012375326275958878, 'rb_actual_F': 0.001999999999991342, 'rb_actual_D': 0.0019999999999891216, 'cb_buy_D': 0.0, 'kappa_cb_F': 0.929, 'size_F': 11.696650796880904, 'nfa_D': -0.1338350582101086, 'ca_res_D': 1.6653345369377348e-16} +[2026-08-19 08:22:24] domestic_bond_clearing_tpi: {'b_gov_D': 1.137033585178862, 'b_gov_F': 0.495994658324439, 'b_D_F': 0.012375326275958878, 'b_F_D': 0.007538986896364331, 'cb_buy_D': 0.0, 'size_F': 11.696650796880904, 'b_D_D': 0.9922837152315064, 'b_F_F': 0.49535011596549516} +[2026-08-19 08:22:24] budget_residual_D: {'b_gov_D': 1.137033585178862, 'G_D': 0.08214336332079797, 'TAX_D': 0.08433546585861022, 'q_b_D': 0.9749060725100298, 'def_rate_D': 0.0, 'recovery_rate_D': 0.3, 'zeta_writeoff_D': 1.0, 'P_CES_D': 1.0002952418131537, 'delta_b_D': 0.07770055706859433, 'writeoff_enabled_D': 0.0, 'b_gov_res_D': -4.163336342344337e-17} +[2026-08-19 08:22:24] external_account_D: {'NX_D': 0.00026767011641889993, 'q_b_D': 0.9749060725100298, 'q_b_F': 0.9659744865875028, 'b_F_D': 0.007538986896364331, 'b_D_F': 0.012375326275958878, 'rb_actual_F': 0.001999999999991342, 'rb_actual_D': 0.0019999999999891216, 'size_F': 11.696650796880904, 'nfa_D': -0.1338350582101086, 'ca_res_D': 1.6653345369377348e-16} +[2026-08-19 08:22:24] domestic_bond_clearing: {'b_gov_D': 1.137033585178862, 'b_gov_F': 0.495994658324439, 'b_D_F': 0.012375326275958878, 'b_F_D': 0.007538986896364331, 'size_F': 11.696650796880904, 'b_D_D': 0.9922837152315064, 'b_F_F': 0.49535011596549516} +[2026-08-19 08:22:24] building dynamic model + baseline Jacobian (this is the ~6 min leg)... +Computing Jacobian G (T=500, 4 exogenous inputs)... + [H_Z zero-pad] no direct shock loading, rows restored as zero: ['nkpc_p_res_D', 'nkpc_p_res_F', 'tot_res', 'union_pi_res'] +G computed successfully. + shock persistences: rho_Z_D=0.8000, rho_def_D=0.9408 + +=== Stability check: debt level at t=499 (should be near 0) === + irfs_Z_D ['b_gov_D'][499] = -0.000028 + irfs_def_D['b_gov_D'][499] = 0.000030 + ρ_b (partial-eq., NOT the stationarity test) = 0.8451 [measured boundary: phi_lamb_D in (0.07, 0.10)] + +=== Pledgeability along the default-shock path === + Delta_bD_eff_D: [0.200000, 0.200000] (SS 0.200000) + Delta_bF_eff_D: [0.200000, 0.200000] (SS 0.200000) + Delta_bF_eff_F: [0.200000, 0.200000] (SS 0.200000) + Delta_bD_eff_F: [0.200000, 0.200000] (SS 0.200000) + +=== Impact signs (t=0, % of own SS) === + variable +1% TFP +1pp default + Y_D -0.0732% -1.9742% + C_D -2.8287% -2.5108% + I_D 5.0004% -3.0046% + N_D -3.7118% -3.0010% + w_D -7.2599% -5.9102% + n_inter_D 8.2670% -11.4073% + K_D 0.1119% -0.0673% + q_b_D 3.2248% -5.9498% + Y_D -0.0732% on a +1% TFP shock: flat on impact, hours-driven (N_D -3.71%). Expected under sticky prices with no monetary accommodation; not a defect. + n_inter_D[0] on default shock = -11.4073% of SS (level dev -24.3879; negative = doom loop correct sign) + Y_D[0] on default shock = -1.9742% of SS (level dev -1.9742; negative = correct sign) +[2026-08-19 08:22:52] baseline done. running TPI... +Computing G_tpi (T=500, 5 exogenous inputs)... + [H_Z zero-pad] no direct shock loading, rows restored as zero: ['nkpc_p_res_D', 'nkpc_p_res_F', 'tot_res', 'union_pi_res'] +G_tpi computed. +Sanity check G_tpi[cb=0] vs baseline G: max |err| = 0.00e+00 (expect < 1e-8) + gamma = 0 ... peak spread = +0.515 pp max|ca_res_D| = 7.21e-08 max|goods_mkt_F| = 1.89e-10 + gamma = 2 ... peak spread = +0.483 pp max|ca_res_D| = 5.55e-08 max|goods_mkt_F| = 1.96e-10 + gamma = 5 ... peak spread = +0.442 pp max|ca_res_D| = 3.38e-08 max|goods_mkt_F| = 2.04e-10 + gamma = 10 ... peak spread = +0.386 pp max|ca_res_D| = 1.47e-08 max|goods_mkt_F| = 2.15e-10 +Sanity check gamma=0 vs irfs_def_D: max |err| = 0.00e+00 (expect < 1e-8) +Sign check (gamma=0): n_inter_D[0] = -2.439e-01, Y_D[0] = -1.974e-02 (both must be negative) + + γ W_D W_F ΔW_D vs γ=0 ΔW_F vs γ=0 +──────────────────────────────────────────────────────────── + 0 -2.6844 0.1722 +0.0000 +0.0000 + 2 -2.4887 0.1526 +0.1956 -0.0195 + 5 -2.1689 0.1240 +0.5155 -0.0482 + 10 -1.5554 0.0778 +1.1290 -0.0944 +──────────────────────────────────────────────────────────── +(Units: % of quarterly SS consumption, discounted over 100 quarters) + +ECB balance-sheet P&L (PV at beta_F over 100q; % of quarterly SS Y_D) +Capital-key conduit: F share 0.929, D share 0.071 (two-country renormalised euro-area key) + γ peak expos. purch. PV EL PV prem PV SS-carry PV MTM PV loading +────────────────────────────────────────────────────────────────────────────────── + 0 0.000% 0.000% 0.0000% 0.0000% 0.0000% 0.0000% nan + 2 0.942% 1.343% 0.0719% 0.0374% -0.0000% 0.0245% 0.52 + 5 2.154% 3.218% 0.1603% 0.0808% -0.0000% 0.0534% 0.50 + 10 3.762% 6.063% 0.2706% 0.1303% -0.0000% 0.0869% 0.48 +────────────────────────────────────────────────────────────────────────────────── +loading = prem PV / EL PV — premium income the CB earns per unit of expected loss it absorbs. Measured from this run; no closed form, no target. + γ=2 : F bears EL PV = 0.0668% Y_D, receives prem PV = 0.0347% (memo at full euro-area key 26.1%: EL 0.0188%) + γ=5 : F bears EL PV = 0.1489% Y_D, receives prem PV = 0.0751% (memo at full euro-area key 26.1%: EL 0.0418%) + γ=10: F bears EL PV = 0.2513% Y_D, receives prem PV = 0.1211% (memo at full euro-area key 26.1%: EL 0.0706%) + closed-loop pole at gamma = 26.50; capping the effectiveness curve at gamma = 19.88 (0.75 x pole — beyond the pole is a different branch, and the approach to it is dominated by it) +[2026-08-19 08:23:27] TPI done. +Traceback (most recent call last): + File "/Users/Adam/Documents/uni/phd/research/QUANTITATIVE_MODEL/diagnostics/cb_audit/probe_pipeline.py", line 202, in + main() + File "/Users/Adam/Documents/uni/phd/research/QUANTITATIVE_MODEL/diagnostics/cb_audit/probe_pipeline.py", line 158, in main + arrays[f"G_{o}__shock_def_D"] = np.array(G[o]["shock_def_D"]) + ~~~~^^^^^^^^^^^^^^^ +KeyError: 'shock_def_D' diff --git a/diagnostics/cb_audit/probe_portfolio.py b/diagnostics/cb_audit/probe_portfolio.py new file mode 100644 index 0000000..cd4f10f --- /dev/null +++ b/diagnostics/cb_audit/probe_portfolio.py @@ -0,0 +1,237 @@ +"""CB-block audit — the decisive portfolio diagnostic. + +Reports the FULL 2x2 sovereign-holdings matrix (plus the CB row) before and after +TPI, in consistent aggregate market-value units. + +WHY THIS NEEDS ITS OWN SOLVE. probe_pipeline.py dumped b_D_D and b_D_F only. The +matrix needs all four legs plus both government stocks, and the per-capita +conventions differ across them (see UNITS below), so partial data cannot be +rescaled after the fact. + +UNITS -- the whole point of this diagnostic, and the thing that has bitten this +repo before (CLAUDE.md's "percentages must divide by their own SS level"): + + b_D_D D banks holding D paper D aggregate (per D capita, D size = 1) + b_F_D D banks holding F paper per D capita + b_D_F F banks holding D paper PER F CAPITA -> x size_F for an aggregate + b_F_F F banks holding own paper PER F CAPITA -> x size_F for an aggregate + b_gov_D D government stock D aggregate + b_gov_F F government stock PER F CAPITA -> x size_F for an aggregate + cb_buy_D CB book D aggregate (already; takes no weight) + + q_b_D, q_b_F are BOTH D-good prices (intermediation_P3_D adds q_b_F*b_F_D + straight onto Q_D*K_D; external_account_D books the F-bond receipt with no p), + so market values in D goods need no terms-of-trade conversion. + +The two clearing identities the matrix must satisfy: + D paper: b_gov_D = b_D_D + size_F*b_D_F + cb_buy_D + F paper: size_F*b_gov_F = size_F*b_F_F + b_F_D + +Audit-only. Writes portfolio_matrix.md / .json / .npz. No model source touched. +""" +import os, sys, json, copy, datetime +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +sys.path.insert(0, os.path.join(ROOT, "code")) + +LEGS = ["b_D_D", "b_F_D", "b_D_F", "b_F_F", "b_gov_D", "b_gov_F", + "q_b_D", "q_b_F", "n_inter_D", "n_inter_F", "cb_buy_D", + "spread_rb", "Y_D", "K_D", "theta_D", "theta_F"] +GAMMAS = [0, 2, 5, 10] + + +def ts(): + return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def log(m): + print(f"[{ts()}] {m}", flush=True) + + +def main(): + from calibration import get_calibration + from steady_state import solve_steady_state + from ic_delta_calibration import calibrate_ic_delta + from depreciation_calibration import calibrate_depreciation + from full_model import build_and_solve + import tpi as tpi_mod + + cal = get_calibration() + log("solving steady state...") + ssr = calibrate_depreciation(calibrate_ic_delta(solve_steady_state(cal))) + ss = ssr["ss_final"] if "ss_final" in ssr else ssr + log("steady state solved; building dynamic model...") + mr = build_and_solve(ssr) + log("running TPI...") + tr = tpi_mod.run_tpi(mr) + log("TPI done.") + + G = tr["G_tpi"] + T = tr["T"] + sF = float(ss["size_F"]) + + def g(k): + try: + return float(ss[k]) + except Exception: + return float(ss.toplevel[k]) + + SS = {k: (0.0 if k == "cb_buy_D" else g(k)) for k in LEGS} + irfs = {gam: {k: np.asarray(tr["irfs_tpi"][gam][k]) if k in tr["irfs_tpi"][gam] + else np.zeros(T) for k in LEGS} for gam in GAMMAS} + for gam in GAMMAS: + irfs[gam]["cb_buy_D"] = np.asarray(tr["irfs_tpi"][gam]["cb_buy_D"]) + + # ── the matrix, in AGGREGATE D-GOOD MARKET VALUE ───────────────────────── + # weight[leg] converts the model variable to a D-capita aggregate quantity. + W = {"b_D_D": 1.0, "b_F_D": 1.0, "b_D_F": sF, "b_F_F": sF, + "b_gov_D": 1.0, "b_gov_F": sF, "cb_buy_D": 1.0} + PRICE = {"b_D_D": "q_b_D", "b_F_D": "q_b_F", "b_D_F": "q_b_D", + "b_F_F": "q_b_F", "b_gov_D": "q_b_D", "b_gov_F": "q_b_F", + "cb_buy_D": "q_b_D"} + + def mv_level(leg, gam=None, t=None): + """Market value in D goods. Level if gam is None, else level + deviation. + + Everything is a linearised deviation, so the market value moves as + q*db + b*dq (the dq*db cross term is second order and is dropped, in + line with cb_pnl's convention in code/tpi.py).""" + q, b = SS[PRICE[leg]], SS[leg] + base = W[leg] * q * b + if gam is None: + return base + dq, db = irfs[gam][PRICE[leg]][t], irfs[gam][leg][t] + return base + W[leg] * (q * db + b * dq) + + def qty_level(leg, gam=None, t=None): + base = W[leg] * SS[leg] + if gam is None: + return base + return base + W[leg] * irfs[gam][leg][t] + + # ── clearing identities ────────────────────────────────────────────────── + checks = {} + checks["SS D paper"] = (qty_level("b_gov_D") + - qty_level("b_D_D") - qty_level("b_D_F") + - qty_level("cb_buy_D")) + checks["SS F paper"] = (qty_level("b_gov_F") + - qty_level("b_F_F") - qty_level("b_F_D")) + for gam in GAMMAS: + for t in (0, 4, 20): + checks[f"g{gam} t{t} D paper"] = ( + qty_level("b_gov_D", gam, t) - qty_level("b_D_D", gam, t) + - qty_level("b_D_F", gam, t) - qty_level("cb_buy_D", gam, t)) + checks[f"g{gam} t{t} F paper"] = ( + qty_level("b_gov_F", gam, t) - qty_level("b_F_F", gam, t) + - qty_level("b_F_D", gam, t)) + + L = [] + def p(s=""): + print(s); L.append(s) + + p("# The 2x2 sovereign-holdings matrix, before and after TPI\n") + p(f"Generated {ts()} by `diagnostics/cb_audit/probe_portfolio.py`. " + f"`size_F` = {sF:.6f}.\n") + p("All entries are **aggregate market value in D goods**, `q_b * quantity`, with " + "per-F-capita legs (`b_D_F`, `b_F_F`, `b_gov_F`) scaled by `size_F`. " + "`q_b_D` and `q_b_F` are both D-good prices, so no terms-of-trade conversion " + "enters.\n") + + p("## Clearing identities (must be ~0)\n") + p("| point | D paper residual | F paper residual |") + p("|---|---|---|") + p(f"| steady state | {checks['SS D paper']:+.3e} | {checks['SS F paper']:+.3e} |") + for gam in GAMMAS: + for t in (0, 4, 20): + p(f"| gamma={gam}, t={t} | {checks[f'g{gam} t{t} D paper']:+.3e} | " + f"{checks[f'g{gam} t{t} F paper']:+.3e} |") + p("") + + # ── the matrix itself ──────────────────────────────────────────────────── + ROWS = [("D banks", "b_D_D", "b_F_D"), + ("F banks", "b_D_F", "b_F_F")] + + def render(title, gam, t): + p(f"### {title}\n") + p("| holder | D paper | F paper | total | D paper, % of D issue |") + p("|---|---|---|---|---|") + tot_D = qty_level("b_gov_D", gam, t) * (SS["q_b_D"] if False else 1.0) + mvD_issue = mv_level("b_gov_D", gam, t) + colD = colF = 0.0 + for name, lD, lF in ROWS: + vD, vF = mv_level(lD, gam, t), mv_level(lF, gam, t) + colD += vD; colF += vF + p(f"| {name} | {vD:+.6f} | {vF:+.6f} | {vD+vF:+.6f} | " + f"{100*vD/mvD_issue:6.2f}% |") + vcb = mv_level("cb_buy_D", gam, t) + colD += vcb + p(f"| **CB** | {vcb:+.6f} | 0.000000 | {vcb:+.6f} | " + f"{100*vcb/mvD_issue:6.2f}% |") + p(f"| **total held** | {colD:+.6f} | {colF:+.6f} | {colD+colF:+.6f} | |") + p(f"| **issued** | {mvD_issue:+.6f} | {mv_level('b_gov_F', gam, t):+.6f} | " + f"{mvD_issue+mv_level('b_gov_F', gam, t):+.6f} | |") + p("") + + render("Steady state (TPI dormant, `cb_buy_ss = 0`)", None, None) + for gam in GAMMAS: + for t in (0,): + render(f"Impact of the 1pp default shock, t={t}, gamma={gam}", gam, t) + + # ── deltas vs the no-TPI counterfactual, the decisive table ────────────── + p("## What TPI moves: holdings at t=0 relative to gamma=0 (same shock)\n") + p("Aggregate market value in D goods, and as a % of the D-paper stock at SS.\n") + mvD_ss = mv_level("b_gov_D") + p("| leg | SS level | g=0 | g=2 | g=5 | g=10 | d(g=10 - g=0) | as % of SS D issue |") + p("|---|---|---|---|---|---|---|---|") + for label, leg in (("D banks / D paper", "b_D_D"), + ("F banks / D paper", "b_D_F"), + ("CB / D paper", "cb_buy_D"), + ("D banks / F paper", "b_F_D"), + ("F banks / F paper", "b_F_F"), + ("D govt issue", "b_gov_D"), + ("F govt issue", "b_gov_F")): + v = {gam: mv_level(leg, gam, 0) for gam in GAMMAS} + d = v[10] - v[0] + p(f"| {label} | {mv_level(leg):+.6f} | {v[0]:+.6f} | {v[2]:+.6f} | " + f"{v[5]:+.6f} | {v[10]:+.6f} | {d:+.6f} | {100*d/mvD_ss:+.3f}% |") + p("") + + # ── concentration ratios: what the IC actually sees ────────────────────── + p("## Sovereign concentration `phi = q_b*b / n_inter` — what the IC sees\n") + p("This is the object `intermediation_IC_D/F` reads, so it is where the " + "portfolio shift becomes a constraint effect.\n") + p("| ratio | SS | g=0 t0 | g=2 t0 | g=5 t0 | g=10 t0 |") + p("|---|---|---|---|---|---|") + for label, leg, n in (("phi_bD_D (D bank, own paper)", "b_D_D", "n_inter_D"), + ("phi_bF_D (D bank, F paper)", "b_F_D", "n_inter_D"), + ("phi_bD_F (F bank, D paper)", "b_D_F", "n_inter_F"), + ("phi_bF_F (F bank, own paper)", "b_F_F", "n_inter_F")): + # phi is per-capita on both numerator and denominator -> no size_F weight + q, b, nn = SS[PRICE[leg]], SS[leg], SS[n] + base = q * b / nn + row = [f"{base:.6f}"] + for gam in GAMMAS: + dq, db, dn = (irfs[gam][PRICE[leg]][0], irfs[gam][leg][0], + irfs[gam][n][0]) + row.append(f"{base + (q*db + b*dq)/nn - q*b*dn/nn**2:.6f}") + p(f"| {label} | " + " | ".join(row) + " |") + p("") + + payload = {"size_F": sF, "SS": SS, "checks": checks, + "matrix_mv": {("ss" if gam is None else f"g{gam}"): + {leg: mv_level(leg, gam, None if gam is None else 0) + for leg in W} + for gam in [None] + GAMMAS}} + json.dump(payload, open(os.path.join(HERE, "portfolio_matrix.json"), "w"), + indent=2, default=str) + np.savez_compressed(os.path.join(HERE, "portfolio_matrix.npz"), + **{f"irf_g{gam}_{k}": irfs[gam][k] + for gam in GAMMAS for k in LEGS}) + open(os.path.join(HERE, "portfolio_matrix.md"), "w").write("\n".join(L) + "\n") + log("WROTE portfolio_matrix.md / .json / .npz") + + +if __name__ == "__main__": + main() diff --git a/diagnostics/cb_audit/probe_portfolio_stdout.txt b/diagnostics/cb_audit/probe_portfolio_stdout.txt new file mode 100644 index 0000000..8c712f9 --- /dev/null +++ b/diagnostics/cb_audit/probe_portfolio_stdout.txt @@ -0,0 +1,255 @@ +[2026-08-19 10:07:00] solving steady state... +Solving initial steady state... +Targeting portfolio shares (EBA 2011, 31 Dec 2010)... + size_F = 11.6967 (F/D GDP; every F variable is per F capita) + foreign-held share of the bank-held sovereign stock: D = 0.1273 (EBA 0.1272) F = 0.001299 (EBA 0.001301) + bank-held stock in own-country quarterly GDP: B_D = 1.1370 (EBA 1.1160) B_F = 0.4960 (EBA 0.4829) + D-bank: phi_bD_D = 0.456 phi_bF_D = 0.003 omega_K_D = 1.0000 + F-bank: phi_bD_F = 0.007 phi_bF_F = 0.296 omega_K_F = 1.0000 + over-identifying check (K from the measured balance sheet, target 10.8): K_D = 10.800 K_F = 10.812 +Re-solving SS with new portfolio allocation... +SS re-solved. beta_D=0.99948801 p=1.001508 + +=== GK steady state: sovereign block === + q_b yield q/q yield ann def_rate recovery EL_load delta_b duration + D 0.981293 0.001481 59.25bp 0.0000 0.30 0.701037 0.0777 12.9q + F 0.974574 0.001481 59.25bp 0.0000 0.30 0.701037 0.0568 17.6q + SS spread (D-F) = -0.0020 bp annualised (zero is correct: def_rate_ss = 0 in both countries) + + lambda_gk Omega theta n_inter K q_b*b_own q_b*b_cross + D 1.0191 5.0618 5.5107 2.1379 10.8003 0.9737 0.0073 + F 0.4148 2.6539 6.9414 1.6271 10.8002 0.4828 0.0121 + + Portfolio FOC check nu_i/nu_K vs Delta_i_eff + bank leg nu_i/nu_K Delta_eff residual status + D own 0.200000 0.200000 -7.133e-15 OK + D cross 0.200007 0.200000 6.740e-06 OK + F own 0.200000 0.200000 -4.663e-15 OK + F cross 0.199993 0.200000 -6.740e-06 OK + D ratio 0.999966 1.000000 -3.370e-05 + F ratio 1.000034 1.000000 3.370e-05 + cross-border SS wedges (constant, def_rate-free): F-in-D = +1.997e-03 bp/yr D-in-F = -1.997e-03 bp/yr + psi_lambda_B = 0.0000/0.0000 zeta_writeoff = 1.0/1.0 writeoff_enabled = 0.0/0.0 +IC residual check (SS satisfies the multi-asset IC at the calibrated Delta): + D-bank: Delta_own = 0.2000 Delta_cross = 0.2000 value = 5.241623 divertable_lev = 5.143585 residual = +1.776e-15 + F-bank: Delta_own = 0.2000 Delta_cross = 0.2000 value = 2.778661 divertable_lev = 6.698440 residual = +0.000e+00 + depreciation iter 1: delta_D = 0.02240642 delta_F = 0.02240667 max|rk - target| = 1.906e-05 + depreciation iter 2: delta_D = 0.02238736 delta_F = 0.02239312 max|rk - target| = 1.387e-07 + depreciation iter 3: delta_D = 0.02238722 delta_F = 0.02239299 max|rk - target| = 1.010e-09 + depreciation iter 4: delta_D = 0.02238722 delta_F = 0.02239299 max|rk - target| = 7.355e-12 + depreciation iter 5: delta_D = 0.02238722 delta_F = 0.02239299 max|rk - target| = 5.358e-14 +Verified rk_D = 0.010000 (target 0.0100) +Verified rk_F = 0.010000 (target 0.0100) +Final beta_D = 0.9995343360 +Final beta_F = 0.9995353731 + +beta_D=0.9995343360 beta_F=0.9995353731 p=1.001971 +rb_D=0.002000 rb_F=0.002000 rdep_D=0.000000 rdep_F=0.000000 +q_b_D=0.974906 q_b_F=0.965974 +SS goods residuals: + goods_mkt_D = -4.22811451933347e-07 + goods_mkt_F = -4.181785814207778e-07 + ca_res_D = 1.6653345369377348e-16 + +=== GK steady state: sovereign block === + q_b yield q/q yield ann def_rate recovery EL_load delta_b duration + D 0.974906 0.002000 80.00bp 0.0000 0.30 0.701400 0.0777 12.9q + F 0.965974 0.002000 80.00bp 0.0000 0.30 0.701400 0.0568 17.6q + SS spread (D-F) = -0.0000 bp annualised (zero is correct: def_rate_ss = 0 in both countries) + + lambda_gk Omega theta n_inter K q_b*b_own q_b*b_cross + D 2.1087 10.3462 5.5107 2.1379 10.8067 0.9674 0.0073 + F 0.6414 4.0379 6.9414 1.6271 10.8048 0.4785 0.0121 + + Portfolio FOC check nu_i/nu_K vs Delta_i_eff + bank leg nu_i/nu_K Delta_eff residual status + D own 0.200000 0.200000 -1.624e-14 OK + D cross 0.200000 0.200000 2.058e-13 OK + F own 0.200000 0.200000 -4.746e-15 OK + F cross 0.200000 0.200000 -2.268e-13 OK + D ratio 1.000000 1.000000 -1.110e-12 + F ratio 1.000000 1.000000 1.110e-12 + cross-border SS wedges (constant, def_rate-free): F-in-D = +8.231e-11 bp/yr D-in-F = -9.071e-11 bp/yr + psi_lambda_B = 0.0000/0.0000 zeta_writeoff = 1.0/1.0 writeoff_enabled = 0.0/0.0 + +Block residual Value Status +------------------------------------------------------------------------------------- + IC_D: θ − θ_tgt 0.000000e+00 OK + IC_F: θ − θ_tgt 0.000000e+00 OK + P1_D: nu_K_res 0.000000e+00 OK + P1_D: nu_bh_res 0.000000e+00 OK + P1_D: nu_bx_res 0.000000e+00 OK + P1_D: eta_res 0.000000e+00 OK + P1_F: nu_K_res 0.000000e+00 OK + P1_F: nu_bh_res 0.000000e+00 OK + P1_F: nu_bx_res 0.000000e+00 OK + P1_F: eta_res 0.000000e+00 OK + ca_res_D 1.665335e-16 OK +------------------------------------------------------------------------------------- +All residuals < 1e-8 ✓ +[2026-08-19 10:08:45] steady state solved; building dynamic model... +Computing Jacobian G (T=500, 4 exogenous inputs)... + [H_Z zero-pad] no direct shock loading, rows restored as zero: ['nkpc_p_res_D', 'nkpc_p_res_F', 'tot_res', 'union_pi_res'] +G computed successfully. + shock persistences: rho_Z_D=0.8000, rho_def_D=0.9408 + +=== Stability check: debt level at t=499 (should be near 0) === + irfs_Z_D ['b_gov_D'][499] = -0.000028 + irfs_def_D['b_gov_D'][499] = 0.000030 + ρ_b (partial-eq., NOT the stationarity test) = 0.8451 [measured boundary: phi_lamb_D in (0.07, 0.10)] + +=== Pledgeability along the default-shock path === + Delta_bD_eff_D: [0.200000, 0.200000] (SS 0.200000) + Delta_bF_eff_D: [0.200000, 0.200000] (SS 0.200000) + Delta_bF_eff_F: [0.200000, 0.200000] (SS 0.200000) + Delta_bD_eff_F: [0.200000, 0.200000] (SS 0.200000) + +=== Impact signs (t=0, % of own SS) === + variable +1% TFP +1pp default + Y_D -0.0732% -1.9742% + C_D -2.8287% -2.5108% + I_D 5.0004% -3.0046% + N_D -3.7118% -3.0010% + w_D -7.2599% -5.9102% + n_inter_D 8.2670% -11.4073% + K_D 0.1119% -0.0673% + q_b_D 3.2248% -5.9498% + Y_D -0.0732% on a +1% TFP shock: flat on impact, hours-driven (N_D -3.71%). Expected under sticky prices with no monetary accommodation; not a defect. + n_inter_D[0] on default shock = -11.4073% of SS (level dev -24.3879; negative = doom loop correct sign) + Y_D[0] on default shock = -1.9742% of SS (level dev -1.9742; negative = correct sign) +[2026-08-19 10:09:13] running TPI... +Computing G_tpi (T=500, 5 exogenous inputs)... + [H_Z zero-pad] no direct shock loading, rows restored as zero: ['nkpc_p_res_D', 'nkpc_p_res_F', 'tot_res', 'union_pi_res'] +G_tpi computed. +Sanity check G_tpi[cb=0] vs baseline G: max |err| = 0.00e+00 (expect < 1e-8) + gamma = 0 ... peak spread = +0.515 pp max|ca_res_D| = 7.21e-08 max|goods_mkt_F| = 1.89e-10 + gamma = 2 ... peak spread = +0.483 pp max|ca_res_D| = 5.55e-08 max|goods_mkt_F| = 1.96e-10 + gamma = 5 ... peak spread = +0.442 pp max|ca_res_D| = 3.38e-08 max|goods_mkt_F| = 2.04e-10 + gamma = 10 ... peak spread = +0.386 pp max|ca_res_D| = 1.47e-08 max|goods_mkt_F| = 2.15e-10 +Sanity check gamma=0 vs irfs_def_D: max |err| = 0.00e+00 (expect < 1e-8) +Sign check (gamma=0): n_inter_D[0] = -2.439e-01, Y_D[0] = -1.974e-02 (both must be negative) + + γ W_D W_F ΔW_D vs γ=0 ΔW_F vs γ=0 +──────────────────────────────────────────────────────────── + 0 -2.6844 0.1722 +0.0000 +0.0000 + 2 -2.4887 0.1526 +0.1956 -0.0195 + 5 -2.1689 0.1240 +0.5155 -0.0482 + 10 -1.5554 0.0778 +1.1290 -0.0944 +──────────────────────────────────────────────────────────── +(Units: % of quarterly SS consumption, discounted over 100 quarters) + +ECB balance-sheet P&L (PV at beta_F over 100q; % of quarterly SS Y_D) +Capital-key conduit: F share 0.929, D share 0.071 (two-country renormalised euro-area key) + γ peak expos. purch. PV EL PV prem PV SS-carry PV MTM PV loading +────────────────────────────────────────────────────────────────────────────────── + 0 0.000% 0.000% 0.0000% 0.0000% 0.0000% 0.0000% nan + 2 0.942% 1.343% 0.0719% 0.0374% -0.0000% 0.0245% 0.52 + 5 2.154% 3.218% 0.1603% 0.0808% -0.0000% 0.0534% 0.50 + 10 3.762% 6.063% 0.2706% 0.1303% -0.0000% 0.0869% 0.48 +────────────────────────────────────────────────────────────────────────────────── +loading = prem PV / EL PV — premium income the CB earns per unit of expected loss it absorbs. Measured from this run; no closed form, no target. + γ=2 : F bears EL PV = 0.0668% Y_D, receives prem PV = 0.0347% (memo at full euro-area key 26.1%: EL 0.0188%) + γ=5 : F bears EL PV = 0.1489% Y_D, receives prem PV = 0.0751% (memo at full euro-area key 26.1%: EL 0.0418%) + γ=10: F bears EL PV = 0.2513% Y_D, receives prem PV = 0.1211% (memo at full euro-area key 26.1%: EL 0.0706%) + closed-loop pole at gamma = 26.50; capping the effectiveness curve at gamma = 19.88 (0.75 x pole — beyond the pole is a different branch, and the approach to it is dominated by it) +[2026-08-19 10:09:49] TPI done. +# The 2x2 sovereign-holdings matrix, before and after TPI + +Generated 2026-08-19 10:09:49 by `diagnostics/cb_audit/probe_portfolio.py`. `size_F` = 11.696651. + +All entries are **aggregate market value in D goods**, `q_b * quantity`, with per-F-capita legs (`b_D_F`, `b_F_F`, `b_gov_F`) scaled by `size_F`. `q_b_D` and `q_b_F` are both D-good prices, so no terms-of-trade conversion enters. + +## Clearing identities (must be ~0) + +| point | D paper residual | F paper residual | +|---|---|---| +| steady state | +1.110e-16 | -3.227e-16 | +| gamma=0, t=0 | +5.551e-17 | -5.022e-16 | +| gamma=0, t=4 | +8.327e-17 | -5.291e-16 | +| gamma=0, t=20 | +2.220e-16 | -5.317e-16 | +| gamma=2, t=0 | +1.527e-16 | -5.499e-16 | +| gamma=2, t=4 | -8.500e-17 | +7.286e-17 | +| gamma=2, t=20 | -4.207e-17 | -6.791e-16 | +| gamma=5, t=0 | +4.857e-17 | -8.864e-16 | +| gamma=5, t=4 | +1.180e-16 | +8.674e-17 | +| gamma=5, t=20 | +1.145e-16 | -1.130e-15 | +| gamma=10, t=0 | +1.874e-16 | -1.180e-16 | +| gamma=10, t=4 | +1.249e-16 | -3.695e-16 | +| gamma=10, t=20 | +2.429e-17 | -7.702e-16 | + +### Steady state (TPI dormant, `cb_buy_ss = 0`) + +| holder | D paper | F paper | total | D paper, % of D issue | +|---|---|---|---|---| +| D banks | +0.967383 | +0.007282 | +0.974666 | 87.27% | +| F banks | +0.141118 | +5.596796 | +5.737913 | 12.73% | +| **CB** | +0.000000 | 0.000000 | +0.000000 | 0.00% | +| **total held** | +1.108501 | +5.604078 | +6.712579 | | +| **issued** | +1.108501 | +5.604078 | +6.712579 | | + +### Impact of the 1pp default shock, t=0, gamma=0 + +| holder | D paper | F paper | total | D paper, % of D issue | +|---|---|---|---|---| +| D banks | +0.930292 | +0.007584 | +0.937875 | 87.76% | +| F banks | +0.129756 | +5.625571 | +5.755327 | 12.24% | +| **CB** | +0.000000 | 0.000000 | +0.000000 | 0.00% | +| **total held** | +1.060048 | +5.633155 | +6.693202 | | +| **issued** | +1.060048 | +5.633155 | +6.693202 | | + +### Impact of the 1pp default shock, t=0, gamma=2 + +| holder | D paper | F paper | total | D paper, % of D issue | +|---|---|---|---|---| +| D banks | +0.929302 | +0.008184 | +0.937486 | 87.54% | +| F banks | +0.122907 | +5.626666 | +5.749574 | 11.58% | +| **CB** | +0.009420 | 0.000000 | +0.009420 | 0.89% | +| **total held** | +1.061630 | +5.634850 | +6.696480 | | +| **issued** | +1.061630 | +5.634850 | +6.696480 | | + +### Impact of the 1pp default shock, t=0, gamma=5 + +| holder | D paper | F paper | total | D paper, % of D issue | +|---|---|---|---|---| +| D banks | +0.928030 | +0.008954 | +0.936985 | 87.25% | +| F banks | +0.114123 | +5.627939 | +5.742062 | 10.73% | +| **CB** | +0.021539 | 0.000000 | +0.021539 | 2.02% | +| **total held** | +1.063692 | +5.636893 | +6.700586 | | +| **issued** | +1.063692 | +5.636893 | +6.700586 | | + +### Impact of the 1pp default shock, t=0, gamma=10 + +| holder | D paper | F paper | total | D paper, % of D issue | +|---|---|---|---|---| +| D banks | +0.926341 | +0.009973 | +0.936314 | 86.86% | +| F banks | +0.102520 | +5.629347 | +5.731867 | 9.61% | +| **CB** | +0.037622 | 0.000000 | +0.037622 | 3.53% | +| **total held** | +1.066483 | +5.639320 | +6.705803 | | +| **issued** | +1.066483 | +5.639320 | +6.705803 | | + +## What TPI moves: holdings at t=0 relative to gamma=0 (same shock) + +Aggregate market value in D goods, and as a % of the D-paper stock at SS. + +| leg | SS level | g=0 | g=2 | g=5 | g=10 | d(g=10 - g=0) | as % of SS D issue | +|---|---|---|---|---|---|---|---| +| D banks / D paper | +0.967383 | +0.930292 | +0.929302 | +0.928030 | +0.926341 | -0.003951 | -0.356% | +| F banks / D paper | +0.141118 | +0.129756 | +0.122907 | +0.114123 | +0.102520 | -0.027236 | -2.457% | +| CB / D paper | +0.000000 | +0.000000 | +0.009420 | +0.021539 | +0.037622 | +0.037622 | +3.394% | +| D banks / F paper | +0.007282 | +0.007584 | +0.008184 | +0.008954 | +0.009973 | +0.002389 | +0.216% | +| F banks / F paper | +5.596796 | +5.625571 | +5.626666 | +5.627939 | +5.629347 | +0.003777 | +0.341% | +| D govt issue | +1.108501 | +1.060048 | +1.061630 | +1.063692 | +1.066483 | +0.006435 | +0.581% | +| F govt issue | +5.604078 | +5.633155 | +5.634850 | +5.636893 | +5.639320 | +0.006166 | +0.556% | + +## Sovereign concentration `phi = q_b*b / n_inter` — what the IC sees + +This is the object `intermediation_IC_D/F` reads, so it is where the portfolio shift becomes a constraint effect. + +| ratio | SS | g=0 t0 | g=2 t0 | g=5 t0 | g=10 t0 | +|---|---|---|---|---|---| +| phi_bD_D (D bank, own paper) | 0.452489 | 0.486757 | 0.479736 | 0.470614 | 0.458345 | +| phi_bF_D (D bank, F paper) | 0.003406 | 0.003936 | 0.004167 | 0.004463 | 0.004853 | +| phi_bD_F (F bank, D paper) | 0.007415 | 0.006774 | 0.006428 | 0.005983 | 0.005397 | +| phi_bF_F (F bank, own paper) | 0.294078 | 0.293864 | 0.294447 | 0.295200 | 0.296203 | + +[2026-08-19 10:09:49] WROTE portfolio_matrix.md / .json / .npz diff --git a/diagnostics/cb_audit/probe_stability.md b/diagnostics/cb_audit/probe_stability.md new file mode 100644 index 0000000..c9dcae9 --- /dev/null +++ b/diagnostics/cb_audit/probe_stability.md @@ -0,0 +1,84 @@ +# Step 4 — CB feedback sign and closed-loop stability + +A_cb = d(spread_rb)/d(cb_buy_D), T = 500 + +## 4a. Sign of the feedback + +| lag h | A_cb[h,0] (impulse at t=0) | A_cb[h,h] (diagonal) | +|---|---|---| +| 0 | -4.397083e-03 | -4.397083e-03 | +| 1 | -1.533000e-03 | -4.169005e-03 | +| 2 | -1.518925e-03 | -3.944466e-03 | +| 3 | -1.437022e-03 | -3.724130e-03 | +| 4 | -1.325046e-03 | -3.519005e-03 | +| 5 | -1.202372e-03 | -3.336355e-03 | +| 6 | -1.079358e-03 | -3.178659e-03 | +| 7 | -9.616160e-04 | -3.045240e-03 | + +- `A_cb[0,0] = -4.397083e-03` -> impact effect of a unit purchase on the impact spread is **NEGATIVE (compresses)** +- column sum `sum_h A_cb[h,0] = -1.597028e-02` (cumulative spread response to a one-period purchase at t=0) +- fraction of the t=0 column that is negative: 16.4% +- most positive entry in the t=0 column: +1.849e-05 at lag 111 +- spectral radius of A_cb = 0.452155; max real eigenvalue = +0.452155; min real eigenvalue = -0.023262 +- 1 / max real eigenvalue = +2.2116 (the gamma at which I - gamma*A_cb becomes singular, if the max real eigenvalue is the binding one) + +## 4b. Closed-loop pole and margin + +- condition-number scan: {'gamma_pole': 26.5, 'gamma_safe_max': 19.875} +- intended gamma range: [0, 10] (code/tpi.py gamma_values = [0, 2, 5, 10]) +- margin: pole / gamma_max = 2.65x; the 0.75-safety cap sits at gamma = 19.88 + +## 4c. Per-gamma stability + +| gamma | peak spread (bp ann) | b_gov_D[499] | n_inter_D[0] | Y_D[0] | |lam| spread | |lam| b_gov_D | |lam| n_inter_D | +|---|---|---|---|---|---|---|---| +| 0 | 205.87 | +3.024e-05 | -2.4388e-01 | -1.9742e-02 | 0.939968 | 0.828311 | 0.934720 | +| 2 | 193.25 | +3.694e-04 | -2.1289e-01 | -1.5702e-02 | 0.941372 | 0.841631 | 0.937532 | +| 5 | 176.75 | -5.615e-05 | -1.7260e-01 | -1.0428e-02 | 0.943455 | 0.844431 | 0.941139 | +| 10 | 154.36 | -1.506e-04 | -1.1837e-01 | -3.2741e-03 | 0.946993 | 0.923171 | 0.946025 | + +All moduli must be < 1 for a stationary closed loop. + +## 4d. Breakdown scan over gamma (open-loop grid, up to the pole) + +| gamma | peak spread (bp ann) | compression vs g=0 | cond(I - g A_cb) | |lam| spread | +|---|---|---|---|---| +| 0 | 205.87 | +0.0% | 1.000e+00 | 0.939968 | +| 1 | 199.38 | +3.2% | 3.004e+01 | 0.940676 | +| 2 | 193.25 | +6.1% | 6.461e+02 | 0.941372 | +| 5 | 176.75 | +14.1% | 3.090e+02 | 0.943455 | +| 10 | 154.36 | +25.0% | 4.803e+02 | 0.946993 | +| 15 | 136.73 | +33.6% | 7.833e+02 | 0.951232 | +| 19.88 | 122.90 | +40.3% | 1.406e+03 | 0.958447 | +| 22 | 117.75 | +42.8% | 2.007e+03 | 0.978901 | +| 25 | 111.41 | +45.9% | 4.656e+03 | 0.997033 | +| 26.5 | 109.50 | +46.8% | 1.274e+04 | nan | + +## 4e. On-path fiscal incidence of the conduit + +conduit series present in the dump: ['G_cb_flow_D__cb_buy_D', 'G_rem_cb_D__cb_buy_D', 'G_rem_cb_F__cb_buy_D', 'irf_g0_cb_flow_D', 'irf_g0_rem_cb_D', 'irf_g0_rem_cb_F', 'irf_g10_cb_flow_D', 'irf_g10_rem_cb_D', 'irf_g10_rem_cb_F', 'irf_g2_cb_flow_D', 'irf_g2_rem_cb_D', 'irf_g2_rem_cb_F', 'irf_g5_cb_flow_D', 'irf_g5_rem_cb_D', 'irf_g5_rem_cb_F'] +G columns that do not exist (pure-CB objects have no shock_def_D loading): ['rem_cb_D__shock_def_D', 'rem_cb_F__shock_def_D', 'cb_flow_D__shock_def_D'] +- g=2 rem_cb_D : t0 = -6.688102e-04, t1 = +3.165945e-05, min = -6.688102e-04, max = +3.636726e-05, undiscounted sum(0:100) = +7.724208e-06 +- g=2 rem_cb_F : t0 = -7.466957e-04, t1 = +3.534631e-05, min = -7.466957e-04, max = +4.060237e-05, undiscounted sum(0:100) = +8.623722e-06 +- g=2 cb_flow_D : t0 = -9.419861e-03, t1 = +4.459077e-04, min = -9.419861e-03, max = +5.122150e-04, undiscounted sum(0:100) = +1.087917e-04 +- g=5 rem_cb_D : t0 = -1.529293e-03, t1 = +6.941594e-05, min = -1.529293e-03, max = +8.148337e-05, undiscounted sum(0:100) = +9.616795e-06 +- g=5 rem_cb_F : t0 = -1.707385e-03, t1 = +7.749969e-05, min = -1.707385e-03, max = +9.097242e-05, undiscounted sum(0:100) = +1.073671e-05 +- g=5 cb_flow_D : t0 = -2.153934e-02, t1 = +9.776893e-04, min = -2.153934e-02, max = +1.147653e-03, undiscounted sum(0:100) = +1.354478e-04 +- g=10 rem_cb_D : t0 = -2.671150e-03, t1 = +1.131731e-04, min = -2.671150e-03, max = +1.378937e-04, undiscounted sum(0:100) = -1.127434e-05 +- g=10 rem_cb_F : t0 = -2.982216e-03, t1 = +1.263526e-04, min = -2.982216e-03, max = +1.539519e-04, undiscounted sum(0:100) = -1.258728e-05 +- g=10 cb_flow_D : t0 = -3.762183e-02, t1 = +1.593988e-03, min = -3.762183e-02, max = +1.942164e-03, undiscounted sum(0:100) = -1.587936e-04 +- g=2 TAX_D : t0 = -1.057603e-02, peak|.| = +1.057603e-02 +- g=2 TAX_F : t0 = +4.620592e-04, peak|.| = +4.620592e-04 +- g=5 TAX_D : t0 = -8.136094e-03, peak|.| = +8.136094e-03 +- g=5 TAX_F : t0 = +2.489603e-04, peak|.| = +3.360112e-04 +- g=10 TAX_D : t0 = -4.840994e-03, peak|.| = +4.840994e-03 +- g=10 TAX_F : t0 = -3.985060e-05, peak|.| = +4.535629e-04 + +## 4f. Walras residuals along the closed loop + +| gamma | max|ca_res_D| | max|goods_mkt_D| | max|goods_mkt_F| | +|---|---|---|---| +| 0 | 7.21e-08 | 5.86e-17 | 1.89e-10 | +| 2 | 5.55e-08 | 5.47e-17 | 1.96e-10 | +| 5 | 3.38e-08 | 5.59e-17 | 2.04e-10 | +| 10 | 1.47e-08 | 5.84e-17 | 2.15e-10 | diff --git a/diagnostics/cb_audit/probe_stability.py b/diagnostics/cb_audit/probe_stability.py new file mode 100644 index 0000000..837b018 --- /dev/null +++ b/diagnostics/cb_audit/probe_stability.py @@ -0,0 +1,128 @@ +"""CB-block audit — Step 4 analysis (feedback sign + closed-loop stability). + +Reads diagnostics/cb_audit/probe_pipeline.npz (written by probe_pipeline.py) and +reports: + * the sign and shape of A_cb = d(spread_rb)/d(cb_buy_D); + * the closed-loop pole and the safety margin against the intended gamma range; + * per-gamma stability: b_gov_D[499], and the dominant modulus of the spread, + b_gov_D and n_inter_D tails via the validated Prony estimator; + * the CB conduit's on-path fiscal incidence (rem_cb_D / rem_cb_F signs). + +Audit-only; writes probe_stability.md. +""" +import os, json +import numpy as np +from prony import prony_modulus + +HERE = os.path.dirname(os.path.abspath(__file__)) +d = np.load(os.path.join(HERE, "probe_pipeline.npz")) +J = json.load(open(os.path.join(HERE, "probe_pipeline.json"))) + +L = [] +def p(s=""): + print(s) + L.append(s) + +A_cb = d["A_cb"] +A_def = d["A_def"] +T = A_cb.shape[0] + +p("# Step 4 — CB feedback sign and closed-loop stability\n") +p(f"A_cb = d(spread_rb)/d(cb_buy_D), T = {T}\n") +p("## 4a. Sign of the feedback\n") +p("| lag h | A_cb[h,0] (impulse at t=0) | A_cb[h,h] (diagonal) |") +p("|---|---|---|") +for h in range(8): + p(f"| {h} | {A_cb[h,0]:+.6e} | {A_cb[h,h]:+.6e} |") +neg0 = A_cb[0, 0] < 0 +colsum = A_cb[:, 0].sum() +p("") +p(f"- `A_cb[0,0] = {A_cb[0,0]:+.6e}` -> impact effect of a unit purchase on the " + f"impact spread is **{'NEGATIVE (compresses)' if neg0 else 'POSITIVE (WIDENS — FAIL)'}**") +p(f"- column sum `sum_h A_cb[h,0] = {colsum:+.6e}` (cumulative spread response to a " + f"one-period purchase at t=0)") +p(f"- fraction of the t=0 column that is negative: " + f"{100.0*np.mean(A_cb[:,0] < 0):.1f}%") +p(f"- most positive entry in the t=0 column: {A_cb[:,0].max():+.3e} at lag " + f"{int(np.argmax(A_cb[:,0]))}") +ev = np.linalg.eigvals(A_cb) +p(f"- spectral radius of A_cb = {np.max(np.abs(ev)):.6f}; " + f"max real eigenvalue = {np.max(ev.real):+.6f}; " + f"min real eigenvalue = {np.min(ev.real):+.6f}") +p(f"- 1 / max real eigenvalue = {1.0/np.max(ev.real):+.4f} " + f"(the gamma at which I - gamma*A_cb becomes singular, if the max real " + f"eigenvalue is the binding one)") + +p("\n## 4b. Closed-loop pole and margin\n") +pole = J.get("closed_loop_pole") +p(f"- condition-number scan: {pole}") +p(f"- intended gamma range: [0, 10] (code/tpi.py gamma_values = [0, 2, 5, 10])") +if pole: + p(f"- margin: pole / gamma_max = {pole['gamma_pole']/10.0:.2f}x; " + f"the 0.75-safety cap sits at gamma = {pole['gamma_safe_max']:.2f}") + +p("\n## 4c. Per-gamma stability\n") +p("| gamma | peak spread (bp ann) | b_gov_D[499] | n_inter_D[0] | Y_D[0] | " + "|lam| spread | |lam| b_gov_D | |lam| n_inter_D |") +p("|---|---|---|---|---|---|---|---|") +for g in (0, 2, 5, 10): + sp = d[f"irf_g{g}_spread_rb"] + bg = d[f"irf_g{g}_b_gov_D"] + ni = d[f"irf_g{g}_n_inter_D"] + yd = d[f"irf_g{g}_Y_D"] + m_sp = prony_modulus(sp[:400])[0] + m_bg = prony_modulus(bg[:400])[0] + m_ni = prony_modulus(ni[:400])[0] + p(f"| {g} | {J[f'gamma{g}.peak_spread_bp']:.2f} | " + f"{J[f'gamma{g}.b_gov_D_499']:+.3e} | {ni[0]:+.4e} | {yd[0]:+.4e} | " + f"{m_sp:.6f} | {m_bg:.6f} | {m_ni:.6f} |") +p("") +p("All moduli must be < 1 for a stationary closed loop.") + +p("\n## 4d. Breakdown scan over gamma (open-loop grid, up to the pole)\n") +eps = d["irf_g10_cb_buy_D"] * 0 # placeholder shape +# reconstruct the shock from the gamma=0 spread: spread_0 = A_def @ eps +eps = np.linalg.lstsq(A_def, d["irf_g0_spread_rb"], rcond=None)[0] +p("| gamma | peak spread (bp ann) | compression vs g=0 | cond(I - g A_cb) | |lam| spread |") +p("|---|---|---|---|---|") +base = None +I = np.eye(T) +for g in [0, 1, 2, 5, 10, 15, 19.88, 22, 25, 26.5]: + M = I - g * A_cb + c = np.linalg.cond(M) + sp = np.linalg.solve(M, A_def @ eps) + pk = sp[:100].max() * 4e4 + if base is None: + base = pk + m = prony_modulus(sp[:400])[0] + p(f"| {g:g} | {pk:.2f} | {100*(1-pk/base):+.1f}% | {c:.3e} | {m:.6f} |") + +p("\n## 4e. On-path fiscal incidence of the conduit\n") +have = [k for k in d.files if "rem_cb" in k or "cb_flow" in k] +p(f"conduit series present in the dump: {sorted(have)}") +p(f"G columns that do not exist (pure-CB objects have no shock_def_D loading): " + f"{J.get('G_missing_columns')}") +for g in (2, 5, 10): + for nm in ("rem_cb_D", "rem_cb_F", "cb_flow_D"): + k = f"irf_g{g}_{nm}" + if k in d.files: + x = d[k] + p(f"- g={g:<2} {nm:10s}: t0 = {x[0]:+.6e}, t1 = {x[1]:+.6e}, " + f"min = {x.min():+.6e}, max = {x.max():+.6e}, " + f"undiscounted sum(0:100) = {x[:100].sum():+.6e}") +for g in (2, 5, 10): + for nm in ("TAX_D", "TAX_F"): + k = f"irf_g{g}_{nm}" + if k in d.files: + x = d[k] + p(f"- g={g:<2} {nm:10s}: t0 = {x[0]:+.6e}, peak|.| = {np.abs(x).max():+.6e}") + +p("\n## 4f. Walras residuals along the closed loop\n") +p("| gamma | max|ca_res_D| | max|goods_mkt_D| | max|goods_mkt_F| |") +p("|---|---|---|---|") +for g in (0, 2, 5, 10): + p(f"| {g} | {J[f'gamma{g}.max_ca_res_D']:.2e} | " + f"{J[f'gamma{g}.max_goods_mkt_D']:.2e} | {J[f'gamma{g}.max_goods_mkt_F']:.2e} |") + +open(os.path.join(HERE, "probe_stability.md"), "w").write("\n".join(L) + "\n") +print("\nWROTE probe_stability.md") diff --git a/diagnostics/cb_audit/prony.py b/diagnostics/cb_audit/prony.py new file mode 100644 index 0000000..a1cf229 --- /dev/null +++ b/diagnostics/cb_audit/prony.py @@ -0,0 +1,85 @@ +"""Order-selected Prony / linear-prediction eigenvalue estimator. + +The estimator docs/STATE.md refers to lived in the retired `audit_artifacts/` +harness and is not in the working tree (checked 2026-08-19: no file in the repo +matches /prony/i). Reimplemented here, self-contained, with the two properties +the original was validated on: + + * order SELECTION rather than a fixed order — textbook AR/Prony overfits and + manufactures spurious near-unit-circle roots once the model order exceeds + what the decay actually supports (docs/STATE.md round 2); + * a synthetic self-test (`_selftest`) that must recover known moduli. + +Used only to read the dominant modulus off an IRF tail. Never feeds a model. +""" +import numpy as np + + +def prony_modulus(x, orders=range(1, 9), t0=None, t1=None, tol_r2=0.999): + """Dominant |eigenvalue| of the linear recursion that generates x[t0:t1]. + + Fits x_t = sum_{k=1..p} a_k x_{t-k} by least squares for each p in `orders`, + keeps the smallest p whose in-sample R^2 clears `tol_r2` (falling back to the + best R^2 if none does), and returns the largest companion-matrix eigenvalue + modulus. Returns (modulus, order, r2). + """ + x = np.asarray(x, dtype=float) + if t0 is None: + t0 = int(np.argmax(np.abs(x))) # start after the impact spike + if t1 is None: + t1 = len(x) + y = x[t0:t1] + # Drop a numerically dead tail: rows of ~0 make the design matrix singular + # and the fit reports a meaningless modulus. + scale = np.max(np.abs(y)) if len(y) else 0.0 + if scale == 0.0 or not np.all(np.isfinite(y)): + return float("nan"), 0, float("nan") + keep = np.abs(y) > 1e-14 * scale + if keep.any(): + y = y[: int(np.max(np.nonzero(keep))) + 1] + best = (float("nan"), 0, -np.inf) + for p in orders: + if len(y) < 3 * p + 5: + continue + X = np.column_stack([y[p - k - 1: len(y) - k - 1] for k in range(p)]) + z = y[p:] + if X.shape[0] <= p: + continue + a, *_ = np.linalg.lstsq(X, z, rcond=None) + resid = z - X @ a + ss = float(np.sum((z - z.mean()) ** 2)) + r2 = 1.0 - float(np.sum(resid ** 2)) / ss if ss > 0 else 1.0 + C = np.zeros((p, p)) + C[0, :] = a + if p > 1: + C[1:, :-1] = np.eye(p - 1) + mod = float(np.max(np.abs(np.linalg.eigvals(C)))) + if r2 > best[2]: + best = (mod, p, r2) + if r2 >= tol_r2: + return mod, p, r2 + return best + + +def _selftest(): + t = np.arange(300) + cases = { + "single real 0.90": 0.90 ** t, + "two real 0.95/0.60": 0.95 ** t + 0.5 * 0.60 ** t, + "damped osc r=0.97": 0.97 ** t * np.cos(0.35 * t), + } + truth = {"single real 0.90": 0.90, "two real 0.95/0.60": 0.95, + "damped osc r=0.97": 0.97} + ok = True + for k, v in cases.items(): + m, p, r2 = prony_modulus(v) + err = abs(m - truth[k]) + ok &= err < 1e-6 + print(f" {k:24s} -> |lam|={m:.9f} (truth {truth[k]:.2f}, " + f"err {err:.2e}, order {p}, R2 {r2:.9f})") + print(f" selftest {'PASS' if ok else 'FAIL'}") + return ok + + +if __name__ == "__main__": + _selftest() diff --git a/diagnostics/cb_audit/recommended_fix.md b/diagnostics/cb_audit/recommended_fix.md new file mode 100644 index 0000000..a12ce2a --- /dev/null +++ b/diagnostics/cb_audit/recommended_fix.md @@ -0,0 +1,195 @@ +# Central Bank block audit — recommended fixes + +**None of these are implemented.** Audit-only; no model source was edited. Findings +and evidence are in `VERDICT.md` and `run_log.md`. + +Ordered by what actually needs doing, not by finding number. + +--- + +## R-1 (F-2) — Replace the condition-number pole scan with the exact eigenvalue test, and trim the terminal window before testing + +**Two separate defects, one fix each.** + +### R-1a. Detect poles exactly, not on a grid + +`code/tpi.py:344-353` and `diagnostics/regimes/lottery_math.closed_loop_pole:55` +locate the singularity of `I − γ·A_cb` by scanning 240 grid points for +`cond > 1e4`. The poles of that pencil are known in closed form: for every real +positive eigenvalue `λ` of `A_cb`, `γ = 1/λ` is exactly singular. One `eigvals` call +at `O(T³)` — the same cost as one `cond` evaluation, of which the current code does +240 — replaces the whole scan and cannot step over anything. + +```python +# sketch, NOT applied +w = np.linalg.eigvals(A_cb) +poles = sorted(1.0 / x.real for x in w + if abs(x.imag) < 1e-10 and x.real > 1e-8) +gamma_pole = poles[0] if poles else np.inf +``` + +This alone would have surfaced the γ = 2.2116 root the current scan misses. + +### R-1b. Test the interior operator, not the terminal boundary + +`A_cb`'s last few columns are truncation boundary, not model: `||A[:,499]|| = 3.86` +against ~0.0065 for every interior column, and `A[499,499] = +1.080` is the only +positive diagonal entry and the only entry with modulus above 1. Both "poles" — +γ = 2.2116 and γ ≈ 27.4 — come entirely from those columns; dropping five of them +removes every pole below γ = 36 and changes the reported peak spread by nothing at +γ = 2, 5, 10. + +So R-1a should be applied to a trimmed operator, e.g. `A_cb[:T-T_trim, :T-T_trim]` +with `T_trim` on the order of 10–20, and the trim must be **stated in the printed +diagnostic** so it can never go silent — the same discipline +`solve_jacobian_padded` already uses for its zero-padded rows. + +**Validation before adopting:** re-run the effectiveness curve with the trimmed +detector and confirm `peak_arr` is unchanged to ≤ 0.05 bp at every γ on the existing +grid. The audit already checked this at γ = 2, 5, 10, 20; the fix should check the +whole grid. + +**Consequences to follow through, all documentation:** + +* the γ = 19.88 cap on the effectiveness curve is imposed for a spurious reason and + should be removed or re-derived (the curve may legitimately extend further; whether + it *should* is a linearisation question, not a pole question); +* `CLAUDE.md`'s and `code/tpi.py:332-343`'s claim of "a closed-loop pole at + gamma ~ 27.3 on the post-GK-refactor calibration" is an artefact reading and must be + corrected, not softened; +* `docs/STATE.md` and any paper text asserting a stability ceiling on γ derived from + that pole needs the same treatment. + +**Do not** simply tighten the grid. A finer grid still misses poles between points; it +only moves the threshold at which the same failure recurs. + +--- + +## R-2 (F-1) — Separate the realised transfer from the expected loss, in the code's own output + +This is a reporting fix, not a model fix. The model is correct; the printed table +invites the wrong reading. + +`code/tpi.py:300-323` prints `EL PV`, `prem PV`, `MTM PV` and the per-γ line +"F bears EL PV = … , receives prem PV = …" in one block, with no marker separating +the on-path objects from the off-path ones. `el_pv` is an expectation over a default +event that `writeoff_enabled_D = 0` guarantees never occurs on the traced path; it +appears in no budget constraint. `prem_pv`, `mtm_pv` and `carry_ss_pv` are realised +cash flows that do move `rem_cb_F` and `b_gov_F`. + +Recommended: + +1. Split the table into an **ON-PATH** group (`purchases_pv`, `prem_pv`, + `carry_ss_pv`, `mtm_pv` — the flows that actually pass through + `budget_residual_D_tpi` / `_F_tpi`) and an **OFF-PATH / EXPECTATION** group + (`el_pv`, and the `loading` ratio built from it), with the gate stated in the + header: "`writeoff_enabled = 0`: no credit loss is realised on this branch". +2. Add a direct on-path incidence line computed from the conduit itself rather than + reconstructed — `rem_cb_F` and `rem_cb_D` are already model outputs with + `cb_buy_D` columns in `G_tpi` (confirmed: `G['rem_cb_F']['cb_buy_D']` exists; + they have no `shock_def_D` column, since nothing but CB purchases moves them). + Reporting the discounted sum of `rem_cb_F × size_F × p` next to the P&L table + makes the two objects visibly different quantities rather than two rows of one + table. +3. Mirror the split in `experiments/e1_backstop_schedule.py`'s A5-1 reporting and in + `docs/paper_draft_results.md`'s German-side captions. + +**Paper-side rule this implies:** no sentence may net, sum, or trade off the realised +German transfer against the expected loss. A burden-sharing claim must name which of +the two it quantifies. This is the same class of discipline as CLAUDE.md's existing +ban on "x% fundamental / y% non-fundamental". + +**Note on scope.** Making the credit loss *actually flow* through the conduit is a +different proposition: it means `writeoff_enabled = 1`, which is the E3 +`e3a_realised_writeoff` arm and a change of the paper's S-1 framing, not a fix. Do not +do it as a side effect of R-2. + +--- + +## R-3 (F-4) — Retire or repair the three dangling `diagnostics/` scripts + +| file | state | recommended | +|---|---|---| +| `diagnostics/psilam_breakdown_sweep.py` | raises at line 70 | **delete** — it swept `psi_spread`'s linearity in `psi_lambda_B`, and that relationship no longer exists. Its finding (`PSILAM_BREAKDOWN = 15.0`) is already recorded in `diagnostics/regimes/regime_model.py:41-70`. | +| `diagnostics/psilam_moment_sweep.py` | raises at line 59 | **delete** — same reason; it tuned `psi_lambda_B` to the 150bp moment, and CLAUDE.md already records that tuning history as void. | +| `diagnostics/solve_configs.py` | **does not raise**; silently degenerate | **repair or delete.** Lines 167-173 must stop writing `psi_spread_D/F` into `ss0.toplevel`. With `psi_lambda_B = 0` live, the "G0" arm is now identical to its baseline, so the script's central comparison is vacuous and *looks* like it ran. | + +Deleting is preferable to repairing all three: they exist to characterise a dial that +is now fixed at 0, and git history keeps them. + +**Complementary hardening.** `code/test_nkpc_blocks.py`'s +`test_no_ad_hoc_sovereign_spread_wedge_anywhere` scans `code/*.py` only, which is +structurally why none of these were caught. Extending its glob to `diagnostics/*.py` +and `experiments/*.py` — with the docstring/comment exemption it already implements — +would close the gap. If that is too broad, a narrower rule catching *writes* of banned +names into a steady-state dict would have caught `solve_configs.py:169` specifically. + +--- + +## R-4 (F-5) — Unstale the figure caption + +`code/tpi_plots.py:243` hardcodes `[δ_b = 0.10 → insensitive to q_b_D]`. Derive it +from `ss_final['delta_b_D']` / `['delta_b_F']` as the surrounding captions already do +(`code/tpi_plots.py` was reworked for derived captions in commit `231327c`, and this +line was missed). Cosmetic; no computed number depends on it. + +--- + +## R-5 (F-6) — De-duplicate `cb_pnl` + +`experiments/e1_backstop_schedule.py:55-99` reimplements `code/tpi.py:272-296`. The +two currently agree, and E1's copy is actually the better one (it uses each leg's own +`delta_b`, with a comment recording that an earlier draft using D's duration on both +legs contaminated `carry_ss_pv`). + +`experiments/` deliberately does not import from `code/`, so the fix is not a plain +import. Options, in order of preference: + +1. move the function into `experiments/common.py` and have `code/tpi.py` compute its + P&L by calling into a shared, dependency-free module both can import (a small + `code/cb_accounting.py` importable from both is the cleanest — it introduces no + `code/` → `experiments/` dependency in either direction); +2. failing that, add a test asserting the two implementations agree on a fixed + synthetic IRF, so drift fails loudly. + +Leaving it as two copies is the option that produced the `audit_artifacts/` failure. + +--- + +## R-6 (F-7) — Document what the "central bank" is, in `docs/SPEC.md` + +No code change. Two modelling choices are currently implicit in `code/tpi.py`'s +comments and should be stated where the paper's modelling choices live: + +* **Full per-period pass-through, no CB capital.** The entire net cash flow is + remitted each period by capital key. There is no retained-earnings buffer, so a loss + hits the two treasuries in the period it occurs. A real Eurosystem NCB absorbs + losses against capital and provisions first. +* **No reserve liability, no policy rate.** Purchases are funded by a same-period + capital call on the treasuries rather than by creating remunerated reserves. This is + internally consistent — the model has no policy rate, by the design recorded in + CLAUDE.md's "No policy rate" note — but the seigniorage and + reserve-remuneration legs of an actual APP/TPI are absent by construction, and a + reader will expect them. + +Both belong next to the existing "Nominal deposits against REAL sovereign bonds" +entry, which is the same kind of declared asymmetry. + +--- + +## Not recommended + +* **Adding a CB balance sheet with capital and a capital key** — the brief flags this + as a possible prerequisite to any creditor-side claim. It is not needed here: the + capital key and both remittance legs already exist and are correctly signed and + scaled (F-1). Adding CB *capital* (a retained-earnings stock absorbing losses before + they reach treasuries) would be a modelling build, not a patch — a new state, a + loss-allocation rule, and a recapitalisation trigger — and it is not required by + anything this audit found. +* **Tightening the pole scan's grid** — see R-1a. It reproduces the same failure at a + smaller scale. +* **Touching the four CB equations** — `domestic_bond_clearing_tpi`, + `budget_residual_D_tpi`, `budget_residual_F_tpi`, `external_account_D_tpi` are + correct, mark exclusively at endogenous `q_b`, match `bond_return_D`'s payoff term + for term, and are exactly SS-neutral. Nothing in this audit calls for a change to + any of them. diff --git a/diagnostics/cb_audit/run_log.md b/diagnostics/cb_audit/run_log.md new file mode 100644 index 0000000..c26cc58 --- /dev/null +++ b/diagnostics/cb_audit/run_log.md @@ -0,0 +1,525 @@ +# Central Bank block audit — run log + +Audit-only. No model source was edited. All artefacts under `diagnostics/cb_audit/`. + +Environment: `/opt/anaconda3/envs/ssj/bin/python`. +Repo state at audit start: branch `gk-structural-foc`, HEAD `ea23e94` +("GK refactor stage 1: bounded, exported pledgeability map"), with **uncommitted +working-tree changes** across `code/calibration.py`, `code/depreciation_calibration.py`, +`code/equations_D.py`, `code/equations_F.py`, `code/equations_global.py`, +`code/full_model.py`, `code/steady_state.py`, `code/test_nkpc_blocks.py`, +`code/tpi.py`, `code/tpi_plots.py` (773 insertions / 239 deletions). The audit is of +the **working tree**, not of `ea23e94`. + +--- + +## 2026-08-19 08:12 — Probe A: brief correction to the audit's stated premise + +The task describes the refactor as *"`EL_price` is now integrated into the GK incentive +constraint itself"* and as possibly leaving *"no CB balance sheet and no capital key"*. +Both are out of date; recorded here because two of the audit steps are framed around +them and the framing has to be re-pointed before the findings mean anything. + +1. **`EL_price_D/F` (the anchored expected-loss loading) is DELETED, not moved into the + IC.** Expected default loss now enters through `rb_exp_D` / `rb_exp_F` — the + expected payoff `bond_return_D/F` computes over the default distribution — which is + read by `intermediation_P1_D/F`, the banker's **Bellman envelope**, not by + `intermediation_IC_D/F`. Source: `code/equations_D.py:281` (`bond_return_D` + docstring: "the model's SINGLE SOURCE OF TRUTH for default loss") and + `code/equations_D.py:576` (`intermediation_P1_D`: "EXPECTED DEFAULT LOSS ENTERS + HERE, AND ONLY HERE"). +2. **The IC does carry a separate, differently-motivated default channel** — + `Delta_bD_eff_D` / `Delta_bF_eff_D` from the new `collateral_quality_D` + (`code/equations_D.py:468`), a bounded pledgeability map with local slope + `psi_lambda_B_D`. That is a *collateral haircut* channel, not `EL_price`. At the + preferred baseline `psi_lambda_B_D/F = 0` (`code/calibration.py:288-289`) so this + channel is **switched off** and `Delta_eff == Delta` identically. +3. **There is a CB balance sheet and there is a capital key.** `code/tpi.py:62-97` + implements an explicit ECB conduit: `cb_flow_D` (net cash flow on the CB's D-bond + book) split `kappa_cb_F` to the F treasury and `1 - kappa_cb_F` to the D treasury, + with `kappa_cb_F = 0.929` (`code/calibration.py:438`). + +Steps 1–3 are therefore run against what the code actually does, and the divergence +from the brief is itself logged as a finding in `VERDICT.md`. + +--- + +## 2026-08-19 08:14 — Probe B: `psi_spread` reference sweep (Step 3) + +``` +grep -rn "psi_spread" --include="*.py" . +``` + +Classified by whether the hit is **live code** or **prose**: + +| File | Lines | Kind | Live? | +|---|---|---|---| +| `code/equations_D.py` | 299, 703, 706 | docstring (records the deleted block's algebra) | no | +| `code/equations_F.py` | 511 | docstring | no | +| `code/equations_global.py` | 119, 127 | docstring | no | +| `code/steady_state.py` | 202, 310 | comment | no | +| `code/calibration.py` | 212–271, 398–402 | comment (tuning history, marked void) | no | +| `code/tpi.py` | 313 | comment (records the removed closed form) | no | +| `code/test_nkpc_blocks.py` | 249, 261, 270, 292 | the AST ban-list itself | n/a | +| `experiments/e3_writeoff_s1.py` | 36, 273 | docstring / printed prose | no | +| `experiments/paper_outputs.py` | 703, 705 | docstring | no | +| **`diagnostics/solve_configs.py`** | **130, 167, 169, 173** | **executable** | **YES** | +| **`diagnostics/psilam_breakdown_sweep.py`** | **15, 66–88, 123–149** | **executable** | **YES** | +| **`diagnostics/psilam_moment_sweep.py`** | **7, 59, 65, 73–86** | **executable** | **YES** | + +**Result for the audit's actual Step-3 target — the CB block, the clearing condition +and the residual equations (`code/tpi.py`, `equations_global.domestic_bond_clearing`, +`budget_residual_D/F`): ZERO live references.** The only `psi_spread` string in +`code/tpi.py` is a comment at line 313 explaining that the old +`loading ≈ 1 + psi_spread/EL_price` closed form no longer exists and is not being +replaced by another closed form. + +The three `diagnostics/` scripts are dangling but are outside the CB block; they are +recorded in `VERDICT.md` under a separate heading. Their failure modes differ: + +* `psilam_breakdown_sweep.py:70` and `psilam_moment_sweep.py:59` do a bare + `float(ss["psi_spread_D"])` — these will **raise** on the current model. +* `solve_configs.py:130` is inside `try/except`, so it degrades to `MISSING (...)`; + but line 169 then **writes** `ss0.toplevel["psi_spread_D"] = 0.0`, inventing a + symbol no block reads, and line 173 reads it back successfully. That script does + not crash — it silently produces a `psi_lambda_B = 0` arm that is now identical to + its own baseline, because `psi_lambda_B` is already `0` at the live calibration. + Silent, not loud: worse. + +Corroborating automated check — the AST scanner that enforces the deletion: + +``` +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -q +22 passed in 1.16s +``` + +`test_no_ad_hoc_sovereign_spread_wedge_anywhere` (`code/test_nkpc_blocks.py:261`) +scans `code/*.py` for `psi_spread`, `EL_price`, `divert_bond_foc`, +`divert_portfolio_adj`, `excess_return_` and passes. Note its scope is `code/` only — +it is structurally incapable of catching the three `diagnostics/` hits above. + +Companion sweep for the other deleted names (`excess_return`, `bond_price_ss`, +`divert_bond_foc`, `divert_portfolio_adj`, `portfolio_adj_cost`): all remaining hits +are docstrings. `tau_mp_D/F` still exists as live code (`equations_D.py:630`, +`equations_F.py:441`) but is now a **lump-sum macro-prudential tax**, not a term in +the bond FOC, and is calibrated to zero (`T0_* = T1_* = 0.0`, +`code/calibration.py:446-447`). + +--- + +## 2026-08-19 08:16 — Probe C: what the GK refactor actually changed in the CB layer + +`git diff HEAD -- code/tpi.py` (48 lines) shows the refactor touched **only**: + +* three import renames (`divert_bond_foc_D/F` → `gk_bond_foc_D/F`, + `divert_portfolio_adj` → `gk_cross_border_foc`); +* `cb_pnl`'s expected-loss coefficient, `EL_price_D` → `EL_load_D`; +* the removal of the `loading ≈ 1 + psi_spread/EL_price` printed claim; +* a new closed-loop-pole guard capping the effectiveness grid at `0.75 × pole`. + +**The four CB equations themselves — `domestic_bond_clearing_tpi`, +`budget_residual_D_tpi`, `budget_residual_F_tpi`, `external_account_D_tpi` — are +byte-identical to their pre-refactor versions.** They were never written against the +old FOC's price decomposition (they were written against the *payoff*, in +coupon/survival form), which is why the refactor did not break them. This is the +answer to Step 2's central worry, established by diff rather than by inspection. + +--- + +## 2026-08-19 08:18 — Probe D: line-by-line price-marking trace (Step 2) + +Every place a CB quantity meets a price, and which price it meets: + +| Site | Expression | Price used | Verdict | +|---|---|---|---| +| `tpi.py:50` `domestic_bond_clearing_tpi` | `b_D_D = b_gov_D - size_F*b_D_F - cb_buy_D` | none (quantities) | n/a | +| `tpi.py:73` `cb_flow_D` coupon leg | `delta_b_D*(1 - def_rate_D*h*mult)*cb_buy_D(-1)` | none | matches `bond_return_D` | +| `tpi.py:74` `cb_flow_D` continuation leg | `q_b_D * surv_cont_D * (1-delta_b_D) * cb_buy_D(-1)` | **endogenous `q_b_D`** | OK | +| `tpi.py:75` `cb_flow_D` purchase leg | `- q_b_D * cb_buy_D` | **endogenous `q_b_D`** | OK | +| `tpi.py:108` `external_account_D_tpi` payments | `(1+rb_actual_D)*q_b_D(-1)*(size_F*b_D_F(-1) + kappa_cb_F*cb_buy_D(-1))` | **endogenous `q_b_D(-1)`** | OK | +| `tpi.py:110` `nfa_D` | `- q_b_D*(size_F*b_D_F + kappa_cb_F*cb_buy_D)` | **endogenous `q_b_D`** | OK | +| `tpi.py:290-295` `cb_pnl` | `q_b_D_ss * cb`, plus `mtm_pv` in `dq` | SS price + separate MTM | see below | +| `tpi.py:366` cost curve | `(cb_buy_D[:100] * q_b_D_ss).sum()` | SS price | see below | + +Algebraic cross-check of the coupon/survival form against `bond_return_D` +(`code/equations_D.py:325-331`): + +``` +bond_return_D : payoff = delta_b*(1 - def*h*mult) + (1-delta_b)*q_b*(1 - zeta*def*h*mult) +cb_flow_D : inflow = delta_b*(1 - def*h*mult)*cb(-1) + + q_b*(1 - zeta*def*h*mult)*(1-delta_b)*cb(-1) +``` + +Identical term for term, including the `zeta_writeoff_D` continuation write-down and +the `writeoff_enabled_D` realisation gate. **No stale price, no hardcoded price +decomposition, no surviving reference to the old FOC anywhere in the CB equations.** + +The two SS-price sites are **not** stale-price bugs — they are the deliberate +linearisation convention. `cb_buy_ss = 0`, so `cb` is a pure deviation; `q_b_D_ss * cb` +is the correct first-order market value and `mtm_pv` restores the +(second-order) `dq × cb` revaluation as a separate line, exactly as +`tpi.py:252-255` documents for `el_pv` and `prem_pv`. Flagged in `VERDICT.md` as a +*documented convention*, not a defect. + +One genuinely stale string was found in the CB plotting layer: +`code/tpi_plots.py:243` hardcodes the axis subtitle +`[δ_b = 0.10 → insensitive to q_b_D]`. Live values are `delta_b_D = 0.0777` and +`delta_b_F = 0.0568`. Cosmetic; it is a figure caption, no number is computed from it. + +--- + +## 2026-08-19 08:20 — Probe E: live pipeline solve + +`diagnostics/cb_audit/probe_pipeline.py` — runs `main.py` stages 1–5 plus `run_tpi()`, +skipping figure generation, and dumps Steps 0/4/5 quantities to +`probe_pipeline.json` / `.npz`. Started 08:20:41. + +`EBA_CALIBRATION=True`, `BANK_SCOPE="broad"` read at 08:20:41. + +## 2026-08-19 08:19 — Probe F: Prony estimator + +`docs/STATE.md:2317` and `docs/PROGRESS.md:1499` refer to a "validated order-selected +Prony" eigenvalue estimator. **No such file exists in the working tree** (`grep -rn +-i prony` matches only those two prose lines) — it went with the retired +`audit_artifacts/` harness. Reimplemented self-contained as +`diagnostics/cb_audit/prony.py`, with order selection (the property +`docs/STATE.md:2270` records as necessary: fixed-order fits overfit and manufacture +spurious near-unit-circle roots) and a synthetic self-test: + +``` + single real 0.90 -> |lam|=0.900000000 (truth 0.90, err 1.11e-16, order 1, R2 1.000000000) + two real 0.95/0.60 -> |lam|=0.950000000 (truth 0.95, err 1.22e-15, order 2, R2 1.000000000) + damped osc r=0.97 -> |lam|=0.970000000 (truth 0.97, err 5.55e-16, order 2, R2 1.000000000) + selftest PASS +``` + + +--- + +## 2026-08-19 08:27 — Probe E results: Step 0 (which base) and Step 5 (SS neutrality) + +`probe_pipeline.py` completed 08:26:54 (first attempt 08:23:27 died in the dump +stage on a `KeyError: 'shock_def_D'` — `rem_cb_D`, `rem_cb_F` and `cb_flow_D` are +outputs with a `cb_buy_D` column and no `shock_def_D` column, because nothing but the +CB's own purchases moves them. The guard was widened and the probe re-run; run 1's +stdout is kept as `probe_pipeline_stdout_run1.txt`. The two runs agree on every +number.) + +**Step 0 — the base is ADMISSIBLE.** `EBA_CALIBRATION = True`, `BANK_SCOPE = "broad"`. + +| object | D | F | +|---|---|---| +| `lambda_gk` (IC multiplier) | **+2.108746** | **+0.641387** | +| `Omega` (banker marginal value of net worth) | **+10.346180** | **+4.037867** | +| `nu_K` (marginal value of capital per unit net worth) | **+0.103205** | **+0.040278** | +| `phi_own` = `q_b·b_own/n_inter` | **0.452489** | 0.294078 | +| `phi_cross` | 0.003406 | 0.010710 | +| GK feasibility margin `f·theta − [(1−Δ_own)φ_own + (1−Δ_cross)φ_cross]` | **+0.296567** | **+0.591774** | + +All strictly positive; `assert_gk_well_posed` passes. `theta_D = 5.5107`, `f_D = 0.12`, +so at `Delta = 0.20` the well-posedness ceiling on `phi_own_D` is +`(f·theta − 0.8·phi_cross)/0.8 = 0.8232` and the live value 0.4525 sits at 55% of it. + +The GK portfolio FOC table on the final SS: + +``` + bank leg nu_i/nu_K Delta_eff residual status + D own 0.200000 0.200000 -1.624e-14 OK + D cross 0.200000 0.200000 2.058e-13 OK + F own 0.200000 0.200000 -4.746e-15 OK + F cross 0.200000 0.200000 -2.268e-13 OK + cross-border SS wedges: F-in-D = +8.2e-11 bp/yr D-in-F = -9.1e-11 bp/yr + psi_lambda_B = 0.0/0.0 zeta_writeoff = 1.0/1.0 writeoff_enabled = 0.0/0.0 +``` + +`q_b_D = 0.974906`, `q_b_F = 0.965974`, both yields 80.00 bp annualised, SS spread +−0.0000 bp. `EL_load_D = 0.7014`. All reproduce the CLAUDE.md calibration row exactly. +Baseline default-shock impact also reproduces exactly: peak spread 205.87 bp, +`n_inter_D[0] = −11.4073%`, `Y_D[0] = −1.9742%`. + +**The audit brief's Step-0 premise does not describe this tree.** It anticipates +`phi_bD ≈ 2.39` against a ceiling of 0.336. `2.39` is the `BANK_SCOPE="ct1"` +(stress-test-sample) number that GK-2 retired on 2026-07-31; the live broad-scope +value is 0.4525 and it is inside the ceiling. There is no inadmissible-base caveat to +attach to this audit. + +**Step 5 — SS neutrality is EXACT, not approximate.** Each TPI block was evaluated at +the solved steady state with `cb_buy_D = 0`, alongside its non-TPI counterpart: + +| block output | TPI version | non-TPI version | difference | +|---|---|---|---| +| `b_gov_res_D` | −4.163336e−17 | −4.163336e−17 | **0.000e+00** | +| `nfa_D` | −1.338351e−01 | −1.338351e−01 | **0.000e+00** | +| `ca_res_D` | +1.665335e−16 | +1.665335e−16 | **0.000e+00** | +| `b_D_D` | +9.922837e−01 | +9.922837e−01 | **0.000e+00** | +| `b_F_F` | +4.953501e−01 | +4.953501e−01 | **0.000e+00** | + +and the conduit's own objects are identically zero: +`cb_flow_D = 0.000e+00`, `rem_cb_D = 0.000e+00`, `rem_cb_F = 0.000e+00`, +`b_gov_res_F = −2.776e−17`. Bit-identical, so the refactor cannot have moved the +steady state through the CB block and every prior SS-invariance argument survives. + +Corroborated dynamically by `run_tpi`'s own gate: +`Sanity check G_tpi[cb=0] vs baseline G: max |err| = 0.00e+00`. + +--- + +## 2026-08-19 08:29 — Probe G: Step 4, feedback sign + +`probe_stability.py` (output `probe_stability.md`). + +``` +A_cb[0,0] = -4.397083e-03 NEGATIVE: a unit purchase compresses the impact spread +sum_h A_cb[h,0] = -1.597028e-02 cumulative response to a one-period purchase +``` + +Closed-loop peak spread, monotone and declining: + +| γ | peak spread (bp ann) | compression | `b_gov_D[499]` | `n_inter_D[0]` | `Y_D[0]` | +|---|---|---|---|---|---| +| 0 | 205.87 | — | +3.02e−05 | −2.4388e−01 | −1.9742e−02 | +| 2 | 193.25 | 6.1% | +3.69e−04 | −2.1289e−01 | −1.5702e−02 | +| 5 | 176.75 | 14.1% | −5.62e−05 | −1.7260e−01 | −1.0428e−02 | +| 10 | 154.36 | 25.0% | −1.51e−04 | −1.1837e−01 | −3.2741e−03 | + +Prony dominant moduli (estimator validated above), all < 1 and barely moving with γ: +spread 0.9400 → 0.9470, `b_gov_D` 0.8283 → 0.9232, `n_inter_D` 0.9347 → 0.9460. +Walras residuals stay clean along the whole loop: `max|ca_res_D|` *falls* 7.2e−08 → +1.5e−08 as γ rises, `max|goods_mkt_D|` ~6e−17, `max|goods_mkt_F|` ~2e−10. + +The mechanism, read straight off the Jacobian columns at impact: + +``` + d b_D_D /d cb_buy [0,0] = -0.126779 bonds off D banks' books + d theta_D /d cb_buy [0,0] = -1.177721 required leverage falls (IC slackens) + d n_inter_D/d cb_buy[0,0] = +0.525814 bank net worth recovers + d K_D /d cb_buy [0,0] = +0.017015 capital crowded back in + d q_b_D /d cb_buy [0,0] = +0.037316 bond price up -> spread compresses +``` + +**Step 3's second half confirmed structurally, not just by grep.** `cb_buy_D` reaches +the price through exactly two doors — `intermediation_IC_D`'s `phi_bD_D` and +`k_balance_sheet_D` — and through nothing else. There is no term of the form +`spread += parameter × def_rate` anywhere in the path. + +Note further that with `psi_lambda_B = 0` the own-leg FOC composed with +`intermediation_P1_D` reduces *exactly* to + +``` +rb_exp_D(+1) - rdep_D = 0.20 * (rk_D(+1) - rdep_D) +``` + +— `SDF_banker` and `Omega_p1` cancel in the ratio `nu_bD/nu_K`. Verified at the SS: +`rb_exp_D = 0.002000`, `rk_D = 0.010000`, `rdep_D = 0`, and `0.20 × 0.01 = 0.002` +exactly. So the CB compresses the sovereign spread **only** by lowering `rk_D(+1)`, +i.e. by crowding capital back in. TPI's spread effect and its investment effect are +the same effect in this model; they cannot be reported as separate channels. + +--- + +## 2026-08-19 08:33 — Probe H: the "closed-loop pole" is a truncation artefact + +`run_tpi` prints `closed-loop pole at gamma = 26.50`. Cross-checking that against the +spectrum of `A_cb` did not agree, so the discrepancy was chased down. + +Eigenvalues of `A_cb` (T = 500): + +``` + +0.452155 |lam|=0.452155 pole gamma = 2.2116 + +0.036524 |lam|=0.036524 pole gamma = 27.3793 + -0.023262±0.013576j (complex, no pole) +``` + +Direct scan of `I - gamma*A_cb`: + +``` +gamma smin cond sign(det) peak bp +2.1000 6.1119e-03 1.349e+03 +1 192.65 +2.2000 6.0812e-04 1.419e+04 +1 192.06 +2.2116 1.6529e-06 5.248e+06 +1 189.76 +2.2500 1.9616e-03 4.498e+03 -1 191.77 +``` + +**There is a genuine singularity of the T=500 system at γ = 2.2116, inside the +intended γ range, and the guard in `code/tpi.py:332-353` misses it.** The guard scans +`np.linspace(0.25, 60.0, 240)` — step 0.25 — so it evaluates 2.00 (cond 6.5e2) and +2.25 (cond 4.5e3), both under the 1e4 threshold, and steps straight over. What it +then reports at 26.50 is the *second* eigenvalue's shoulder. This is precisely the +failure its own comment warns about ("a coarse scan steps over a pole this narrow and +sees nothing"). + +**But the pole is not economic.** Modal decomposition of the resonant eigenvector `v`: + +``` +|v| peaks at index 499 (the LAST period of the truncation), max|v| = 0.2779 +mass in the first 100 quarters = 0.0000 +mass in t = 400..499 = 0.9922 +projection of the forcing on the resonant left eigenvector = 6.0e-04 (relative) +``` + +and the terminal columns of `A_cb` are pathological while every interior column is +not: + +``` +||A[:, 0]|| = 5.88e-03 A[ 0, 0] = -4.397e-03 +||A[:,200]|| = 6.51e-03 A[200,200] = -2.176e-03 +||A[:,400]|| = 6.43e-03 A[400,400] = -2.184e-03 +||A[:,495]|| = 2.14e-01 A[495,495] = -5.204e-02 +||A[:,499]|| = 3.86e+00 A[499,499] = +1.080e+00 <- only positive diagonal, only |.|>1 +``` + +The dominant eigenvalue only exists once the last few columns are included +(leading-block spectra: T≤450 → |λ|max ≈ 0.027 complex, no positive real root at all; +T=495 → −0.416; T=500 → +0.452). + +Decisive test — drop the last k rows and columns and re-solve the closed loop: + +| k dropped | smallest positive-real pole γ | max cond over γ∈[0.1,30] | peak bp @ γ=2 / 5 / 10 / 20 | +|---|---|---|---| +| 0 | **2.2116** | 5.45e+05 | 193.25 / 176.75 / 154.36 / 122.59 | +| 5 | 36.81 | **8.51e+01** | 193.25 / 176.75 / 154.36 / 122.56 | +| 20 | 38.18 | 1.87e+01 | 193.25 / 176.75 / 154.36 / 122.56 | +| 100 | 40.96 | 7.76e+00 | 193.25 / 176.75 / 154.36 / 122.55 | + +Dropping five columns collapses the worst conditioning by four orders of magnitude, +removes every pole below γ = 36, and **changes the reported peak spread by nothing at +γ = 2, 5, 10 and by 0.03 bp at γ = 20**. The residual "pole" that remains keeps +migrating with k, so it is boundary-driven too. + +Conclusions: (a) no reported TPI number is affected — every statistic is computed on +`[:100]`, where the artefact has zero mass; (b) the γ = 19.88 cap the effectiveness +curve now carries is imposed for a spurious reason; (c) the claim in `CLAUDE.md` +and `code/tpi.py` that there is a closed-loop pole at γ ≈ 27.3 on this calibration is +wrong — that is the T=500 terminal condition, not the model. + +--- + +## 2026-08-19 08:36 — Probe I: Step 1, where the money actually goes + +Impact remittances (D-goods; F converted at `p` and per F capita in the block, scaled +back to an aggregate here with `size_F = 11.6967`): + +| γ | `cb_flow_D[0]` | `rem_cb_D[0]` (D aggregate) | `rem_cb_F[0]` × `size_F` (F aggregate) | ratio F/D | +|---|---|---|---|---| +| 2 | −9.4199e−03 | −6.6881e−04 | −8.7338e−03 | 13.059 | +| 5 | −2.1539e−02 | −1.5293e−03 | −1.9971e−02 | 13.059 | +| 10 | −3.7622e−02 | −2.6711e−03 | −3.4882e−02 | 13.059 | + +Capital key implies `0.929/0.071 = 13.085`; the 0.2% gap is the endogenous +terms-of-trade conversion (`p = 1.00197`). The split is correct and it is not a +self-financing loop. + +Both remittances are **negative at impact** — a capital call. Each treasury funds its +share through its own fiscal rule at its own sovereign terms. + +Downstream fiscal incidence: + +| γ | `TAX_D[0]` | `TAX_F[0]` | Σ₁₀₀ `TAX_D` | Σ₁₀₀ `TAX_F` | `C_D[0]` | `C_F[0]` | ΔW_D | ΔW_F | +|---|---|---|---|---|---|---|---|---| +| 0 | −1.2449e−02 | +6.2537e−04 | +9.4021e−02 | −1.4470e−03 | −1.6959e−02 | +1.5843e−03 | — | — | +| 2 | −1.0576e−02 | +4.6206e−04 | +9.0618e−02 | −1.4255e−03 | −1.2681e−02 | +1.2794e−03 | +0.1956 | −0.0195 | +| 5 | −8.1361e−03 | +2.4896e−04 | +8.6147e−02 | −1.3804e−03 | −7.0811e−03 | +8.8080e−04 | +0.5155 | −0.0482 | +| 10 | −4.8410e−03 | −3.9851e−05 | +8.0123e−02 | −1.2724e−03 | +5.4953e−04 | +3.3869e−04 | +1.1290 | −0.0944 | + +German cumulative taxes rise with γ (Σ₁₀₀ `TAX_F` moves −1.447e−03 → −1.272e−03), +German consumption gain shrinks, German welfare falls monotonically. **The creditor +side is real, signed correctly, and quantitatively representable.** + +Who actually gives up the bonds — differentiating the clearing identity +`b_D_D = b_gov_D − size_F·b_D_F − cb_buy_D` with respect to `cb_buy_D`: + +| horizon | `d b_D_D` | `d b_gov_D` | `size_F · d b_D_F` | +|---|---|---|---| +| 0 | −0.1268 | +0.0325 | **−0.8408** | +| 1 | −0.0671 | −0.0539 | **−0.9867** | +| 4 | −0.0589 | −0.0488 | **−0.9899** | +| 12 | −0.0253 | −0.0202 | **−0.9949** | + +84% of a CB purchase at impact, and ~99% from t=1 on, is absorbed by **German** banks +shedding their Greek book, not by Greek banks. Only 13% at impact and 2–6% thereafter +comes off Greek balance sheets. Recorded as F-3 in `VERDICT.md`; `psi_bD_F = 0.5` is +the elasticity that governs it. + +--- + +## 2026-08-19 10:09 — Probe J: the 2×2 sovereign-holdings matrix (decisive diagnostic) + +`probe_portfolio.py`, full output `portfolio_matrix.md` / `.json` / `.npz`. Required +its own solve: `probe_pipeline.py` dumped `b_D_D` and `b_D_F` only, and the four legs +carry different per-capita conventions, so partial data cannot be rescaled after the +fact. + +**Units, which are the point of the diagnostic.** `b_D_D` and `b_F_D` are per D capita +(D size = 1); `b_D_F`, `b_F_F` and `b_gov_F` are PER F CAPITA and take `size_F = +11.696651`; `cb_buy_D` is already a D aggregate and takes no weight. `q_b_D` and +`q_b_F` are both D-good prices, so market values need no `p` conversion. Market-value +deviations are `q·db + b·dq`, dropping the second-order cross term, matching +`cb_pnl`'s convention. + +**Both clearing identities hold to ≤ 1.2e−15** at the SS and at t = 0, 4, 20 for every +γ ∈ {0, 2, 5, 10} — 26 checks, all machine-zero. The matrix is internally consistent. + +Steady state (aggregate market value, D goods): + +``` +holder D paper F paper total share of D issue +D banks 0.967383 0.007282 0.974666 87.27% +F banks 0.141118 5.596796 5.737913 12.73% +CB 0.000000 0.000000 0.000000 0.00% +issued 1.108501 5.604078 6.712579 +``` + +γ = 0 (crisis, no TPI) → γ = 10, at impact: + +``` + D paper share of D issue + g=0 g=10 g=0 g=10 +D banks 0.930292 0.926341 87.76% 86.86% +F banks 0.129756 0.102520 12.24% 9.61% +CB 0.000000 0.037622 0.00% 3.53% +issued 1.060048 1.066483 +``` + +Sourcing of the CB book, Δ(γ=10 − γ=0): + +``` +F banks / D paper -0.027236 72.4% of the book +D govt new issue +0.006435 17.1% +D banks / D paper -0.003951 10.5% +CB +0.037622 100.0% +identity: -0.003951 - 0.027236 + 0.037622 = +0.006435 = D govt issue change +``` + +Concentration `phi = q_b·b/n_inter`, what `intermediation_IC_D/F` reads: + +``` + SS g=0 g=2 g=5 g=10 +phi_bD_D (D bank, own) 0.452489 0.486757 0.479736 0.470614 0.458345 +phi_bF_D (D bank, F) 0.003406 0.003936 0.004167 0.004463 0.004853 +phi_bD_F (F bank, D) 0.007415 0.006774 0.006428 0.005983 0.005397 +phi_bF_F (F bank, own) 0.294078 0.293864 0.294447 0.295200 0.296203 +``` + +Three results: + +1. The shock raises `phi_bD_D` by +0.034268; γ = 10 removes 0.028412 of that — + **82.9% of the crisis concentration spike undone**. +2. It is a **denominator** effect. Numerator `q_b_D·b_D_D` moves −0.4% between γ = 0 + and γ = 10 (0.930292 → 0.926341); `n_inter_D` recovers +6.6%. The CB barely takes + bonds off Greek banks, and does not need to. +3. `phi_bD_F` falls monotonically in γ, to 27% below its SS level at γ = 10, while + `phi_bF_D` rises 42% above SS. **TPI accelerates cross-border retrenchment in both + directions**; it does not reverse it. + +Cross-check against Probe I: in market value F banks supply 72.4% of the book, in pure +quantity (differentiating the clearing identity, price effect stripped out) 84% at +impact and ~99% from t = 1. The gap is the price support inflating every holder's +mark. Both figures are correct; the quantity figure is the one to quote for "who sold". + +--- + +## 2026-08-19 10:15 — Canonicalisation + +Audit promoted to `docs/cb_mechanism.md` (**canonical**), with an index row added to +CLAUDE.md's docs table. No model source modified at any point in this audit; the +`code/` entries in `git status` are the pre-existing uncommitted GK refactor. diff --git a/diagnostics/psilam_breakdown_sweep.py b/diagnostics/psilam_breakdown_sweep.py index a326a85..6911870 100644 --- a/diagnostics/psilam_breakdown_sweep.py +++ b/diagnostics/psilam_breakdown_sweep.py @@ -52,7 +52,7 @@ def main(): from steady_state import solve_steady_state from ic_delta_calibration import calibrate_ic_delta from depreciation_calibration import calibrate_depreciation - from full_model import build_and_solve + from full_model import build_and_solve, solve_jacobian_padded cal = get_calibration() print(f"[{ts()}] live psi_lambda_B_D = {cal['psi_lambda_B_D']}, " @@ -80,8 +80,8 @@ def psi_spread(country, g): for c in ("D", "F"): ssg.toplevel[f"psi_lambda_B_{c}"] = g ssg.toplevel[f"psi_spread_{c}"] = psi_spread(c, g) - Gg = ha.solve_jacobian(ssg, unknowns=unk, targets=tgt, - inputs=["Z_D", "shock_def_D", "Z_F", "shock_def_F"], T=T) + Gg = solve_jacobian_padded(ha, ssg, unk, tgt, + ["Z_D", "shock_def_D", "Z_F", "shock_def_F"], T) irf = Gg @ {"Z_D": zero, "Z_F": zero, "shock_def_D": dshock, "shock_def_F": zero} sp = np.asarray(irf["spread_rb"]) r = dict(psilam=g, diff --git a/diagnostics/psilam_moment_sweep.py b/diagnostics/psilam_moment_sweep.py index 7686691..d12b557 100644 --- a/diagnostics/psilam_moment_sweep.py +++ b/diagnostics/psilam_moment_sweep.py @@ -43,7 +43,7 @@ def main(): from steady_state import solve_steady_state from ic_delta_calibration import calibrate_ic_delta from depreciation_calibration import calibrate_depreciation - from full_model import build_and_solve + from full_model import build_and_solve, solve_jacobian_padded print(f"[{ts()}] pipeline: calibration -> SS -> ic_delta -> depreciation") cal = get_calibration() @@ -73,8 +73,8 @@ def main(): ssg.toplevel["psi_spread_D"] = psi_spread_base * g / psilam_base ssg.toplevel["psi_spread_F"] = psi_spread_base * g / psilam_base # EL_price_D/F untouched (anchored) - Gg = ha.solve_jacobian(ssg, unknowns=unk, targets=tgt, - inputs=["Z_D", "shock_def_D", "Z_F", "shock_def_F"], T=T) + Gg = solve_jacobian_padded(ha, ssg, unk, tgt, + ["Z_D", "shock_def_D", "Z_F", "shock_def_F"], T) irf = Gg @ {"Z_D": zero, "Z_F": zero, "shock_def_D": dshock, "shock_def_F": zero} sp = np.asarray(irf["spread_rb"])[:100] nD = np.asarray(irf["n_inter_D"])[:100] diff --git a/diagnostics/regimes/lottery_math.py b/diagnostics/regimes/lottery_math.py index 964ff97..76e33c5 100644 --- a/diagnostics/regimes/lottery_math.py +++ b/diagnostics/regimes/lottery_math.py @@ -16,6 +16,25 @@ """ import numpy as np + +#: Fraction of the closed-loop pole treated as the strongest REPRESENTABLE intervention. +#: Not 0.98. Measured on the post-2026-08-18 cache, the loading schedule is monotone +#: decreasing in gamma up to 0.85*pole and turns at 0.90 — the singularity's influence +#: bleeds in well below it, and at 0.98*pole the discounted consumption gains reach +#: +12% of steady-state consumption, which is the pole talking, not the policy. 0.75 +#: keeps a clear margin (gamma = 19.88, cond(I - gamma*A_cb) = 1.4e3, 40.3% compression). +POLE_SAFETY_FRACTION = 0.75 + + +class CompressionInfeasible(RuntimeError): + """A named-regime compression target is unreachable below the closed-loop pole. + + Raised, not silently clamped: whether the model can represent an intervention + strong enough to hit a given compression is a result about the policy experiment, + and callers are expected to report the regime as infeasible. + """ + + def shift_k(M, k): if k == 0: return M.copy() @@ -33,28 +52,102 @@ def peak(x, n=100): positive spread response this module targets.""" return float(np.asarray(x)[:n].max()) -def gamma_for_compression(A_def, A_cb, eps, target, lo=0.0, hi=25.0, tol=1e-8): +def closed_loop_pole(A_cb, lo=0.0, hi=60.0, n=241, cond_max=1.0e4): + """Smallest gamma at which (I - gamma*A_cb) is near-singular, or None. + + The closed loop has a pole where 1/gamma hits an eigenvalue of A_cb. Past it the + peak-spread-vs-gamma curve jumps to a different branch, and any bisection that + brackets across it is meaningless — it reads two branches as one smooth decline. + + Located by CONDITION NUMBER rather than by a monotonicity scan, because a scan can + step straight over a narrow pole and see nothing: on the post-2026-08-18 cache the + spike at gamma ~ 27.3 is one grid point wide on a 61-point scan of [0,40]. + """ + grid = np.linspace(lo, hi, n) + T = A_cb.shape[0] + for g in grid: + if g == 0.0: + continue + if np.linalg.cond(np.eye(T) - g * A_cb) > cond_max: + return float(g) + return None + + +def gamma_for_compression(A_def, A_cb, eps, target, lo=0.0, hi=40.0, tol=1e-8): """Bisect for the gamma whose closed-loop peak spread is (1-target) x passive peak. Verifies monotonicity of peak(gamma) over a scan grid first (spec §14). - hi=25 (was 60, 2026-07-31). Bisection's validity condition is monotonicity on the - BRACKETING INTERVAL, not on an arbitrarily wide scan; hi is just the upper bracket. - At the current calibration peak(gamma) falls monotonically 187.2 -> 34.2bp across - [0,25] and then ticks UP 1.1bp at gamma=30 -- closed-loop saturation at 81% - compression, not economics -- which aborted the whole run. The spec's targets sit - at gamma~1.6 (25% compression) and ~5.1 (50%), far inside the monotone region, so - the check is unchanged in substance and still fires on any real non-monotonicity - within the search range. Raise hi only if a target stops bracketing (the assert - below will say so).""" + hi=40 (2026-08-07; was 25 from 2026-07-31, 60 before that). Bisection's validity + condition is monotonicity on the BRACKETING INTERVAL, not on an arbitrarily wide + scan; hi is just the upper bracket, and the assert below is what tells you to move + it. + + RAISED because the 50% target stopped bracketing on [0,25] after the country-size + asymmetry and the rem_cb_F conduit fix (see docs/STATE.md). TPI is materially less + effective than before both changes -- the same compression now needs ~8x the + intervention: + + target gamma before gamma now + 25% ~1.6 ~13 + 50% ~5.1 ~34 + + Measured on cache_G_main_v3_psilam3p01 (passive peak 149.9bp): + + gamma 0 10 20 30 40 40.5 42 42.5 + peak bp 149.9 116.2 94.4 79.3 69.6 69.6 538.8 57.0 + + peak(gamma) declines monotonically to 53.6% compression at gamma=40, turns at + ~40.5, and there is a CLOSED-LOOP SINGULARITY at gamma ~ 42 (I - gamma*A_cb going + near-singular; compute_tpi_irfs prints a conditioning warning there) with a + separate branch beyond it. Do NOT set hi past 40 -- a coarse scan that samples 40 + and 50 reads the two branches as one smooth decline and hides the pole. + + NOTE FOR THE PAPER: the aggressive regime now sits ~8 gamma-units below that + singularity, where before the fixes it was at gamma~5.1 with the pole far away. + How much intervention the model can represent is now a live constraint, not a + formality. + + THE POLE MOVED AGAIN — 2026-08-18, GK structural refactor. It is now at + gamma ~ 27.3 (cond 1.4e5), and the reachable compression below it tops out at + + gamma 0 10 20 25 26 26.7 | 27.3 = POLE + peak bp 205.9 154.4 122.6 111.4 109.8 109.7 | + compress 0% 25% 40% 45.9% 46.7% 46.7% | + + So the 25% (medium) target still solves cleanly at gamma ~ 10, and the **50% + (aggressive) target is INFEASIBLE** — the model cannot represent an intervention + strong enough to halve the peak spread without crossing a closed-loop singularity. + That is a result about the policy experiment's own parameterisation and is raised as + `CompressionInfeasible` for callers to report, NOT worked around by widening `hi` + past the pole. Widening it reads the far branch (which does reach 50% at + gamma ~ 27.9) as a continuation of the near one, which it is not. + + `hi` is capped at 0.98*pole automatically, so the INFEASIBILITY VERDICT is measured + against the true reachable set. That is deliberately not the same as the FALLBACK + gamma a caller should then use: `common.named_regime_gammas` falls back to + `POLE_SAFETY_FRACTION * pole` = 0.75*pole, because the loading schedule is monotone + only up to ~0.85*pole and the responses at 0.98*pole are dominated by proximity to + the singularity. Verdict at the edge, reporting well inside it.""" + pole = closed_loop_pole(A_cb, lo=lo, hi=max(hi, 60.0)) + if pole is not None and pole <= hi: + hi = 0.98 * pole p0 = peak(closed_loop(A_def, A_cb, eps, 0.0)[0]) grid = np.linspace(lo, hi, 61) peaks = np.array([peak(closed_loop(A_def, A_cb, eps, g)[0]) for g in grid]) if not np.all(np.diff(peaks) < 0): bad = grid[1:][np.diff(peaks) >= 0] raise RuntimeError(f"peak spread not monotone in gamma near {bad[:5]} — " - f"bisection invalid (spec §14 monotonicity check)") + f"bisection invalid (spec §14 monotonicity check). " + f"Nearest closed-loop pole: {pole}") f = lambda g: 1.0 - peak(closed_loop(A_def, A_cb, eps, g)[0]) / p0 - target - assert f(lo) < 0 < f(hi), f"target {target} not bracketed on [{lo},{hi}]" + if not (f(lo) < 0 < f(hi)): + raise CompressionInfeasible( + f"{100*target:.0f}% peak-spread compression is not reachable on " + f"[{lo}, {hi:.3f}]. Maximum attainable compression below the closed-loop " + f"pole ({'none found' if pole is None else f'gamma = {pole:.2f}'}) is " + f"{100*(1 - peaks.min()/p0):.2f}%. Report the regime as infeasible — do NOT " + f"widen the bracket past the pole, which splices a different branch onto " + f"this one.") while hi - lo > tol: mid = 0.5 * (lo + hi) lo, hi = (mid, hi) if f(mid) < 0 else (lo, mid) diff --git a/diagnostics/regimes/regime_model.py b/diagnostics/regimes/regime_model.py index 8b1b250..f3cf2dc 100644 --- a/diagnostics/regimes/regime_model.py +++ b/diagnostics/regimes/regime_model.py @@ -91,7 +91,7 @@ def _live_psilam(): SS_META = ["q_b_D_ss:q_b_D", "b_D_D_ss:b_D_D", "b_gov_D_ss:b_gov_D", "Y_D_ss:Y_D", "C_D_ss:C_D", "I_D_ss:I_D", "NX_D_ss:NX_D", "n_inter_D_ss:n_inter_D", "K_D_ss:K_D", "TAX_D_ss:TAX_D", "P_CES_D_ss:P_CES_D", - "beta_D:beta_D", "beta_F:beta_F", "EL_price_D:EL_price_D", + "beta_D:beta_D", "beta_F:beta_F", "EL_load_D:EL_load_D", # schema 2: needed by E1's cb_pnl port and E2's identity # schema 3: delta_b_F is NOT delta_b_D (0.0568 vs 0.0777 — the two # countries' bank books have different measured durations). E1's @@ -155,28 +155,13 @@ def build_tpi_model_main(tpi, financial_solved_D, financial_solved_F, single place the model is defined — a second copy is how the retired audit_artifacts/ harness drifted into testing a different model.""" t = tpi - hh_D = t.hh_extended_D if hh_D is None else hh_D - hh_F = t.hh_extended_F if hh_F is None else hh_F - return sj.create_model([ - t.deposit_return_D, t.tax_rule_D, hh_D, t.ghh_composite_D, - t.sdf_D, t.sdf_banker_D, t.government_default_D, financial_solved_D, - t.bond_return_D, t.bank_return_D, t.capital_fund_D, t.cap_adj_cost_inter_D, t.macro_pru_tax_D, - t.intermediation_P2_D, t.intermediation_P3_D, t.k_balance_sheet_D, - t.capital_adj_D, t.capital_producer_profit_D, t.budget_residual_D_tpi, - t.labor_D, t.labor_market_D, t.labor_demand_D, t.banker_div_res_D, - t.market_clearing_D, t.welfare_agg_D, - t.deposit_return_F, t.tax_rule_F, hh_F, t.ghh_composite_F, - t.sdf_F, t.sdf_banker_F, t.government_default_F, financial_solved_F, - t.bond_return_F, t.bank_return_F, t.capital_fund_F, t.cap_adj_cost_inter_F, t.macro_pru_tax_F, - t.intermediation_P2_F, t.intermediation_P3_F, t.k_balance_sheet_F, - t.capital_adj_F, t.capital_producer_profit_F, t.budget_residual_F_tpi, - t.labor_F, t.labor_market_F, t.labor_demand_F, t.banker_div_res_F, - t.market_clearing_F, t.welfare_agg_F, - t.ces_price_D, t.import_demand_D, t.ces_price_F, t.import_demand_F, - t.trade_balance, t.external_account_D_tpi, t.domestic_bond_clearing_tpi, - t.bond_yield, t.portfolio_level_anchors, t.divert_portfolio_adj, - t.divert_bond_foc_D, t.divert_bond_foc_F, t.global_goods_mkt, - ], name="Full 2-Country MU HANK — TPI Extension (regimes cache, main)") + from full_model import build_block_list + return sj.create_model( + build_block_list(financial_solved_D, financial_solved_F, + hh_D=hh_D, hh_F=hh_F, + overrides=t.tpi_overrides()), + name="Full 2-Country MU HANK — TPI Extension (regimes cache, main)", + ) def _ss_tpi(ss_final, kappa_cb_F): @@ -189,8 +174,9 @@ def _ss_tpi(ss_final, kappa_cb_F): def _solve_G(model, ss_tpi, unk, tgt, T, label): log(f"- {datetime.datetime.now():%Y-%m-%d %H:%M:%S} solving G_tpi at psi_lambda_B={label} ...") - return model.solve_jacobian(ss_tpi, unknowns=unk, targets=tgt, - inputs=["Z_D", "shock_def_D", "Z_F", "shock_def_F", "cb_buy_D"], T=T) + from full_model import solve_jacobian_padded + return solve_jacobian_padded(model, ss_tpi, unk, tgt, + ["Z_D", "shock_def_D", "Z_F", "shock_def_F", "cb_buy_D"], T) def _col(G, o, i, T): @@ -260,7 +246,7 @@ def build_caches(force=False): log(f"\n## Cache build (main model) — {datetime.datetime.now():%Y-%m-%d %H:%M:%S}") log(f"- calibration: psi_lambda_B={cal['psi_lambda_B_D']}, mv_rule={cal['mv_rule_D']}, " f"recovery_rate={cal['recovery_rate_D']}, kappa_cb_F={kappa_cb_F}") - log(f"- EL_price_D = {float(ss['EL_price_D']):.6f} (main recovery=0.30; NOT the ms-regime 0.102491 anchor)") + log(f"- EL_load_D = {float(ss['EL_load_D']):.6f} (endogenous expected-loss loading from bond_return_D; replaced the deleted EL_price_D anchor on 2026-08-18)") model = build_tpi_model_main(tpi, res["financial_solved_D"], res["financial_solved_F"]) @@ -277,11 +263,20 @@ def build_caches(force=False): f"(probe found -1.9455e-2 → expect match; A_cb<0 = backstop COMPRESSES on main)") np.savez_compressed(paths[psilam_live], **_extract(G28, ss28, T, dshock, psilam_live)) - ss0 = _ss_tpi(ss, kappa_cb_F) - ss0.toplevel["psi_lambda_B_D"] = 0.0; ss0.toplevel["psi_lambda_B_F"] = 0.0 - ss0.toplevel["psi_spread_D"] = 0.0; ss0.toplevel["psi_spread_F"] = 0.0 - G0 = _solve_G(model, ss0, unk, tgt, T, "0.0") - np.savez_compressed(paths[0.0], **_extract(G0, ss0, T, dshock, 0.0)) + # Since 2026-08-18 the live psi_lambda_B IS 0.0, so the counterfactual cache would be + # a bit-identical second solve of the same Jacobian (~4 min). Skip it; paths already + # collapsed to one entry, and load_cache(0.0) finds the file either way. + if psilam_live != 0.0: + ss0 = _ss_tpi(ss, kappa_cb_F) + # psi_lambda_B now enters ONLY through collateral_quality_D/F (Delta_*_eff), a + # genuine endogenous block, so patching the parameter alone is complete. The old + # warning that "only the psi_spread channel picks the patch up" is void: that + # anchor was deleted with the 2026-08-18 structural refactor. + ss0.toplevel["psi_lambda_B_D"] = 0.0; ss0.toplevel["psi_lambda_B_F"] = 0.0 + G0 = _solve_G(model, ss0, unk, tgt, T, "0.0") + np.savez_compressed(paths[0.0], **_extract(G0, ss0, T, dshock, 0.0)) + else: + log("- psi_lambda_B = 0 is LIVE; the counterfactual cache is the same solve, skipped") log(f"- caches written: {[os.path.basename(p) for p in paths.values()]}") return paths diff --git a/diagnostics/regimes/regimes_log.md b/diagnostics/regimes/regimes_log.md index fe8d16a..650698d 100644 --- a/diagnostics/regimes/regimes_log.md +++ b/diagnostics/regimes/regimes_log.md @@ -570,3 +570,342 @@ Stage A (main) complete. note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. - caches written: ['cache_G_main_v3_psilam8p50_cal3397854d.npz', 'cache_G_main_v3_psilam0p00_cal3397854d.npz'] + +## Cache build (main model) — 2026-08-06 10:46:15 +- calibration: psi_lambda_B=7.85, mv_rule=0.0, recovery_rate=0.3, kappa_cb_F=0.929 +- EL_price_D = 0.056134 (main recovery=0.30; NOT the ms-regime 0.102491 anchor) +- 2026-08-06 10:46:15 solving G_tpi at psi_lambda_B=7.85 ... +- model-build sanity: G_tpi[cb=0] vs baseline spread_rb max|err| = 0.00e+00 (expect <1e-8) +- cross-check vs SA-1 probe: d(spread_rb)/d(cb_buy)[0,0] = -3.59256e-02 (probe found -1.9455e-2 → expect match; A_cb<0 = backstop COMPRESSES on main) + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- 2026-08-06 10:46:49 solving G_tpi at psi_lambda_B=0.0 ... + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- caches written: ['cache_G_main_v3_psilam7p85_cal685f7838.npz', 'cache_G_main_v3_psilam0p00_cal685f7838.npz'] + +## Cache build (main model) — 2026-08-06 10:49:54 +- calibration: psi_lambda_B=7.85, mv_rule=0.0, recovery_rate=0.3, kappa_cb_F=0.929 +- EL_price_D = 0.056134 (main recovery=0.30; NOT the ms-regime 0.102491 anchor) +- 2026-08-06 10:49:54 solving G_tpi at psi_lambda_B=7.85 ... +- model-build sanity: G_tpi[cb=0] vs baseline spread_rb max|err| = 0.00e+00 (expect <1e-8) +- cross-check vs SA-1 probe: d(spread_rb)/d(cb_buy)[0,0] = -3.61333e-02 (probe found -1.9455e-2 → expect match; A_cb<0 = backstop COMPRESSES on main) + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- 2026-08-06 10:50:28 solving G_tpi at psi_lambda_B=0.0 ... + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- caches written: ['cache_G_main_v3_psilam7p85_cal1dda3628.npz', 'cache_G_main_v3_psilam0p00_cal1dda3628.npz'] + +## Cache build (main model) — 2026-08-06 10:53:05 +- calibration: psi_lambda_B=7.85, mv_rule=0.0, recovery_rate=0.3, kappa_cb_F=0.929 +- EL_price_D = 0.701743 (main recovery=0.30; NOT the ms-regime 0.102491 anchor) +- 2026-08-06 10:53:05 solving G_tpi at psi_lambda_B=7.85 ... +- model-build sanity: G_tpi[cb=0] vs baseline spread_rb max|err| = 0.00e+00 (expect <1e-8) +- cross-check vs SA-1 probe: d(spread_rb)/d(cb_buy)[0,0] = -5.79526e-02 (probe found -1.9455e-2 → expect match; A_cb<0 = backstop COMPRESSES on main) + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- 2026-08-06 10:53:40 solving G_tpi at psi_lambda_B=0.0 ... + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- caches written: ['cache_G_main_v3_psilam7p85_cal717eb4c5.npz', 'cache_G_main_v3_psilam0p00_cal717eb4c5.npz'] + +## Cache build (main model) — 2026-08-06 17:32:26 +- calibration: psi_lambda_B=2.92, mv_rule=0.0, recovery_rate=0.3, kappa_cb_F=0.929 +- EL_price_D = 0.056134 (main recovery=0.30; NOT the ms-regime 0.102491 anchor) +- 2026-08-06 17:32:26 solving G_tpi at psi_lambda_B=2.92 ... +- model-build sanity: G_tpi[cb=0] vs baseline spread_rb max|err| = 0.00e+00 (expect <1e-8) +- cross-check vs SA-1 probe: d(spread_rb)/d(cb_buy)[0,0] = -1.82121e-02 (probe found -1.9455e-2 → expect match; A_cb<0 = backstop COMPRESSES on main) + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- 2026-08-06 17:32:54 solving G_tpi at psi_lambda_B=0.0 ... + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- caches written: ['cache_G_main_v3_psilam2p92_cal14989c17.npz', 'cache_G_main_v3_psilam0p00_cal14989c17.npz'] + +## Cache build (main model) — 2026-08-06 17:35:19 +- calibration: psi_lambda_B=2.92, mv_rule=0.0, recovery_rate=0.3, kappa_cb_F=0.929 +- EL_price_D = 0.056134 (main recovery=0.30; NOT the ms-regime 0.102491 anchor) +- 2026-08-06 17:35:19 solving G_tpi at psi_lambda_B=2.92 ... +- model-build sanity: G_tpi[cb=0] vs baseline spread_rb max|err| = 0.00e+00 (expect <1e-8) +- cross-check vs SA-1 probe: d(spread_rb)/d(cb_buy)[0,0] = -1.88075e-02 (probe found -1.9455e-2 → expect match; A_cb<0 = backstop COMPRESSES on main) + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- 2026-08-06 17:35:49 solving G_tpi at psi_lambda_B=0.0 ... + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- caches written: ['cache_G_main_v3_psilam2p92_cal542ac30c.npz', 'cache_G_main_v3_psilam0p00_cal542ac30c.npz'] + +## Cache build (main model) — 2026-08-06 17:38:24 +- calibration: psi_lambda_B=2.92, mv_rule=0.0, recovery_rate=0.3, kappa_cb_F=0.929 +- EL_price_D = 0.701743 (main recovery=0.30; NOT the ms-regime 0.102491 anchor) +- 2026-08-06 17:38:24 solving G_tpi at psi_lambda_B=2.92 ... +- model-build sanity: G_tpi[cb=0] vs baseline spread_rb max|err| = 0.00e+00 (expect <1e-8) +- cross-check vs SA-1 probe: d(spread_rb)/d(cb_buy)[0,0] = -3.50133e-02 (probe found -1.9455e-2 → expect match; A_cb<0 = backstop COMPRESSES on main) + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- 2026-08-06 17:38:53 solving G_tpi at psi_lambda_B=0.0 ... + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- caches written: ['cache_G_main_v3_psilam2p92_cala1e97c65.npz', 'cache_G_main_v3_psilam0p00_cala1e97c65.npz'] + +## Cache build (main model) — 2026-08-07 16:51:05 +- calibration: psi_lambda_B=3.01, mv_rule=0.0, recovery_rate=0.3, kappa_cb_F=0.929 +- EL_price_D = 0.056134 (main recovery=0.30; NOT the ms-regime 0.102491 anchor) +- 2026-08-07 16:51:05 solving G_tpi at psi_lambda_B=3.01 ... +- model-build sanity: G_tpi[cb=0] vs baseline spread_rb max|err| = 0.00e+00 (expect <1e-8) +- cross-check vs SA-1 probe: d(spread_rb)/d(cb_buy)[0,0] = -3.82512e-03 (probe found -1.9455e-2 → expect match; A_cb<0 = backstop COMPRESSES on main) + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- 2026-08-07 16:51:35 solving G_tpi at psi_lambda_B=0.0 ... + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- caches written: ['cache_G_main_v3_psilam3p01_calcce64baf.npz', 'cache_G_main_v3_psilam0p00_calcce64baf.npz'] + +## Cache build (main model) — 2026-08-07 17:25:58 +- calibration: psi_lambda_B=3.01, mv_rule=0.0, recovery_rate=0.3, kappa_cb_F=0.929 +- EL_price_D = 0.056134 (main recovery=0.30; NOT the ms-regime 0.102491 anchor) +- 2026-08-07 17:25:58 solving G_tpi at psi_lambda_B=3.01 ... +- model-build sanity: G_tpi[cb=0] vs baseline spread_rb max|err| = 0.00e+00 (expect <1e-8) +- cross-check vs SA-1 probe: d(spread_rb)/d(cb_buy)[0,0] = -3.95413e-03 (probe found -1.9455e-2 → expect match; A_cb<0 = backstop COMPRESSES on main) + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- 2026-08-07 17:26:24 solving G_tpi at psi_lambda_B=0.0 ... + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- caches written: ['cache_G_main_v3_psilam3p01_cal00cdd39c.npz', 'cache_G_main_v3_psilam0p00_cal00cdd39c.npz'] + +## Cache build (main model) — 2026-08-07 17:29:20 +- calibration: psi_lambda_B=3.01, mv_rule=0.0, recovery_rate=0.3, kappa_cb_F=0.929 +- EL_price_D = 0.701743 (main recovery=0.30; NOT the ms-regime 0.102491 anchor) +- 2026-08-07 17:29:20 solving G_tpi at psi_lambda_B=3.01 ... +- model-build sanity: G_tpi[cb=0] vs baseline spread_rb max|err| = 0.00e+00 (expect <1e-8) +- cross-check vs SA-1 probe: d(spread_rb)/d(cb_buy)[0,0] = -7.48834e-03 (probe found -1.9455e-2 → expect match; A_cb<0 = backstop COMPRESSES on main) + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- 2026-08-07 17:29:46 solving G_tpi at psi_lambda_B=0.0 ... + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- caches written: ['cache_G_main_v3_psilam3p01_cal1ec97eb4.npz', 'cache_G_main_v3_psilam0p00_cal1ec97eb4.npz'] + +## Cache build (main model) — 2026-08-18 12:01:43 +- calibration: psi_lambda_B=0.0, mv_rule=0.0, recovery_rate=0.3, kappa_cb_F=0.929 +- EL_load_D = 0.701400 (endogenous expected-loss loading from bond_return_D; replaced the deleted EL_price_D anchor on 2026-08-18) +- 2026-08-18 12:01:43 solving G_tpi at psi_lambda_B=0.0 ... +- model-build sanity: G_tpi[cb=0] vs baseline spread_rb max|err| = 0.00e+00 (expect <1e-8) +- cross-check vs SA-1 probe: d(spread_rb)/d(cb_buy)[0,0] = -4.39708e-03 (probe found -1.9455e-2 → expect match; A_cb<0 = backstop COMPRESSES on main) + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- psi_lambda_B = 0 is LIVE; the counterfactual cache is the same solve, skipped +- caches written: ['cache_G_main_v3_psilam0p00_cal86c89892.npz'] + +## Cache build (main model) — 2026-08-18 12:09:05 +- calibration: psi_lambda_B=0.0, mv_rule=0.0, recovery_rate=0.3, kappa_cb_F=0.929 +- EL_load_D = 0.701400 (endogenous expected-loss loading from bond_return_D; replaced the deleted EL_price_D anchor on 2026-08-18) +- 2026-08-18 12:09:05 solving G_tpi at psi_lambda_B=0.0 ... +- model-build sanity: G_tpi[cb=0] vs baseline spread_rb max|err| = 0.00e+00 (expect <1e-8) +- cross-check vs SA-1 probe: d(spread_rb)/d(cb_buy)[0,0] = -3.66298e-03 (probe found -1.9455e-2 → expect match; A_cb<0 = backstop COMPRESSES on main) + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- psi_lambda_B = 0 is LIVE; the counterfactual cache is the same solve, skipped +- caches written: ['cache_G_main_v3_psilam0p00_cal5de1a58e.npz'] + +## Cache build (main model) — 2026-08-18 12:12:06 +- calibration: psi_lambda_B=0.0, mv_rule=0.0, recovery_rate=0.3, kappa_cb_F=0.929 +- EL_load_D = 0.055790 (endogenous expected-loss loading from bond_return_D; replaced the deleted EL_price_D anchor on 2026-08-18) +- 2026-08-18 12:12:06 solving G_tpi at psi_lambda_B=0.0 ... +- model-build sanity: G_tpi[cb=0] vs baseline spread_rb max|err| = 0.00e+00 (expect <1e-8) +- cross-check vs SA-1 probe: d(spread_rb)/d(cb_buy)[0,0] = -2.38572e-03 (probe found -1.9455e-2 → expect match; A_cb<0 = backstop COMPRESSES on main) + note: output `Phi_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `Phi_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + **MISSING OPTIONAL OUTPUT `G_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `ra_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `lambda_gk_D`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_WEALTH`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + **MISSING OPTIONAL OUTPUT `GINI_C`** — not in main's G_tpi.outputs; panel zero-filled/omitted WITH a caption note, never silently. + note: output `div_fund_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `div_fund_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `shock_def_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. + note: output `T_D` has no Jacobian column for `cb_buy_D` at this calibration -> zero response (filled 0 T x T); economically = o does not respond to i. +- psi_lambda_B = 0 is LIVE; the counterfactual cache is the same solve, skipped +- caches written: ['cache_G_main_v3_psilam0p00_cal14b2aff3.npz'] diff --git a/diagnostics/solve_configs.py b/diagnostics/solve_configs.py index 1620a5a..6b2e0ab 100644 --- a/diagnostics/solve_configs.py +++ b/diagnostics/solve_configs.py @@ -101,7 +101,7 @@ def main(): from steady_state import solve_steady_state from ic_delta_calibration import calibrate_ic_delta from depreciation_calibration import calibrate_depreciation - from full_model import build_and_solve + from full_model import build_and_solve, solve_jacobian_padded # ---- Steps 1-4: calibration -> SS -> ic_delta -> depreciation (once) ---- logts("Step 1: get_calibration()") @@ -173,8 +173,8 @@ def main(): ["psi_lambda_B_D", "psi_lambda_B_F", "psi_spread_D", "psi_spread_F"])) exogenous = ["Z_D", "shock_def_D", "Z_F", "shock_def_F"] - G0 = ha_full.solve_jacobian( - ss0, unknowns=unknowns_tp, targets=targets_tp, inputs=exogenous, T=T) + G0 = solve_jacobian_padded( + ha_full, ss0, unknowns_tp, targets_tp, exogenous, T) logts("G0 computed.") zero = np.zeros(T) diff --git a/diagnostics/substitution_v2/exp_psilam0.py b/diagnostics/substitution_v2/exp_psilam0.py index c397e42..e2107d9 100644 --- a/diagnostics/substitution_v2/exp_psilam0.py +++ b/diagnostics/substitution_v2/exp_psilam0.py @@ -47,7 +47,7 @@ def main(): from steady_state import solve_steady_state from ic_delta_calibration import calibrate_ic_delta from depreciation_calibration import calibrate_depreciation - from full_model import build_and_solve + from full_model import build_and_solve, solve_jacobian_padded log(f"\n## Experiment — output response at psi_lambda_B = 0 (EL_price ON) — {ts()}") cal = get_calibration() @@ -61,8 +61,8 @@ def main(): ss0.toplevel["psi_spread_D"] = 0.0; ss0.toplevel["psi_spread_F"] = 0.0 # EL_price stays anchored (~0.1025) log(f"- {ts()} solving psi_lambda_B=0, psi_spread=0, EL_price={float(ss0['EL_price_D']):.6f}") - G = ha.solve_jacobian(ss0, unknowns=unk, targets=tgt, - inputs=["Z_D", "shock_def_D", "Z_F", "shock_def_F"], T=T) + G = solve_jacobian_padded(ha, ss0, unk, tgt, + ["Z_D", "shock_def_D", "Z_F", "shock_def_F"], T) d = irf_to_dict(G @ {"Z_D": np.zeros(T), "Z_F": np.zeros(T), "shock_def_D": dshock, "shock_def_F": np.zeros(T)}) np.savez(os.path.join(HERE, "irfs_psilam0_full.npz"), **d) diff --git a/diagnostics/substitution_v2/solve_v2.py b/diagnostics/substitution_v2/solve_v2.py index 3c8e6c8..4da0b82 100644 --- a/diagnostics/substitution_v2/solve_v2.py +++ b/diagnostics/substitution_v2/solve_v2.py @@ -46,7 +46,7 @@ def main(): from steady_state import solve_steady_state from ic_delta_calibration import calibrate_ic_delta from depreciation_calibration import calibrate_depreciation - from full_model import build_and_solve + from full_model import build_and_solve, solve_jacobian_padded log(f"\n## Step 0 — solve — {ts()}") cal = get_calibration() @@ -103,7 +103,7 @@ def solve(el_price, tag): ssg.toplevel["EL_price_F"] = el_price log(f"- {ts()} solving [{tag}] psi_lambda_B={PSILAM}, psi_spread={psi_spread_28:.6f}, " f"EL_price={el_price:.6f}") - G = ha.solve_jacobian(ssg, unknowns=unk, targets=tgt, inputs=inputs, T=T) + G = solve_jacobian_padded(ha, ssg, unk, tgt, inputs, T) irf = G @ {"Z_D": zero, "Z_F": zero, "shock_def_D": dshock, "shock_def_F": zero} d = irf_to_dict(irf) np.savez(os.path.join(HERE, f"irfs_2p8_{tag}.npz"), **d) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 78f6d35..acfaade 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,8 +1,289 @@ # Handoff Notes +## Session status — 2026-08-24: paper, not model + +The last session touched **no model source**. Work was on the Overleaf project and +`Empirics/`: + +- The **introduction is rewritten** and pushed (Overleaf `a0aeb1c`). It now asks three + mechanism questions rather than a counterfactual-history question, names TPI as the + modelled instrument, concedes explicitly that Greece would not have met TPI's + eligibility criteria, and separates from Bi–Foerster–Traum on three specific points. +- **`Empirics/motivation_figures.py`** is new and generates the two motivation figures + (`fig_greece_motivation`, `fig_euro_yield_decoupling`). Re-run it after any data + refresh: `/opt/anaconda3/envs/ssj/bin/python Empirics/motivation_figures.py --refresh`. +- **`docs/referee_report_2.md`** is the standing referee report. Its ten findings are the + best available list of what the paper still needs. + +**Next on the paper, in order.** Sections 2–4 are the coauthor's global nonlinear model +and have not been reconciled with the sequence-space code — that reconciliation is the +blocking item. Section 4 (TPI) is an empty stub. The abstract is `Lorem Ipsum`. Appendix B +is unmodified template boilerplate and there is no calibration table anywhere in the +manuscript. + +**Two live corrections.** `docs/paper_draft_results.md` Table 4 (quintile incidence) was +generated at `ea23e94`, before the GK structural refactor; the aggregate numbers still +match STATE.md but the quintile numbers should be regenerated before they go to print +(`experiments/e4_distribution.py`, then `experiments/paper_outputs.py`). And `CLAUDE.md`'s +GK-1 row says `Delta_bD_D = 0.20` "makes Greek paper worse collateral than capital", which +contradicts the code comment in `intermediation_IC_D` and the IC algebra — lower `Delta` +means *less* divertable, hence better collateral. Fix the wording. + +--- + +## READ FIRST: `docs/cb_mechanism.md` is canonical — 2026-08-19 + +The CB-block audit (`diagnostics/cb_audit/`) is done and promoted. **No model source changed.** +The CB block came through the GK refactor correct and untouched, SS neutrality is exact, and +the spread feedback is signed right. Three things it establishes that change how you write: + +- **TPI's spread effect and its investment effect are the same effect.** The identity + `rb_exp_D(+1) − rdep_D = 0.20·(rk_D(+1) − rdep_D)` is exact at `psi_lambda_B = 0`, so the CB + can only compress the spread by lowering `rk_D`. Never present them as two channels. +- **~72% of the CB book is bought from German banks** (84% in quantity at impact, ~99% from + t=1), 17% is new Greek issuance, 10% comes off Greek banks. The concentration relief is a + **denominator** effect: `n_inter_D` recovers +6.6% while the numerator moves −0.4%. Do not + write that the ECB absorbs bonds from Greek banks. +- **Never net the realised German transfer against `el_pv`.** With `writeoff_enabled = 0` no + credit loss flows through the conduit at all; the printed "F bears EL PV" is an off-path + expectation that appears in no budget constraint. + +**Correction to the pole bullet below: there is no closed-loop pole.** γ ≈ 27.3 is a T=500 +terminal-truncation artefact (resonant eigenvector: 0.0000 of its mass in the first 100 +quarters, 0.9922 in t=400–499; `A_cb[499,499] = +1.080` is the only positive diagonal in the +matrix). Dropping five columns removes every pole below γ=36 and changes the reported peak +spread by nothing at γ=2/5/10. No reported number is wrong — but the γ=19.88 cap is imposed +for a spurious reason and the pole is not a stability ceiling. Fix R-1 proposed, not applied. + +Open items the audit raised and did **not** fix: `experiments/e1_backstop_schedule.py` +duplicates `cb_pnl`; `code/tpi_plots.py:243` hardcodes a stale `δ_b = 0.10`; and three +`diagnostics/psilam_*` / `solve_configs.py` scripts still reference the deleted `psi_spread` — +`solve_configs.py` being the dangerous one, since it does not crash, it silently produces a +`psi_lambda_B = 0` arm identical to its own baseline. See `recommended_fix.md`. + +## DONE: GK structural refactor (`gk-structural-foc`), stages 1–5 — 2026-08-18 + +Read `docs/STATE.md` -> *GK structural refactor* first. Short version: the Greek spread is now +generated by the bond's state-contingent payoff inside the genuine GK portfolio FOC. There is +no free spread parameter anywhere in the model. + +**The finding that matters, if you read nothing else.** `psi_spread_D` was not padding the +spread — it was standing in for a **mis-specified bond payoff**. `zeta_writeoff_D = 0` wrote +down only the current coupon on default and left the perpetuity's continuation value whole, +understating the loss on a 12.9-quarter claim by 12.6×. With `zeta_writeoff = 1` the GK +mechanism generates 205.9 bp on a 1pp shock with `psi_lambda_B = 0` and no wedge at all; the +old specification needed `psi_lambda_B = 3.01` plus `psi_spread = 0.60` to reach 150 bp. The +stage-1 conclusion that the GK mechanism was too weak was wrong. + +**What this changes for the paper, immediately:** + +- **The TPI loading is 0.48–0.52, below 1.** The over-compensation headline is reversed. The CB + earns the same premium and absorbs a 12.6× larger expected loss. Rewrite, do not caveat. +- **The loading is near-invariant across all three arms** (within 4% across a 12.6× change in + the priced loss). It is a property of the GK pricing structure, not of the risk's size — the + most robust number in the exercise, and the one worth leading with. +- **Never write "x% fundamental / y% non-fundamental".** Deleted from `fig04` and its caption. + Linearised model; the channels operate jointly. `psi_lambda_B = 0` vs `3.01` is a **model + counterfactual**, not an empirical decomposition. +- **Peak spread is 205.9 bp against a 150 bp empirical moment, and nothing is tuned to close + it.** If the moment is wanted back, the honest lever is the **size of the 1pp default- + probability shock** — which was itself sized against the 12.6×-too-small priced loss — and it + must be argued on its own evidence. Not a reason to reintroduce a wedge. +- **The "aggressive" regime is 40.3% compression, not 50%.** There is a closed-loop pole at + `gamma ≈ 27.3`; 50% is only reachable on the far branch, and the pole's influence bleeds in + from ~0.85 x pole. `common.named_regime_gammas` falls back to + `POLE_SAFETY_FRACTION * pole = 19.875` and says so loudly. Fix the prose. +- **Never plot a gamma grid past `lottery_math.closed_loop_pole`.** It cost two broken paper + figures. `e1.loading_schedule`, `code/tpi.py`'s effectiveness curve and + `named_regime_gammas` all cap now; anything new must too. +- **E1–E4 and every `experiments/paper/fig0*.png` are STALE.** The SS moved + (`q_b_D 0.968941 -> 0.974906`). Rebuild the regime cache FIRST + (`diagnostics/regimes/regime_model.py --force`), then `run_all.py`, then + `e4_distribution.py` -> `paper_outputs.py`. +- Old `diagnostics/psilam_*` and `diagnostics/substitution_v2/` scripts probe the deleted + specification and will fail on import. Superseded; delete or port, your call. + +Three things not to rediscover: + +- **`np.exp` cannot appear in an SSJ `@simple` block.** `AccumulatedDerivative` supports + arithmetic operators only. Use rational forms. Same severity as `solve_jacobian_padded`. +- **`psi_bD_F`/`psi_bF_D` = 0.5 are NOT spread wedges — do not delete them in stage 4.** They + are the coexistence device letting two banks with different `nu_K` and `Delta` hold the same + bond at one price, and the stationarity device for the external position. BFT's CES home + bias does the identical job (their fn 6). Removing them makes the model not solve. +- **Any new block must go in BOTH `full_model.build_block_list()` and `steady_state.py`'s + list.** Missing the second gives a `KeyError` on `ss_final` at the Jacobian step, twelve + minutes in. The fast tests do not cover block-list wiring; a test that every block input is + either produced in the list or present in `ss_final` would pay for itself. + +**Identification remains the weak point and both fixes are blocked.** The Eurosystem/LCH +haircut path **does not exist as data** — the ECB never disclosed haircuts on Greek paper for +2010-12. The Acharya-Steffen route needs equity returns the repo does not have. + +That blockage is now handled by **setting `psi_lambda_B = 0`, not by fitting it.** The old +practice — calibrate the dial to 150bp, then use it to explain 91% of that spread — is gone; +it was the calibration restated as a finding. The spread is now whatever the corrected payoff +and the GK portfolio FOC deliver, and `psi_lambda_B = 3.01` survives only as a labelled +diagnostic arm. + +## RESOLVED 2026-08-18: foreign banks now DO retrench + +`b_D_F` falls **−2.10%** on impact in the Arm-1 baseline (−3.16% at `psi_lambda_B = 3.01`), +where the pre-refactor model had it *rising*. Two changes did it, and neither was aimed at this +problem: + +1. `intermediation_P1_F` now converts D-good bond returns into F goods with `p/p(+1)`. It + previously compared an unconverted `rb_actual_F(+1)` with the F-good `rdep_F` while the old + `divert_portfolio_adj` applied the conversion to the same return — the two sides of the F + bank's problem were on different unit conventions. +2. The cross-border condition is stated on the marginal values (`nu_bD_F = Delta_bD_eff_F·nu_K_F` + plus the stock cost) rather than as a hand-written required-premium rule, so whatever + convention `P1_F` uses is used consistently on both sides. + +The section below is the pre-refactor record. **The "ruled out" list is still valid** — those +routes were correctly excluded — but the diagnostic it recommends (decomposing `d b_D_F` off +`divert_portfolio_adj`) refers to a block that no longer exists. + +## Open problem (HISTORICAL, resolved above): foreign banks do not retrench + +On a 1pp default shock the F bank **increases** its Greek holdings (`b_D_F` rises; +peak around t≈13) instead of cutting them. That is contrary to the 2010–12 record +and to the closest published analogue — Bi, Foerster & Traum (FRBSF WP 2025-10), +whose Foreign intermediary reduces Italian holdings by ~2% of GDP while its own +economy still expands. It also undercuts the instrument's rationale: the ECB is +meant to be the risk-neutral buyer of last resort, with private core banks +retrenching and reinforcing the doom loop TPI addresses. + +The country-size asymmetry (2026-08-07) shrank the impact response by an order of +magnitude — to a near-miss — but did not flip the sign, and from t≈4 the +mark-to-market term dominates outright. The FOC decomposition of `d b_D_F` off +`divert_portfolio_adj` isolates the four contributions (MTM, terms of trade, +`−d rdep_F`, risk premium) and is the right diagnostic for any candidate fix. + +**Ruled out, with evidence — do not re-litigate these:** + +- *Convex/threshold terms in `def_rate`.* `def_rate_ss = 0`, so anything quadratic + has exactly zero first-order effect in the linearised solution. BFT can use + nonlinearity because they solve at second order with regime switching. +- *The Bohn rule.* Swept over {0.15, 0.10, 0.07, 0.05} on 2026-08-07. `b_D_F` turns + negative only in the divergent region past the 0.10–0.07 stability boundary. No + stationary calibration of `phi_lamb_D` produces retrenchment. +- *The fiscal limit.* Estimated from Greek data the same way BFT estimate theirs + from Italian CDS. The best-fitting value (`def_scale_D` ≈ 0.63) puts the model in + a region where `psi_lambda_B` is not continuously calibratable, and the apparent + retrenchment at that value sits past a pole. See `docs/STATE.md`. + +So the fiscal block cannot deliver this. The remaining routes are all on the +portfolio side, none yet chosen: a home-bias risk-pricing wedge in `prem_DF`; a +larger cross-border sensitivity on `Delta_bD_eff` in `intermediation_IC_F`; porting +BFT's CES/Krenz bond aggregator (`sigma_b = -2`, `gamma_b` from domestically-held +shares); or a moral-suasion/forced-absorption device on the D side, which is +closest to BFT's actual mechanism. Note the impact sign of `b_D_F` is a +near-cancellation of FOC terms ~300x its size and flips non-monotonically — the +medium-horizon peak is the better-behaved target. + +**Do not trust the printed `rho_b` gate** when choosing `phi_lamb_D`. It is +partial-equilibrium, omits `def_scale_D`, and predicts a stability floor of 0.05 +against a measured 0.10–0.07. + +**Note on targets.** The numbered "Live Claims", and the declining loading +schedule in particular, are **not** success criteria and must not be used to +evaluate model changes. Report them as outputs. The real gates are the calibration +moments (150bp peak GR–DE spread) and the correctness checks (Walras residuals, +GK well-posedness, impact signs). + ## Where to start -- Working branch: `main`. Production entry point: `code/main.py` (orchestrates +- **FIRST: regenerate the downstream artefacts. They are stale.** The country-size + asymmetry landed on 2026-08-07 (`size_F = 11.697`) with `psi_lambda_B` re-tuned + **2.92 → 3.01**; see `docs/STATE.md` → *Country-size asymmetry*. `code/main.py` + is verified against the new calibration, but E1–E4 and every paper artefact + still reflect the old one — **and everything TPI-related predates the + `rem_cb_F` conduit fix, so those numbers are wrong by an amount that grows with + γ.** Run **in this order** — the ordering is load-bearing, the experiments never + re-solve the model: + ``` + /opt/anaconda3/envs/ssj/bin/python diagnostics/regimes/regime_model.py --force + /opt/anaconda3/envs/ssj/bin/python experiments/run_all.py + /opt/anaconda3/envs/ssj/bin/python experiments/e4_distribution.py + /opt/anaconda3/envs/ssj/bin/python experiments/paper_outputs.py + ``` + Affected: `docs/experiments_results.md`, `docs/paper_draft_results.md`, the + eight tracked `experiments/paper/fig0*.png`. + +- **PAPER EDIT REQUIRED — the constrained-seller number changed.** The default + loading split is now **8.36% fundamental / 91.64% collateral friction** + (`EL_price_D = 0.056134`, `psi_spread_D = 0.615358`), a ratio of 10.96:1. It + was 3.4% / 96.6% (28.6:1). (`psi_spread` is exactly linear in `psi_lambda_B` + at a fixed SS — verified 2026-08-07 when the 2.92 → 3.01 retune moved + `psi_spread_F` 0.465088 → 0.479423, precisely the 3.01/2.92 ratio — so this + rescaled from the 2.92 figures without a re-solve.) The claim survives in + direction but "essentially + all of the spread was a constrained-seller phenomenon" must become "roughly + nine tenths of it". `experiments/paper_outputs.py`'s + `fig04_spread_decomposition` caption is derived at run time and will pick this + up automatically once regenerated — but the *prose in the paper* will not. + +- **Sweeping `psi_lambda_B`: re-solve the pipeline, do not patch the SS.** The SS + is genuinely `psi_lambda_B`-neutral (bit-identical `goods_mkt_D`, `K_D`, + `beta_D` at every value), but patching `psi_lambda_B_D/F` + `psi_spread_D/F` + onto a solved SS and re-solving only the Jacobian is still **wrong** — it + predicted 150.33bp at `psi = 2.73` where the pipeline gives 139.60, because + `intermediation_IC_D`'s `Delta_bD_eff` collateral channel ignores the patch. + A full re-solve is ~2 minutes. The `rho_def` bisection was thrown away and + redone over exactly this. + +- **`rho_def` and `rho_Z` now live in `code/calibration.py`**, section *Shock + processes*, not in `code/full_model.py`. `rho_Z` stays at 0.80 — the + Markov-switching estimate disciplines the sovereign-risk shock only. + +- **MOSTLY CLOSED: `Y_D` negative for only ONE quarter (issue I-1).** + `rho_def = 0.9408` took cumulative 40-quarter `Y` from −0.049 to **−2.542** and + the count of negative-`Y` quarters in the first 40 from 5 to **37**. Residual + defect: a small positive blip at q2–q4 (+0.0115, +0.0264, +0.0111 — all under + +0.03% of SS) before `Y` goes negative again at q5 and stays there through q39. + If a deeper, monotone bust is wanted, the next hypothesis is the `n_inter_D` + rebound (+1.09 by q3, peaking **+2.94 at q8**), **not** another capital + friction. The original I-1 write-up below is retained because its two rejected + hypotheses must not be re-tested. + + *Historical (pre-`rho_def` fix), retained for the rejected hypotheses:* see + `docs/STATE.md` → *Open issue I-1*. On the default shock `Y_D` was −0.5064, + −0.0026, **+0.0929**, … then a positive hump; Bi–Foerster–Traum stay negative + ~20 quarters. **Two frictions were tested and both rejected — do not + re-test either.** + 1. `chi1` (intermediary capital adjustment cost): raising it makes both the + trough *and* the rebound bigger. Stays 0. + 2. `omega_I` (investment-flow cost `S(I/I(-1))`, added 2026-08-06): the block + is **live in `capital_adj_D/F` but calibrated to 0**. Sweeping 0/2/5/10 + shrinks the contraction toward zero rather than lengthening it — at + `omega_I >= 5`, `Y_D[0]` goes **positive**. Full table in STATE.md. + + Both fail identically: they reallocate the impact between `I` and `C` + (`C_D[0]` goes −0.51 → +0.23 as `omega_I` rises) without deepening the + aggregate contraction. **The persistence problem is not a missing investment + friction.** The `n_inter_D` rebound to **+3.6% by q5** — which gets *larger*, + not smaller, at every positive `omega_I` — is the more promising next + hypothesis. Start there, not on another capital-adjustment parameter. + + If a positive `omega_I` is ever adopted, `psi_lambda_B` must be re-tuned: + peak spread drifts 150.1 → 163–168 bp off the 150 bp target. + +- **Regenerating the `omega_I = 0` equivalence reference.** `/tmp/nkpc_irfs_nominal.npz` + is **stale** (predates the `psi_lambda_B` 8.5 → 7.85 re-tune; differs by 1.56). + Regenerate with `/opt/anaconda3/envs/ssj/bin/python code/dump_irfs.py OUT.npz` + *before* making a change, and compare after. The current change passes at + 1.08e-13 over all 45 arrays. + +- **The model is sticky-price with nominal deposit contracts.** The `add-nkpc` + workstream (`docs/superpowers/plans/2026-08-05-nominal-rigidities.md`) is + **COMPLETE** — Tasks 1–16, all committed, all results regenerated. Read + *Nominal rigidities (`add-nkpc`) — complete* further down this file before + touching anything, and `docs/STATE.md`'s top section for the full tables. + The two things not to rediscover the hard way are `solve_jacobian_padded()` + (SSJ cannot solve this system without it) and the regime-cache rebuild + ordering; both are written up in that section. +- Working branch: `add-nkpc` (to be merged to `main`). Production entry point: `code/main.py` (orchestrates `calibration.py`, `steady_state.py`, `ic_delta_calibration.py`, `depreciation_calibration.py`, `full_model.py`, `tpi.py`, `irf_plots.py`, `tpi_plots.py`). The legacy notebook `code/model_v12.ipynb` has been removed. @@ -34,14 +315,17 @@ silently running a mistyped calibration, and `write_results` refuses to write `NaN`. - **E2's headline finding, which changes how ΔY should be reported:** the output - response is the small residue of an investment channel and a net-export channel - each ~4× larger and opposite in sign. Report the decomposition, never the - headline ΔY. See `docs/STATE.md` for the table. + **E2's headline finding, which changes how ΔY should be reported:** report the + decomposition, never the headline ΔY — the channels **cancel** and land on + different households. (Under the flexible-price model the output response was + additionally a small *residue* of channels ~4× larger; under sticky prices the + largest channel is 0.25× the headline, so the magnitude ordering has reversed + but the instruction has not.) See `docs/STATE.md` for the table. **E1's headline:** the loading schedule is monotone decreasing at all 59 finite - grid points (4.51 → 2.07 over γ=0.5→30), confirming Live Claim 5 on a fine grid. - Every cross-check against `code/main.py` passes. Run with + grid points (**4.43 → 1.49** over γ ∈ [0.51, 30.00] on the sticky-price model), + confirming Live Claim 5 on a fine grid. Every cross-check against + `code/main.py` passes. Run with `/opt/anaconda3/envs/ssj/bin/python experiments/e1_backstop_schedule.py`. **First-draft material is ready.** `experiments/paper_outputs.py` → @@ -59,20 +343,31 @@ residue of two nearly-cancelling terms (bottom decile PV: −41.6 consumption vs −44.4 mass, netting +2.8). Never describe the wealth cut as household behaviour. - **Incidence result:** the crisis is progressive — PV consumption +0.95% for the - lowest income quintile against −0.59% for the highest, monotone in between; the - backstop's protection runs the same way (+0.40 vs +0.07). But note every - quintile's consumption *rises* on impact: the model's crisis is an investment - bust, not a consumption bust, which is counterfactual for Greece 2010–13 and - must be confronted in the draft. + **Incidence result** (regenerated on the sticky-price model 2026-08-06 — see + Table 4 of `docs/paper_draft_results.md`, which is authoritative): the crisis is + progressive. PV consumption **+0.4250%** for the lowest income quintile against + **−0.9073%** for the highest, monotone in between; the backstop's protection + runs the same way (**+2.01** vs **+1.34**). The flex-price figures previously + quoted here (+0.95 / −0.59, gains +0.40 / +0.07) are superseded. + + > **RESOLVED (Task 17, 2026-08-06).** `experiments/paper_outputs.py` no longer + > has a hardcoded `CAPTIONS` dict. Each figure now builds its own caption from + > the arrays it plots and hands it to `save()`, which registers it — so + > `fig08_deciles`'s caption reads the same `pv` object Table 4 does and cannot + > drift from it. **Figure captions are quotable again**, but quote them from a + > freshly regenerated `docs/paper_draft_results.md`, not from memory. **S-1 RESOLVED 2026-08-04: `writeoff_enabled=0` stays** — the pure risk-premium framing. E3 becomes an appendix robustness result and a *stated caveat*: the over-compensation claim is conditional on no realised principal writedown. - **Default-loading split corrected to 3.1% / 96.9%** (fundamental expected loss / - collateral friction). The 10.9% / 89% in older sections is pre-EBA. 96.9% is a - stronger version of the constrained-seller claim — use it. + **Default-loading split is 3.4% / 96.6%** (fundamental expected loss / + collateral friction) at the live `psi_lambda_B = 7.85`. `EL_price_D = 0.056134` + is invariant to `psi_lambda_B`; `psi_spread_D` is *linear* in it, so the + 8.5 → 7.85 sticky-price re-tune moved it 1.737724 → 1.604839 and the split + 3.1%/96.9% → **3.4%/96.6%** (Task 17). The 10.9% / 89% in older sections is + pre-EBA. Either way 96.6% is a strong version of the constrained-seller claim — + use it, but **re-derive it whenever `psi_lambda_B` moves.** **New: the backstop damps the oscillation, it does not lower the spread path.** Cushioning is concentrated at impact; by ~q4 the paths converge and the spread @@ -81,11 +376,13 @@ **E3's numbers, for the appendix — read before writing the TPI section.** Full writeoff (`writeoff_enabled=1`, `zeta_writeoff=1`) takes `EL_price_D` from - 0.056134 to 0.701743 (12.5×) and **collapses the loading from 4.00/3.17 to - 0.37/0.28 — below 1**. The CB becomes *under*-compensated, inverting SPEC Live - Claim 1. Coupon-only writeoff (`zeta=0`) is negligible by contrast (loading - 3.93/3.13). So **S-1 is not a robustness detail — it decides whether the paper's - central over-compensation result holds.** Author decision, now with numbers. + 0.056134 to 0.701743 (12.5×) and takes the loading from 3.82/2.90 to + **2.46/0.26**. On the sticky-price model the inversion is **partial: medium + holds above 1 at 2.46, and only aggressive falls below at 0.26** — the + flex-price model had both below 1 (0.37/0.28). Coupon-only writeoff (`zeta=0`) + is negligible by contrast (3.77/2.87). So S-1 still decides whether the paper's + over-compensation result holds *under strong intervention*, but no longer + overturns it across the schedule. **Blocking a paper claim — A5-1's third object is misnamed.** The code reports `Σ β^t (pd_passive − pd_intervention)`, which is **negative** because the @@ -99,21 +396,27 @@ the `0.0717` still quoted in older doc sections and CLAUDE.md. It is the TPI loading's denominator — re-derive it, don't copy it. -> **Current state (2026-07-31). The EBA calibration is LIVE and verified.** +> **The EBA calibration is LIVE and verified** (established 2026-07-31; the +> *steady-state* content below is current, the *dynamics* were re-measured on the +> sticky-price model 2026-08-06 and are given in the `add-nkpc` section). > `EBA_CALIBRATION = True`, `BANK_SCOPE = "broad"` in `code/calibration.py`. > > Measured: `theta` 5.51/6.94 (GK-eligible assets / CT1), `delta_b` 0.0777/0.0568 > (sovereign maturity ladder repriced at the end-2010 market yield), the sovereign > book, `K/Y`. Implied: `n_inter` 2.138/1.627 = `(Q*K + sovereign)/theta`, and > `phi_own` 0.456/0.296. `omega_K = 1` — the passive-fund device is gone. -> Free/tuned: `psi_lambda_B = 8.5` (150bp target), `Delta = 0.2/0.4`, -> `phi_lamb = 0.15`, `mv_rule = 0`. +> Free/tuned: **`psi_lambda_B = 7.85`** (150bp target; was 8.5 until the +> sticky-price re-tune of 2026-08-06), `Delta = 0.2/0.4`, `phi_lamb = 0.15`, +> `mv_rule = 0`. > -> Verified end-to-end: `K_D=10.800`/`K_F=10.832` (target 10.8), IC residual -> −8.9e−16, `ca_res_D=6.9e−17`, `b_gov_D[499]=1.4e−05`, -> `n_inter_D[0]=−3.380% of SS` (level dev −7.227 — see the units fix below), -> `Y_D[0]=−0.0149%` (**Y-1 resolved**), `rk_D=rk_F=0.010000` (**RK-1 resolved**), -> peak spread 150.4bp, TPI loading 4.35/4.01/3.44 declining. +> Verified end-to-end, steady state (unchanged by the sticky-price work): +> `K_D=10.800`/`K_F=10.832` (target 10.8), IC residual −8.9e−16, +> `ca_res_D=6.9e−17`, `rk_D=rk_F=0.010000` (**RK-1 resolved**). +> Dynamics, **current** (sticky prices + nominal deposits, `psi_lambda_B=7.85`): +> `b_gov_D[499]=4.6e−05`, `n_inter_D[0]=−4.296% of SS`, `Y_D[0]=−0.5064%` +> (**Y-1 resolved**), peak spread 150.0bp, TPI loading 3.82/2.90 declining. +> *(The flex-price values were `n_inter_D[0]=−3.380%`, `Y_D[0]=−0.0149%`, peak +> spread 150.4bp, loading 4.35/4.01/3.44.)* > > Getting here took three fixes, all documented in `docs/eba_calibration.md`: > (1) the hidden `ratio=Delta_cross/Delta_own=2.0` closure in @@ -144,11 +447,14 @@ > **Open items:** (1) `beliefs.json` dates from 2026-07-23 (estimated MS chain on the > FRED peripheral–Bund composite; calibration-independent). (2) `Y_D[0]` is positive > under both intervening regimes and the A5 `dY_D` trough never goes negative — -> output never falls under the backstop. At `gamma_aggressive=12.7` this is likely -> linear-rule overshoot; check before reporting intervening-regime output paths. -> (3) The `theta`-for-the-whole-sector assumption is the one load-bearing judgement -> left in the bank block; an ECB BSI cross-check on bank credit to NFCs would test -> it. (4) S-1 (`writeoff_enabled=0`) still an author decision. +> output never falls under the backstop. **This is now an order of magnitude larger +> (+0.2008 / +0.8721) and `n_inter_D[0]` has gone positive too (+0.924), i.e. the +> aggressive backstop produces an impact boom** — see the watch item in the +> `add-nkpc` section. Still plausibly linear-rule overshoot; diagnose before +> reporting intervening-regime paths. (3) The `theta`-for-the-whole-sector +> assumption is the one load-bearing judgement left in the bank block; an ECB BSI +> cross-check on bank credit to NFCs would test it. (4) S-1 **RESOLVED 2026-08-04** +> (`writeoff_enabled=0` stays, pure risk-premium framing). ## Quick start @@ -289,6 +595,134 @@ consequential for the paper right now: | `code/tpi_plots.py`, `code/irf_plots.py` | Figure-generation scripts (regenerate from `main`) | | Overleaf | https://www.overleaf.com/project/698b4f88aeef1d0e1d08cc0c | +## Nominal rigidities (`add-nkpc`) — complete (Tasks 1–16, 2026-08-05/06) + +**The workstream is done.** Sixteen tasks, all committed, all downstream results +regenerated. Full tables in `docs/STATE.md`'s top section; the changelog entry is +in `docs/PROGRESS.md`. + +### What the model now is + +- **Sticky prices.** Rotemberg price Phillips curves + `pi = beta*pi(+1) + kappa_p*(mu_p*mc − 1)` in both countries (`price_nkpc_D/F`), + a markup wedge `w = mu_p*mc*(1−alpha)*Y/N` in `labor_demand_D/F`, and a markup + rent `profit = (1 − mu_p*mc)*(1−alpha)*Y` (`firm_profit_D/F`) distributed to + households **in proportion to productivity `e`** via `income_D/F`. **Wages stay + flexible** — `labor_market_D/F` is untouched, and is *allowed* to be untouched + precisely because the rent is routed proportional to `e` rather than lump-sum. +- **Nominal closure with no policy rate.** `terms_of_trade` turns the + monetary-union identity `p/p(-1) = (1+pi_F)/(1+pi_D)` into a residual on the + existing unknown `p`, pinning the inflation differential; `union_inflation` + (`omega_pi_D*pi_D + (1−omega_pi_D)*pi_F = 0`) pins the level as the + `phi_pi → ∞` limit of an ECB rule on union PPI, stated as an abstraction. At + `omega_pi_D = 0.071` (renormalised capital key) **93% of any terms-of-trade + move is Greek deflation, 7% German inflation** — the internal-devaluation + pattern. No financial contract in the model carries a policy rate, so no Fisher + relation is needed to close it. +- **Nominal deposits.** `i_dep_D/F` is the nominal rate and the solver unknown. + `rdep_D/F` **keeps its name** as the derived ex-ante real rate (so + `intermediation_P1`, `divert_bond_foc` and `divert_portfolio_adj` were never + touched); `rdep_expost_D/F` is the realised rate carrying the inflation + surprise, consumed by `bank_return_D/F` and `capital_fund_D/F`. That is the + Fisher channel. `rdep_expost` carries its own `(-1)` — do not double-lag it. +- **Sovereign bonds stay real.** A deliberate asymmetry that maximises banks' + Fisher exposure. **It must be stated as such in the paper.** +- **27×27 solver system** (was 23×23): `+mc_D, pi_D, mc_F, pi_F` unknowns, + `+nkpc_p_res_D/F, tot_res, union_pi_res` targets. One block-list definition, + `full_model.build_block_list()`. +- **The steady state is bit-identical to pre-change** — markups are + subsidy-neutralised (`mc_ss = 1/mu_p`) and `pi_ss = 0`, so every new object is + exactly zero at SS. + +### The numbers + +Impact on the 1pp default shock, % of own SS level. Both columns sit on the same +150bp peak-spread moment, so this is like-for-like: + +| | flex, real deposits (`psi_lambda_B=8.5`) | sticky + nominal (7.85) | +|---|---|---| +| peak spread | 150.4 bp | 150.0 bp | +| `Y_D[0]` | −0.0149 | **−0.5064** | +| `C_D[0]` | **+0.2164** | **−0.5103** | +| `I_D[0]` | −0.7718 | −1.0114 | +| `n_inter_D[0]` | −3.3804 | −4.2962 | + +Price stickiness alone does most of it (`Y_D[0]` → −0.4923, `C_D[0]` → −0.4904); +nominal deposits add a Fisher amplification ~11× larger on bank net worth than on +output (`n_inter_D[0]` −4.0140 → −4.6155), which is the correct signature. +`psi_lambda_B` was then re-tuned 8.5 → 7.85 to put peak spread back on 150bp. + +E1 regime table (regenerated): + +| regime | γ | peak spread bp | `Y_D[0]` | `C_D[0]` | `I_D[0]` | `n_inter_D[0]` | loading | +|---|---|---|---|---|---|---|---| +| passive | 0 | 150.1 | −0.5064 | −0.5103 | −1.0114 | −4.296 | n/a | +| medium | 3.2515 | 112.6 | +0.2008 | +0.5285 | −0.2934 | −1.649 | 3.82 | +| aggressive | 9.0163 | 75.1 | +0.8721 | +1.5143 | +0.3977 | **+0.924** | 2.90 | + +**Live Claim 5 survives** (loading monotone decreasing, 4.43 → 1.49 over 59 grid +points on γ ∈ [0.51, 30.00], above 1 throughout). **Live Claim 1 survives** +(3.82 / 2.90). E2's identity closes at 3.5e−17 against its 1e−07 assertion. + +### Open items + +1. **The one-quarter-spike caveat — do not drop it from the write-up.** Output + and consumption are both positive from quarter 1, and flexible-price + consumption is in fact *more* persistently negative from quarter 2 on. Nominal + deposits deepen the impact quarter but do not lengthen the recession + (`C_D[1]` is essentially unmoved: +0.1141 → +0.1144). Bi-Foerster-Traum's + output stays negative ~20 quarters. **The honest claim is that the model fixes + the impact quarter, not that it resolves the investment-bust counterfactual.** +2. **WATCH ITEM: the aggressive backstop now produces an impact boom.** + `n_inter_D[0] = +0.924` where it was −1.099, with `Y_D[0] = +0.8721` and + `C_D[0] = +1.5143`. That is a much stronger intervention effect than before and + a referee will press on it. It may still be linear-rule overshoot at + `γ = 9.02`, but it now reaches bank net worth, not just output. Diagnose before + reporting intervening-regime paths. +3. **Candidate follow-ons.** **Nominal sovereign bonds** (would give the sovereign + an inflation-erosion channel and flip the sign of the bank's net Fisher + exposure) and a **Sims-Wu loan-in-advance constraint** (Bi-Foerster-Traum's + persistence device — the one-quarter spike is the symptom it would address). +4. **Prose-vs-table agreement is only partly guarded.** `paper_outputs.py`'s + `CAPTIONS` dict is fixed (Task 17): it is empty at import and filled by + `save()` from each figure's own arrays, so a caption cannot outlive the numbers + it describes, and `main()` asserts fig01's caption and Table 3 agree on impact + net worth. **`experiments/run_all.py` still has no such assertion**, and there + is no pytest covering either — the checks are runtime asserts inside the + generators. A rendered-prose test remains a genuine follow-on. The generic + lesson stands: **never write a number, a direction, or the word "monotone" into + a caption as a literal** — derive it, and let a sign flip rewrite the sentence. + +### Two things not to rediscover the hard way + +1. **SSJ 1.0.0 drops H_Z rows for targets reachable from no shock**, so stock + `Block.solve_jacobian` cannot solve this system — it returns a 23-row H_Z + against a 27×27 H_U and numpy raises `size 11500 is different from 13500`. + Everything routes through `full_model.solve_jacobian_padded()`, which restores + those rows as zeros (exact, since `dH/dZ` at fixed unknowns is identically zero + when the shock never appears in the equation). All nine call sites were + converted in Task 9b, and the invariant + `grep -rn "\.solve_jacobian(" --include="*.py" code experiments diagnostics | + grep -v solve_jacobian_padded` must stay **empty**. A 25×25 rewrite was + considered and rejected — same defect, smaller numbers. +2. **Rebuild the regime cache BEFORE running `experiments/run_all.py`.** The + experiments never re-solve the model; they read + `diagnostics/regimes/regime_model.py`'s cached Jacobians. The cache is keyed on + a hash of the whole live calibration + (`regime_model._calibration_fingerprint`), so a stale cache can never be picked + up silently *by name* — but running the experiments first will happily + re-report the old model. Current tag: `psilam7p85_cal685f7838`. And **E4 is not + wired into `run_all.py`**: `experiments/e4_distribution.py` is a separate entry + point feeding `experiments/paper_outputs.py`. Regenerating E1–E3 does not + regenerate E4 or the eight tracked paper figures. + +### Test entry points + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -v # 17 tests, ~1s +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py code/test_eba_calibration.py experiments/ -v # 40 passed +``` + ## Run environment ``` diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index dbd7551..f742bf3 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -14,6 +14,754 @@ and `.githooks/pre-commit` (terminal commits; enable with --- +## 2026-08-24 — Paper introduction rewritten; motivation figures added (`gk-structural-foc`) + +**No model source changed.** `code/`, the calibration and every solved object are +untouched; this entry covers `Empirics/` and the Overleaf project. + +- **`Empirics/motivation_figures.py`** (new). Emits two paper figures with captions + baked into the PNGs, per the repo's figure convention. Outputs to the gitignored + `Empirics/outputs/`; the tracked copies live in the Overleaf project at + `VIVA/figures/`. + - `fig_greece_motivation` — 2×2: Greek general government debt (€bn), debt/GDP, + the ten-year yield against the Bund with the spread shaded, and real GDP against + real investment indexed to 2007Q4. + - `fig_euro_yield_decoupling` — ITA/ESP/PRT/GRC ten-year yields against the Bund, + 1995–2026, with ECB/OMT/TPI markers. + - Sources: Eurostat `gov_10q_ggdebt` (S13, `na_item=GD`) and `namq_10_gdp` (B1GQ, + P51G, CLV10_MEUR, SCA); the FRED yield panel already cached by + `Empirics/graph_spreads.py`. + - Palette is the existing Okabe–Ito order, validated for colour-vision separation + (worst adjacent pair ΔE 11.0 deutan, 15.6 normal). Germany is drawn in ink and + dashed rather than given a categorical hue, since it is the benchmark. + +- **Two facts the figures establish, both checked against the series.** Real + investment fell 69.7% from 2007Q4 to the 2015Q3 trough against a 27.4% fall in + GDP — the asymmetry that motivates an intermediary-constraint channel rather than + a demand or labour-wedge channel. And the March 2012 PSI cut €75bn and 33.7 points + of debt ratio, with the ratio back at its 2011Q4 level by 2013Q2 and the stock not + until 2021Q2. + +- **Introduction rewritten** (Overleaf `866a537`, `a0aeb1c`). Reframed from a + counterfactual-history question to three mechanism questions; TPI named as the + modelled instrument via the three design features the model uses; Bi–Foerster–Traum + separation expanded to three substantive points (CES portfolio aggregator vs. + equilibrium retrenchment; their reduced-form financing friction vs. no additive + component here; their joint fundamental/non-fundamental scope vs. our deliberate + narrowing). Distributional incidence promoted to the second reported finding. + +- **Two errors in the previous draft, corrected.** The "87% of the outstanding + stock" motivating statistic was a model share of model-issued paper and wrong as an + empirical claim by roughly five times; replaced with the EBA figures (€54bn against + €23bn of Core Tier 1, 2.4× equity). And `Delta` was glossed as *pledgeability*, + which is backwards relative to the proportionality identity — from + `intermediation_IC_D` it is the **divertable** share, so `Delta = 0.20` makes + sovereign paper better collateral than capital. **`CLAUDE.md` still carries the old + wording** ("makes Greek paper worse collateral than capital") and contradicts both + the code comment and the IC algebra; flagged, not yet changed. + +- **`psi_lambda_B` slated for DELETION** (decision, 2026-08-24). Not "0 in the baseline + with a diagnostic arm at 3.01" — the parameter goes, and with it the risk-sensitive + branch of `collateral_quality_D/F`. It is the analogue of Bi–Foerster–Traum's calibrated + liquidity-risk channel, which their Table 1 shows carrying most of both financial + moments, so removing it outright is the paper's sharpest separation from the nearest + published analogue. The introduction now asserts the absence. Until the deletion lands, + `code/` and the paper disagree — see STATE.md. + +- **`docs/referee_report_2.md`** (new, `8fc0786`) — hostile second-referee report on + the paper's motivation, ten findings. The introduction has been rewritten against + it; Sections 2–4, the abstract and the appendices are unaddressed. + +--- + +## 2026-08-19 — Central-bank block audit; `docs/cb_mechanism.md` promoted to canonical (`gk-structural-foc`) + +Diagnose-and-report audit of the CB block against the refactored pricing. **No model source +changed.** Evidence in `diagnostics/cb_audit/` (`run_log.md`, `VERDICT.md`, +`recommended_fix.md`, four probes, a self-tested Prony estimator). + +- **The CB block survived the refactor untouched and is correct.** The four TPI blocks + (`domestic_bond_clearing_tpi`, `budget_residual_D/F_tpi`, `external_account_D_tpi`) are + byte-identical to their pre-refactor versions — they were written against the *payoff* in + coupon/survival form, never against the old FOC's price decomposition. All mark exclusively + at endogenous `q_b_D`; `cb_flow_D` matches `bond_return_D` term for term including + `zeta_writeoff_D` and `writeoff_enabled_D`. Zero live `psi_spread` references in the CB + block, clearing condition or residual equations. +- **SS neutrality is exact, not approximate.** Every TPI block output is bit-identical + (difference `0.000e+00`) to its non-TPI counterpart at the steady state, and + `cb_flow_D = rem_cb_D = rem_cb_F = 0`. +- **The mechanism, stated canonically.** With `psi_lambda_B = 0`, `SDF_banker` and `Omega_p1` + cancel in `nu_bD_D/nu_K_D`, leaving `rb_exp_D(+1) - rdep_D = 0.20*(rk_D(+1) - rdep_D)` + exactly. The CB has **no direct lever on the spread**; it compresses only by lowering + `rk_D`. TPI's spread effect and its investment effect are therefore ONE effect and must not + be reported as two. +- **The 2x2 sovereign-holdings matrix (new decisive diagnostic, `probe_portfolio.py`).** Both + clearing identities close to <=1.2e-15 at 26 checkpoints. ~72% of the CB book (84% in pure + quantity, ~99% from t=1) is bought from **German** banks, 17% is new Greek issuance, only + 10% comes off Greek banks. TPI at gamma=10 undoes 82.9% of the crisis rise in `phi_bD_D`, + and does it through the DENOMINATOR — numerator moves -0.4%, `n_inter_D` recovers +6.6%. + German banks' Greek exposure falls to 27% below SS. The ECB buys German banks out; it does + not share the exposure. +- **The "closed-loop pole at gamma ~ 27.3" is a T=500 terminal-truncation artefact.** The + resonant eigenvector carries 0.0000 of its mass in the first 100 quarters and 0.9922 in + t=400-499; `||A_cb[:,499]|| = 3.86` against ~0.0065 for every interior column and + `A_cb[499,499] = +1.080` is the only positive diagonal in the matrix. Dropping five columns + removes every pole below gamma=36 and changes the reported peak spread by **nothing** at + gamma=2/5/10. The condition-number scan in `code/tpi.py` and + `lottery_math.closed_loop_pole` also steps clean over a nearer apparent singularity at + gamma=2.2116. No reported number is affected; the guard and the documented claim are wrong. + Fix proposed (R-1), **not implemented**. +- **Reporting hazard recorded (F-1).** `writeoff_enabled = 0` means no credit loss ever flows + through the conduit, so the realised German transfer and the printed "F bears EL PV" are + different objects living in different places. They must never be netted. +- Three `diagnostics/` scripts still execute against the deleted `psi_spread`; + `solve_configs.py` is the dangerous one — it does not crash, it silently produces a + `psi_lambda_B = 0` arm identical to its own baseline. + +--- + +## 2026-08-18 — GK structural refactor stages 2–5: no sovereign spread wedge (`gk-structural-foc`) + +The sovereign spread is now generated by the bond's state-contingent payoff inside the genuine +GK portfolio FOC. Full detail and numbers in `docs/STATE.md` -> *GK structural refactor*. + +- **Root cause was the PAYOFF, not the pricing block.** `zeta_writeoff_D = 0` wrote down only + the current coupon on default and left the perpetuity's continuation value whole, + understating the loss on a 12.9-quarter claim by `[delta_b + (1-delta_b)q_b]/delta_b = 12.6x` + (`EL 0.0561` against the contract's `0.7014`). `psi_spread_D = 0.615` was standing in for + almost exactly that gap. The stage-1 conclusion that the GK mechanism was too weak to + generate the spread was wrong — it was being fed the wrong payoff. +- **Deleted, not recalibrated.** `psi_spread_D/F`; `EL_price_D/F` as a pricing wedge; + `divert_bond_foc_D/F`; `divert_portfolio_adj`; `bond_price_ss_D/F`; `domestic_bond_foc_D/F`; + `portfolio_adj_cost`; `excess_return_bD_D_ss`, `excess_return_bF_F_ss`, + `excess_return_F_D_ss`, `excess_return_D_F_ss`. +- **`bond_return_D/F` is the single source of truth** for the payoff and emits three things: + `rb_exp` (expected — the only return the pricing equations read), `rb_actual` (realised + branch, still gated by `writeoff_enabled = 0`, so S-1's pure risk-premium framing stands), + and `EL_load` (diagnostic; read by `code/tpi.py`'s CB P&L and nothing else). + `zeta_writeoff_D/F = 1`. +- **`gk_bond_foc_D/F`** impose `nu_own = Delta_own_eff * nu_K`, which with + `intermediation_P1_D/F` is `rb_exp(+1) - rdep = Delta_eff*(rk(+1) - rdep)`. `q_b_D`/`q_b_F` + became SS UNKNOWNS with `rb_D_res`/`rb_F_res` as their targets. +- **`gk_cross_border_foc`** (in `equations_global.py`) states the same FOC on the two + cross-border legs plus the `psi_bF_D`/`psi_bD_F` stock cost, divided through by + `SDF_banker*Omega_p1` so those keep their calibrated units. This also removed a genuine + DOUBLE COUNT: from the 2026-08-17 draft the expected loss sat in both `intermediation_P1` + and `divert_portfolio_adj`, so the cross-border legs netted it twice while the own legs + netted it once. `Omega_p1_D/F` are now exported from `P1` rather than duplicated. +- **`psi_lambda_B_D/F = 0` is the preferred baseline.** No independent Greek observable + identifies a sovereign-specific haircut *elasticity*. `3.01` is retained as a diagnostic arm + only. +- **`Delta_bF_D`, `Delta_bD_F`: 0.40 -> 0.20, forced not fitted.** With the own legs pinning + both bond prices, and `rk_D = rk_F`, `rdep_D = rdep_F = 0` at a riskless SS, the cross-border + ratio `nu_cross/nu_K` is no longer free. Holding 0.40 leaves a constant 80bp/yr cross-border + wedge. Measured wedges at the live calibration: `8e-11` / `-9e-11` bp/yr. +- **Pre-existing units bug fixed in `intermediation_P1_F`.** `q_b_D` and `q_b_F` are both + D-good prices, but `P1_F` compared an unconverted `rb_actual_F(+1)` with the F-good `rdep_F` + while the old cross-border block applied `p/p(+1)` to the same return. The conversion now + lives once, in `P1_F`. SS-neutral (`p` constant at SS); first-order relevant off it. +- **Depreciation calibration is now ITERATED** to a fixed point (5 passes, `5e-14`). One pass + left `rk_D = 0.009981` once `q_b` joined the SS unknowns and the `delta -> K -> rk -> q_b -> K` + loop closed. `rk_D = rk_F = 0.010000` exactly, as RK-1 requires. +- **New guard `steady_state.report_gk_steady_state`** prints the §13 diagnostic table and + RAISES if any of the four portfolio FOCs is violated or any `Delta_*_eff` leaves `[0,1]`. + Runs on every solved SS. Two new tests in `code/test_nkpc_blocks.py`: an AST scan that fails + if any deleted name reappears in live `code/*.py`, and a structural double-counting check + that no portfolio condition takes both a `nu` and a `def_rate`/`EL` object. 38 fast tests pass. +- **Results, 1pp shock (nothing tuned to any moment).** Peak spread **205.9 bp**; German yield + **-16.2 bp** on impact (endogenous flight to quality, no F-side wedge); `b_DD` **+2.12%** + while `K_D` **-0.067%** (the intended balance-sheet crowding-out); `b_DF` **-2.10%** — German + banks now DO retrench, reversing the open problem in HANDOFF.md; `n_inter_D` **-11.41%**, + `Y_D` **-1.974%**, `C_D` **-2.511%**. `b_gov_D[499] = 3.0e-05`. +- **TPI loading is 0.520 / 0.504 / 0.482 at gamma = 2/5/10 — BELOW 1.** The old 3.82/2.90 + over-compensation headline is reversed, entirely through the denominator: the CB earns the + same premium but absorbs a 12.6x larger expected loss. The decline in gamma (the + self-extinguishing premium) survives. E3's 2026-08-06 `zeta_writeoff = 1` finding is no + longer a robustness variant; it is the baseline. +- **Documentation fix, not a regression:** `goods_mkt_D` is `-4.23e-07`, and a clean worktree + at `91ac778` prints `-4.2493e-07` on the same pipeline. CLAUDE.md's `<= 1e-14` threshold for + that residual was never met on this calibration and has been corrected to `1e-6`. + `ca_res_D` (`1.7e-16`) is the residual that genuinely reaches machine zero. +- **`experiments/` rebased.** `fig04_spread_decomposition` no longer draws an + `EL_price`/`psi_spread` share bar — that framing is forbidden in a linearised model and the + split was mostly a calibration artefact. It now plots the Greek yield under direct + expected-loss pricing (required return frozen at SS, bond price solved forward on the + model's own recursion) against the equilibrium Greek yield and the GR-DE spread. + **Finding: the intermediary channel is a QUANTITY amplifier, not a price amplifier** -- + direct pricing alone gives 213.8bp against an equilibrium 189.7bp Greek yield, and the + spread exceeds the Greek yield only because the German leg falls 16.2bp. A first draft + plotted `EL_load_D*def_rate_D` (a one-period capital-loss rate) against `spread_rb` (a + coupon-equivalent yield); not commensurate, and it produced a spurious 0.62 + "amplification factor". E3's variants + are rebased on `zeta = 1` (`e3a_realised_writeoff`, `e3b_coupon_only_pricing`). Cache key + `EL_price_D` -> `EL_load_D` in `diagnostics/regimes/regime_model.py` and + `experiments/e1_backstop_schedule.py`. +- **The "aggressive" named regime is no longer 50% compression — it is 40.3%.** The closed + loop has a POLE at `gamma ~ 27.3`; max compression below it is 46.6% and the 50% target is + met only on the far branch. New `lottery_math.closed_loop_pole` locates it by CONDITION + NUMBER (a 61-point monotonicity scan of [0,40] steps straight over a pole this narrow and + reports a spurious non-monotonicity — exactly how the first regeneration attempt failed) and + `CompressionInfeasible` is raised. **The fallback is 0.75 x pole, not 0.98**: measured, the + loading schedule is monotone in gamma only up to ~0.85 x pole, and at 0.98 x pole the + discounted consumption gains hit +11..+12.4% of SS consumption and Greek output goes +1.15% + on impact — the singularity, not the policy. New shared constant + `lottery_math.POLE_SAFETY_FRACTION = 0.75`; `common.named_regime_gammas` falls back to + `gamma = 19.875` (40.3%), `e1.loading_schedule` and `code/tpi.py`'s effectiveness curve cap + their grids the same way. `medium` is unaffected at `gamma = 9.989` (25.0%). + `experiments/paper_outputs.py`'s second copy of the gamma solve now routes through + `common.named_regime_gammas` — one definition, not two. **Paper prose calling the aggressive + regime "50% compression" must be corrected to 40.3%.** +- **`code/tpi_plots.py` figure-8 text was stale in four places and is now derived.** It + hard-coded "rho=0.8" (rho_def has been 0.9408 since 2026-08-06), a panel title + "Premium Peaks (gamma~26)" that no longer describes the shape, annotations written for + a schedule that STARTS above ell=1 ("timid intervention -> high loading (SMP-type)"), + and a caption asserting the loading falls "toward the fair-insurance limit ell=1" while + printing "from 0.5x to 0.5x". All four now read off the data; the caption states plainly + that the schedule sits BELOW 1 throughout. The 'actuarially fair' label also moved off + the subplot title it was overprinting. +- **New impact-sign table on BOTH shocks in `build_and_solve`.** Added because + `fig_irf_overview_macro.png` looks like Y_D collapses under TFP; it does not — that is + the default shock's line. Measured: +1% TFP gives Y_D **-0.073%** (flat) with N_D -3.71%, + w_D -7.26%, I_D +5.00% — the standard sticky-price contractionary-technology result + under a phi_pi -> infinity normalisation and GHH labour supply. Not a defect; the guard + fires only below -0.5%. +- **Two figure defects the pole caused, both fixed.** `fig02` plotted the loading spiking to + 1.17 and collapsing to 0.38 across two grid points, and its caption's own two-branch test — + written for the old world where the loading STARTED above 1 — read that artefact as + "crossing below the actuarially fair benchmark of 1". `fig05` showed German exposure + plunging to -50% of `Y_D`. The caption now has a third branch and states plainly that the + loading stays BELOW 1 throughout, so over-compensation must not be asserted. +- **STALE:** every E1–E4 artefact and `experiments/paper/fig0*.png`. The SS moved + (`q_b_D 0.968941 -> 0.974906`); rebuild the regime cache before the experiments, not after. +- Old `diagnostics/psilam_*` and `diagnostics/substitution_v2/` scripts still reference + `psi_spread`/`EL_price` and will fail. They are one-off historical probes of the deleted + specification; superseded, not ported. + +## 2026-08-17 — GK structural refactor stage 1: bounded pledgeability (`gk-structural-foc`) + +Audit of the sovereign-risk-to-bank-financing block, then the first of five stages. Full +detail in `docs/STATE.md` -> *GK structural refactor*. + +- **Audit finding.** The chain `p_def -> Delta_bD_eff -> IC -> lambda_gk/Omega -> P1 -> q_b_D` + is broken at the third arrow. `Delta_bD_eff` moves only `theta_D`; the Greek spread comes + entirely from `divert_bond_foc_D`, which touches no endogenous GK object and carries the + frozen `psi_spread_D`. `bond_price_ss_D`, `steady_auxilliary_D`, `smart_steady_D` are all + SS-only and absent from `build_block_list()`. +- **GK portfolio optimality is violated at the SS.** `nu_bD_D/nu_K_D = 0.2491` against + `Delta_bD_D = 0.20`, and `nu_bD_D == nu_bF_D` bit-identically (0.02696043) while + `Delta_bD_D = 0.20` vs `Delta_bF_D = 0.40`. `steady_auxilliary_D` defines the marginal + values from returns and never restricts them; the portfolio FOCs are imposed nowhere. This + is *why* the wedges exist. +- **Stage 1 (this commit).** New `collateral_quality_D/F` export the four `Delta_*_eff_*` + under a bounded map `Delta + (1-Delta)*z/(1+z)`, `z = psi_lambda_B*def_rate(+1)/(1-Delta)`. + Local slope is `psi_lambda_B` exactly (SSJ Jacobian: 3.0100000000), so IRFs are unchanged; + range `[Delta,1)` closes the domain hole at `def_rate(+1) > 0.266` where the old linear form + drove `1-Delta_eff` negative. +- **New SSJ gotcha recorded.** `np.exp` in a `@simple` block raises + `TypeError: ... AccumulatedDerivative`. Simple blocks differentiate through a dual-number + type supporting arithmetic operators only. Hence the rational rather than exponential + saturation. +- **Doc drift corrected.** `docs/eba_calibration.md` ledger said `Delta_own` committed at 0.80 + and cross at 0.90; CLAUDE.md's GK-1 row said `Delta=0.85/0.90 -> lambda_gk_D=+0.927`. Live + values are **0.20 / 0.40** with `lambda_gk_D = 2.2129`. Both docs described the CT1-scope + world; GK-2's broad scope cut `phi_own` 2.39 -> 0.456, which satisfies feasibility at the + inherited `Delta`, so the raise was never adopted. ~4x error for anyone computing the + collateral channel from the old numbers. +- **Verification.** `code/main.py` exit 0, bit-identical: `n_inter_D[0] = -6.7366%`, + `Y_D[0] = -0.8521%`, peak spread +0.375 pp, `goods_mkt_D = -4.2493163257550925e-07`, + `max abs(goods_mkt_F)` 2.06e-10..2.12e-10 across the gamma grid. 35 fast tests pass. +- **Rejected en route.** A prior `writeoff-test` branch flipped `zeta_writeoff`/ + `writeoff_enabled` to 1 to test whether realising the default loss cures the `n_inter_D` + overshoot (+3.14% at t=8). It does not: everything scales 3-4x (peak spread 538.5 bp, + `Y_D[0]` -3.68%) with timing untouched — `n_inter_D` still turns positive at t=4 and peaks + *higher*, +5.53%. Relative overshoot halves (peak/|trough| 0.465 -> 0.234), so it bites on + the right margin but nowhere near enough. Branch deleted, S-1 stands. + +--- + +## 2026-08-07 — Fiscal rule and fiscal limit audited; `Empirics/fiscal_limit.py` added + +No model changes. Two existing mechanisms audited to see whether the fiscal block could +generate foreign retrenchment without new wedges or shocks. It cannot, and the audit +changed what is claimable about both parameters. Full detail in `docs/STATE.md` -> +*Fiscal rule and fiscal limit: what is identified*. + +- **`phi_lamb_D` sweep {0.15, 0.10, 0.07, 0.05}.** Stability boundary is between **0.10 + and 0.07**, not the 0.05 the pipeline's printed `rho_b` gate predicts — that gate is + partial-equilibrium and omits `def_scale_D`, so it is optimistic enough to land a user + on a divergent calibration. Retrenchment (`b_D_F` < 0) appears ONLY in the divergent + region, so it is an artefact: **no stationary calibration of the Bohn rule produces + retrenchment.** `Y_D[0]` is insensitive across the stable range (-0.852% to -0.871%). + `phi_lamb_D` = 0.15 keeps ~1.5x margin over the true floor and stays. +- **`mv_rule_D` = 0 justified.** Par and market-value debt gaps move in opposite + directions in a crisis (par positive 39/40 quarters, market-value negative 40/40, the + latter 2.88x larger); the market-value rule would CUT taxes 0.75% of quarterly GDP at + impact, reading a wider spread as a windfall. Maastricht debt is nominal face value, so + the par rule is institutionally correct. The "market-value rule REQUIRED" comment was + stale from the CT1 scope (`phi_bD_D` = 2.39 vs 0.456 under the broad scope) and is retired. +- **Fiscal limit estimated** (`Empirics/fiscal_limit.py`, new): BFT's logistic on Eurostat + `gov_10q_ggdebt` plus the repo's Greek-Bund spreads. Preferred pre-OMT sample + `eta0 = -14.80 (0.55)`, `eta_s = 7.67 (0.47)`, R2 = 0.849, n = 50 — same family as BFT's + Italian `-10.70 / 5.25`. Post-2012 data must be excluded because OMT severed the + debt-spread link (debt 152% -> 181%, spread 13.4pp -> 6.3pp -> 1.4pp), which would build + the studied policy into the parameter. +- **`def_scale_D` stays at 0.25**, now with provenance: it sits inside the estimated range + (0.04 full / 0.19 crisis / 0.63 pre-OMT). The best-fitting 0.633 is unusable — at that + value `psi_lambda_B` is not continuously calibratable (divergences at 2.00 and 2.34 + bracketing a marginal island at 2.10-2.20; the 150bp target sits within 0.09 of a + blow-up). Curvature is qualitatively wrong (0.5 concave vs 3.9-10.9 convex estimated) + but is not identified at first order. +- **Benchmark check:** BFT set `phi_T` = 3 *"to ensure stability of the debt path"* — they + do not calibrate their fiscal rule either. What they estimate is the fiscal limit. Their + rule can be weaker because default is realised (`Delta_t = delta_b` writes debt down) and + their default probability is logistic/bounded; with `writeoff_enabled_D` = 0 the tax rule + is this model's only stabilising device. + +--- + +## 2026-08-07 — Country-size asymmetry: F is 11.7x D (`fix-cross-border-units`) + +**The defect.** The model normalised `Y_D_ss = Y_F_ss = 1` — Greece and Germany the same +size — while every EBA moment is a ratio to its **own** country's net worth. Cross-border +stocks built as `phi * n_holder / q` therefore landed in the **holder's** units. The model +could match the portfolio-composition moment (`phi_bD_F = 0.0075`, DE banks' Greek book / +DE bank net worth) **or** the market-structure moment (foreigners hold 12.72% of the +bank-held Greek stock), never both: joint consistency needs `n_F/n_D = 8.85` against the +model's 0.761, and the gap is exactly the Germany/Greece GDP ratio. Matching composition, +as the model did, put the foreign share at **1.25%** against 12.72% in the data, and +symmetrically overstated Greek banks' share of the bank-held Bund stock at 1.50% vs 0.13%. + +**The fix.** `size_F = 11.697` (Eurostat 2010 annual GDP, `data/eba_moments.json` +`raw_EURm`, exposed by `calibration.load_eba_size_ratio`). Convention: **every F variable +is per F capita and O(1); every D variable is a D aggregate** (`size_D == 1`). The weight +appears in exactly the blocks where the two countries meet — `trade_balance`, +`external_account_D`, `global_goods_mkt`, `domestic_bond_clearing`, plus the three `_tpi` +overrides. Nothing inside the F household, bank or production blocks changes, and no grid +is rescaled. + +**Home bias split.** A single shared `omega` is inconsistent with size asymmetry: at +`omega_F = omega_D` the larger country's imports from the smaller come out `size_F` times +too large. Symmetric bilateral trade intensity pins the pair, +`size_F*(1-omega_F) = (1-omega_D)`, giving `omega_D = 0.85` (unchanged) and +`omega_F = 0.98717`. + +**Bug this exposed — every TPI result before today carried it.** `budget_residual_F_tpi` +paid `rem_cb_F = kappa_cb_F * cb_flow_D / p`, a **D-aggregate** ECB cash flow, into a +**per-F-capita** budget. At equal country size the missing weight was exactly 1.0, so it +was invisible: `goods_mkt_F` sat at 2e-10 for the whole history of the block. Under +`size_F` it leaked up to **1.98e-2 of F GDP at gamma=10** while gamma=0 stayed clean — +the signature of a conduit-only units error. Fixed by `/ size_F`; `max|goods_mkt_F|` is +now 2.06e-10..2.12e-10 across the whole gamma grid. + +**Recalibration.** `size_F` makes a Greek shock a much smaller shock to F, damping +cross-border amplification: peak spread fell to 145.20 bp at the incumbent +`psi_lambda_B = 2.92`. Re-bisected on the same 150.14 bp moment (SS re-solved per point): +`2.92 -> 145.20`, **`3.01 -> 149.93` adopted**, local slope 52.6 bp/unit. + +**Verification** (`code/main.py`, exit 0): foreign shares **0.1274** (EBA 0.1272) and +**0.001298** (EBA 0.001301) — both moments now hold jointly; `phi_bD_F` exact; +`K_D = 10.800`, `K_F = 10.824` against the 10.8 over-identifying check; +`goods_mkt_D/F ~ 4.2e-07`; `ca_res_D = -2.8e-17`; IC residuals machine-zero; GK +well-posed; `n_inter_D[0] = -6.7366%`, `Y_D[0] = -0.8521%` (both correct sign); +peak spread 150.0 bp. 48/48 fast tests pass, including a new +`code/test_cross_border_units.py` that locks the per-capita/aggregate convention and +asserts the direction of the weight so it cannot be silently inverted. + +**Reported outputs that moved.** TPI loading schedule 5.60 / 5.43 / 5.18 at +gamma = 2/5/10. Spread compression at gamma=10 is 22.4%. German-side responses are now +an order of magnitude smaller, which is the point: `rdep_F` falls 2.5 bp on the default +shock where it fell 17.1 bp before, and `n_inter_F` rises 0.14% where it rose 1.20%. +Cross-border absorption correspondingly matters far more for Greece — freezing `b_D_F` +now costs `Y_D` -0.83% -> -1.32% and `C_D` -0.77% -> -1.58%. + +**Superseded within the same branch.** An interim patch first scaled the two cross-border +stocks directly into issuer units. That matched market structure but broke composition +(`phi_bD_F` 0.0075 -> 0.086) and pushed `K_F` to 10.672 — it traded one moment for the +other rather than satisfying both. Replaced by `size_F`. + +--- + +## 2026-08-06 — Regeneration on the MS-disciplined shock + +- Rebuilt regime cache, E1-E4 and all paper figures at `rho_def=0.9408`, `psi_lambda_B=2.92`. E2 closes at 1.1e-16. +- **Live Claim 5 weakened:** loading schedule 4.43->1.49 becomes 5.65->4.59. Still monotone, but the premium no longer approaches extinction. Live Claim 1 correspondingly stronger (floor 1.49 -> 4.59). + +## 2026-08-06 — `rho_def` promoted to the calibration and disciplined at 0.9408; `psi_lambda_B` re-tuned 7.85 → 2.92 (`add-nkpc`) + +**Problem.** The sovereign-risk shock's persistence was **hardcoded at `rho_def = 0.80` in +`code/full_model.py:220`**, next to `rho_Z_D = 0.8`. It was therefore neither stated nor +defended as a calibration choice, and it implied a **14-month** crisis +(`0.80^(1/3) = 0.9283` monthly → 13.95 months). The repo's own estimation contradicts that. + +**The estimate.** `Empirics/outputs/ms_regime_GRC.npz` fits three Markov-switching states to +monthly Greek–Bund spreads (348 obs, 1997-06 to 2026-06). The crisis state has mean spread +**9.63pp** and monthly persistence **0.9798499**, an expected duration of **49.6 months**; the +realised episode ran 2010-04 to 2017-12, **92 months**. Quarterly equivalent +`0.9798499^3 = 0.94076` → **`rho_def = 0.9408`** (16.9 quarters). + +**Changes.** +- `code/full_model.py` — `rho_Z_D` and `rho_def_D` now read from `calibration_start` with the + old literals as a fallback, and the resolved values are printed. +- `code/calibration.py` — new *Shock processes* block: `rho_def_D/F = 0.9408`, + `rho_Z_D/F = 0.80`. **`rho_Z` deliberately unchanged** — the MS estimate is about sovereign + spreads, not TFP. +- `code/calibration.py` — `psi_lambda_B_D/F` **7.85 → 2.92** (`EBA_CALIBRATION` branch only; + the `else 3.0` branch is untouched). + +**Re-tune.** Peak spread is monotone increasing in `psi_lambda_B`; at the new persistence the +old 7.85 gave **470.62 bp** against the 150bp GR–DE moment. Full pipeline re-solve per point: +7.85 → 470.62, 2.73 → 139.60, 2.8909 → 148.50, 2.9181 → 149.99, **2.92 → 150.09 (adopted)**. +Harness sanity anchor at `7.85 / rho=0.80` reproduced the recorded baseline bit-for-bit +(150.14bp, `Y_D[0] = −0.5064`, `C_D[0] = −0.5103`). + +**Method finding worth keeping.** Sweeping `psi_lambda_B` by patching it and `psi_spread` onto +an already-solved SS and re-solving only the Jacobian is **wrong**, even though the SS really +is `psi_lambda_B`-neutral (bit-identical `goods_mkt_D`/`K_D`/`beta_D` at every value). It +predicted 150.33bp at `psi = 2.73` where the pipeline gives 139.60 — the +`intermediation_IC_D` `Delta_bD_eff` collateral channel does not pick the patch up, only +`divert_bond_foc_D`'s `psi_spread` does. The first bisection was discarded and redone. + +**Results.** Like-for-like at 150bp: `Y_D[0]` −0.5064 → **−0.7502**, `C_D[0]` −0.5103 → +**−0.7014**, `I_D[0]` −1.0114 → **−1.7107**, `n_inter_D[0]` −4.2962 → **−6.2710**. Cumulative +40-quarter `Y` −0.0492 → **−2.5420** (51.7×); negative-`Y` quarters in the first 40: 5 → **37**; +spread above half-peak q3 → **q11**. All four impact signs stay negative, so the `add-nkpc` +consumption sign flip survives. + +**Paper-level consequence.** `psi_spread_D` is linear in the dial, so it falls 1.604839 → +**0.596959** against an unchanged `EL_price_D = 0.056134`. The default-loading split moves +**3.38% / 96.62% → 8.60% / 91.40%** fundamental / collateral friction — a friction:fundamental +ratio of **10.63:1**, down from 28.59:1. The constrained-seller claim survives in direction but +"essentially all of it" must become "roughly nine tenths of it". +`experiments/paper_outputs.py`'s `fig04_spread_decomposition` prose needs re-deriving again. + +**Issue I-1 substantially resolved** — and by the shock process, not by a capital friction, so +the earlier conclusion that `chi1`/`omega_I` were the wrong hypotheses holds. Residual defect: +`Y_D` still blips marginally positive at q2–q4 (all under +0.03% of SS) before going negative +from q5 and staying there. + +**Stability — passes, and moves away from the risk.** SS bit-identical +(`goods_mkt_D = -4.2493506589857954e-07`, `IC_D = 1.776357e-15`, `All residuals < 1e-8 ✓`); +`b_gov_D[499]` on the default shock **fell** 4.63e−05 → 2.04e−05; `ρ_b = 0.8451 < 0.95`; no +`assert_gk_well_posed` failure; all four TPI gammas converge (`max|ca_res_D| ≤ 6.39e−08`). +The re-tune lowers `psi_lambda_B`, i.e. away from the high-`psi_lambda_B` breakdown region. +TPI loading 5.55 / 5.37 / 5.13 at γ = 2/5/10 — monotone decreasing and above 1, so Live +Claims 1 and 5 both survive. + +**Tests:** 42 passed (`code/test_nkpc_blocks.py code/test_eba_calibration.py experiments/`), +unchanged from HEAD. + +**STALE:** E1–E4, `docs/experiments_results.md`, `docs/paper_draft_results.md` and the eight +tracked `experiments/paper/fig0*.png` all predate this calibration. Regenerate in order: +`diagnostics/regimes/regime_model.py --force` → `experiments/run_all.py` → +`experiments/e4_distribution.py` → `experiments/paper_outputs.py`. + +--- + +## 2026-08-06 — Investment-flow adjustment cost `S(I/I(-1))`, added inactive at `omega_I = 0` (`add-nkpc`) + +**Problem.** On the default shock output falls for exactly **one quarter** and then turns +positive: `Y_D` = −0.5064, −0.0026, **+0.0929**, +0.0829, +0.0548, … The comparable published +model (Bi–Foerster–Traum) keeps output negative for ~20 quarters. Investment is the driver — +`I_D` = −1.0114, −0.2671, then a sustained **boom** peaking +0.3324 at q5 that drags `Y` up +with it. The diagnosis: the model had **no adjustment cost on the flow of investment**. +Nothing penalised `I/I(-1)`; the only capital friction was the Q-based cost on the `I/K` +ratio, which lets investment jump down and snap straight back. + +**What was added.** `capital_adj_D/F` now carry `S(x) = (omega_I/2)(x-1)^2` with `x = I/I(-1)`. +Effective investment is `I_eff = (1-S)*I` and the installation technology runs on `I_eff`, so +the investment FOC becomes + +``` +1 = Q*mpi*[(1-S) - S'*(I/I(-1))] + beta*Q(+1)*mpi(+1)*S'(+1)*(I(+1)/I)^2 +``` + +with `mpi = gamma0*(1-ksi)*iota^(-ksi)`. New calibration entry `omega_I_D/F`, **committed at +0.0**, so the committed model is provably unchanged. + +**Why it is exactly SS-neutral.** `S(1) = S'(1) = 0`. At the steady state the FOC collapses to +`Q*mpi = 1`, which is the old `q_res = Q - 1/mpi` — the *same root*. The two forms differ by +the factor `mpi`, and since the old residual is zero at SS, `dr = mpi_ss * dr̃` exactly: a +constant row scaling of the target system, which `-H_U^{-1} H_Z` is invariant to. So the +linearised solution is invariant too, not merely the steady state. + +**Discounted at `beta`, not the SDF — and this is not an approximation.** `S'(1) = 0` means +the intertemporal term multiplies a factor that is *zero at SS*, so linearising it uses only +`SDF_ss = beta`; the SDF's own deviation contributes nothing to first order. This is the +identical argument `price_nkpc_D/F` already uses (`pi_ss = 0` there). It is also **required**: +taking `SDF_D` makes SSJ's topological sort fail outright with +`hh_D -> capital_fund_D -> capital_adj_D -> sdf_D -> ghh_composite_D -> hh_D`. Locked by an +assertion in `test_flow_adjustment_cost_vanishes_at_steady_state` that `SDF_*` is **not** an +input and `beta_*` is. + +**Equivalence gate.** `omega_I = 0` reproduces the pre-change model to **1.08e-13** worst +relative deviation across all 45 dumped arrays (`dump_irfs.py` run at `231327c` immediately +before the edit). Note the older `/tmp/nkpc_irfs_nominal.npz` is **stale** — it predates the +`psi_lambda_B` 8.5 → 7.85 re-tune and differs by 1.56; do not use it as a reference. + +**Sweep — the hypothesis is NOT supported.** SS bit-identical at every value +(`K_D = 10.8000000000`, `beta_D = 0.999534992056`), confirming SS-neutrality. + +| `omega_I` | `Y_D[0]` % | `Y_D` trough % | contiguous neg. quarters | cum. `Y_D` (40q) | `I_D[0]` % | `I_D` peak boom % | peak spread | +|---|---|---|---|---|---|---|---| +| **0** | −0.5064 | −0.5064 | 2 | −0.0492 | −1.0114 | +0.3324 | 150.1 bp | +| 2 | −0.0287 | −0.0483 | 3 | **+0.2097** | −0.3786 | +0.2573 | 163.7 bp | +| 5 | **+0.0486** | −0.0078 | **0** | +0.3001 | −0.2201 | +0.2082 | 167.2 bp | +| 10 | **+0.0854** | −0.0015 | **0** | +0.3697 | −0.1290 | +0.1748 | 168.4 bp | + +The cost does smooth investment — the impact drop shrinks monotonically from −1.01 to −0.13 +and the q5 boom from +0.33 to +0.17 — but it **does not convert the V into a sustained U**. +It shrinks the whole contraction toward zero. `omega_I = 2` buys one extra negative quarter +(3 vs 2) at the price of an impact trough **18× shallower** and a cumulative 40-quarter `Y` +response that flips **positive**. At `omega_I >= 5` `Y_D[0]` itself goes positive, tripping +the sign check in CLAUDE.md's *Typical iteration* step 4. + +**Mechanism, and why it echoes the `chi1` result.** Making investment sluggish frees the +household budget rather than the economy's resources: `C_D[0]` moves from −0.5103 (at 0) to ++0.1092 (at 2) to +0.2276 (at 10). This is the *same failure mode* as the earlier rejected +`chi1` diagnostic — penalising a capital/investment margin just shifts the burden between `I` +and `C` instead of deepening the aggregate contraction. The persistence problem is therefore +**not** a missing investment friction, and the next hypothesis should look elsewhere (the +`n_inter` rebound at +3.6% by q5 is the more likely engine). + +**Left at `omega_I = 0`** pending an author decision. Peak spread drifting 150.1 → 163–168 bp +off the 150 bp target is a second reason not to adopt a positive value without re-tuning +`psi_lambda_B`. + +Tests: `code/test_nkpc_blocks.py` **19 passed**; full suite **50 passed**. + +--- + +## 2026-08-06 — Paper figure captions derive from results (`add-nkpc`, Task 17) + +**Why.** `experiments/paper_outputs.py` carried a module-level `CAPTIONS` dict of literal +prose written against the flexible-price model. The sticky-price conversion and the +`psi_lambda_B` 8.5 → 7.85 re-tune (Tasks 1–16) left every caption stale and three of them +*inverted*, and because captions are baked into the PNGs, the repo was shipping eight tracked +figures and a generated `docs/paper_draft_results.md` whose prose contradicted its own tables. +The clearest case: `fig08_deciles` claimed the lowest quintile "gains 0.95%" and the highest +loses 0.59%, against a Table 4 *in the same file* reading **+0.4250** and **−0.9073**. This is +the identical hazard Task 15 fixed inside `run_all.py`. + +**Fix (structural, not a substitution).** `CAPTIONS` is now empty at import and filled at run +time. `save(fig, name, caption)` takes the caption as a required argument and registers it; +each figure builds it from the arrays it just plotted, via a new `_caption_figNN` helper. +Directional claims are selected from the data by `_monotone`, `_first_quarter` and sign +tests, so a flip rewrites the sentence rather than lying in it — e.g. `fig02` will print "does +NOT fall — the self-extinguishing-premium claim fails at this calibration" if the loading +schedule ever stops declining, and `fig05` will refuse the German-ledger reading if exposure +and loading stop moving in opposite directions. `main()` gained a prose-vs-table assertion: +fig01's caption and Table 3 must agree on impact bank net worth via their two independent +routes (cache vs `e1.run()` payload). + +**What the captions were wrong about.** + +| figure | was | now (derived) | +|---|---|---| +| `fig01` | "net worth 3.4%", "investment −0.77%" | −4.3% / −1.0%; adds the reversal quarters (net worth q5, spread q8) | +| `fig02` | "4.5× … 2.1×" | 4.43× at γ=0.51 → 1.49× at γ=30, monotone, above 1 throughout | +| `fig03` | "each roughly four times the headline" | **inverted**: consumption carries 0.99× the headline, investment +0.25×, NX −0.21× | +| `fig04` | "3% / 97%" | **3.4% / 96.6%**, re-derived at `psi_lambda_B = 7.85` | +| `fig05` | qualitative only | endpoints: exposure 0 → 0.92% of quarterly `Y_D`, loading 4.43× → 1.49× | +| `fig06` | net path smaller "at every horizon" | **false in the impact quarter**; true in 14 of the first 16 | +| `fig07` | 23/52/25 hardcoded | read from the npz (22.9/52.4/24.7), hawk span 2010–2014 derived; genuinely model-independent | +| `fig08` | "lowest gains 0.95%, highest 0.59%"; "consumption rises on impact" | Q1 +0.4250 / Q5 −0.9073, backstop gain +2.01 / +1.34; consumption **falls** ~0.51% in every quintile on impact | + +**`fig04` derivation.** Loading per unit of default probability = `EL_price_D + psi_spread_D` +(bond-pricing FOC, `equations_D.py:566`). `EL_price_D = (1−0.30)·0.0777006/0.968941 = +0.056134`, invariant to `psi_lambda_B`. `psi_spread_D = lambda_gk_D·psi_lambda_B_D / +(beta_inter_D·Omega_D)` (`steady_state.py:104`) is linear in `psi_lambda_B`, so 8.5 → 7.85 +took it 1.737724 → 1.604839. Split 0.056134/1.660973 = **3.4% fundamental / 96.6% friction**. + +**Verification.** 8 figures + `docs/paper_draft_results.md` regenerated (~95s, no Jacobian +re-solve — runs off the existing regime cache). Every caption cross-checked against the +corresponding table: no contradictions. `pytest code/test_nkpc_blocks.py +code/test_eba_calibration.py experiments/` → **40 passed**. No model, calibration or equation +change; results tables are numerically identical to the pre-Task-17 run. + +--- + +## 2026-08-05/06 — Nominal rigidities: sticky prices + nominal deposit contracts become the baseline (`add-nkpc`, Tasks 1–16) + +*One entry for the whole workstream (16 commits, `2015edd`…`120dcf6` plus this doc pass). +Per-task detail is in the commits; the consolidated state is `docs/STATE.md`.* + +**Why.** The flexible-price model's response to a 1pp default shock was +`Y_D[0] = −0.0149%` and `C_D[0] = +0.2164%` — two orders of magnitude below +Bi-Foerster-Traum's −0.6% and with consumption *rising* in a crisis. With flexible labour +supply and competitive labour demand, `Y` drops out of the labour block entirely and `N` is +pinned by `Z`, `K`, `P_CES` alone: there was nothing for aggregate demand to act on. + +**Structural changes** (`code/equations_D.py`, `equations_F.py`, `equations_global.py`): + +- **Task 1** — extracted `full_model.build_block_list()`, now the single model definition, + shared by `full_model.py`, `tpi.py` (via a new `tpi_overrides()` for its four `_tpi` swaps) + and `diagnostics/regimes/regime_model.py`. Pure no-op, verified `main.py` output + byte-identical (8172 bytes, empty diff). +- **Task 2** — `firm_profit_D/F`: `profit = (1 − mu_p*mc)*(1−alpha)*Y`, the markup rent left + once labour is paid `mu_p*mc*(1−alpha)*Y` and capital keeps `alpha*Y`. Unrouted this is a + Walras leak of the W-1/W-2 class. +- **Task 3** — `price_nkpc_D/F`: Rotemberg curves `pi = beta*pi(+1) + kappa_p*(mu_p*mc − 1)`. + The gap is the *ratio*, so it is unit-free, linearises to exactly `mc_hat` for any `mu_p`, + and published Calvo slopes map onto `kappa_p` with no SS rescaling. +- **Task 4** — markup wedge in `labor_demand_D/F`: `w = mu_p*mc*(1−alpha)*Y/N`. Employment is + no longer purely supply-determined. `labor_market_D/F` (labour supply) deliberately + untouched — wages stay flexible. +- **Task 5** — `terms_of_trade` + `union_inflation` close the nominal side with **no policy + rate**. `p/p(-1) = (1+pi_F)/(1+pi_D)` pins the inflation differential off the existing + unknown `p`; `omega_pi_D*pi_D + (1−omega_pi_D)*pi_F = 0` pins the level (the `phi_pi → ∞` + limit of an ECB rule on union PPI, stated as an abstraction). At `omega_pi_D = 0.071`, + 93% of any terms-of-trade move is Greek deflation, 7% German inflation. +- **Task 6** — markup rent reaches households through `income_D/F`, in proportion to + productivity `e` rather than lump-sum. `w*N*e + profit*e = (1−alpha)*Y*e` exactly (max abs + diff 2.2e-16), so the wedge bites only on the firm's hiring decision and household income + is untouched. `income_D/F` are hetinputs, so a signature change was sufficient to wire + `profit_D/F` into `hh_extended_D/F.inputs`. +- **Tasks 11–12** — nominal deposit contracts. New `deposit_rates_D/F(i_dep, pi)` emit + `rdep` (**unchanged name, unchanged ex-ante meaning**) and `rdep_expost` (realised real + rate, carrying the inflation surprise); `deposit_return_D/F` takes `i_dep`, `P_CES`, `pi`. + `bank_return_D/F` and `capital_fund_D/F` switch to `rdep_expost_D/F` — the Fisher channel. + Keeping the `rdep` name meant `intermediation_P1_D/F`, `divert_bond_foc_D/F` and + `divert_portfolio_adj` needed **zero changes** and remain correctly ex-ante (verified by + `.inputs` introspection in both directions). T-2 not reopened: the rate is still locked at + `i_dep(-1)`; only the deflator is period-t. Sovereign bonds stay **real** — a deliberate + asymmetry that maximises banks' Fisher exposure. + +**Calibration** (`code/calibration.py`, Tasks 7 and 14): `mu_p_D/F = 1.20`, +`mc_D/F = 1/1.20` (retargeted from a dead placeholder `1.0`, the subsidy neutralisation that +keeps the SS bit-identical), `kappa_p_D/F = 0.0871` (Calvo θ=0.75 at β=0.985; Bi-Foerster- +Traum's implied 0.0846 to within 3%), `pi_D/F = 0.0`, `omega_pi_D = 0.071` (renormalised +two-country capital key — deliberately *not* GDP weights, which would erase the 93/7 split), +`rdep_D/F → i_dep_D/F`. And **`psi_lambda_B_D/F` 8.5 → 7.85**: stickiness plus Fisher had +pushed peak spread to 162.0bp, an 8% overshoot of the paper's 150bp moment. Re-bisected +(8.5 → 162.14bp, 7.0 → 136.21bp, 7.8 → 149.16bp, **7.85 → 150.14bp adopted**), `b_gov_D[499]` +in the ~1e−5..1e−4 band throughout. `EBA_CALIBRATION` branch only; the pre-EBA `else 3.0` +branch untouched. Bisection table recorded in a comment at the parameter. + +**Solver system 23×23 → 27×27** (Tasks 8, 9, 13). `+mc_D, pi_D, mc_F, pi_F` to `unknowns_tp` +(and `rdep_D/F → i_dep_D/F`); `+nkpc_p_res_D/F, tot_res, union_pi_res` to `targets_tp`. +`steady_state.py` carries the six new blocks too (`labor_demand_D/F` deliberately excluded — +SS still uses `labor_ss_D/F`). + +**The steady state is bit-identical throughout.** Subsidy-neutralised markups +(`mu_p*mc = 1`, `profit_ss = 0`) and `pi_ss = 0` make every new SS residual exactly +`0.000000e+00`. `goods_mkt_D = -4.2493506589857954e-07`, +`goods_mkt_F = -4.1914559989475464e-07`, `ca_res_D = 6.852157730108388e-17`, +`IC_D: θ − θ_tgt = 1.776357e-15`, `ρ_b = 0.8451` — unchanged at every task, including after +the `psi_lambda_B` re-tune (the dial only touches dynamics). At `pi = 0`, +`i_dep_D = rdep_D = rdep_expost_D = 0.0` exactly. + +**Gates passed.** + +- *Flex-price equivalence* (Task 9): as `kappa_p → ∞` the 27×27 system reproduces the + pre-change 23×23 IRFs with textbook O(1/`kappa_p`) convergence — worst relative deviation + 2.925e−03 / **2.925e−04** / 2.925e−05 at `kappa_p` = 1e4/1e5/1e6, gate threshold 1e−3. + Every one of the 30 IRF series shrinks by exactly 10.00× per decade and the SS levels are + bit-identical. Binding series `w_D`, `N_D` — the two objects the wedge acts on. Harness: + `code/dump_irfs.py`. +- *Fisher sign* (Task 13): real → nominal deposits deepens `n_inter_D[0]` −4.0140% → + −4.6155% (~15% deeper, ~11× the effect on output), reaching output only through the + intermediary. Had the Task 12 ex-post/ex-ante substitution been backwards, net worth would + have gone *less* negative. + +**SSJ 1.0.0 defect found and worked around** (Tasks 9 and 9b). `CombinedBlock._jacobian` +seeds from the shock list and returns `total_Js[original_outputs & total_Js.outputs, :]`, so +a target reachable from no shock is silently dropped from H_Z; `Block.solve_jacobian` then +hands mismatched shapes to `np.linalg.solve` (`size 11500 is different from 13500`). All four +new targets are pure functions of the solver's own unknowns, so H_Z came back with 23 rows +against a 27×27 H_U. New **`full_model.solve_jacobian_padded()`** restores the rows as zeros +— **exact, not an approximation**, since `dH/dZ` at fixed unknowns is identically zero when +the shock never appears in the equation — and otherwise mirrors `Block.solve_jacobian` +line-for-line, printing the padded row names on every solve. All nine call sites across +`code/`, `experiments/` and `diagnostics/` were converted; +`grep -rn "\.solve_jacobian(" --include="*.py" code experiments diagnostics | grep -v +solve_jacobian_padded` must stay empty. A 25×25 rewrite was considered and rejected — it +would hit the identical defect with smaller numbers. + +**Results.** Impact on the 1pp default shock, % of own SS level, both columns on the same +150bp moment: + +| | flex, real deposits (8.5) | sticky + nominal (7.85) | +|---|---|---| +| peak spread | 150.4 bp | 150.0 bp | +| `Y_D[0]` | −0.0149 | **−0.5064** | +| `C_D[0]` | **+0.2164** | **−0.5103** | +| `I_D[0]` | −0.7718 | −1.0114 | +| `n_inter_D[0]` | −3.3804 | −4.2962 | + +Price stickiness alone (Task 10, deposits still real) does most of it: `Y_D[0]` −0.0149 → +−0.4923 (33×), `C_D[0]` +0.2164 → −0.4904 (**sign flip**), `I_D[0]` only 1.28× — so the extra +output decline is the markup wedge shifting labour demand, not an investment story. The +`kappa_p` sweep {0.03, 0.0871, 0.2} is monotone and stable (`b_gov_D[499]` ~1.5e−05 +throughout) and `C_D[0]` is negative across the whole sticky range, so the sign flip is not +knife-edge. + +**Caveat, recorded and not to be dropped: this is a one-quarter spike, not a downturn.** +Output and consumption are both positive from quarter 1, and flexible-price consumption is +*more* persistently negative from quarter 2 on. `C_D[1]` is essentially unmoved by nominal +deposits (+0.1141 → +0.1144) — the entire Fisher effect is an impact-quarter effect. +Bi-Foerster-Traum's output stays negative ~20 quarters. The honest claim is that the model +fixes the **impact quarter**, not that it resolves the investment-bust counterfactual. + +**E1–E4 regenerated** (Task 15). Regime cache rebuilt with +`diagnostics/regimes/regime_model.py --force` **first**, `experiments/run_all.py` second — +the ordering is load-bearing, because `experiments/` never re-solves the model and would +otherwise have silently re-reported flex-price numbers. New caches tagged +`psilam7p85_cal685f7838`; confirmed consumed via every provenance stamp in +`docs/experiments_results.md`. + +- **E1**: γ for the same 0/25/50% compression falls 5.0798 → 3.2515 (medium) and 12.7260 → + 9.0163 (aggressive) — the backstop is more powerful per unit under sticky prices. Loading + 4.00/3.17 → **3.82/2.90**, still monotone decreasing, 4.43 → 1.49 over 59 grid points and + above 1 throughout. **Live Claims 1 and 5 both survive.** +- **E2**: `market_clearing_D` closes at 3.5e−17 / 1.1e−16 / 2.2e−16 against the 1e−07 + assertion — no Rotemberg resource cost leaked into the resource constraint. But the + headline-vs-channels finding **reverses**: the largest single channel is now 0.25× the + headline, where under flex prices it was ~4×. `docs/SPEC.md`'s ΔY caution was restated + accordingly — it now rests on the channels *cancelling*, not on the headline being the + smaller object. +- **E3**: `writeoff_enabled=1` alone still negligible and SS-neutral (drift 0.000e+00). Full + writeoff now inverts Live Claim 1 **only at aggressive** (loading 0.26) — medium holds at + 2.46, where the flex model had both below 1 (0.37/0.28). The appendix robustness claim was + narrowed accordingly in `CLAUDE.md` and `docs/STATE.md`. +- **E4 + paper artefacts**: `experiments/cache_e4_deciles.npz` was stale and E4 is **not** + wired into `run_all.py` — `e4_distribution.py` is a separate entry point feeding + `paper_outputs.py`. Both rebuilt, re-emitting all 8 tracked `experiments/paper/fig0*.png` + and `docs/paper_draft_results.md`. + +**NEW WATCH ITEM.** `n_inter_D[0]` is now **positive (+0.924)** under the aggressive +backstop, where it was −1.099. With `Y_D[0] = +0.8721` and `C_D[0] = +1.5143`, the aggressive +backstop produces an impact *boom* in the crisis country rather than merely cushioning the +bust. The old watch item (`Y_D[0]` positive under intervening regimes) survives and is an +order of magnitude larger. + +**Two generated-document hazards found in Task 15**: `run_all.py` carried two prose captions +with flex-price numbers hardcoded as string literals. E3's `psi_lambda_B = 8.5` was merely +stale; E2's "each roughly 4× the headline" was **asserting the opposite of the table printed +immediately above it**. Both now compute from provenance/results. No test covers agreement +between rendered prose and rendered tables — still open. + +**Also fixed along the way.** `code/dump_irfs.py` now stores SS levels for every dumped +series with an assertion — `I_D` had no `ss__` entry, so a consumer fell back to a divisor of +1.0 and reported a level deviation as a percentage (the mislabelling class `CLAUDE.md` +already records for `n_inter` and `K`). And one test-authoring bug in Task 5: +`test_closure_puts_93pct_of_tot_move_into_D_deflation` originally asserted a first-order log +identity against the exact nonlinear `tot_res`, whose O(dlog_p²) truncation is `0.429*dlog_p` +in relative terms and swamped its own `rel=1e-6`; replaced with an exact net-rate-split +assertion (`pi_F − pi_D == dlog_p`, `share_D == 1−omega`) that is robust to any +`omega_pi_D`. The blocks were correct throughout; only the test needed fixing. + +**Tests:** `code/test_nkpc_blocks.py` 17 passed (~1s); full suite +`code/test_nkpc_blocks.py code/test_eba_calibration.py experiments/` → **40 passed**. + +**Task 16 (this commit)** — documentation pass: `docs/STATE.md`, `docs/PROGRESS.md`, +`docs/HANDOFF.md` consolidated from sixteen per-task appendices into one section each; +`docs/SPEC.md` gains the four new modelling choices and its restated ΔY caution; +`CLAUDE.md` gains `build_block_list()`, `solve_jacobian_padded()` and its grep invariant, +the new calibration, the four new residuals in the iteration checklist, and the +`test_nkpc_blocks.py` entry point, plus the corrected S-1 and `experiments/` descriptions. + +--- + ## 2026-08-05 — E4 distributional incidence; net-effects and MS-regime figures [this commit] Three additions to the first-draft set, at the author's request. diff --git a/docs/SPEC.md b/docs/SPEC.md index b38d521..a26b214 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -47,9 +47,71 @@ Build a tractable two-country general equilibrium model with heterogeneous house - **GK agency problem:** divertable fraction `Delta` drives the IC constraint binding. Multi-asset IC requires separate `Delta` for each asset class. - **Walras redundancy:** equations `ca_res_D` and `goods_mkt_F` are dropped from targets. Post-fix they hold to machine tolerance; see `docs/walras_forensics.md`. +*Added 2026-08-06 with the `add-nkpc` workstream (sticky prices + nominal deposits). Full +numbers in `docs/STATE.md`.* + +- **Rotemberg price Phillips curve, subsidy-neutralised.** `pi = beta*pi(+1) + kappa_p*(mu_p*mc − 1)` + in both countries, with the markup wedge `mu_p*mc` entering labour demand + (`w = mu_p*mc*(1−alpha)*Y/N`). Wages stay **flexible**. Writing the gap as the *ratio* + `mu_p*mc − 1` rather than a level difference makes it unit-free: it linearises to exactly + `mc_hat` for any `mu_p`, so published Calvo slopes map straight onto `kappa_p` with no + steady-state rescaling, and `mu_p` is a free normalisation to first order. The steady state + sets `mc_ss = 1/mu_p`, i.e. a production subsidy `tau_s = 1 − 1/mu_p` neutralising the + markup, so `mu_p*mc = 1`, `profit_ss = 0`, `pi_ss = 0`, and **the entire steady state is + bit-identical to the flexible-price model**. That is what makes the sticky-price results + comparable to the earlier ones rather than confounded by a re-solved SS. `kappa_p → ∞` + recovers flexible prices exactly, which is the standing equivalence gate. + *Why it is needed at all:* under flexible prices, flexible labour supply plus competitive + labour demand eliminate `Y` from the labour block entirely and pin `N` on `Z`, `K`, `P_CES` + alone — there is nothing for aggregate demand to act on, and the crisis response is two + orders of magnitude too small. +- **Union-inflation normalisation as the nominal anchor; no Taylor rule.** The monetary-union + identity `p/p(-1) = (1+pi_F)/(1+pi_D)` pins the inflation *differential* off the existing + unknown `p` (the nominal exchange rate is fixed at 1, so terms-of-trade movement **is** the + inflation differential). The *level* is pinned by + `omega_pi_D*pi_D + (1−omega_pi_D)*pi_F = 0` — the `phi_pi → ∞` limit of an ECB rule on + union-wide PPI inflation. **There is deliberately no modelled policy rate**: no financial + contract in this model carries one, so no Fisher relation is required to close the nominal + side, and adding a Taylor rule would introduce a free parameter with no additional + discipline. State it in the paper as an abstraction (perfectly credible union-inflation + targeting), not as a modelled reaction function. `omega_pi_D = 0.071` is the renormalised + two-country ECB capital key, **not** GDP weights: GDP weights would split any + terms-of-trade move ~50/50 (because the model normalises `Y_D_ss ~ Y_F_ss ~ 1`) and erase + the 93/7 Greek-deflation / German-inflation pattern that the 2010–12 internal devaluation + actually took. +- **Nominal deposits against real sovereign bonds — a deliberate asymmetry.** Deposit + contracts are nominal (`i_dep` is the contracted rate; `rdep_expost` carries the inflation + surprise into `bank_return` and `capital_fund`), while sovereign bonds remain real. This is + a choice, not an oversight: it makes banks nominal debtors and real creditors, which + **maximises their Fisher exposure** and is the configuration under which the deflation + channel does the most work. **It must be stated as such in the paper**, since the opposite + convention (nominal sovereign debt) would give the sovereign an inflation-erosion channel + and flip the sign of the bank's net Fisher position. Nominal sovereign bonds are a + candidate extension, not a correction. +- **The markup rent is distributed in proportion to productivity `e`, not lump-sum.** Once + labour is paid `mu_p*mc*(1−alpha)*Y` and capital keeps `alpha*Y`, the residual + `(1 − mu_p*mc)(1−alpha)Y` must be routed somewhere or it is a Walras leak of the W-1/W-2 + class. Routing it proportional to `e` makes labour-plus-profit income per unit of `e` + exactly `(1−alpha)*Y*e` — identical to the flexible-price model — so the markup wedge bites + only on the *firm's hiring decision* and never on household income, and `labor_market_D/F` + (labour supply) needs no change. A **lump-sum rebate was rejected because it is + countercyclical**: markup rents rise when `mc` falls, so a lump-sum transfer would hand the + largest windfall to the poorest households exactly in the downturn, manufacturing a + progressive incidence result as an artifact of the rebate rule rather than of the + transmission mechanism this paper is about. + ## Calibration strategy -**Current (2026-07-22), see `docs/eba_calibration.md` for the full parameter → +> **Values in this section date from 2026-07-22 and several are superseded.** The live +> calibration table is `docs/STATE.md`. In particular: `psi_lambda_B_D/F = 7.85` (not +> 1.1793 — re-tuned 2026-07-31 to 8.5 for `BANK_SCOPE="broad"`, then 2026-08-06 to 7.85 +> once sticky prices and the Fisher channel pushed the spread response to 162bp); +> `EL_price_D/F = 0.056134` (not 0.0717 — that predates the EBA `delta_b=0.0777`, +> `q_b=0.969`); `delta_b_D/F = 0.0777/0.0568`, measured from the sovereign maturity +> ladder; `phi_lamb_D/F = 0.15`. The *reasoning* below is still the reasoning; the +> numbers are not all current. **Re-derive, do not copy.** + +**As of 2026-07-22, see `docs/eba_calibration.md` for the full parameter → moment map and `docs/STATE.md` for the live calibration table:** - Bilateral GR/DE bank exposures from the EBA 2011 stress-test disclosure (31 Dec 2010 actual): own-book concentration `phi_bD_D_ss=2.39` (GR), @@ -187,10 +249,13 @@ why the litigation was tortured, not a claim to have out-theorised the Court. 1. **Expected P&L favours the CB.** `EL_price·def_rate` is actuarially fair by construction — the expected loss is *fully* compensated, not partially. - `psi_spread·def_rate` sits **on top**. **Current calibration (2026-07-22, - `psi_lambda_B=1.1793`, `recovery_rate=0.30`): loading (TPI premium PV / - expected-loss PV) is 3.59/3.03/2.47 at gamma=2/5/10** — over-compensated, - declining in aggressiveness. (This number moved twice the same day: first + `psi_spread·def_rate` sits **on top**. **Current calibration (2026-08-06, + sticky prices + nominal deposits, `psi_lambda_B=7.85`, `recovery_rate=0.30`): + loading (TPI premium PV / expected-loss PV) is 3.82 at the medium regime and + 2.90 at the aggressive one, and above 1 at all 59 grid points of the schedule** + — over-compensated, declining in aggressiveness. **The claim survived the move + to sticky prices**; the flex-price values were 4.00/3.17. (An earlier number + moved twice on 2026-07-22: first recalibrating `psi_lambda_B` to the 150bp target gave 2.54/2.14/1.74; resolving `recovery_rate` afterward — which shrinks `EL_price`, the denominator — raised it to the current 3.59/3.03/2.47. Both supersede the @@ -226,8 +291,10 @@ why the litigation was tortured, not a claim to have out-theorised the Court. *because* the marginal holder is constrained; TPI relieves the constraint. So intervention erodes its own profit source: more credible backstop → spreads compress toward fundamentals → `psi_spread` shrinks → less earned - per unit. **Confirmed post-recalibration**: loading declines monotonically - in gamma at the current calibration (3.59→3.03→2.47 at gamma=2/5/10). + per unit. **Confirmed, and it survived the move to sticky prices**: at the + current calibration (2026-08-06) the loading declines monotonically in gamma + at all 59 finite grid points, **4.43 → 1.49 over γ ∈ [0.51, 30.00]**, staying + above 1 throughout. The *decline* is the claim; the level is not. **"Germany profits" and "TPI works" are in tension.** 6. **The `EL_price`/`psi_spread` decomposition must not be confused with Bocola-Dovis's.** Ours is expected-loss vs collateral-friction; theirs is @@ -294,13 +361,24 @@ already established creditor-interest-in-bailouts via portfolio diversification, so the trade channel is *positioning*, not novelty. Cite them and differentiate on channel. -**Do not lead with ΔY.** A small headline output number can be *only* small -because two large channels (investment contraction, NX cushion) are netting -out — and they land on different households. That's a strength (a RANK model -can't see it) only if framed as the reallocation it is, not as "nothing -happened." Check the current model's investment/NX decomposition before -asserting this — the specific magnitudes reported in earlier drafts predate -the EBA recalibration and need re-verification. +**Report the decomposition, not the headline ΔY — because the channels cancel, +and they land on different households.** A consumption expansion, an investment +contraction and a net-export cushion offset each other; that offsetting is the +economics, and a RANK model cannot see it. Frame it as the reallocation it is, +never as "nothing happened." + +*Restated 2026-08-06.* The earlier version of this caution said the headline was +*only* small because it was the residue of channels ~4× its size. **That is no +longer true and must not be repeated.** Under flexible prices `dY[0]` moved ++4.9e−04 passive → aggressive against an investment channel of +2.2e−03 and net +exports of −1.9e−03. Under sticky prices `dY[0]` moves **+1.38e−02** while +investment moves +3.41e−03 and net exports −2.92e−03 — the largest single channel +is now **0.25× the headline, not 4×**. The instruction is unchanged; its +justification has inverted. Arguably this is an improvement: output is no longer +a numerically fragile residue of nearly-cancelling terms, so the headline can be +quoted without the earlier caveat that it is an artifact of near-cancellation — +but the decomposition still carries the distributional content, which is the +reason to lead with it. Current numbers: `docs/experiments_results.md` (E2). ### The TL;DR as it currently stands diff --git a/docs/STATE.md b/docs/STATE.md index da68144..5b101c1 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -1,9 +1,1476 @@ # Project State -**Branch:** `eba-recalibration` | **Date:** 2026-07-31 | **Status:** **EBA calibration rebuilt, identified, and LIVE** (`EBA_CALIBRATION=True`, `BANK_SCOPE="broad"`). Y-1 and RK-1 resolved; spread on target at 150.4bp; TPI loading declining. +**Branch:** `gk-structural-foc` | **Date:** 2026-08-18 | **Status: the GK structural refactor is COMPLETE (stages 1–5).** Sovereign pricing is now generated by the state-contingent bond payoff inside the genuine Gertler–Karadi portfolio FOC. `psi_spread_D/F`, `EL_price_D/F`, `divert_bond_foc_D/F`, `divert_portfolio_adj`, `bond_price_ss_D/F`, `domestic_bond_foc_D/F`, `portfolio_adj_cost` and the `excess_return_*_ss` anchors are all **deleted**. `zeta_writeoff = 1`, `psi_lambda_B = 0`, all four `Delta = 0.20`. Peak spread on a 1pp shock is **205.9 bp** (not tuned to anything) and the TPI loading is **0.48–0.52**, i.e. **below 1** — the over-compensation headline is reversed. See *GK structural refactor* below. + +> **E1–E4 and every `experiments/paper/fig0*.png` are STALE.** The steady state moved +> (`q_b_D 0.968941 → 0.974906`). Rebuild the regime cache FIRST +> (`diagnostics/regimes/regime_model.py --force`), then `experiments/run_all.py`, then +> `experiments/e4_distribution.py` → `experiments/paper_outputs.py`. Quoting the current +> artefacts quotes the pre-refactor model. + +## `psi_lambda_B` to be DELETED — decided 2026-08-24 + +**Decision, not a calibration choice.** `psi_lambda_B_D/F` (collateral-friction +amplification dial) is 0 today and will never be non-zero again. The parameter and the +risk-sensitive branch of `collateral_quality_D/F` are to be removed from `code/`, leaving +`Delta_bD_eff_D = Delta_bD_D` and `Delta_bF_eff_D = Delta_bF_D` as constants. + +**Why it matters beyond tidiness.** It is the closest analogue in this model to +Bi–Foerster–Traum's *liquidity risk channel* (`eta^v_t/eta^v = 1 + phi^eta[P(s) − P(sbar)]`, +their eq. 2.24, `phi^eta = 0.0125` calibrated to the 2012 Italian episode). Their own Table 1 +shows that channel carrying most of both financial moments: dropping it takes the peak yield +spread from 6.4 to 2.0 pp against 5.0 in the data, and the excess return on government debt +from 5.1 to 0.9 against 5.1. **Removing ours outright is therefore the paper's sharpest +point of separation**, and the introduction now asserts it as an absence — "no parameter +anywhere in the model makes a balance-sheet friction a function of the default probability". + +**Open gap until the deletion lands:** the paper claims the device does not exist while +`code/equations_D.py::collateral_quality_D` still contains it at coefficient zero. The fix +is the deletion, not a hedge in the text. `code/test_nkpc_blocks.py::test_no_ad_hoc_ +sovereign_spread_wedge_anywhere` is the natural place to add `psi_lambda_B` to the banned +-names AST scan once it is gone. + +**Consequence for the diagnostic arms.** The `psi_lambda_B = 3.01` counterfactual is void. +Anything in `diagnostics/psilam_*` that varies it is dead code. + +--- + +## Paper motivation and figures — 2026-08-24 + +**No model source, calibration or solved object changed by this entry.** + +Two motivation figures are now generated by `Empirics/motivation_figures.py` and +tracked in the Overleaf project at `VIVA/figures/` (`Empirics/outputs/` is +gitignored). Both carry their captions baked into the PNG. + +| figure | contents | source | +|---|---|---| +| `fig_greece_motivation` | Greek general government debt (€bn), debt/GDP, ten-year yield vs. Bund with the spread shaded, real GDP vs. real investment indexed to 2007Q4 | Eurostat `gov_10q_ggdebt` (S13, `na_item=GD`), `namq_10_gdp` (B1GQ / P51G, CLV10_MEUR, SCA); FRED yield panel | +| `fig_euro_yield_decoupling` | ITA/ESP/PRT/GRC ten-year yields vs. Bund, 1995–2026, ECB/OMT/TPI markers | FRED `IRLTLT01{IT,ES,GR,PT,DE}M156N` via `Empirics/outputs/spreads_fred.csv` | + +**Two data facts the introduction now leans on**, both computed from the series and +reproducible by re-running the script: + +- Real investment fell **69.7%** from 2007Q4 to the 2015Q3 trough, against a + **27.4%** fall in real GDP — a ratio of 2.5. This is the disciplining asymmetry for + an intermediary-constraint channel; a demand or labour-wedge channel has no reason + to produce it. It is also the closest thing the paper has to an external check on + `I_D[0] = −3.005%` against `Y_D[0] = −1.974%` (a ratio of 1.5 — **the model + produces the sign and the ordering but only about 60% of the observed relative + amplitude**; worth stating rather than hiding). +- The March 2012 PSI cut **€75bn** and **33.7 points** of debt ratio. The ratio was + back at its 2011Q4 level by **2013Q2**; the stock was not regained until **2021Q2**. + +**Correction carried from the paper rewrite.** The introduction previously described +`Delta` as *pledgeability*. From `intermediation_IC_D`, +`theta_tgt = value/lambda_gk + (1 − Delta_bD)·phi_bD + (1 − Delta_bF)·phi_bF`, so +`Delta` is the **divertable** share relative to capital: `Delta = 0.20` means sovereign +paper is one-fifth as divertable as capital, hence *better* collateral, hence it earns +a fifth of the capital premium via `nu_bD/nu_K = Delta`. **`CLAUDE.md`'s GK-1 row still +says the opposite** ("makes Greek paper worse collateral than capital") and should be +corrected; the code comment in `intermediation_IC_D` already has it right. + +--- + +## Central-bank mechanism — 2026-08-19, CANONICAL + +`docs/cb_mechanism.md` is now the **canonical** description of the TPI central bank: what the +object is, how it transmits, the 2×2 sovereign-holdings matrix, and the reporting rules that +follow. It supersedes every ad-hoc CB description elsewhere in this file and in figure +captions. Audit evidence in `diagnostics/cb_audit/`. **No model source was changed.** + +Four things to carry: + +1. **The spread identity.** At `psi_lambda_B = 0`, composing `gk_bond_foc_D` with + `intermediation_P1_D` cancels `SDF_banker_D` and `Omega_p1_D`, leaving + `rb_exp_D(+1) − rdep_D = 0.20·(rk_D(+1) − rdep_D)` **exactly** (verified at SS: + `0.20 × 0.010000 = 0.002000`; four-leg FOC table holds to ≤2.1e−13). The CB has no direct + lever on the spread — it compresses only by lowering `rk_D`. **TPI's spread effect and its + investment effect are one effect.** +2. **Who sells.** ~72% of the CB book in market value (84% in quantity at impact, ~99% from + t=1) comes from **German** banks, 17% from new Greek issuance, 10% from Greek banks. The + ECB buys German banks out of Greek paper; it does not share the exposure. +3. **The relief is a net-worth effect.** TPI at γ=10 undoes 82.9% of the crisis rise in + `phi_bD_D` (0.452489 → 0.486757 → 0.458345), with the numerator moving −0.4% and + `n_inter_D` recovering +6.6%. Describe the channel as price support → mark-to-market + recapitalisation, never as balance-sheet or quantity relief. +4. **SS neutrality is exact** — every TPI block output bit-identical to its non-TPI + counterpart, `cb_flow_D = rem_cb_D = rem_cb_F = 0`. + +**Correction to this document and to HANDOFF.md: there is no closed-loop pole.** The γ ≈ 27.3 +figure recorded below and in `code/tpi.py` is a T=500 terminal-truncation artefact — the +resonant eigenvector has 0.0000 of its mass in the first 100 quarters and 0.9922 in +t = 400–499; `||A_cb[:,499]|| = 3.86` against ~0.0065 for every interior column; +`A_cb[499,499] = +1.080` is the only positive diagonal entry. Dropping five columns removes +every pole below γ = 36 and changes the reported peak spread by **nothing** at γ = 2, 5, 10. +The 0.25-step condition-number scan also steps over a nearer apparent singularity at +γ = 2.2116. **No reported number is affected** (every statistic is computed on `[:100]`, where +the artefact has no mass), but the γ = 19.88 cap is imposed for a spurious reason and the pole +must not be cited as a stability ceiling on γ. Fix proposed at +`diagnostics/cb_audit/recommended_fix.md` R-1; **not implemented**. + +## GK structural refactor — 2026-08-17/18 (`gk-structural-foc`), COMPLETE + +**The defect (audited 2026-08-17, code-traced, not inferred from comments).** The intended +chain `p_def -> Delta_bD_eff -> IC -> lambda_gk/Omega -> intermediation_P1_D -> q_b_D` is +**broken at the third arrow**. What the code does: + +- `Delta_bD_eff` enters `intermediation_IC_D` ONLY. It moves `theta_D` (leverage) and + dead-ends there. +- The Greek spread is set entirely by `divert_bond_foc_D`, a hand-written pricing rule that + references **no endogenous GK object** — not `Delta_bD_eff`, not `nu_bD_D`, not `theta_D`. + Only the frozen scalar `psi_spread_D = lambda_gk_D*psi_lambda_B_D/(beta_inter_D*Omega_D)`, + evaluated once at the SS in `_apply_ss_anchors`. +- `bond_price_ss_D` is **not in the dynamic block list** at all; `steady_auxilliary_D`, + `smart_steady_D` likewise. `lambda_gk_D` is therefore a constant in the dynamics + (legitimately — it is the structural divertable fraction, BFT's `eta_bar^v`, not a + multiplier). `Omega` IS time-varying but only as the local `Omega_p1_D` inside + `intermediation_P1_D`; it is never exported. + +**GK portfolio optimality is VIOLATED at the steady state**, which is why the wedge exists: + +| condition GK requires | required | actual | +|---|---|---| +| `nu_bD_D / nu_K_D = Delta_bD_D` | 0.200 | **0.2491** | +| `nu_bF_D / nu_K_D = Delta_bF_D` | 0.400 | **0.2491** | +| `nu_bD_D / nu_bF_D = Delta_bD_D/Delta_bF_D` | 0.500 | **1.000** | + +`nu_bD_D` and `nu_bF_D` are bit-identical (0.02696043) while their divertability weights +differ 2x. Root cause: `steady_auxilliary_D` *defines* the marginal values from returns +(`nu_bD_D = beta_inter_D*Omega_D*(rb_actual_D - rdep_D)`); it never *restricts* returns. The +portfolio FOCs — relative excess return = relative divertability — are imposed **nowhere**, +in neither the SS nor the dynamics. With no structural link from collateral quality to +required return, a channel had to be bolted on. + +**Consequence: the refactor is NOT SS-neutral.** Imposing `nu_i = Delta_i*nu_K` moves the +D-bank own-sovereign excess return 24.9 -> 20.0 bp/q and the cross-border leg 24.9 -> 40.0 +bp/q. Every EBA moment must be re-hit. Author decision 2026-08-17: full recalibration +(option **i**), and `EL_price_D` is **retained** (option **b**) — S-1 stands, the fundamental +loading stays priced off-path, relocated into the return inside `intermediation_P1_D` rather +than sitting in a standalone spread formula. + +### Stage 1 (DONE) — bounded pledgeability, exported + +New `collateral_quality_D/F` blocks export `Delta_bD_eff_D`, `Delta_bF_eff_D`, +`Delta_bF_eff_F`, `Delta_bD_eff_F`. `intermediation_IC_D/F` consume them instead of computing +inline. + +``` +z = psi_lambda_B * def_rate(+1) / (1 - Delta) +Delta_eff = Delta + (1 - Delta) * z/(1+z) +``` + +Range `[Delta, 1)`, monotone, and `d Delta_eff/d def_rate(+1)|_0 = psi_lambda_B` EXACTLY — +verified against the SSJ Jacobian at 3.0100000000. Replaces the unbounded linear map, which +left `[0,1]` at `def_rate(+1) > (1-Delta)/psi_lambda_B = 0.266` and turned the collateral +bonus negative beyond that. + +**SSJ GOTCHA — no transcendental functions in `@simple` blocks.** The natural `1-exp(-z)` +saturation raises `TypeError: loop of ufunc does not support argument 0 of type +AccumulatedDerivative`. SSJ differentiates simple blocks with a dual-number type implementing +arithmetic operators only. `z/(1+z)` is built from `* / +` alone. Rank this alongside +`solve_jacobian_padded` in the SSJ-defect list. + +Residual caveat: the rational form has a pole at `def_rate(+1) = -(1-Delta)/psi_lambda_B` = +-0.2658. Unreachable (`def_rate_ss = 0`, shock positive, linearised solve never evaluates the +nonlinear map) but would bind in `code/global/`. + +**Verification — `code/main.py` exit 0, bit-identical to baseline:** + +| check | baseline | stage 1 | +|---|---|---| +| `n_inter_D[0]` | -6.7366% | **-6.7366%** | +| `Y_D[0]` | -0.8521% | **-0.8521%** | +| peak spread | +0.375 pp | **+0.375 pp** | +| `goods_mkt_D` | -4.2493163257550925e-07 | identical | +| `max abs(goods_mkt_F)` over gamma | 2.06e-10..2.12e-10 | identical | +| `K_D` / `K_F` | 10.800 / 10.824 | identical | + +Two wiring bugs were caught by the pipeline, not by the 35 fast tests (which passed before +each): `np.exp` in a `@simple` block, and `collateral_quality_D/F` missing from +`steady_state.py`'s block list so `ss_final` lacked `Delta_bF_eff_F`. **Worth a test that +every input of every block in `build_block_list()` is either produced by another block in the +list or present in `ss_final`** — would have caught both in a second instead of two 12-minute +runs. + +### Stages 2–5 (DONE — 2026-08-18) + +Completed together, because stages 2 and 3 move the steady state and there is no coherent +intermediate state to stop at. **The end state is stronger than the plan above:** `EL_price_D` +was *not* retained. Once the bond payoff itself was repaired there was nothing left for it to +do, so it is deleted as a pricing object and survives only as the endogenous diagnostic +`EL_load_D`. + +**The plan's own diagnosis was incomplete, and this is the substantive finding.** Stage 1 +recorded `psi_spread_D` as a free spread wedge that had to go. It was — but it was *standing in +for a mis-specified bond payoff*, not merely padding the spread. `zeta_writeoff_D = 0` meant a +sovereign default wrote down only the **current coupon** and left the perpetuity's continuation +value untouched. On a 12.9-quarter claim that understates the loss by + +``` +[delta_b + (1-delta_b)*q_b] / delta_b = [0.0777 + 0.9223*0.9749] / 0.0777 = 12.6x +``` + +so the expected loss per unit of default probability was `0.0561` where the contract implies +`0.7014`. `psi_spread_D = 0.615` was almost exactly the difference. The old +"8.4% fundamental / 91.6% friction" decomposition was therefore mostly an artefact of the +missing principal leg — and the conclusion that "the GK mechanism is too weak to generate the +spread" was wrong. It is not weak. It was being fed the wrong payoff. + +**What changed, block by block.** + +| object | before | after | +|---|---|---| +| `bond_return_D/F` | emits `rb_actual` only | emits `rb_actual` (realised branch), **`rb_exp`** (expected payoff — the only thing priced), **`EL_load`** (diagnostic) | +| `zeta_writeoff_D/F` | `0.0` | **`1.0`** — default writes down continuation value too | +| `intermediation_P1_D/F` | priced `rb_actual(+1)`; stage-1 draft netted `EL_price*def_rate(+1)` | prices `rb_exp(+1)`. `P1_F` now converts D-good bond returns with `p/p(+1)` (units fix — see below) | +| `divert_bond_foc_D/F` | hand-written spread rule with `psi_spread`, `excess_return_*_ss`, `psi_bD_D`, `tau_mp` | **deleted**; replaced by `gk_bond_foc_D/F`: `nu_own = Delta_own_eff * nu_K` | +| `divert_portfolio_adj` | `excess_return_*_ss + (EL_price + psi_spread)*def_rate(+1) - psi*(b-b_ss) - tau_mp` | **deleted**; replaced by `gk_cross_border_foc`: same FOC + the `psi` stock cost | +| `bond_price_ss_D/F` | priced `q_b` off `SDF_banker` alone | **deleted**; `q_b_D`, `q_b_F` are SS unknowns pinned by `rb_D_res`, `rb_F_res` | +| `domestic_bond_foc_D/F`, `portfolio_adj_cost` | dead code, imported but never in a block list | **deleted** | +| `psi_spread_D/F`, `EL_price_D/F`, `excess_return_*_ss` | anchors set in `_apply_ss_anchors` | **deleted** | +| `psi_lambda_B_D/F` | `3.01`, tuned to a 150bp moment | **`0.0`** — preferred baseline; `3.01` is a diagnostic arm | +| `Delta_bF_D`, `Delta_bD_F` | `0.40` | **`0.20`** — forced, see below | +| `Omega_p1_D/F` | local variable inside `P1` | exported, so `gk_cross_border_foc` can restate the FOC in return units without duplicating the algebra | + +**Two things the plan did not anticipate.** + +1. **All four `Delta` must be equal at this steady state.** The own-sovereign FOCs pin `q_b_D` + and `q_b_F`; the cross-border FOCs then have no free price left. At `def_rate_ss = 0`, + `rk_D = rk_F = 0.01` (RK-1) and `rdep_D = rdep_F = 0` (`i_dep_ss = 0`, `pi_ss = 0`), the + cross-border ratio `nu_cross/nu_K` is forced to equal the own-sovereign one. Keeping + `Delta_cross = 0.40` would have left a constant **80bp/yr** cross-border wedge — exactly the + kind of object this refactor exists to remove. Measured wedges at the live calibration are + `8e-11` and `-9e-11` bp/yr. Note this makes the C-1 row's "`Delta_bD_D/F = 0.2/0.4` are + genuine hardcoded inputs" only half true: 0.2 is an input, and the cross value is now + *implied*. +2. **`intermediation_P1_F` had a units bug, pre-existing and first-order.** `q_b_D` and `q_b_F` + are both **D-good** prices (`intermediation_P3_F` and `k_balance_sheet_F` divide the whole + bond block by `p`; `government_ss_F` divides F's own coupon and issuance by `p`), while + `rk_F`, `rdep_F` and F net worth are F goods. `P1_F` compared an unconverted `rb_actual_F(+1)` + with `rdep_F`, while the old cross-border block applied `p/p(+1)` to the same return — the + two sides of the F bank's problem were on different unit conventions. The conversion now + lives in exactly one place (`P1_F`) and everything stated on the `nu`s inherits it. SS is + untouched (`p` constant ⇒ conversion is 1). + +**`psi_bD_F`/`psi_bF_D` = 0.5 stayed, as stage 4 required.** They are the reason two banks with +different `nu_K` can hold the same bond at one price, they load on the bond STOCK and carry no +`def_rate`, and they are identically zero at the calibrated position. `gk_cross_border_foc` +divides the FOC through by `SDF_banker*Omega_p1` so they keep their calibrated units. + +**`psi_bD_D`/`psi_bF_F` (own-leg adjustment costs, both `0.0`) are now unreferenced** — their +only consumer was the deleted `domestic_bond_foc_D/F`. Left in `calibration.py` rather than +removed in the same pass; harmless, but they are dead. + +### Verification (2026-08-18) + +`code/main.py` runs clean end to end. `report_gk_steady_state` now runs on every solved SS and +raises on a violated FOC or an out-of-range `Delta_eff`. + +| check | value | +|---|---| +| `nu_bD_D/nu_K_D − Delta_bD_eff_D` | `−1.62e−14` | +| `nu_bF_D/nu_K_D − Delta_bF_eff_D` | `+2.06e−13` | +| `nu_bF_F/nu_K_F − Delta_bF_eff_F` | `−4.75e−15` | +| `nu_bD_F/nu_K_F − Delta_bD_eff_F` | `−2.27e−13` | +| `nu_bD_D / nu_bF_D` vs `Delta_bD_D/Delta_bF_D` | `1.0000` vs `1.0000` | +| cross-border SS wedges | `+8e−11` / `−9e−11` bp/yr | +| `Delta_*_eff` over the whole simulated path | `0.2000` flat at `psi_lambda_B = 0`; `[0.2004, 0.2377]` at `3.01` — inside `[0,1]` in both | +| `ca_res_D` | `1.67e−16` | +| `goods_mkt_D` / `goods_mkt_F` | `−4.23e−07` / `−4.18e−07` | +| `b_gov_D[499]` | `3.02e−05` (stationary) | +| fast tests | 38 passed | + +**`goods_mkt_D` at `−4.2e−07` is PRE-EXISTING, not a regression.** Measured directly against a +clean worktree at `91ac778`, which prints `−4.2493163257550925e−07` on the same pipeline; the +refactored branch prints `−4.2281e−07`, marginally smaller. CLAUDE.md's `≤ 1e−14` acceptance +threshold for this residual has never been met on this calibration and has been corrected. +`ca_res_D` is the residual that genuinely reaches machine zero. + +**Depreciation calibration is now ITERATED.** `delta = alpha*Y/K − rk_target` is exact only at +the `K` and `Y` the *next* solve returns. That was tolerable while `q_b` sat outside the +solver; with `q_b_D`/`q_b_F` as SS unknowns the loop `delta → K → rk → q_b → balance sheet → K` +is strong enough that one pass left `rk_D = 0.009981`. Five passes reach `5e−14`, and +`rk_D = rk_F = 0.010000` exactly, as RK-1 requires. + +### Steady state — identical in ALL THREE ARMS + +Both dials are exactly allocation-neutral at the steady state, and the reason for `zeta` is +worth stating because it is not obvious. `zeta_writeoff` multiplies `zeta*def_rate*h` inside +the **expected** payoff, and `def_rate_ss = 0` kills that term just as it kills the realised +one — so `rb_exp = rb_actual` at the SS whatever `zeta` is. `psi_lambda_B` multiplies +`def_rate(+1)` inside `collateral_quality`, likewise zero. Measured: `q_b_D = 0.974906` in +Arms 1, 2 and 3 to every printed digit. **`zeta_writeoff` is allocation-neutral and +dynamically decisive** — it changes `EL_load_D` 0.0558 → 0.7014 and the peak spread +12.3 → 205.9 bp without moving a single steady-state quantity. + + + +`q_b_D = 0.974906`, `q_b_F = 0.965974`, both yields **80.0 bp** annualised, **SS spread +exactly 0** (correct: `def_rate_ss = 0` in both countries, so there is nothing to price). +`EL_load_D = EL_load_F = 0.7014` — identical because `EL_load = h*(1 + yield)` and the two +yields are equalised by the two own-sovereign FOCs at a common `rk` and `rdep`. +`lambda_gk_D = 2.1087`, `Omega_D = 10.3462`, `theta_D = 5.5107`, `n_inter_D = 2.1379`, +`b_D_D = 0.9923`, `K_D = 10.8067`, `beta_D = 0.999534`, `rdep_D = 0`, `rk_D = 0.010000`. +Note `beta_D` moved `0.99953` from the pre-refactor `0.99953` — unchanged to 5 dp — while +`q_b_D` moved `0.968941 → 0.974906` and the own-sovereign excess return `24.91 → 20.00 bp/q`, +which is precisely the FOC being imposed. + +### Results — 1pp Greek default-probability shock + +| | **Arm 1** baseline `ζ=1, ψλ=0` | **Arm 2** diagnostic `ζ=1, ψλ=3.01` | **Arm 3** diagnostic `ζ=0, ψλ=0` | old wedge spec `ζ=0, ψλ=3.01, ψs=0.60` | +|---|---|---|---|---| +| `EL_load_D` | **0.7014** | 0.7014 | 0.0558 | 0.0561 (as `EL_price_D`) | +| peak GR–DE spread | **205.9 bp** | 210.0 bp | 12.3 bp | 150.0 bp (a tuned moment) | +| peak Greek yield | **189.7 bp** | 186.1 bp | 11.6 bp | n/a | +| German yield, impact | **−16.2 bp** | −24.0 bp | −0.79 bp | n/a | +| `q_b_D` impact | **−5.950%** | −5.837% | −0.362% | n/a | +| `b_DD` impact | **+2.116%** | +2.789% | +0.129% | n/a | +| `b_FD` impact | **+3.449%** | +5.191% | +0.211% | n/a | +| `b_DF` (DE banks' Greek book) | **−2.101%** | −3.163% | −0.128% | rose (open problem) | +| `K_D` impact | **−0.0673%** | −0.1024% | −0.0041% | n/a | +| `n_inter_D` impact | **−11.407%** | −15.970% | −0.698% | −4.296% | +| `theta_D` impact | **+9.655%** | +13.533% | +0.591% | n/a | +| `Y_D` impact | **−1.974%** | −2.855% | −0.121% | −0.506% | +| `C_D` impact | **−2.511%** | −3.533% | −0.153% | −0.510% | +| `I_D` impact | **−3.005%** | −4.572% | −0.184% | n/a | +| `Y_F` impact | **+0.150%** | +0.217% | +0.009% | n/a | +| `Delta_bD_eff_D` range on path | **0.2000 flat** | [0.2004, 0.2377] | 0.2000 flat | n/a | +| `b_gov_D[499]` | `3.0e−05` | `1.1e−04` | `7.5e−07` | `2.0e−05` | + +Five things to read off this table. + +- **Arm 3 is the size of the repair.** Coupon-only pricing gives a 12.3 bp peak spread against + the corrected 205.9 bp — a factor of **16.7**, against a 12.6× ratio in `EL_load_D`. The + excess is balance-sheet amplification compounding on a bigger initial impulse. Arm 3 is a + diagnostic of what the payoff correction contributes and is **not an economic + specification**: a model in which defaulting on a 13-quarter perpetuity costs the holder only + one quarter's coupon is not a description of sovereign debt. + +- **Greek banks buy MORE Greek debt while capital contracts** (`b_DD +2.12%`, `K_D −0.067%`). + That is the intended GK portfolio-reallocation channel: the sovereign's required return rises + with its default risk, so it becomes relatively attractive within a shrinking balance sheet, + and private capital intermediation is what gives way. It is a *result*, not an assumption — + nothing forces Greek banks to retrench. +- **German banks DO retrench** (`b_DF −2.10%`), which reverses the open problem recorded under + *Open problem: foreign banks do not retrench* in HANDOFF.md. Under the old wedge the F bank + increased its Greek book on a Greek shock, contrary to the 2010–12 record and to + Bi–Foerster–Traum. Attribution is the `P1_F` units fix plus stating the cross-border condition + on the `nu`s. +- **Flight to quality is present and endogenous** (German yield −16 bp on impact) with no + German-side wedge of any kind. +- **The real response is now 4× the old one** (`Y_D −1.97%` against `−0.51%`), because the + priced loss is 12.6× larger. The spread is 206 bp against a 150 bp empirical target. + +### The intermediary channel is a QUANTITY amplifier, not a price amplifier + +Worth stating because the natural reading of the old specification was the opposite, and +because a first draft of `fig04` got it wrong. Take the model's own pricing condition and +freeze the intermediary's required excess return at its steady-state value +`s0 = Delta_bD_D·(rk_ss − rdep_ss) = 0.002`, then solve the bond price forward along the +realised `def_rate_D` path: + +``` +q_t = (1 − h·d_{t+1}) · [delta_b + (1−delta_b)·q_{t+1}] / (1 + s0), q_T = q_ss +``` + +| | impact (t=0) | peak | +|---|---|---| +| Greek yield, direct expected-loss pricing at frozen `s0` | 213.8 bp | 213.8 bp | +| Greek yield, equilibrium | 189.7 bp | 189.7 bp | +| GR–DE spread, equilibrium | 205.9 bp | 205.9 bp | + +Direct expected-loss pricing accounts for **more** than the whole equilibrium Greek yield +response. General-equilibrium movement in `rk_D` and `rdep_D` lowers the required return +slightly, so the intermediary channel is a modest **offset at the price** — while being a +large amplifier for **quantities** (`n_inter_D −11.4%`, `Y_D −1.97%`, `I_D −3.00%`). The +spread exceeds the Greek yield because the German leg falls 16.2 bp, not because of +amplification on the Greek leg. Arm 2 says the same thing from the other direction: +`psi_lambda_B = 3.01` adds 4.2 bp of spread and 0.9 pp of output. + +**Units trap, recorded so it is not repeated.** The first `fig04` draft plotted +`EL_load_D · def_rate_D` — a one-period expected capital-loss rate — against `spread_rb`, +a per-period coupon-equivalent yield. Not commensurate: it read 331 bp against a 206 bp +spread, an "amplification factor" of 0.62, which is the mismatch and not a mechanism. The +backward recursion above puts both on the yield measure. + +### Impact signs on BOTH shocks — new diagnostic, and a chart that misleads + +`build_and_solve` now prints impact signs for the TFP shock as well as the default shock, +because `outputs/fig_irf_overview_macro.png` *looks* like `Y_D` collapses on impact under +TFP. It does not — that plunge is the **default** shock's red dashed line, and at that +scale the two are easy to confuse. Measured on a +1% TFP shock: + +| | `Y_D` | `C_D` | `I_D` | `N_D` | `w_D` | `n_inter_D` | `K_D` | `q_b_D` | +|---|---|---|---|---|---|---|---|---| +| +1% TFP | **−0.073%** | −2.829% | +5.000% | −3.712% | −7.260% | +8.267% | +0.112% | +3.225% | +| +1pp default | −1.974% | −2.511% | −3.005% | −3.001% | −5.910% | −11.407% | −0.067% | −5.950% | + +Output is **flat**, not perverse, and the mechanism is the standard sticky-price +contractionary-technology result (Galí 1999): with prices sticky and the union-inflation +normalisation being the `phi_pi → ∞` limit — no monetary accommodation whatever — firms meet +unchanged demand with fewer hours. GHH preferences make labour supply very wage-elastic, +which is why `N_D` and `w_D` move so much while `Y_D` barely does. The warning in +`build_and_solve` fires only below −0.5% so it flags a genuinely perverse response rather +than this one. + +### Interpretive discipline — how the two arms may and may not be described + +Arm 2 minus Arm 1 is **the model-implied incremental effect of sovereign-risk-sensitive +pledgeability**. It is a *model counterfactual*, not an empirical decomposition, and it must +never be reported as "x% of the spread is fundamental and y% is not". This is a linearised +equilibrium model: direct expected-loss pricing and balance-sheet amplification operate +simultaneously on the same path. + +What the arms actually show is worth stating precisely, because it is not what the old +specification implied. Turning the pledgeability elasticity on adds **only 4.2 bp to the peak +spread** (205.9 → 210.0), but amplifies the real contraction substantially (`Y_D` −1.97% → +−2.86%, `n_inter_D` −11.4% → −16.0%). Under the old specification `psi_lambda_B` was the +*dominant* determinant of the spread; with the payoff corrected it is a second-order price +channel and a first-order quantity channel. `psi_lambda_B = 0` is the baseline because the +Greek episode provides no independent observable that identifies a sovereign-specific haircut +elasticity — it should be described as *the model without an independently identified +sovereign-risk-sensitive haircut elasticity*, never as evidence that collateral is irrelevant. + +### TPI — loading is measured, never targeted + +`code/tpi.py` no longer prints the `1 + psi_spread/EL_price` closed form; that formula existed +only because the spread was a hand-written affine function of `def_rate` and has no structural +basis now. The loading is `prem_pv / el_pv` from `run_tpi`, with `el_pv` computed off the +endogenous `EL_load_D`. + +Arm 1 baseline: + +| γ | peak spread (bp) | compression | EL PV (% Y_D) | premium PV (% Y_D) | **loading** | ΔW_D | ΔW_F | +|---|---|---|---|---|---|---|---| +| 0 | 205.9 | — | 0 | 0 | n/a | 0 | 0 | +| 2 | 193.2 | 6.1% | 0.0719 | 0.0374 | **0.520** | +0.196 | −0.020 | +| 5 | 176.8 | 14.1% | 0.1603 | 0.0808 | **0.504** | +0.515 | −0.048 | +| 10 | 154.4 | 25.0% | 0.2706 | 0.1303 | **0.482** | +1.129 | −0.094 | + +**The loading is now below 1 and this is a headline change.** The old schedule was 3.82 / 2.90, +supporting an over-compensation claim. The mechanism is entirely in the denominator: the +central bank earns the same premium per unit of exposure, but the expected loss it absorbs is +12.6× larger once the principal leg is priced. E3's 2026-08-06 finding that `zeta_writeoff = 1` +drives the loading below 1 is therefore no longer a robustness variant — **it is the baseline**, +and any paper text asserting over-compensation must be rewritten rather than caveated. The +loading still declines in γ (0.520 → 0.482), so the self-extinguishing-premium result survives, +and D still gains what F loses by roughly 10:1. + +**The loading is near-invariant across all three arms**, which is the most robust single number +in the exercise: + +| γ | Arm 1 `ζ=1, ψλ=0` | Arm 2 `ζ=1, ψλ=3.01` | Arm 3 `ζ=0, ψλ=0` | +|---|---|---|---| +| 2 | 0.520 | 0.504 | 0.513 | +| 5 | 0.504 | 0.491 | 0.501 | +| 10 | 0.482 | 0.474 | 0.483 | + +A 12.6× change in the priced loss and a 3.01 change in the pledgeability elasticity move it by +under 4%. That is because premium income and absorbed expected loss scale together: the loading +is a property of the GK pricing structure — how much of the risk premium the intervention +extinguishes as it absorbs the risk — not of how large the risk is. It is the one result here +that does **not** depend on the payoff calibration, and it should be presented as such. + +### ⚠ The "aggressive" named regime is no longer 50% compression + +The closed loop `spread = (I − γ·A_cb)^{-1} A_def ε` has a **pole at γ ≈ 27.3** +(`cond(I − γA_cb) = 1.4e5`; one grid point wide on a 61-point scan of [0,40], which is why +`gamma_for_compression`'s monotonicity scan stepped straight over it and reported a spurious +non-monotonicity instead). Reachable compression below the pole: + +| γ | 0 | 10 | 20 | 25 | 26 | 26.7 | **27.3 = pole** | 28 | +|---|---|---|---|---|---|---|---|---| +| peak spread (bp) | 205.9 | 154.4 | 122.6 | 111.4 | 109.8 | 109.7 | — | 102.1 | +| compression | 0% | 25.0% | 40.5% | 45.9% | 46.7% | 46.7% | — | 50.4% | + +The 50% target is met only at γ ≈ 27.9, on the **far side** of the singularity — a different +branch, not a stronger version of the same policy. + +**The pole's influence bleeds in well below it, so the fallback is not "as close as we dare".** +Measured sweep of the loading schedule's monotonicity and the achieved compression: + +| γ / pole | γ | cond(I − γA_cb) | compression | loading | schedule monotone below? | +|---|---|---|---|---|---| +| 0.60 | 15.90 | 8.6e2 | 34.9% | 0.460 | yes | +| **0.75** | **19.88** | **1.4e3** | **40.3%** | **0.449** | **yes** | +| 0.85 | 22.52 | 2.2e3 | 43.4% | 0.445 | yes | +| 0.90 | 23.85 | 3.1e3 | 44.8% | 0.446 | **no** | +| 0.98 | 25.97 | 7.9e3 | 46.6% | 0.468 | **no** | + +At 0.98 the discounted consumption gains reach **+11 to +12.4%** of steady-state consumption +across quintiles and Greek output goes **+1.15%** on impact — that is the singularity talking, +not the policy. `lottery_math.POLE_SAFETY_FRACTION = 0.75` is therefore the one constant both +consumers read. + +Handling, in `lottery_math.closed_loop_pole` / `CompressionInfeasible` and +`common.named_regime_gammas`: `medium` still solves cleanly at **γ = 9.989** (25.0%); +`aggressive` falls back to **γ = 19.875**, achieving **40.3%**. Resulting named-regime table: + +| regime | γ | peak spread | `Y_D[0]` | `C_D[0]` | `I_D[0]` | `n_inter_D[0]` | loading | +|---|---|---|---|---|---|---|---| +| passive | 0 | 205.9 bp | −1.974% | −2.511% | −3.005% | −11.407% | n/a | +| medium | 9.989 | 154.4 bp | −0.329% | +0.079% | −1.610% | −5.541% | 0.48 | +| aggressive | 19.875 | 122.9 bp | +0.683% | +1.696% | −0.805% | −2.037% | 0.45 | + +**With the grid capped the loading schedule is MONOTONE DECREASING** — 0.53 → 0.45 over +γ ∈ [0.34, 19.88]. The self-extinguishing-premium result is clean; the earlier "Monotone +decreasing: NO" was the pole, not the economics. Note `C_D` and `Y_D` turn **positive on +impact** under intervention: the backstop overshoots on the impact quarter, which is the same +one-quarter timing anomaly as I-1 and should be understood before the aggressive row is quoted. The infeasibility *verdict* is +still measured against the true reachable set (bracket capped at 0.98×pole) — verdict at the +edge, reporting well inside it. `experiments/e1_backstop_schedule.loading_schedule` and +`code/tpi.py`'s effectiveness curve cap their γ grids the same way; `experiments/paper_outputs.py` +had a second copy of the γ solve that bypassed all of this and now routes through +`common.named_regime_gammas`, so there is one definition of the regimes. + +**Any paper prose describing the aggressive regime as "50% peak-spread compression" is now +wrong.** Say **40.3%**, or "the strongest intervention the model represents cleanly". That the +constraint binds at all is itself a result: with the payoff corrected, the backstop is materially +less effective per unit of γ than the pre-refactor model implied, and how much intervention the +model can represent is a live limit rather than a formality. + +**What the pole cost us in artefacts, recorded because it was not obvious.** Before the cap, +`fig02` plotted the loading spiking to 1.17 and collapsing to 0.38 across two grid points, and +its caption's own logic read that as "crossing below the actuarially fair benchmark of 1" — the +caption had a two-branch test written for the old world where the loading *started* above 1. +`fig05` showed German exposure plunging to −50% of `Y_D`. Both are gone. The caption now has a +third branch and says plainly that the loading stays **below** 1 throughout, so the paper must +not assert over-compensation. + +**Identification is still the weak point.** Both routes proposed for disciplining +`psi_lambda_B` were checked on 2026-08-17 and are blocked: +- *Eurosystem/LCH haircut path* — **the data does not exist.** The ECB did not disclose + haircuts applied to Greek paper 2010-12 (Bruegel labels the period "unknown" and had to + replicate ECB methodology from ratings). The rating threshold was suspended for Greece in + May 2010 so the published schedule did not apply as written, and Greek repo left LCH + clearing. Best available is a counterfactual schedule used as an instrument. +- *Acharya-Steffen equity regression* — cross-section IS in `data/` (90 bank columns) but the + equity returns are not, and many EBA banks are unlisted. Multi-week data project. + +That blockage is why **`psi_lambda_B = 0` is now the baseline** rather than a fitted value. The +old practice — calibrate the dial to the 150bp spread, then use it to argue that ~91% of that +spread is a pledgeability friction — was the calibration restated as a finding. It is gone. The +spread is now whatever the corrected payoff and the GK portfolio FOC deliver (205.9 bp on a 1pp +shock), and the gap to the 150 bp empirical moment is a **shock-calibration** question, not a +reason to reintroduce a wedge. The 1pp shock was itself sized under the old, 12.6×-too-small +priced loss; a smaller default-probability innovation is the honest lever if the moment is +wanted back, and it must be argued on its own evidence. + + **Country-size asymmetry is live: `size_F = 11.697`.** Every F variable is per F capita; every D variable is a D aggregate. Both EBA cross-border moments — portfolio composition and market structure — now hold jointly for the first time. `psi_lambda_B` re-tuned **2.92 -> 3.01** to hold the 150bp spread moment (149.93bp). Sticky prices with nominal deposits remain the baseline; 27x27 solver; `rho_def = 0.9408`; EBA calibration LIVE (`EBA_CALIBRATION=True`, `BANK_SCOPE="broad"`). **E1-E4 and all paper artefacts are STALE** — regenerate before quoting anything. + +## Country-size asymmetry — 2026-08-07 (`fix-cross-border-units`) + +**What was wrong.** `Y_D_ss = Y_F_ss = 1` made Greece and Germany the same size, while every +EBA moment is a ratio to its own country's net worth. Cross-border stocks built as +`phi * n_holder / q` landed in the holder's units, so the model could satisfy + +| moment | value | model, pre-fix | +|---|---|---| +| composition — `phi_bD_F` (DE banks' GR book / DE bank NW) | 0.0075 | 0.0075 (matched) | +| market structure — foreign share of bank-held GR stock | 0.1272 | **0.0125** | +| market structure — foreign share of bank-held Bund stock | 0.0013 | **0.0150** | + +but never composition and market structure together: joint consistency requires +`n_F/n_D = 8.85` against the model's 0.761, a gap equal to the Germany/Greece GDP ratio +(11.697). Note `omega_pi_D = 0.071` already carried the size asymmetry on the nominal side +(capital key); quantities never did. + +**The fix.** `size_F = 11.697`, from Eurostat 2010 annual GDP in `data/eba_moments.json` +(`raw_EURm.GDP_ann_F / GDP_ann_D`), read by `calibration.load_eba_size_ratio()`. + +> **Convention — the thing to remember.** Every F-side variable is **per F capita**; every +> D-side variable is a **D aggregate**. A per-capita F quantity meeting a D aggregate takes +> `* size_F`; a D aggregate meeting a per-capita F quantity takes `/ size_F`. + +It appears in exactly seven places: `trade_balance`, `external_account_D`, +`global_goods_mkt`, `domestic_bond_clearing` (`equations_global.py`) and +`domestic_bond_clearing_tpi`, `external_account_D_tpi`, `budget_residual_F_tpi` +(`code/tpi.py`). `omega` split into `omega_D = 0.85` / `omega_F = 0.98717` by +`size_F*(1-omega_F) = (1-omega_D)`. + +**A TPI bug this exposed, present since the block was written.** `rem_cb_F` paid the +ECB's D-aggregate cash flow into F's per-capita budget. At equal country size the missing +weight was exactly 1.0 — invisible, `goods_mkt_F` at 2e-10 — so it passed every check the +repo ran. Under `size_F` it leaked **1.98e-2 of F GDP at gamma=10**, growing in gamma, +while gamma=0 stayed clean. **Every TPI welfare and P&L number computed before 2026-08-07 +carries this error, increasing with deployment.** Now 2.06e-10..2.12e-10 across the grid. + +**Post-fix verification** (`code/main.py`, exit 0): + +| check | value | threshold | +|---|---|---| +| foreign share, GR stock | 0.1274 | EBA 0.1272 | +| foreign share, Bund stock | 0.001298 | EBA 0.001301 | +| `phi_bD_F` | 0.0075 | exact by construction | +| `K_D` / `K_F` over-ident. | 10.800 / 10.824 | 10.8 | +| `goods_mkt_D` / `goods_mkt_F` | -4.25e-07 / -4.19e-07 | <= 1e-7 order | +| `ca_res_D` | -2.78e-17 | <= 1e-7 | +| `max abs(goods_mkt_F)` over gamma | 2.06e-10 .. 2.12e-10 | was 5.5e-3 .. 2.0e-2 | +| IC residual D / F | -8.9e-16 / 0.0 | machine zero | +| `n_inter_D[0]` / `Y_D[0]` | -6.7366% / -0.8521% | both negative | +| peak spread | 150.0 bp | 150.14 bp moment | + +**Reported outputs.** Loading schedule 5.60 / 5.43 / 5.18 at gamma = 2/5/10; spread +compression 22.4% at gamma=10. + +**TPI is materially less effective than before these fixes.** The named-regime +construction solves gamma for fixed peak-spread compression targets, and those gammas +moved by roughly 8x: + +| target | gamma before | gamma now | +|---|---|---| +| 25% compression | ~1.6 | ~13 | +| 50% compression | ~5.1 | ~34 | + +Measured on `cache_G_main_v3_psilam3p01` (passive peak 149.9bp). The 50% target stopped +bracketing on the old `[0,25]` search interval, which aborted `experiments/run_all.py` +outright; `lottery_math.gamma_for_compression` now uses `hi=40`. **Any gamma or +compression figure quoted from before 2026-08-07 is wrong by roughly this factor.** + +**There is a closed-loop singularity at gamma ~ 42** — `I - gamma*A_cb` going +near-singular — with a separate solution branch beyond it: + +``` + gamma 0 10 20 30 40 40.5 42 42.5 + peak bp 149.9 116.2 94.4 79.3 69.6 69.6 538.8 57.0 +``` + +`peak(gamma)` declines monotonically to 53.6% compression at gamma=40, turns at ~40.5, +and blows up at ~42. **A coarse scan sampling 40 and 50 reads the two branches as one +smooth decline and hides the pole entirely** — that error was made and caught during +this session. `hi=40` keeps the search inside the monotone region; the 61-point +monotonicity check in `gamma_for_compression` is what catches any recurrence. + +**Consequence worth stating in the paper:** the aggressive (50%-compression) regime now +sits ~8 gamma-units below that singularity, where before these fixes it was at +gamma~5.1 with the pole far away. How much intervention the model can represent has +become a live constraint on the policy experiment rather than a formality. + +F-side responses are now an order of magnitude smaller — +`rdep_F` -2.5bp (was -17.1bp), `n_inter_F` +0.14% (was +1.20%), `Y_F` +0.062% — which is +the expected consequence of a Greek shock hitting an economy 11.7x larger. Conversely, +core absorptive capacity now matters much more to Greece: freezing `b_D_F` costs `Y_D` +-0.83% -> -1.32% and `C_D` -0.77% -> -1.58%. + +### Open items + +- **F is Germany, not the core aggregate.** The EBA sample is GR + DE by construction + (`data/eba_moments.json` `meta.country_map`), so widening F to the core euro area needs + French/Dutch/Belgian sovereign holdings the moment file does not contain. Until then + `size_F` and the foreign shares are Germany-specific and understate core exposure. +- **Foreign banks still do not retrench.** On a 1pp default shock `b_D_F` rises rather + than falls, contrary to the 2010-12 record and to Bi-Foerster-Traum's benchmark (their + Foreign intermediary cuts Italian holdings ~2% of GDP; FRBSF WP 2025-10, Figure 4). The + size asymmetry shrank the impact response by an order of magnitude to a near-miss but + did not flip the sign; from t~4 the MTM term dominates outright. See the FOC + decomposition below. +- **E1-E4 and all paper artefacts are stale** and must be regenerated in the documented + order before any number is quoted. + + +## Fiscal rule and fiscal limit: what is identified — 2026-08-07 + +Two audits of existing mechanisms, prompted by the question of whether the fiscal +block could generate foreign retrenchment without adding wedges or shocks. Neither +adds anything to the model. Both change what can be *claimed* about it. + +### `phi_lamb_D` (Bohn rule) — sweep on the live par rule + +Full re-solve per point, 1pp default shock, everything else at the live calibration. + +| `phi_lamb_D` | peak `b_gov_D` | t | endog. default | `b_D_F`[0] | `b_D_F` pk | `b_D_D` pk | spread | `Y_D`[0] | `b_gov_D`[499] | +|---|---|---|---|---|---|---|---|---|---| +| 0.15 (live) | +1.513% | 3 | 0.199pp (19.9%) | +0.2707% | +5.12% | +1.53% | 149.9bp | -0.852% | 7.1e-05 | +| 0.10 | +1.829% | 4 | 0.240pp (24.0%) | +0.2005% | +6.07% | +1.76% | 161.9bp | -0.871% | 3.3e-04 | +| 0.07 | +8.767% | 119 | 1.148pp (114.8%) | -0.7206% | +22.67% | +6.74% | 314.0bp | -1.670% | **-1.1e-01** | +| 0.05 | -4.253% | 119 | 0.552pp (55.1%) | -0.1731% | +9.69% | -3.56% | 690.3bp | -1.089% | **+1.6e-01** | + +**Findings.** + +1. **The stability boundary is between 0.10 and 0.07** — not 0.05. `b_gov_D`[499] jumps + three orders of magnitude and alternates sign; peak debt migrates to t=119 (no + interior peak); at 0.05 the endogenous default contribution *falls* while the + spread doubles and peak debt goes negative, the documented sign-flip signature of + a crossed pole. +2. **The pipeline's printed `rho_b` gate is unreliable.** It predicts a floor of 0.05. + It is labelled partial-equilibrium and omits `def_scale_D`, the channel that does + the amplifying, so it is systematically optimistic — by enough that using it to + pick `phi_lamb_D` lands on a divergent calibration. +3. **Retrenchment appears only in the divergent region.** `b_D_F` goes negative at 0.07 + and 0.05 and nowhere else. Within the stable region the impact response moves the + right way but far too little (+0.271% -> +0.201%) while the medium-horizon peak moves + the *wrong* way (+5.12% -> +6.07%). **There is no stationary calibration of the Bohn + rule that produces retrenchment.** +4. `Y_D`[0] is insensitive to the rule across the stable range (-0.852% to -0.871%). The + parameter matters for regime and spread, not for the headline output number. +5. **`phi_lamb_D` = 0.15 is therefore not "3x more aggressive than needed"** (an earlier + claim in this file's history, derived from the `rho_b` gate). It carries ~1.5x margin + over the true floor — a normal safety factor. + +### `mv_rule_D` = 0 (par debt gap): why, and what it means + +The par and market-value gaps move in **opposite directions** in a crisis, because +`q_b_D` falls 4.5% while face value rises only ~1.5%: + +``` + par gap positive 39/40 quarters -> the rule TIGHTENS + mv gap negative 40/40 quarters -> the rule LOOSENS, peak |mv| = 2.88x peak |par| +``` + +Under `mv_rule_D` = 1 the treasury reads a wider spread as a mark-to-market windfall +and *cuts* `T_ls_D` by 0.0075 (0.75% of quarterly GDP) at impact. That is why the +market-value variant needed `phi_lamb_D` = 0.60 to stay stationary — it was fighting +its own perverse sign. + +**Justification the code previously lacked:** EU fiscal surveillance defines general +government debt at **nominal face value** (Maastricht), explicitly not marked to market, +so the par rule is what the framework Greece was actually subject to keys off. It is +also the economically correct one for an issuer that must roll at the new yields. + +**The "market-value rule REQUIRED / `mv_rule_D` = 0 explodes" comment was stale** — it +entered at `c6230a2` under the CT1 scope where `phi_bD_D` = 2.39; `988c213` moved to +`BANK_SCOPE="broad"` where that moment is 0.456, a 5.2x weaker doom loop. Retired. + +### Fiscal limit `def_scale_D` — estimated (`Empirics/fiscal_limit.py`) + +Mirrors Bi-Foerster-Traum eq (3.1), `log(P/(1-P)) = eta0 + eta_s * s(-1)`, with `P` the +quarterly default probability implied from the Greek-Bund spread via the credit +triangle at the model's own `recovery_rate_D` = 0.30, and `s` = Eurostat `gov_10q_ggdebt` +Maastricht debt / annual GDP. + +| sample | eta0 | eta_s | R2 | n | implied `def_scale_D` | +|---|---|---|---|---|---| +| pre-crisis 2000Q1-2009Q3 (BFT design) | -18.35 (2.55) | 10.92 (2.36) | 0.373 | 38 | 0.0067 | +| **pre-OMT 2000Q1-2012Q3 (best fit)** | **-14.80 (0.55)** | **7.67 (0.47)** | **0.849** | **50** | **0.63** | +| full 2000Q1-2026Q1 | -9.13 (0.45) | 2.67 (0.29) | 0.449 | 104 | 0.04 | +| crisis 2009Q4-2013Q4 | -6.87 (1.41) | 2.29 (0.91) | 0.295 | 17 | 0.19 | +| *BFT, Italian pre-crisis CDS* | *-10.70* | *5.25* | — | — | — | + +**The post-2012 sample must be excluded, and the reason is itself a result:** + +``` + 2010Q1-2012Q3 debt 152.1% of GDP spread 13.38pp + 2013Q1-2019Q4 debt 181.1% of GDP spread 6.28pp + 2020Q1-2026Q1 debt 176.7% of GDP spread 1.43pp +``` + +Debt higher, spread ~9x lower: OMT severed the debt-spread link. Estimating the fiscal +limit on post-2012 data builds the effect of the policy under study into the parameter +meant to measure fundamentals. + +**Decision: `def_scale_D` stays at 0.25.** It sits inside the estimated range +(0.04-0.63) and near the crisis-sample value (0.19). The best-fitting pre-OMT estimate +(0.633) is **not usable**: at that value the amplification dial is not continuously +calibratable — + +``` + psi_lambda_B 1.9505 -> 123.9bp stable | 2.0000 -> 1730.6bp DIVERGENT + 2.1000 -> 130.4bp | 2.2000 -> 143.8bp (b_gov[499] -4.8e-03, marginal) + 2.3381 -> 1908.1bp DIVERGENT (n_inter_D[0] -74%, endog 902% of shock) +``` + +The 150bp moment would be reached near 2.25, within 0.09 of a divergence, with another +at 2.0 below it. **The model needs a flatter fiscal limit than the best-fitting sample +implies in order to leave room for the collateral friction.** + +**Curvature is qualitatively wrong but not identified at first order.** +`def_curvature_D` = 0.5 is *concave* (marginal sensitivity falls with debt); every sample +estimates *convex* (3.9-10.9), which is what a fiscal limit should be. Because the model +is solved to first order, only the steady-state slope enters the dynamics and +`def_scale_D`/`def_curvature_D` are not separately identified — so this affects how the +mechanism is described, and would matter in a nonlinear solve (as in BFT's second-order +regime-switching solution), but not the IRFs. + +**Caveats.** Scope conversion assumes bank-held debt moves proportionally with total +(`sigma` = 0.195, pinned so the model SS equals the EBA base-date debt ratio); in 2010-12 +it did not. Spread-implied probabilities are risk-neutral — BFT inherit the same wedge +from CDS. The model's SS has zero default risk at a debt ratio that in the data carried +a great deal, so only the slope can be matched, not the level. + +### Benchmark check: BFT do not calibrate their fiscal rule either + +*"We set the response of lump-sum taxes to debt to `phi_T` = 3 **to ensure stability of +the debt path**"* (FRBSF WP 2025-10, calibration section). What they **do** estimate is +the fiscal limit, from Italian CDS. Two structural differences explain why their rule +can be weaker: their default is **realised** (`Delta_t = delta_b` writes the obligation +down when the limit is breached) and their default probability is **logistic**, i.e. +bounded. With `writeoff_enabled_D` = 0 the tax rule is this model's **only** debt- +stabilising device, which is why its required strength is high. That is a consequence of +the S-1 framing choice and should be stated as such. + + +## `rho_def` disciplined by the MS regime estimate — 2026-08-06 + +**What changed.** The sovereign-risk shock's persistence was **hardcoded at `rho_def = 0.80` +in `code/full_model.py`** (alongside `rho_Z_D = 0.8`), i.e. buried in the solve driver rather +than stated as a calibration choice. Both are now calibration entries +(`code/calibration.py`, section *Shock processes*), read via +`calibration_start.get(..., 0.8)` so an older calibration dict still runs. `rho_Z` is +**deliberately left at 0.80** — the estimate below speaks to sovereign spreads only. + +### The estimate that disciplines it + +`Empirics/outputs/ms_regime_GRC.npz` — a three-state Markov-switching fit to **monthly** +Greek–Bund spreads, 348 observations, 1997-06 to 2026-06: + +| state | mean spread (pp) | monthly persistence `P[i,i]` | expected duration (months) | ergodic share | +|---|---|---|---|---| +| calm | 0.380 | 0.98734 | 79.0 | 0.362 | +| stress | 2.054 | 0.97572 | 41.2 | 0.392 | +| **crisis** | **9.632** | **0.9798499** | **49.6** | 0.245 | + +The crisis state is the object the model's default shock represents. Its monthly persistence +is `0.9798499312312894`, so the quarterly equivalent is + +``` +0.9798499312312894 ** 3 = 0.9407596880708793 -> rho_def = 0.9408 +``` + +with an implied quarterly duration of `1/(1−0.9408) = 16.9 quarters`. The realised Greek +episode ran **2010-04 to 2017-12 = 92 months**, which is longer still. + +**What the old value implied.** `rho_def = 0.80` inverts to a monthly persistence of +`0.80^(1/3) = 0.9283` and an expected duration of **13.95 months** — a 14-month crisis. That +is roughly a quarter of what the repo's own estimation says, and it was the **binding +constraint on how long the contraction lasted**, not crisis size: a prior sweep holding peak +spread pinned at 150bp moved cumulative 40-quarter `Y` from −0.049 (`rho=0.80`) to −0.784 +(0.90), −2.021 (0.95) and −2.485 (0.98), with the spread staying above half-peak until q3 / +q6 / q10 / q15 respectively. + +### Re-tuning `psi_lambda_B` + +A more persistent shock raises the peak spread for a given amplification, so holding the +150bp GR–DE peak-spread moment required re-bisecting the one amplification dial. **Every row +below is a full pipeline re-solve** at `rho_def = 0.9408`: + +| `psi_lambda_B` | peak spread (bp ann.) | note | +|---|---|---| +| 7.85 | **470.62** | old value at the new persistence — 3.1× the target | +| 2.73 | 139.60 | | +| 2.8909 | 148.50 | | +| 2.9181 | 149.99 | | +| **2.92** | **150.09** | **ADOPTED** (0.09bp from target) | + +Sanity anchor: the same harness at `psi_lambda_B = 7.85, rho_def = 0.80` reproduces +**150.14 bp**, `Y_D[0] = −0.5064`, `C_D[0] = −0.5103`, `I_D[0] = −1.0114`, +`n_inter_D[0] = −4.2962`, `b_gov_D[499] = 4.63e−05` — bit-for-bit the recorded baseline. + +**METHOD WARNING — do not sweep this dial by patching the steady state.** The obvious +shortcut is to patch `psi_lambda_B_D/F` and `psi_spread_D/F` onto an already-solved SS and +re-solve only the Jacobian, on the grounds that the SS is `psi_lambda_B`-neutral. **The SS +premise is true** (`goods_mkt_D = −4.2493506589857954e−07`, `K_D = 10.800000000000002`, +`beta_D = 0.9995349920563089` are bit-identical at every `psi_lambda_B` tested) **and the +conclusion is still wrong.** The shortcut reproduces the baseline exactly at the *unpatched* +value but drifts off it: it predicted 150.33bp at `psi_lambda_B = 2.73` where the real +pipeline gives **139.60** — a 7% error, always in the same direction, because only the +`divert_bond_foc_D` `psi_spread` loading picks the patch up and the `intermediation_IC_D` +`Delta_bD_eff = Delta_bD_D + psi_lambda_B_D·def_rate_D(+1)` collateral channel does not. The +first bisection run here was thrown away for exactly this reason. **Re-solve the pipeline per +point** — it is only ~2 minutes each. + +### PAPER-LEVEL CONSEQUENCE — the default-loading split moves + +Total default loading per unit of default probability is `EL_price_D + psi_spread_D` from the +bond-pricing FOC. `EL_price_D` does not depend on `psi_lambda_B`; `psi_spread_D = +lambda_gk_D · psi_lambda_B_D / (beta_inter_D · Omega_D)` is **linear** in it. + +| | `psi_lambda_B = 7.85` | `psi_lambda_B = 2.92` | +|---|---|---| +| `EL_price_D` (fundamental) | 0.056134 | 0.056134 | +| `psi_spread_D` (collateral friction) | 1.604839 | **0.596959** | +| total loading | 1.660973 | **0.653093** | +| **fundamental share** | **3.38%** | **8.60%** | +| **friction share** | **96.62%** | **91.40%** | +| friction : fundamental | 28.59 : 1 | **10.63 : 1** | + +**The constrained-seller claim survives but is quantitatively weaker.** The paper has been +using 3.4% / 96.6% as a *strong* version of the claim that the Greek spread was overwhelmingly +a constrained-seller phenomenon rather than a fundamental default-risk phenomenon. At +~11:1 rather than ~29:1 the claim is still decisive in direction — the friction is an order of +magnitude larger than the fundamental — but "essentially all of it" is no longer defensible; +"roughly nine tenths of it" is. **`experiments/paper_outputs.py`'s `fig04_spread_decomposition` +prose must be re-derived again** (it was last re-derived to 3.4% / 96.6% in Task 17). + +Note this is the *honest* direction for the move: the amplification dial was doing less work +because the persistence, previously understated, is now carrying part of the load. + +### Impulse response — the persistence problem is largely fixed + +Both columns hit the same 150bp peak-spread moment, so this is like-for-like. + +| | baseline (`psi=7.85`, `rho_def=0.80`) | **new (`psi=2.92`, `rho_def=0.9408`)** | +|---|---|---| +| peak spread (bp ann.) | 150.14 | **150.09** | +| `Y_D[0]` (% SS) | −0.5064 | **−0.7502** | +| `C_D[0]` (% SS) | −0.5103 | **−0.7014** | +| `I_D[0]` (% SS) | −1.0114 | **−1.7107** | +| `n_inter_D[0]` (% SS) | −4.2962 | **−6.2710** | +| cumulative `Y_D`, 40q | −0.0492 | **−2.5420** (51.7×) | +| cumulative `Y_D`, 20q | — | −1.4818 | +| negative-`Y` quarters in first 40 | 5 | **37** | +| spread ≥ half-peak until | q3 | **q11** | +| `b_gov_D[499]` (default shock) | 4.63e−05 | **2.04e−05** | + +First 12 quarters, % of own SS: + +| q | `Y_D` | `C_D` | `I_D` | `n_inter_D` | spread (bp) | +|---|---|---|---|---|---| +| 0 | −0.7502 | −0.7014 | −1.7107 | −6.2710 | 150.09 | +| 1 | −0.1337 | +0.1311 | −1.0465 | −2.7050 | 144.26 | +| 2 | +0.0115 | +0.2748 | −0.6915 | −0.3823 | 138.13 | +| 3 | +0.0264 | +0.2426 | −0.4857 | +1.0918 | 131.31 | +| 4 | +0.0111 | +0.1805 | −0.3611 | +1.9968 | 124.06 | +| 5 | −0.0068 | +0.1257 | −0.2843 | +2.5242 | 116.67 | +| 6 | −0.0211 | +0.0843 | −0.2369 | +2.8034 | 109.40 | +| 7 | −0.0313 | +0.0545 | −0.2079 | +2.9212 | 102.38 | +| 8 | −0.0382 | +0.0334 | −0.1903 | +2.9353 | 95.71 | +| 9 | −0.0429 | +0.0183 | −0.1798 | +2.8844 | 89.44 | +| 10 | −0.0460 | +0.0074 | −0.1735 | +2.7937 | 83.58 | +| 11 | −0.0481 | −0.0005 | −0.1697 | +2.6801 | 78.12 | + +And the tail: `Y_D` = −0.0517 (q15), −0.0528 (q19), −0.0533 (q23), −0.0534 (q27), −0.0532 +(q31), −0.0527 (q35), −0.0520 (q39). **`I_D` is negative at every one of the first 40 +quarters** (−1.71 → −0.099). + +**Signs (Step 4c) all survive:** `n_inter_D[0] = −6.2710`, `Y_D[0] = −0.7502`, +`C_D[0] = −0.7014`, `I_D[0] = −1.7107` — all negative. The consumption sign flip, the headline +result of the `add-nkpc` workstream, is intact and roughly 37% larger. + +### Honest reading of what this does and does not fix + +- **Fixed:** cumulative output loss, which was the real complaint. −0.049 → −2.542 over 40 + quarters; output is negative in **37 of the first 40 quarters**; the spread stays above + half-peak to q11 instead of q3. +- **NOT fixed:** the shape at the short end. `Y_D` still turns *marginally* positive at + q2–q4 (+0.0115, +0.0264, +0.0111 — three quarters, all under +0.03% of SS) before going + negative again from q5 and staying there. So it is a shallow, long, persistently negative + path with a small early blip, not a monotone Bi–Foerster–Traum bust. **Issue I-1 is + substantially, not completely, resolved** — and note the fix was *neither* of the two + capital-adjustment frictions I-1 tested and rejected. `chi1` and `omega_I` stay at 0. +- `n_inter_D` still rebounds to positive (+1.09 by q3, peaking +2.94 at q8). That rebound is + now *larger*, not smaller, and remains the most promising next hypothesis if a deeper + persistent bust is wanted. + +### Stability verdict — PASSES, and moves *away* from the unstable region + +Full `code/main.py`: + +- Steady state **bit-identical**: `goods_mkt_D = -4.2493506589857954e-07`, + `goods_mkt_F = -4.1914559989475464e-07`, `ca_res_D = 6.852157730108388e-17`, + `IC_D: θ − θ_tgt = 1.776357e-15`. `All residuals < 1e-8 ✓` +- `b_gov_D[499]` on the default shock **fell** 4.63e−05 → **2.04e−05**; on the TFP shock + 1.48e−06. A more persistent shock was the genuine stability risk here and it did not + materialise, because the re-tune moves `psi_lambda_B` *down*, away from the documented + high-`psi_lambda_B` breakdown region rather than toward it. +- `ρ_b (partial-eq.) = 0.8451` (target < 0.95), unchanged. +- No `assert_gk_well_posed` failure. +- All four TPI gammas converge: `max|ca_res_D|` ≤ 6.39e−08, `max|goods_mkt_F|` ≤ 1.73e−09, + both inside 1e−07. `G_tpi[cb=0]` vs baseline `G` = 0.00e+00; γ=0 vs `irfs_def_D` = 0.00e+00. +- Sign check at γ=0: `n_inter_D[0] = −1.341e−01`, `Y_D[0] = −7.502e−03`, both negative. + +### TPI loading schedule — still monotone decreasing, still above 1 + +The regime cache was **not** rebuilt (it would have to precede `experiments/run_all.py`, and +that is a ~45-minute chain on top of this change), so these are the loadings `code/main.py` +itself prints, not E1's solved named regimes: + +| γ | peak spread (pp) | peak exposure | EL PV | prem PV | **loading** | +|---|---|---|---|---|---| +| 0 | +0.375 | 0.000% | 0.0000% | 0.0000% | n/a | +| 2 | +0.311 | 0.602% | 0.0036% | 0.0200% | **5.55** | +| 5 | +0.243 | 1.176% | 0.0074% | 0.0396% | **5.37** | +| 10 | +0.177 | 1.715% | 0.0112% | 0.0574% | **5.13** | + +**Live Claim 5 (self-extinguishing premium) and Live Claim 1 (over-compensation, loading > 1) +both survive** — 5.55 → 5.37 → 5.13 is monotone decreasing and comfortably above 1. Note the +loading *rose* relative to the old schedule even though `psi_spread/EL_price` fell from 28.6 +to 10.6: the theoretical small-γ limit `1 + psi_spread/EL_price` is now 11.6 rather than +29.6, but the realised loading is dominated by the shock's persistence, which lengthens the +premium stream the ECB collects relative to the expected loss it bears. Welfare moves the +same way as before (`ΔW_D` = +0.0209 / +0.1005 / +0.3562; `ΔW_F` = −0.0323 / −0.1041 / +−0.2673). + +**STALE ARTEFACTS — must be regenerated before any paper output is trusted.** In order: +`diagnostics/regimes/regime_model.py --force`, then `experiments/run_all.py`, then +`experiments/e4_distribution.py` and `experiments/paper_outputs.py`. Every number in +`docs/experiments_results.md`, `docs/paper_draft_results.md` and the eight tracked +`experiments/paper/fig0*.png` currently reflects `psi_lambda_B = 7.85` / `rho_def = 0.80`. + +## Open issue I-1: output is negative for only ONE quarter (2026-08-06) + +> **UPDATE 2026-08-06 — substantially resolved by `rho_def = 0.9408`, see the section above.** +> Output is now negative in 37 of the first 40 quarters and cumulative 40-quarter `Y` is +> −2.5420 rather than −0.0492. The residual defect is a small positive blip at q2–q4 (all +> under +0.03% of SS). The diagnosis below — that the problem was *not* a missing investment +> friction — was correct: the fix was the shock process, not the capital block. `chi1` and +> `omega_I` stay at 0. Everything below is retained as the record of the two rejected +> hypotheses; **do not re-test either.** + +**The symptom.** On the default shock `Y_D` = −0.5064, −0.0026, **+0.0929**, +0.0829, +0.0548, ++0.0309, … — one quarter of contraction, then a positive hump. Bi–Foerster–Traum keep output +negative for ~20 quarters. `I_D` = −1.0114, −0.2671, then a sustained boom peaking **+0.3324 +at q5**, and `n_inter_D` rebounds to **+3.72% by q4**. The paper cannot claim a persistent +sovereign-risk contraction on this path. + +**Two hypotheses tested, both rejected.** + +1. **Intermediary capital adjustment cost `chi1`** (earlier diagnostic). Raising it makes the + impact trough *deeper* AND the rebound *larger* — penalising capital **growth** shifts the + burden from investment onto consumption. `chi1` stays at 0. Do not revisit. +2. **Investment-flow adjustment cost `omega_I`** (2026-08-06, this entry). Now **implemented + and live in the equations**, `S(I/I(-1)) = (omega_I/2)(I/I(-1)-1)^2` in `capital_adj_D/F`, + but **calibrated to 0**. It smooths investment as designed and still fails to produce + persistence — see the sweep below. + +**`omega_I` sweep on the default shock.** Steady state **bit-identical at every value** +(`K_D = 10.8000000000`, `beta_D = 0.999534992056`), as the `S(1) = S'(1) = 0` construction +guarantees. + +| `omega_I` | `Y_D[0]` % | `Y_D` trough % | contiguous neg. quarters | cum. `Y_D` (40q) | `I_D[0]` % | `I_D` peak boom % | peak spread | +|---|---|---|---|---|---|---|---| +| **0 (live)** | −0.5064 | −0.5064 | 2 | −0.0492 | −1.0114 | +0.3324 | 150.1 bp | +| 2 (BFT value) | −0.0287 | −0.0483 | 3 | **+0.2097** | −0.3786 | +0.2573 | 163.7 bp | +| 5 | **+0.0486** | −0.0078 | **0** | +0.3001 | −0.2201 | +0.2082 | 167.2 bp | +| 10 | **+0.0854** | −0.0015 | **0** | +0.3697 | −0.1290 | +0.1748 | 168.4 bp | + +The friction works — impact investment drop falls monotonically −1.01 → −0.13, q5 boom +0.33 +→ +0.17 — but it **shrinks the contraction toward zero instead of lengthening it**. +`omega_I = 2` buys exactly one extra negative quarter for an impact trough 18× shallower and a +cumulative 40-quarter `Y` response that flips **positive**. At `omega_I >= 5`, `Y_D[0]` is +positive, which trips the sign check in CLAUDE.md *Typical iteration* step 4. + +**Why both failed the same way.** `C_D[0]` moves +0.5103 → +0.1092 → +0.2276 across +`omega_I` = 0, 2, 10. Sluggish investment relaxes the household budget rather than destroying +resources, so consumption absorbs whatever investment does not. Both candidate frictions +merely **reallocate the impact between `I` and `C`**; neither deepens or extends the aggregate +contraction. **Conclusion: the persistence problem is not a missing investment friction.** The +`n_inter_D` rebound to +3.6% by q5 — which is *larger*, not smaller, at every positive +`omega_I` — is the more likely engine and is where the next hypothesis should go. + +**Author decision pending:** keep `omega_I = 0` (current) or adopt a positive value. Adopting +one also requires re-tuning `psi_lambda_B`, since peak spread drifts 150.1 → 163–168 bp off the +150 bp target. + +### `omega_I` implementation notes + +- `capital_adj_D/F` take `omega_I_D/F` and **`beta_D/F`, not `SDF_D/F`**. Two reasons, and the + first makes the second free: `S'(1) = 0`, so the intertemporal term multiplies a factor that + is zero at SS and only `SDF_ss = beta` survives linearisation — **first-order exact**, the + same argument `price_nkpc_D/F` already uses for `pi_ss = 0`. And it is *required*: taking + `SDF_D` makes SSJ's topological sort fail with `hh_D -> capital_fund_D -> capital_adj_D -> + sdf_D -> ghh_composite_D -> hh_D`. A test asserts `SDF_*` is not an input. +- `q_res` was rewritten from `Q - 1/mpi` to `Q*mpi*[...] + beta*[...] - 1`. Same root; the two + differ by the factor `mpi`, an exact constant row scaling of the target at first order, so + `-H_U^{-1}H_Z` and hence the whole linearised solution is invariant. +- **Equivalence gate:** `omega_I = 0` reproduces the pre-change model to **1.08e-13** worst + relative deviation over all 45 `dump_irfs.py` arrays. Reference regenerated at `231327c` + immediately before the edit — the older `/tmp/nkpc_irfs_nominal.npz` is **stale** (predates + the `psi_lambda_B` 8.5 → 7.85 re-tune, differs by 1.56) and must not be used. + +## Regeneration on the MS-disciplined shock (2026-08-06) — Live Claim 5 WEAKENED + +Cache, E1-E4 and all paper figures rebuilt at `rho_def = 0.9408`, `psi_lambda_B = 2.92`. +E2 closes at 1.1e-16 against its 1e-7 assertion. `fig07_ms_regimes` correctly +unchanged (estimated from market data, model-independent). + +### The loading schedule — read this before quoting Live Claim 5 + +| | before (`rho`=0.80, `psi`=7.85) | after (`rho`=0.9408, `psi`=2.92) | +|---|---|---| +| schedule over γ ∈ [0.51, 30] | 4.43 -> **1.49** | 5.65 -> **4.59** | +| decline | **−66%** | **−19%** | +| monotone decreasing | YES | YES | +| minimum | **1.49** | **4.59** | + +**Live Claim 1 (over-compensation, loading > 1) is STRONGER** — the floor rises +from 1.49 to 4.59, so it no longer depends on where on the schedule you look. + +**Live Claim 5 (self-extinguishing premium) is MATERIALLY WEAKER.** The claim +worked because loading fell *toward 1*: intervene hard enough and the ECB earns +roughly the actuarially fair expected loss. It now bottoms at 4.6x. Monotonicity +survives, the economic punchline does not in its present form. A longer crisis +accumulates premium over more quarters than expected loss, so the ratio stays +high even under heavy intervention. **Do not write "self-extinguishing" without +re-deriving what the 19% decline supports.** + +### E1 regime table + +| regime | γ | peak spread bp | `Y_D[0]` | `C_D[0]` | `I_D[0]` | `n_inter_D[0]` | loading | +|---|---|---|---|---|---|---|---| +| passive | 0 | 150.1 | −0.7502 | −0.7014 | −1.7107 | −6.271 | n/a | +| medium | 3.1033 | 112.6 | +0.1834 | +0.7441 | −0.8688 | −2.922 | 5.48 | +| aggressive | 8.9732 | 75.0 | +1.1336 | +2.2400 | −0.0508 | +0.469 | 5.17 | + +Investment is now negative even under the aggressive backstop (−0.0508, was ++0.3977), and the aggressive-backstop net-worth boom is milder (+0.469, was ++0.924) while its consumption effect is larger (+2.24, was +1.51). + +## Nominal rigidities (`add-nkpc`) — COMPLETE (Tasks 1–16, 2026-08-05/06) + +The model went from flexible prices with real deposit contracts to **sticky prices with +nominal deposit contracts**, and every downstream result was regenerated. This one section +replaces the sixteen per-task sections that previously accumulated here; the per-task detail +is in `docs/PROGRESS.md` and in the commits. + +### What the model now is + +**Sticky prices.** Rotemberg price Phillips curves `pi = beta*pi(+1) + kappa_p*(mu_p*mc - 1)` +in both countries (`price_nkpc_D/F`). The gap is written as the *ratio* `mu_p*mc - 1`, not a +level difference, so it is unit-free and linearises to exactly `mc_hat` for any `mu_p` — +published Calvo slopes map straight onto `kappa_p` with no SS rescaling, and `mu_p` is a free +normalisation to first order. Discounted at constant `beta_D/F` rather than the SDF, which is +immaterial to first order because `pi_ss = 0`. + +**Markup wedge in labour demand.** `labor_demand_D/F` now solve `w = mu_p*mc*(1-alpha)*Y/N` +in place of the competitive `w = (1-alpha)*Y/N`. This is the point of the whole exercise: +previously, flexible labour supply combined with competitive labour demand eliminated `Y` +entirely and left `N` pinned by `Z`, `K` and `P_CES` alone, with **nothing for aggregate +demand to act on**. With the wedge, a move in `mc` shifts labour demand, so `N` and `Y` move +together. **Wages stay flexible** — `labor_market_D/F` is untouched. + +**Markup rent.** `firm_profit_D/F` compute `profit = (1 - mu_p*mc)*(1-alpha)*Y`, the residual +left over once labour is paid `mu_p*mc*(1-alpha)*Y` while capital keeps `alpha*Y`. Left +unrouted this would be a Walras leak of the W-1/W-2 class. It is distributed to households +**in proportion to productivity `e`**, not lump-sum, through `income_D/F` — which makes +labour-plus-profit income per unit of `e` exactly `(1-alpha)*Y*e`, identical to the +flex-price model (max abs diff 2.2e-16). So the markup wedge bites only on the firm's hiring +decision, never on household income, and `labor_market_D/F` needs no change. + +**Nominal closure with no policy rate.** `terms_of_trade` (global) turns the monetary-union +identity `p/p(-1) = (1+pi_F)/(1+pi_D)` into a residual on `p`, an unknown that already +existed: the nominal exchange rate is fixed at 1, so terms-of-trade movement *is* the +inflation differential, and this pins `pi_D − pi_F` off existing plumbing. `union_inflation` +supplies the missing level normalisation, `omega_pi_D*pi_D + (1-omega_pi_D)*pi_F = 0` — the +`phi_pi -> inf` limit of an ECB Taylor rule on union-wide PPI inflation, stated explicitly as +an abstraction rather than a modelled policy rule. **No financial contract in this model +carries a policy rate**, so no Fisher relation is needed to close it. Solved together: +`pi_D = -(1-omega_pi_D)*dlog p`, `pi_F = omega_pi_D*dlog p`. At the renormalised capital-key +`omega_pi_D = 0.071`, **93% of any terms-of-trade adjustment is Greek PPI deflation and 7% is +German inflation** — the 2010–12 internal-devaluation pattern. GDP weights would split it +~50/50 (counterfactual), because the model normalises `Y_D_ss ~ Y_F_ss ~ 1`. + +**Nominal deposits.** `deposit_rates_D/F` take the nominal rate `i_dep_D/F` (now the solver +unknown) and `pi_D/F`, and return two objects: `rdep_D/F`, which **keeps its name and its +ex-ante meaning** (the real rate for t → t+1, locked at t), and `rdep_expost_D/F`, the +realised real rate `(1+i_dep(-1))/(1+pi) - 1` that carries the inflation surprise. Because +`rdep` kept its name, `intermediation_P1_D/F`, `divert_bond_foc_D/F` and +`divert_portfolio_adj` were **untouched** and remain correctly ex-ante (verified by +introspecting `.inputs`). `bank_return_D/F` and `capital_fund_D/F` consume `rdep_expost_D/F` +— that is the Fisher channel. T-2 is not reopened: `deposit_return_D/F` still locks the rate +at `i_dep(-1)`; only the deflator (`P_CES`, `pi`) is period-t. Note `rdep_expost` carries its +own `(-1)` internally, so writing `rdep_expost_D(-1)` would double-lag it. + +**Sovereign bonds stay real.** A deliberate asymmetry, not an oversight: banks are nominal +debtors on deposits and real creditors on the sovereign book, which maximises their Fisher +exposure. **This must be stated as a modelling choice in the paper**, not left implicit. +Nominal sovereign bonds are a candidate follow-on. + +**Solver system: 23×23 → 27×27.** `unknowns_tp` gains `mc_D, pi_D, mc_F, pi_F` and swaps +`rdep_D/F → i_dep_D/F`; `targets_tp` gains `nkpc_p_res_D/F, tot_res, union_pi_res`. Nothing +existing was renamed or dropped. The block list has one definition, +`full_model.build_block_list()`, shared by `full_model.py`, `tpi.py` (which supplies its four +`_tpi` swaps via `tpi.tpi_overrides()`) and `diagnostics/regimes/regime_model.py`. + +**The steady state is bit-identical to pre-change.** Markups are subsidy-neutralised +(`mc_ss = 1/mu_p`, so `mu_p*mc = 1` and `profit_ss = 0`) and `pi_ss = 0`, so every new object +is exactly `0.000000e+00` at SS. The monitored numbers are unchanged through every task: +`goods_mkt_D = -4.2493506589857954e-07`, `goods_mkt_F = -4.1914559989475464e-07`, +`ca_res_D = 6.852157730108388e-17`, `IC_D: θ − θ_tgt = 1.776357e-15`, `ρ_b = 0.8451`, +all residuals `< 1e-8 ✓`. At `pi = 0` the three deposit rates collapse exactly: +`i_dep_D = rdep_D = rdep_expost_D = 0.000000000000`. + +### Calibration added and changed + +| parameter | value | source / rationale | +|---|---|---| +| `mu_p_D/F` | 1.20 | gross price markup, `epsilon_p = 6`. Free to first order under the subsidy neutralisation, so the level needs no defending. | +| `mc_D/F` | `1/1.20 = 0.8333` | subsidy-neutralised SS real marginal cost (`tau_s = 1 − 1/mu_p`). Retargeted from a dead placeholder `1.0`; this is what keeps the SS bit-identical. | +| `kappa_p_D/F` | 0.0871 | Rotemberg slope from Calvo `theta_p = 0.75` at `beta = 0.985`, i.e. `(1−θ)(1−βθ)/θ`. Agrees with Bi-Foerster-Traum's implied 0.0846 to within 3%; consistent with the ~4-quarter euro-area IPN median price duration. | +| `pi_D/F` | 0.0 | SS PPI inflation, exact. | +| `omega_pi_D` | 0.071 | 1 − the renormalised two-country ECB capital key (BuBa 26.1 / BoG 2.0). **Deliberately not GDP weights**, which would erase the 93/7 internal-devaluation split. | +| `i_dep_D/F` | 0.000 | renamed from `rdep_D/F`; `rdep_D/F` is now a solved output, not a calibration input. | +| **`psi_lambda_B_D/F`** | **7.85** (was 8.5) | **re-tuned.** Sticky prices + the Fisher channel pushed peak spread to 162.0bp at 8.5, an 8% overshoot of the paper's 150bp moment. Re-bisected: 8.5 → 162.14bp, 7.0 → 136.21bp, 7.8 → 149.16bp, **7.85 → 150.14bp (adopted, within 1bp)**. `b_gov_D[499]` stayed in the ~1e−5..1e−4 band throughout — lowering the dial moves *away* from the high-`psi_lambda_B` breakdown region, not toward it. `EBA_CALIBRATION` branch only; the pre-EBA `else 3.0` branch is untouched. | + +### Headline results + +Impact response to the 1pp default shock, % of own SS level. Both columns hit the same +150bp peak-spread moment, so this is **like-for-like**. + +| | flex, real deposits (`psi_lambda_B=8.5`) | sticky + nominal, re-tuned (7.85) | +|---|---|---| +| peak spread (bp ann) | 150.4 | 150.0 | +| `Y_D[0]` | −0.0149 | **−0.5064** | +| `C_D[0]` | **+0.2164** | **−0.5103** | +| `I_D[0]` | −0.7718 | −1.0114 | +| `n_inter_D[0]` | −3.3804 | −4.2962 | + +Decomposed into the two structural changes (Tasks 10 and 13, both measured at +`psi_lambda_B = 8.5` so the dial is held fixed): + +| step | `Y_D[0]` | `C_D[0]` | `I_D[0]` | `n_inter_D[0]` | +|---|---|---|---|---| +| flex, real deposits | −0.0149 | **+0.2164** | −0.7718 | −3.3804 | +| + price stickiness (deposits still real) | −0.4923 | **−0.4904** | −0.9907 | −4.0140 | +| + nominal deposits | −0.5449 | −0.5499 | −1.0849 | −4.6155 | +| + `psi_lambda_B` 8.5 → 7.85 (back on 150bp) | −0.5064 | −0.5103 | −1.0114 | −4.2962 | + +- **Price stickiness alone flips `C_D[0]`** and takes `Y_D[0]` 33× larger. The extra output + decline is **not** an investment story (`I_D` moved 1.28×, `Y_D` 33×) — it is the markup + wedge shifting labour demand directly, which is the mechanism the design intended. +- **Nominal deposits are the Fisher channel and the signature is correct**: the amplification + lands hardest on bank net worth (−0.60pp, ~15% deeper, ~11× larger than the effect on + output) and only reaches output through the intermediary. Had the ex-post/ex-ante + substitution been backwards, `n_inter_D[0]` would have gone *less* negative. +- `Y_D[0] = −0.49%` (stickiness alone) sits against Bi-Foerster-Traum (FRBSF WP 2025-10) at + −0.6%. The pre-change model was two orders of magnitude below that. Price stickiness closes + the gap **without** their Sims-Wu loan-in-advance constraint. + +**`kappa_p` sweep — monotone, stable, and the sign flip is not knife-edge:** + +| `kappa_p` | `Y_D[0]` | `C_D[0]` | `I_D[0]` | `n_inter_D[0]` | `b_gov_D[499]` | +|---|---|---|---|---|---| +| 0.03 (stickier) | −0.7280 | −0.8401 | −1.0987 | −4.3244 | 1.50e−05 | +| **0.0871** (calibrated) | **−0.4923** | **−0.4904** | −0.9907 | −4.0140 | 1.4e−05 | +| 0.2 (more flexible) | −0.3105 | −0.2211 | −0.9077 | −3.7736 | 1.42e−05 | +| flex limit | −0.0149 | +0.2164 | −0.7718 | −3.3804 | 1.4e−05 | + +`C_D[0]` is negative across the whole sticky range and positive only in the flexible limit. + +### CAVEAT — this is a one-quarter spike, not a downturn. Do not drop it. + +First 8 quarters, % of SS: + +``` +Y_D sticky, real dep : -0.4923 +0.0083 +0.1006 +0.0881 +0.0580 +0.0329 +0.0161 +0.0063 +Y_D sticky, nominal dep: -0.5449 -0.0011 +0.1021 +0.0912 +0.0604 +0.0342 +0.0165 +0.0061 +C_D sticky, real dep : -0.4904 +0.1141 +0.1553 +0.0790 +0.0046 -0.0440 -0.0685 -0.0761 +C_D sticky, nominal dep: -0.5499 +0.1144 +0.1676 +0.0903 +0.0124 -0.0395 -0.0665 -0.0758 +C_D flex : +0.2164 +0.0702 -0.0161 -0.0624 -0.0829 -0.0873 -0.0822 -0.0721 +``` + +Output and consumption are both **positive from quarter 1**, and flexible-price consumption +is in fact *more* persistently negative from quarter 2 on. Nominal deposits deepen the impact +quarter (`C_D[1]` is essentially unmoved, +0.1141 → +0.1144 — the entire Fisher effect is an +impact-quarter effect) but do **not** lengthen the recession. Bi-Foerster-Traum's output stays +negative for ~20 quarters. + +**The honest claim is that the model fixes the impact quarter, not that it resolves the +investment-bust counterfactual.** Do not write "resolves the investment-bust counterfactual" +without this qualification. + +### The SSJ defect and `solve_jacobian_padded()` — a hard requirement + +SSJ 1.0.0's `CombinedBlock._jacobian` seeds from the shock list and ends with +`total_Js[original_outputs & total_Js.outputs, :]`, only visiting a block whose inputs +intersect that list. A target that is a **pure function of the solver's own unknowns** +therefore never enters H_Z and is silently dropped. All four new targets are exactly that (no +`Z_*`/`shock_def_*` symbol appears anywhere in `nkpc_p_res_D/F`, `tot_res`, `union_pi_res`), +so SSJ returned a 23-row H_Z against a 27×27 H_U and `Block.solve_jacobian` handed mismatched +shapes to `np.linalg.solve` (`size 11500 is different from 13500`). + +**Fix: `full_model.solve_jacobian_padded()`**, which restores the missing rows as zeros. This +is **exact, not an approximation** — `dH/dZ` at fixed unknowns is identically zero when the +shock symbol never appears in the equation. It otherwise reproduces `Block.solve_jacobian` +line-for-line and prints the padded row names on every solve, so the padding can never go +silent. + +**Every Jacobian call site in the repo routes through it** (converted in Task 9b: +`code/full_model.py`, `code/tpi.py`, `diagnostics/regimes/regime_model.py`, +`experiments/e4_distribution.py`, `diagnostics/solve_configs.py`, +`diagnostics/psilam_moment_sweep.py`, `diagnostics/psilam_breakdown_sweep.py`, +`diagnostics/substitution_v2/solve_v2.py`, `diagnostics/substitution_v2/exp_psilam0.py`). +The invariant to keep: + +```bash +grep -rn "\.solve_jacobian(" --include="*.py" code experiments diagnostics | grep -v solve_jacobian_padded +``` + +must stay **empty**. A 25×25 rewrite (folding the four targets into existing equations) was +considered and rejected: it would hit the identical defect with smaller numbers. + +### Flex-price equivalence gate (the wiring check) + +As `kappa_p -> inf` the NKPC forces `mu_p*mc -> 1` and labour demand collapses to +competitive, so the 27×27 system must reproduce the pre-change 23×23 IRFs. It does, with +textbook O(1/`kappa_p`) convergence — worst relative IRF deviation `2.925e-03` at 1e4, +**`2.925e-04` at 1e5** (gate threshold 1e-3, **PASSED**), `2.925e-05` at 1e6. Every one of +the 30 IRF series shrinks by a ratio of exactly 10.00 per decade and the 4 SS levels are +bit-identical at every `kappa_p`. The binding series are `w_D` and `N_D`, the two objects the +markup wedge acts on directly. A clean 1/`kappa_p` rate rather than a stuck floor is the +evidence that the blocks are wired correctly. Comparison harness: `code/dump_irfs.py`. + +### E1–E4 regenerated (Task 15) + +**Ordering is load-bearing.** `diagnostics/regimes/regime_model.py --force` must run +**before** `experiments/run_all.py`: the experiments never re-solve the model, they read +cached Jacobian response matrices, so the reverse order silently re-reports the old model's +numbers. New caches are tagged `psilam7p85_cal685f7838` (the old set was `psilam8p50` under +`calde195df2`/`cal004630e7`/`cal3397854d`); every provenance stamp in +`docs/experiments_results.md` now reads `calibration 685f7838 · psi_lambda_B=7.85`, which is +the check that the fresh cache was the one consumed. + +**E1 — backstop schedule** (old = flex at `psi_lambda_B=8.5`, new = sticky+nominal at 7.85): + +| | passive | medium | aggressive | +|---|---|---|---| +| γ — old | 0 | 5.0798 | 12.7260 | +| **γ — new** | **0** | **3.2515** | **9.0163** | +| peak spread (bp ann) — old | 150.3 | 112.7 | 75.2 | +| **peak spread (bp ann) — new** | **150.1** | **112.6** | **75.1** | +| `Y_D[0]` (% SS) — old | −0.0149 | +0.0111 | +0.0338 | +| **`Y_D[0]` (% SS) — new** | **−0.5064** | **+0.2008** | **+0.8721** | +| `C_D[0]` (% SS) — old | +0.2164 | +0.3040 | +0.3855 | +| **`C_D[0]` (% SS) — new** | **−0.5103** | **+0.5285** | **+1.5143** | +| `I_D[0]` (% SS) — old | −0.7718 | −0.2903 | +0.1217 | +| **`I_D[0]` (% SS) — new** | **−1.0114** | **−0.2934** | **+0.3977** | +| `n_inter_D[0]` (% SS) — old | −3.380 | −2.167 | −1.099 | +| **`n_inter_D[0]` (% SS) — new** | **−4.296** | **−1.649** | **+0.924** | +| loading — old | n/a | 4.00 | 3.17 | +| **loading — new** | **n/a** | **3.82** | **2.90** | + +The peak-spread row is nearly unchanged **by construction** — the regimes are *defined* as +0/25/50% compression and γ is solved to hit that. The informative move is that the same +compression now costs a **smaller** γ (3.25 vs 5.08; 9.02 vs 12.73): the backstop is more +powerful per unit under sticky prices. + +- **Live Claim 5 (self-extinguishing premium) SURVIVES.** The loading is monotone decreasing + in γ, **4.43 → 1.49** across the full schedule (59 finite grid points over γ ∈ [0.51, + 30.00]), reported monotone by E1's own check. +- **Live Claim 1 (over-compensation, loading > 1) SURVIVES.** 3.82 / 2.90 at the named + regimes, and above 1 at every grid point. The level is slightly below the flex-price + schedule; the *shape* is the claim, not the level. + +**E2 — closure held.** The `market_clearing_D` identity closes at `max|residual|` = +3.53e−17 / 1.09e−16 / 2.22e−16 against the 1e−07 assertion. **No Rotemberg resource cost +leaked into the resource constraint**, which is what makes this check meaningful. + +**E2's headline-vs-channels finding REVERSES, and `docs/SPEC.md`'s caution was restated +accordingly.** Under flex prices, `dY[0]` moved +4.9e−04 passive → aggressive while +investment moved +2.2e−03 and net exports −1.9e−03: the headline was a small residue of +channels ~4× its size. Under sticky prices `dY[0]` moves **+1.38e−02** while investment moves ++3.41e−03 and net exports −2.92e−03 — the largest single channel is now **0.25×** the +headline, not 4×. "Report the decomposition, not the headline ΔY" still stands, but the +*reason* is now that the channels **cancel and land on different households**, not that the +headline is the smaller object. This is arguably an improvement: output is no longer a +numerically fragile residue of nearly-cancelling terms. + +**E3 — S-1 under sticky prices: the inversion is now only partial.** + +| setting | `EL_price_D` | peak spread, passive | loading (medium) | loading (aggressive) | +|---|---|---|---|---| +| baseline | 0.056134 | 150.1 | 3.82 | 2.90 | +| `writeoff_enabled=1` | 0.056134 | 149.4 | 3.77 | 2.87 | +| `+ zeta_writeoff=1` | 0.701743 | 252.3 | **2.46** | **0.26** | + +`writeoff_enabled=1` alone remains negligible and SS-neutral (max SS drift 0.000e+00), +confirming the S-1 decision. Under full writeoff the loading no longer falls below 1 +*everywhere* — **medium holds at 2.46; only aggressive inverts, at 0.26**, where the +flex-price model had both below 1 (0.37/0.28). **The appendix robustness claim must be +narrowed:** full writeoff inverts Live Claim 1 only at aggressive intervention, not across +the schedule. Peak spread remains non-monotone in γ under this setting, so the named-regime +construction still breaks there and all rows are evaluated at the baseline's γ. + +**E4 and paper artefacts.** `experiments/cache_e4_deciles.npz` was stale (predating the whole +sticky-price workstream) and **E4 is not wired into `run_all.py`** — `e4_distribution.py` is +a separate entry point feeding `paper_outputs.py`. Both were rebuilt, re-emitting all 8 +tracked `experiments/paper/fig0*.png` and `docs/paper_draft_results.md`. Anyone regenerating +E1–E3 and assuming E4 came along will ship paper artefacts built on the old model. + +### NEW WATCH ITEM — the aggressive backstop now produces an impact boom + +`n_inter_D[0]` is **positive (+0.924)** under the aggressive regime, where it was −1.099 +under flex prices. Together with `Y_D[0] = +0.8721` and `C_D[0] = +1.5143`, the aggressive +backstop produces an impact **boom** in the crisis country rather than merely cushioning the +bust. **Flag this prominently — a referee will press on it.** At `γ_aggressive = 9.02` this +may still be linear-rule overshoot (the same suspicion that attached to the old +`γ_aggressive = 12.7`), but it is now much larger and reaches bank net worth, not just +output. + +The pre-existing watch item — `Y_D[0]` positive under both intervening regimes — **survives +and is an order of magnitude larger** (+0.0111/+0.0338 → +0.2008/+0.8721). What has changed +favourably is the **passive** column: `C_D[0]` flips +0.2164 → −0.5103, so the backstop now +moves consumption *across zero* rather than raising an already-positive number. + +### Generated-document hazard found in Task 15 + +`experiments/run_all.py` carried two prose captions with flex-price numbers baked in as +string literals. E3's `psi_lambda_B = 8.5` was merely stale; E2's "each roughly 4× the +headline" was **asserting the opposite of the table printed directly above it**. Both now +compute from `provenance`/`components_impact`, and the E2 caption selects its own leading +sentence from the data. A partial prose-vs-table check now exists in `paper_outputs.main()` +(see below), but **`run_all.py` still has no such assertion** — that part of the gap is open. + +### Generated-document hazard — **RESOLVED (Task 17, 2026-08-06)** + +The same hazard was live in `experiments/paper_outputs.py`, whose module-level `CAPTIONS` +dict hardcoded flexible-price prose that was baked into the eight tracked +`experiments/paper/fig0*.png` and into `docs/paper_draft_results.md`. Task 15 regenerated the +*figures and tables* but never the caption strings, so the generated document contradicted +itself. What it said, and what the data actually says: + +| caption | said | derived value now | +|---|---|---| +| `fig08_deciles` | "the lowest gains 0.95%… highest 0.59%" | Table 4 in the same file: Q1 **+0.4250**, Q5 **−0.9073** | +| `fig08_deciles` | "Consumption rises for every income quintile on impact" | it **falls** ~0.51% in every quintile; the min over 40q is the impact quarter, not quarter five | +| `fig02_loading_schedule` | "4.5× … 2.1×" | **4.43 → 1.49** over γ ∈ [0.51, 30], monotone, above 1 throughout | +| `fig03_dy_decomposition` | "each roughly four times the headline" | **inverted** — consumption carries **0.99×** the headline, investment **+0.25×**, NX **−0.21×** | +| `fig01_transmission` | "cuts bank net worth 3.4%… investment (−0.77% on impact)" | **−4.296%** and **−1.0114%** | +| `fig04_spread_decomposition` | "3% / 97%" | re-derived at `psi_lambda_B = 7.85`: **3.4% / 96.6%** | +| `fig06_net_effects` | net path "at every horizon far smaller than the components" | **false in the impact quarter** — passive `ΔY[0] = −5.06` exceeds every component; the claim holds in **14 of the first 16** quarters | +| `fig05_incidence`, `fig07_ms_regimes` | checked, directionally sound | `fig05` now carries endpoints (exposure 0 → 0.92% of quarterly `Y_D`, loading 4.43× → 1.49×); `fig07` is estimated from `Empirics/outputs/ms_regime_COMPOSITE.npz` and is genuinely **model-independent** — ergodic shares 22.9/52.4/24.7 → 23/52/25, hawk span 2010-09…2014-02, both now read from the npz | + +**The fix is structural, not a substitution.** `CAPTIONS` is now an empty dict filled at run +time: `save(fig, name, caption)` takes the caption as an argument, and each figure builds it +from the same arrays it just plotted. Directional words are *selected* from the data too — +`_monotone`, `_first_quarter` and sign tests decide whether a caption says "falls +monotonically" or "does NOT fall", "reverses by quarter 5" or "never reverses", "a residue of +larger offsetting channels" or "the largest object in the decomposition". A sign flip now +rewrites the sentence instead of lying inside it. + +**`fig04`'s re-derivation.** Total default loading per unit of default probability is +`EL_price_D + psi_spread_D` from the bond-pricing FOC (`code/equations_D.py:566`). +`EL_price_D = (1 − recovery) · delta_b / q_b = 0.70 · 0.0777006 / 0.968941 = 0.056134`, which +does not move with `psi_lambda_B`. `psi_spread_D = lambda_gk_D · psi_lambda_B_D / +(beta_inter_D · Omega_D)` (`code/steady_state.py:104`) is **linear** in `psi_lambda_B`, so the +8.5 → 7.85 re-tune took it 1.737724 → **1.604839**. Split: 0.056134 / 1.660973 = **3.4%** +fundamental, **96.6%** friction (was 3.1% / 96.9% at 8.5). The figure's own title already +printed both terms live; only the caption prose was stale. + +`paper_outputs.main()` now also asserts that fig01's caption and Table 3 quote the same +impact net worth by their two independent routes (the cache directly vs `e1.run()`'s +payload), which is the first check anywhere that rendered prose agrees with a rendered table. +Table 4 and fig08's caption share the same `pv` object, so they agree by construction. + +### Test entry points + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -v # 17 tests, ~1s +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py code/test_eba_calibration.py experiments/ -v # 40 passed +``` + +### Open follow-ons + +1. **Nominal sovereign bonds.** Currently real by design (see above). Making them nominal + would give the sovereign an inflation-erosion channel and change the sign of the bank's + net Fisher exposure. +2. **Sims-Wu loan-in-advance constraint.** Bi-Foerster-Traum's device for persistence. The + one-quarter-spike caveat is the symptom it would address. +3. **`experiments/paper_outputs.py`'s `CAPTIONS` are stale and in two places inverted** — see + the table above. Blocking: the eight tracked paper figures currently carry flex-price + prose baked into the image files. +4. **A test that rendered prose agrees with rendered tables**, in `run_all.py` and in + `paper_outputs.py`. Both have now shipped self-contradicting generated documents. +5. **The aggressive-backstop impact boom** (watch item above) needs a diagnosis before the + intervening-regime paths are reported. ## Policy experiments (`experiments/`) — **COMPLETE 2026-08-03** +> **HISTORICAL from here down.** Every number below this line was measured on the +> **flexible-price, real-deposit** model at `psi_lambda_B = 8.5`. The live results are in +> the `add-nkpc` section at the top of this file and in `docs/experiments_results.md`. +> The *design* and *method* notes below are still current; the *numbers* are not. + E1, E2 and E3 all land, with the orchestrator writing `docs/experiments_results.md`. **Two results change how the paper must be written** — E2's ΔY decomposition (report the decomposition, never the headline) @@ -69,6 +1536,15 @@ source citations are literal text. the 10.9%/89% recorded at the pre-EBA calibration. This is Live Claim 3's quantitative core and 96.9% is a materially stronger version of it. +> **SUPERSEDED 2026-08-06 (Task 17): the live split is 3.4% / 96.6%.** The +> `psi_spread_D=1.737724` above is at `psi_lambda_B=8.5`; the sticky-price re-tune to +> 7.85 moves it to 1.604839 (`psi_spread` is linear in `psi_lambda_B`, `EL_price` is +> not a function of it at all). The "Generated-document hazard" section above carries +> the derivation. The qualitative claim is unaffected. + +> The "every number derived live" claim was true of the tables and **false of the +> captions**, which were literal prose until Task 17. See the hazard section above. + **New finding from the transmission figure — the backstop does not shift the whole spread path down.** Its cushioning is concentrated at impact; by roughly quarter four the net-worth and investment paths across regimes have converged, and the @@ -293,7 +1769,10 @@ equalised and that the `delta_b_F` fix below is correct. > contaminated `carry_ss_pv`. Caught by an assertion written into `cb_pnl` before > the code was first run. Cache rebuilt; E2 re-ran identically. -## EBA REBUILD (2026-07-31) — read this first +## EBA REBUILD (2026-07-31) — the live calibration's derivation (dynamics superseded) + +*The parameter → moment map below is live. The IRF and TPI numbers in it are flex-price +and are superseded by the `add-nkpc` section at the top of this file.* The EBA 2011 moment set was rebuilt from scratch to be identified rather than back-solved (`code/eba_calibration.py`, `data/eba_moments.json`, @@ -483,7 +1962,11 @@ backstop. ## Historical (2026-07-22 onward) — superseded by the section above -## CURRENT CALIBRATION (2026-07-30) — supersedes every table below +## CALIBRATION AS OF 2026-07-30 — HISTORICAL, supersedes every table below it only + +> **Not current.** This section records the brief pre-EBA revert. `EBA_CALIBRATION = True` +> and `BANK_SCOPE = "broad"` have been live since 2026-07-31, and `psi_lambda_B = 7.85` +> since 2026-08-06. See the `add-nkpc` and EBA REBUILD sections above. **The calibration was reverted to its pre-EBA values.** Everything below this section describes the EBA-anchored calibration and is now **historical**. Structural fixes @@ -513,10 +1996,13 @@ is a **hard break**, not the "narrow, mild zone" described below. Measured direc **Default-loading split.** `EL_price_D=0.0717` vs `psi_spread_D=0.8385` → fundamental expected loss is **10.9%** of the default loading, GK collateral friction **89%**. -> **SUPERSEDED 2026-08-04.** Those are pre-EBA numbers. At the live calibration -> `EL_price_D=0.056134` and `psi_spread_D=1.737724`, so the split is **3.1% -> fundamental / 96.9% collateral friction** — a materially stronger version of the -> constrained-seller claim. Do not quote 10.9%/89%. +> **SUPERSEDED 2026-08-04, revised 2026-08-06.** Those are pre-EBA numbers. At the +> live calibration `EL_price_D=0.056134` and `psi_spread_D=1.604839` +> (`psi_lambda_B=7.85` after the sticky-price re-tune; it was 1.737724 at 8.5, and +> `psi_spread` is linear in `psi_lambda_B`), so the split is **3.4% fundamental / +> 96.6% collateral friction** — a materially stronger version of the +> constrained-seller claim. Do not quote 10.9%/89%, and re-derive rather than +> re-quote whenever `psi_lambda_B` moves. **Units.** `spread_rb` is a *quarterly* rate deviation; annualise ×4×1e4 for comparison with the 150bp target. diff --git a/docs/cb_mechanism.md b/docs/cb_mechanism.md new file mode 100644 index 0000000..720fa65 --- /dev/null +++ b/docs/cb_mechanism.md @@ -0,0 +1,353 @@ +# The central-bank mechanism — canonical statement + +**Status: canonical.** This is the reference description of what the TPI central bank +is, how it transmits, and what may and may not be claimed about it. Established by the +CB-block audit of 2026-08-19 (`diagnostics/cb_audit/`) against the working tree of +`gk-structural-foc` after the GK structural refactor. Every number below is measured, +not asserted; the probes that produced them are named at each point. + +Supersedes ad-hoc descriptions of the CB in `docs/STATE.md` and in figure captions. +When this document and a caption disagree, this document is right and the caption is +stale. + +--- + +## 1. What the object is — and what it is not + +The model's "ECB" is a **capital-key conduit with full per-period pass-through**. It +is not a central bank in the institutional sense, and the difference matters for what +the paper can claim. + +**It has:** + +* a bond book, `cb_buy_D` (CB holdings of D sovereign paper), a D aggregate; +* a net cash flow on that book, `cb_flow_D` (CB net cash flow, D goods), computed in + the same coupon/survival form as the private payoff; +* a capital key, `kappa_cb_F` (F share of CB profit and loss) `= 0.929`, splitting + that flow between the two treasuries; +* remittances to **both** treasuries — `rem_cb_D` and `rem_cb_F` (D and F treasury + receipts from the CB) — that enter their respective budget constraints. + +**It does not have:** + +* **capital or retained earnings.** The entire net cash flow is remitted every period. + There is no loss-absorption buffer, so a loss reaches the two treasuries in the + period it occurs. A real Eurosystem national central bank absorbs losses against + capital and provisions first. +* **a reserve liability, or a policy rate.** Purchases are funded by a same-period + capital call on the two treasuries, not by creating remunerated reserves. This is + internally consistent — the model has no policy rate at all, the union-inflation + normalisation being the `phi_pi` (Taylor coefficient on union inflation) → ∞ limit — + but the seigniorage and reserve-remuneration legs of an actual APP or TPI are absent + by construction. + +Both absences are deliberate modelling choices and both must be stated as such in the +paper. They are not defects and they are not to be "fixed" without a design decision. + +## 2. The four equations + +`code/tpi.py` supplies four blocks through `tpi_overrides()`, swapped into the shared +`full_model.build_block_list()`. They are the whole central bank; there is no fifth +place where `cb_buy_D` appears. + +| block | replaces | what it does | +|---|---|---| +| `domestic_bond_clearing_tpi` | `domestic_bond_clearing` | `b_D_D = b_gov_D − size_F·b_D_F − cb_buy_D` | +| `budget_residual_D_tpi` | `budget_residual_D` | computes `cb_flow_D`; remits `(1−kappa_cb_F)` of it to D | +| `budget_residual_F_tpi` | `budget_residual_F` | remits `kappa_cb_F` of it to F, converted `/p/size_F` | +| `external_account_D_tpi` | `external_account_D` | books the F-owned share `kappa_cb_F·cb_buy_D` as an external claim on D | + +**Units.** `cb_buy_D` is already a D aggregate — the ECB buys a quantity of D debt — +so unlike `b_D_F` (F-bank holdings of D paper, per F capita) it takes no `size_F` +(F size relative to D) weight. The F remittance takes **two** conversions, `/p` for +the good and `/size_F` for the population; omitting the second leaked up to 2e−02 of F +GDP through `goods_mkt_F` (F goods-market residual) at γ = 10 while γ = 0 stayed clean +— the signature of a conduit-only units error. + +**Pricing.** All four mark exclusively at the endogenous `q_b_D` (D sovereign bond +price). `cb_flow_D` matches `bond_return_D`'s state-contingent payoff term for term, +including the `zeta_writeoff_D` (continuation-value write-down switch) continuation +leg and the `writeoff_enabled_D` (realisation gate) multiplier. Audited line by line +(`run_log.md` Probe D); no stale price, no hardcoded price decomposition, no surviving +reference to the deleted pre-refactor FOC. + +The GK structural refactor did **not** touch these four blocks. They were written +against the *payoff*, in coupon/survival form, never against the old FOC's price +decomposition, which is why they survived unchanged (`run_log.md` Probe C). + +## 3. Steady-state neutrality is exact + +`cb_buy_ss = 0` — TPI is dormant at rest. Evaluated at the solved steady state, every +TPI block output is **bit-identical** to its non-TPI counterpart: + +| output | TPI | non-TPI | difference | +|---|---|---|---| +| `b_gov_res_D` (D budget residual) | −4.163336e−17 | −4.163336e−17 | **0.000e+00** | +| `nfa_D` (D net foreign assets) | −1.338351e−01 | −1.338351e−01 | **0.000e+00** | +| `ca_res_D` (D current-account residual) | +1.665335e−16 | +1.665335e−16 | **0.000e+00** | +| `b_D_D` (D-bank holdings of own paper) | +9.922837e−01 | +9.922837e−01 | **0.000e+00** | +| `b_F_F` (F-bank holdings of own paper) | +4.953501e−01 | +4.953501e−01 | **0.000e+00** | + +and `cb_flow_D`, `rem_cb_D`, `rem_cb_F` are identically zero. Confirmed dynamically by +`run_tpi`'s own gate: `G_tpi[cb=0]` versus the baseline Jacobian, `max|err| = 0.00e+00`. + +**Consequence:** no steady-state result and no SS-invariance argument anywhere in the +project can be affected by the CB block. The refactor did not move the SS through it. + +## 4. How the spread is generated, and therefore what the CB can do to it + +At the preferred baseline `psi_lambda_B_D/F` (collateral-friction amplification dial) +`= 0`, so `Delta_bD_eff_D` (effective pledgeability of D paper to D banks) `≡ 0.20`. +Compose `gk_bond_foc_D` (`nu_bD_D/nu_K_D = Delta_bD_eff_D`) with `intermediation_P1_D` +and the intermediary stochastic discount factor `SDF_banker_D` and franchise value +`Omega_p1_D` **cancel in the ratio**, leaving + +``` +rb_exp_D(+1) − rdep_D = 0.20 · ( rk_D(+1) − rdep_D ) +``` + +exactly. Verified at the steady state: `rb_exp_D` (expected D bond return) +`= 0.002000`, `rk_D` (D return on capital) `= 0.010000`, `rdep_D` (D ex-ante real +deposit rate) `= 0`, and `0.20 × 0.01 = 0.002` to machine precision; the four-leg FOC +table verifies to ≤ 2.1e−13 on every solved SS. + +**This is the single most important structural fact about the mechanism.** The +sovereign risk premium is pinned as a fixed fraction of the capital premium. The +central bank has no direct lever on the spread — there is no term of the form +`spread += parameter × def_rate` anywhere on its path, and never was one to revive. +**The only way the CB can compress the spread is by lowering `rk_D(+1)`, that is, by +crowding capital back in.** + +It follows that in this model **TPI's spread effect and its investment effect are the +same effect**. They cannot be reported as two channels, decomposed against each other, +or traded off. Any sentence of the form "TPI compresses the spread, and separately +supports investment" is wrong. + +## 5. The transmission chain + +`cb_buy_D` reaches the price through exactly two doors — `intermediation_IC_D`'s +`phi_bD_D` (D-bank sovereign concentration) and `k_balance_sheet_D` — and through +nothing else. Measured off the TPI Jacobian at impact (`probe_stability.py`): + +``` + d b_D_D / d cb_buy [0,0] = -0.126779 modest quantity relief on D banks + d theta_D / d cb_buy [0,0] = -1.177721 required leverage falls, IC slackens + d n_inter_D/ d cb_buy [0,0] = +0.525814 D-bank net worth recovers (MTM on q_b_D) + d K_D / d cb_buy [0,0] = +0.017015 capital crowded back in + d q_b_D / d cb_buy [0,0] = +0.037316 bond price up -> spread compresses +``` + +Read in the order the mechanism actually runs: the purchase supports `q_b_D`, the +price support recapitalises D banks through mark-to-market on the book they still +hold, the recapitalisation slackens the incentive constraint, capital is crowded back +in, `rk_D` falls, and §4's identity then requires `rb_exp_D` to fall — which *is* the +spread compression. The quantity relief is a minor contributor; see §6. + +Aggregate effect, closed loop: `A_cb[0,0] = d(spread_rb)/d(cb_buy_D)[0,0] = +−4.397083e−03`, negative as required, and peak spread falls monotonically +205.87 → 193.25 → 176.75 → 154.36 bp annualised at γ = 0/2/5/10. + +## 6. The decisive diagnostic — the 2×2 sovereign-holdings matrix + +`diagnostics/cb_audit/probe_portfolio.py`, full output in `portfolio_matrix.md`. +Aggregate market value in D goods, `q_b × quantity`, with the per-F-capita legs scaled +by `size_F = 11.696651`. Both clearing identities close to ≤ 1.2e−15 at every point +reported, at the steady state and at t = 0, 4, 20 for every γ. + +### 6.1 Steady state — a home-biased small system beside a large indifferent one + +| holder | D paper | F paper | total | share of D issue | +|---|---|---|---|---| +| **D banks** | 0.967383 | 0.007282 | 0.974666 | **87.27%** | +| **F banks** | 0.141118 | 5.596796 | 5.737913 | **12.73%** | +| **CB** | 0.000000 | 0.000000 | 0.000000 | 0.00% | +| issued | 1.108501 | 5.604078 | 6.712579 | | + +The D banking system holds 87% of its own sovereign and essentially nothing else +(F paper is 0.75% of its book). The F system is 5.9× larger and holds D paper worth +2.5% of its own book. **The exposure is radically asymmetric, and that asymmetry is +what the intervention acts on.** + +### 6.2 The crisis, before TPI (γ = 0, 1pp default shock, impact) + +| holder | D paper | F paper | share of D issue | +|---|---|---|---| +| **D banks** | 0.930292 | 0.007584 | **87.76%** | +| **F banks** | 0.129756 | 5.625571 | **12.24%** | +| issued | 1.060048 | 5.633155 | | + +The D-paper stock loses 4.4% of market value (1.1085 → 1.0600), almost entirely price. +F banks shed D paper, D banks buy Bunds — and **the D banks' *share* of their own +sovereign rises, 87.27% → 87.76%.** Retrenchment concentrates the risk on the balance +sheet least able to carry it. This is the doom loop stated as a portfolio fact. + +### 6.3 After TPI (γ = 10, same shock, impact) + +| holder | D paper | F paper | share of D issue | +|---|---|---|---| +| **D banks** | 0.926341 | 0.009973 | **86.86%** | +| **F banks** | 0.102520 | 5.629347 | **9.61%** | +| **CB** | 0.037622 | 0.000000 | **3.53%** | +| issued | 1.066483 | 5.639320 | | + +### 6.4 Who actually sells to the central bank + +Holdings at t = 0 relative to the γ = 0 counterfactual, same shock: + +| leg | Δ(γ=10 − γ=0) | % of SS D issue | share of the CB book | +|---|---|---|---| +| **F banks / D paper** | **−0.027236** | −2.457% | **72.4%** | +| D banks / D paper | −0.003951 | −0.356% | 10.5% | +| D govt issue (new supply) | +0.006435 | +0.581% | 17.1% | +| **CB / D paper** | **+0.037622** | +3.394% | 100% | +| D banks / F paper | +0.002389 | +0.216% | — | +| F banks / F paper | +0.003777 | +0.341% | — | + +The clearing identity closes exactly: +`Δ(D banks) + Δ(F banks) + Δ(CB) = −0.003951 − 0.027236 + 0.037622 = +0.006435 = +Δ(D govt issue)`, to machine precision, and the three sourcing shares sum to 100.0%. + +**Roughly three-quarters of the central bank's book is bought from German banks, one +sixth is new Greek issuance, and only one tenth comes off Greek banks.** In pure +quantity terms — differentiating the clearing identity, which strips the price effect +out — the German share is higher still: 84% at impact and ~99% from t = 1 onward. The +elasticity governing it is `psi_bD_F` (F-bank cross-border portfolio adjustment cost) +`= 0.5` in `gk_cross_border_foc`. + +**The ECB is not sharing Greek exposure with German banks. It is buying them out of +it, at a price its own purchases are raising.** + +### 6.5 What the incentive constraint sees + +`phi = q_b·b / n_inter` is the object `intermediation_IC_D/F` reads, so this is where +the portfolio shift becomes a constraint effect: + +| ratio | SS | γ=0 | γ=2 | γ=5 | γ=10 | +|---|---|---|---|---|---| +| `phi_bD_D` (D bank, own paper) | 0.452489 | **0.486757** | 0.479736 | 0.470614 | **0.458345** | +| `phi_bF_D` (D bank, F paper) | 0.003406 | 0.003936 | 0.004167 | 0.004463 | 0.004853 | +| `phi_bD_F` (F bank, D paper) | 0.007415 | 0.006774 | 0.006428 | 0.005983 | **0.005397** | +| `phi_bF_F` (F bank, own paper) | 0.294078 | 0.293864 | 0.294447 | 0.295200 | 0.296203 | + +Three readings, and they are the core result of this document: + +1. **TPI at γ = 10 undoes 82.9% of the crisis-induced rise in Greek banks' sovereign + concentration** — the shock raises `phi_bD_D` by 0.034268, and TPI removes 0.028412 + of that. +2. **It does so almost entirely through the denominator.** Between γ = 0 and γ = 10 + the numerator `q_b_D·b_D_D` moves −0.4% (0.930292 → 0.926341) while net worth + `n_inter_D` (D-bank net worth) recovers +6.6%. The concentration relief is a + net-worth effect, not a quantity effect. §6.4 and §5 say the same thing from two + directions: the CB barely takes bonds off Greek banks, and it does not need to. +3. **German banks' Greek exposure falls further the more the CB does.** `phi_bD_F` + goes 0.007415 → 0.006774 under the shock alone and on to 0.005397 at γ = 10, i.e. + 27% below its steady-state level. Meanwhile Greek banks buy *more* Bunds + (`phi_bF_D` up 42% from SS at γ = 10). TPI accelerates cross-border retrenchment in + both directions rather than reversing it. + +## 7. Fiscal incidence and the capital key + +The conduit is real and correctly scaled. Impact remittances, F converted at `p` +(terms of trade) and scaled back to an aggregate by `size_F`: + +| γ | `cb_flow_D[0]` | `rem_cb_D[0]` (D agg.) | `rem_cb_F[0]` × `size_F` | ratio F/D | +|---|---|---|---|---| +| 2 | −9.4199e−03 | −6.6881e−04 | −8.7338e−03 | 13.059 | +| 5 | −2.1539e−02 | −1.5293e−03 | −1.9971e−02 | 13.059 | +| 10 | −3.7622e−02 | −2.6711e−03 | −3.4882e−02 | 13.059 | + +against `0.929/0.071 = 13.085` implied by the key; the 0.2% gap is the endogenous +terms of trade (`p = 1.00197`). Both remittances are **negative at impact** — a +capital call, which each treasury funds through its own fiscal rule at its own +sovereign terms. + +Downstream, German cumulative taxes rise monotonically with γ (Σ₁₀₀ `TAX_F`, F tax +revenue: −1.4470e−03 → −1.2724e−03 from γ = 0 to γ = 10), German consumption gain +shrinks, and German welfare falls monotonically (ΔW_F = −0.0195 / −0.0482 / −0.0944 at +γ = 2/5/10) against Greek gains of +0.1956 / +0.5155 / +1.1290. + +**The intervention is not self-financing, the creditor side exists, and it is signed +and scaled correctly.** Statements about German exposure are computable here. + +## 8. The two profit-and-loss objects, and the rule against netting them + +`writeoff_enabled_D = 0` — the S-1 framing — so `haircut_mult_D = 0` in `cb_flow_D` +and **no credit loss ever flows through the conduit**. On the branch the IRF traces, +the German treasury books a pure gain. Two different objects therefore appear in +`run_tpi`'s output and they are not commensurable: + +| object | what it is | where it lives | +|---|---|---| +| `prem_pv`, `carry_ss_pv`, `mtm_pv`, `purchases_pv` | **on-path**, realised cash flows | actually pass through `budget_residual_D/F_tpi` | +| `el_pv` and the `loading` ratio built from it | **off-path**, an expectation over a default that never occurs on this branch | appear in **no** budget constraint | + +At γ = 10 the printed line reads "F bears EL PV = 0.2513% `Y_D`, receives prem PV = +0.1211%". The first number is an expectation; the second is a transfer. The loading +0.52 / 0.50 / 0.48 says the CB earns roughly fifty cents of premium per euro of +*expected* loss absorbed — under-compensated in expectation — while the traced path +shows the same position ending in profit. **Both are true and they are about different +things.** + +**Rule.** No sentence may net, sum, or trade off the realised German transfer against +the expected loss. Any burden-sharing claim must name which of the two it quantifies. +Same class of discipline as the standing ban on "x% fundamental / y% non-fundamental". + +## 9. Stability — and a claim not to re-derive + +`A_cb` = `d(spread_rb)/d(cb_buy_D)`. Prony dominant moduli are all below 1 and barely +move with γ (spread 0.9400 → 0.9470, `b_gov_D` 0.8283 → 0.9232, `n_inter_D` 0.9347 → +0.9460); `b_gov_D[499]` stays ≤ 3.7e−04; Walras residuals stay clean and +`max|ca_res_D|` actually *falls* with γ, 7.2e−08 → 1.5e−08. The closed loop is well +behaved across the whole intended range. + +**There is no closed-loop pole.** `code/tpi.py` prints one at γ = 26.50 and +`CLAUDE.md` records "γ ~ 27.3"; both are wrong, and there is a nearer apparent +singularity at γ = 2.2116 that the guard's 0.25-step condition-number scan steps over +entirely. All of it is a T = 500 terminal-truncation artefact: the resonant eigenvector +carries 0.0000 of its mass in the first 100 quarters and 0.9922 in t = 400–499, +`||A_cb[:,499]|| = 3.86` against ~0.0065 for every interior column, and +`A_cb[499,499] = +1.080` is the only positive diagonal entry in the matrix. Dropping +five columns removes every pole below γ = 36 and changes the reported peak spread by +**nothing** at γ = 2, 5, 10. + +No reported number is affected — every statistic is computed on `[:100]`, where the +artefact has no mass. But the γ = 19.88 cap on the effectiveness curve is imposed for a +spurious reason, and the pole should not be cited as a stability ceiling on γ. Fix +proposed at `diagnostics/cb_audit/recommended_fix.md` R-1; not implemented. + +## 10. Reporting rules + +Consolidated, for the paper and for anything generated into `docs/`: + +1. **Never** describe TPI's spread effect and its investment effect as separate + channels. §4 — they are one effect. +2. **Never** net the realised German transfer against the expected loss. §8. +3. Describe the relief channel as **price support and mark-to-market recapitalisation**, + not as balance-sheet or quantity relief. §5, §6.5. +4. State that roughly three-quarters of the CB book is bought from **German** banks. + §6.4. Do not write that the ECB absorbs bonds from Greek banks. +5. State the two absences — no CB capital, no reserve liability or policy rate — as + modelling choices. §1. +6. Do not cite a stability ceiling on γ derived from the "closed-loop pole". §9. +7. The standing bans carry over: no "x% fundamental / y% non-fundamental", and the + loading schedule is a measured outcome, never a target. + +## 11. Provenance + +| artefact | what it establishes | +|---|---| +| `diagnostics/cb_audit/run_log.md` | timestamped probe-by-probe evidence | +| `diagnostics/cb_audit/VERDICT.md` | audit findings, most severe first | +| `diagnostics/cb_audit/recommended_fix.md` | proposed fixes; **none implemented** | +| `diagnostics/cb_audit/probe_pipeline.py` | live solve: Steps 0/4/5 quantities | +| `diagnostics/cb_audit/probe_stability.py` | feedback sign, Prony moduli, fiscal incidence | +| `diagnostics/cb_audit/probe_portfolio.py` | §6, the 2×2 matrix | +| `diagnostics/cb_audit/prony.py` | order-selected Prony estimator, self-test passing | + +Base audited: `EBA_CALIBRATION = True`, `BANK_SCOPE = "broad"`, all four `Delta = 0.20`, +`psi_lambda_B = 0`, `zeta_writeoff = 1`, `writeoff_enabled = 0`, `kappa_cb_F = 0.929`, +`recovery_rate = 0.30`. `lambda_gk_D/F` (GK incentive-constraint multiplier) +`= +2.1087 / +0.6414`, `Omega_D/F` (banker marginal value of net worth) +`= +10.3462 / +4.0379`, `phi_bD_D = 0.4525` against a well-posedness ceiling of 0.8232. +The GK block is well posed; this is not the inadmissible CT1-scope regime, in which +`phi_bD_D` was 2.39. diff --git a/docs/eba_calibration.md b/docs/eba_calibration.md index f3c0638..1bb06e3 100644 --- a/docs/eba_calibration.md +++ b/docs/eba_calibration.md @@ -419,8 +419,8 @@ Emitted into `data/eba_moments.json` under `identification`, and mirrored here. | Parameter | Status | |---|---| -| `Delta_bD_D`, `Delta_bF_F` (own) | **Partially identified — bounded below, not point-identified.** GK feasibility at the measured `theta`/`phi_own`/`f` forces `Delta_own > ~0.73 (D) / ~0.71 (F)`; see "GK feasibility" above. Committed at 0.80. The level above the bound is an author decision. | -| `Delta_bF_D`, `Delta_bD_F` (cross) | Still unidentified. Committed at 0.90 to preserve `Delta_own < Delta_cross`. The feasibility bound barely constrains these (cross-holdings are ~1% of the book), so they are close to free. | +| `Delta_bD_D`, `Delta_bF_F` (own) | **CORRECTED 2026-08-17 — live value is 0.20, not 0.80.** The `Delta_own > ~0.73 (D) / ~0.71 (F)` bound below was derived at the **CT1 scope**, where `phi_own = 2.390`. Under the live `BANK_SCOPE="broad"` the concentration ratio is 0.456, the feasibility condition `f*theta > (1-Delta_own)*phi_own + (1-Delta_cross)*phi_cross` slackens, and `Delta_own = 0.20` passes `assert_gk_well_posed` on every solved SS. So the bound does **not** bind at the live scope and `Delta_own` is currently a **free inherited parameter with no live lower bound and no moment**. Verify against `get_calibration()`, not against this table's history. | +| `Delta_bF_D`, `Delta_bD_F` (cross) | **CORRECTED 2026-08-17 — live value is 0.40, not 0.90.** Still unidentified. `Delta_own < Delta_cross` is preserved (0.20 < 0.40): each bank finds its **own** sovereign the better collateral, a home-bias convention (home repo access, home-jurisdiction eligibility) that should be stated in the paper rather than assumed. | | `psi_lambda_B_D/F` | Tuned to the 150bp GR–DE spread target. The mechanical channel is now measured, so this parameter does far less work than before — but its level is still one moment, one parameter. **Proper identification needs bank equity returns regressed on the EBA exposure cross-section (Acharya–Steffen). Those returns are not in this repo.** The exposure cross-section *is* (per-bank columns in the disclosure), so this is a data-acquisition task, not a modelling one. | | `def_scale_D/F` | 0.25, hand-set. Exceeds the 2011 GR crisis peak (0.12–0.23). | | `f_D/F` | 0.12, GK literature. `bank-cal` has 0.03. Not an EBA object. | diff --git a/docs/experiments_results.md b/docs/experiments_results.md index c38ec35..a187f87 100644 --- a/docs/experiments_results.md +++ b/docs/experiments_results.md @@ -1,20 +1,22 @@ # Policy experiments — standard results -*Generated 2026-08-03T13:05:24 from `7eacc65` · **working tree DIRTY** · calibration `de195df2` · scope `broad` · `psi_lambda_B=8.5` · `mv_rule=0` · `recovery_rate=0.3` · `writeoff_enabled=0` · `zeta_writeoff=0`* +*Generated 2026-08-18T12:31:10 from `ea23e94` · **working tree DIRTY** · calibration `86c89892` · scope `broad` · `psi_lambda_B=0.0` · `mv_rule=0` · `recovery_rate=0.3` · `writeoff_enabled=0` · `zeta_writeoff=1`* Generated by `experiments/run_all.py`. **Do not edit by hand** — edit the experiment and re-run, or the next run discards the edit. Design spec: `docs/superpowers/specs/2026-08-01-policy-experiments-design.md`. ## E1 — Backstop schedule -*Generated 2026-08-03T12:09:36 from `b3d5889` · **working tree DIRTY** · calibration `de195df2` · scope `broad` · `psi_lambda_B=8.5` · `mv_rule=0` · `recovery_rate=0.3` · `writeoff_enabled=0` · `zeta_writeoff=0`* +*Generated 2026-08-18T12:31:06 from `ea23e94` · **working tree DIRTY** · calibration `86c89892` · scope `broad` · `psi_lambda_B=0.0` · `mv_rule=0` · `recovery_rate=0.3` · `writeoff_enabled=0` · `zeta_writeoff=1`* -γ selection: peak-spread compression 0/25/50% (spec section 7); gamma solved, not chosen. +γ selection: γ is **solved** for peak-spread compression, not chosen. `medium` = 25% (spec section 7). **`aggressive` is NOT 50%**: since the 2026-08-18 GK structural refactor that target lies beyond a closed-loop pole at γ ≈ 27.3, so it falls back to the strongest intervention the model can represent — γ just below the pole, achieving **≈46.6%**. See `common.named_regime_gammas` / `lottery_math.closed_loop_pole`. + +Rule recorded in the results file at run time: gamma SOLVED for peak-spread compression, not chosen. medium = 25% (spec section 7). aggressive was 50%, but since the 2026-08-18 GK structural refactor that target lies beyond a closed-loop pole at gamma ~ 27.3 and is unreachable; it falls back to the strongest intervention the model can represent, gamma just below the pole, achieving ~46.6%. DO NOT describe the aggressive regime as 50% compression -- see common.named_regime_gammas and lottery_math.closed_loop_pole. | regime | γ | peak spread (bp ann) | Y_D[0] (% SS) | C_D[0] (% SS) | I_D[0] (% SS) | n_inter_D[0] (% SS) | loading | |---|---|---|---|---|---|---|---| -| passive | 0.0000 | 150.3 | -0.0149 | +0.2164 | -0.7718 | -3.380 | n/a | -| medium | 5.0798 | 112.7 | +0.0111 | +0.3040 | -0.2903 | -2.167 | 4.00 | -| aggressive | 12.7260 | 75.2 | +0.0338 | +0.3855 | +0.1217 | -1.099 | 3.17 | +| passive | 0.0000 | 205.9 | -1.9742 | -2.5108 | -3.0046 | -11.407 | n/a | +| medium | 9.9894 | 154.4 | -0.3287 | +0.0792 | -1.6100 | -5.541 | 0.48 | +| aggressive | 19.8750 | 122.9 | +0.6830 | +1.6963 | -0.8053 | -2.037 | 0.45 | ### A5-1 — three separate objects @@ -23,14 +25,14 @@ Generated by `experiments/run_all.py`. **Do not edit by hand** — edit the expe | regime | exposure PV (% Y) | expected loss PV (% Y) | `pd_D` differential PV | |---|---|---|---| | passive | 0.0000 | 0.00000 | +0.000000 | -| medium | 0.4265 | 0.00228 | -0.001541 | -| aggressive | 1.1062 | 0.00391 | -0.004651 | +| medium | 6.0571 | 0.27036 | -0.013694 | +| aggressive | 11.2618 | 0.41108 | -0.020882 | > **The third column is misnamed in the code and its sign needs an author decision.** It reports `Σ β^t (pd_passive − pd_intervention)`, which is negative because the backstop lets Greece run a *larger* primary deficit — it relaxes required austerity. So negative means Greece is better off, the opposite of what "fiscal saving" implies. Flip the sign or rename it ("austerity relief, PV") before quoting it. Magnitudes are unaffected. ### Loading schedule (Live Claim 5) -59 finite grid points over γ ∈ [0.51, 30.00]: loading falls from **4.51** to **2.07**. Monotone decreasing: **YES**. +59 finite grid points over γ ∈ [0.34, 19.88]: loading falls from **0.53** to **0.45**. Monotone decreasing: **YES**. The self-extinguishing premium is the *decline*, so the schedule — not any single point — is the object. @@ -42,76 +44,78 @@ SECONDARY. SPEC: do not lead with welfare — it is a delicate decomposition-dep | regime | W_D | W_F | |---|---|---| -| passive | +0.0399 | +0.0403 | -| medium | +0.0646 | +0.0189 | -| aggressive | +0.1056 | -0.0310 | +| passive | -2.6844 | +0.1722 | +| medium | -1.5568 | +0.0779 | +| aggressive | +0.2018 | -0.0239 | ## E2 — ΔY decomposition -*Generated 2026-08-03T12:08:12 from `b3d5889` · **working tree DIRTY** · calibration `de195df2` · scope `broad` · `psi_lambda_B=8.5` · `mv_rule=0` · `recovery_rate=0.3` · `writeoff_enabled=0` · `zeta_writeoff=0`* +*Generated 2026-08-18T12:30:46 from `ea23e94` · **working tree DIRTY** · calibration `86c89892` · scope `broad` · `psi_lambda_B=0.0` · `mv_rule=0` · `recovery_rate=0.3` · `writeoff_enabled=0` · `zeta_writeoff=1`* Identity (`market_clearing_D`): `dY = P_ss·dC + C_ss·dP_CES + dI + dG + dΦ + dT + dNX`. `goods_mkt_D` is a targeted residual (≤1e−14), so this closes to solver tolerance — the decomposition is **self-verifying**, and the runner asserts closure at 1e-07 rather than warning. | regime | Y_D[0] (% SS) | Y_D trough (% SS) | dI PV | dNX PV | dC(qty) PV | dC(price) PV | max\|residual\| | |---|---|---|---|---|---|---|---| -| passive | -0.0149 | -0.0149 | +3.050e-03 | +1.414e-04 | -3.750e-04 | +5.088e-05 | 6.75e-17 | -| medium | +0.0111 | +0.0065 | +3.758e-03 | +2.251e-04 | -1.505e-04 | +1.262e-04 | 1.30e-16 | -| aggressive | +0.0338 | +0.0043 | +3.920e-03 | +4.997e-05 | +8.898e-04 | +1.631e-04 | 2.27e-16 | +| passive | -1.9742 | -1.9742 | -3.463e-02 | +9.647e-03 | -2.713e-02 | +2.960e-03 | 7.90e-17 | +| medium | -0.3287 | -0.3287 | -4.281e-02 | +3.248e-03 | +4.602e-03 | +2.181e-03 | 8.59e-17 | +| aggressive | +0.6830 | -0.1542 | -5.393e-02 | -2.112e-03 | +2.883e-02 | +1.311e-03 | 1.66e-16 | ### Impact (t=0) decomposition, level deviations | component | passive | medium | aggressive | |---|---|---|---| -| consumption_quantity | +1.462e-03 | +2.055e-03 | +2.606e-03 | -| consumption_price | +1.763e-04 | -3.416e-04 | -7.955e-04 | -| investment | -1.868e-03 | -7.025e-04 | +2.945e-04 | +| consumption_quantity | -1.696e-02 | +5.353e-04 | +1.146e-02 | +| consumption_price | +6.198e-04 | +3.674e-05 | -3.263e-04 | +| investment | -7.269e-03 | -3.895e-03 | -1.948e-03 | | government | +0.000e+00 | +0.000e+00 | +0.000e+00 | | portfolio_cost | +0.000e+00 | +0.000e+00 | +0.000e+00 | | macropru_tax | +0.000e+00 | +0.000e+00 | +0.000e+00 | -| net_exports | +7.955e-05 | -9.005e-04 | -1.767e-03 | -| **dY[0] total** | **-1.494e-04** | **+1.107e-04** | **+3.377e-04** | +| net_exports | +3.872e-03 | +3.554e-05 | -2.357e-03 | +| **dY[0] total** | **-1.974e-02** | **-3.287e-03** | **+6.830e-03** | -> **The headline output number is the residue of two much larger offsetting channels.** Passive → aggressive, `dY[0]` moves by ~+4.9e−04 while investment moves ~+2.2e−03 and net exports ~−1.9e−03 — each roughly 4× the headline and opposite in sign. This confirms `docs/SPEC.md`'s standing caution as a measured property of this calibration. **Report the decomposition, not the headline ΔY.** +> **The headline output number is no longer a residue of larger offsetting channels — it now exceeds each of them.** passive → aggressive, `dY[0]` moves by +2.66e-02 while investment moves +5.32e-03 and net exports -6.23e-03 — the largest single channel is 0.23x the headline. **Report the decomposition, not the headline ΔY** — the channels still offset, and `docs/SPEC.md`'s standing caution is about their cancellation, not about which term happens to be largest. > `government`, `portfolio_cost` and `macropru_tax` are **verified** zero, not merely uncached: `G_D` is constant and absent from the Jacobian, `Phi_D` has no Jacobian column (the portfolio adjustment cost is quadratic about its anchor, so its level deviation is second-order), and `T_D` is identically zero at `T0=T1=0`. The identity closes *because* all three are genuinely zero. ## E3 — S-1 writeoff -*Generated 2026-08-03T12:32:16 from `de03a8e` · **working tree DIRTY** · calibration `de195df2` · scope `broad` · `psi_lambda_B=8.5` · `mv_rule=0` · `recovery_rate=0.3` · `writeoff_enabled=0` · `zeta_writeoff=0`* +*Generated 2026-08-18T12:06:02 from `ea23e94` · **working tree DIRTY** · calibration `86c89892` · scope `broad` · `psi_lambda_B=0.0` · `mv_rule=0` · `recovery_rate=0.3` · `writeoff_enabled=0` · `zeta_writeoff=1`* -`writeoff_enabled` is steady-state-neutral: every writeoff term is multiplied by `def_rate_ss = 0`. `zeta_writeoff` is **not** — it enters the `EL_price` anchor at `code/steady_state.py:107-112` *ungated by* `writeoff_enabled`, and `EL_price` is the loading's denominator. S-1 is therefore two nested variants, not one switch. +The two switches answer different questions. `writeoff_enabled` selects which BRANCH the impulse response traces and is steady-state-neutral: every realised writeoff term is multiplied by `def_rate_ss = 0`. `zeta_writeoff` governs what is PRICED — whether a default writes down the perpetuity's continuation value alongside its coupon. Both are SS-neutral, for the same reason: every writeoff term is multiplied by `def_rate_ss = 0`, inside `rb_exp` as well as inside `rb_actual`. So `zeta_writeoff` is allocation-neutral while still changing the linearised pricing equation, and hence every dynamic result. Since the 2026-08-18 refactor the baseline is `zeta_writeoff = 1`; `e3b_coupon_only_pricing` is the §12 Arm-3 diagnostic showing what the pre-refactor coupon-only payoff was worth. -| setting | `writeoff_enabled` | `zeta_writeoff` | EL_price_D | peak spread, passive (bp ann) | loading (medium) | loading (aggressive) | +| setting | `writeoff_enabled` | `zeta_writeoff` | EL_load_D | peak spread, passive (bp ann) | loading (medium) | loading (aggressive) | |---|---|---|---|---|---|---| -| baseline | 0 | 0.0 | 0.056134 | 150.3 | 4.00 | 3.17 | -| e3a_coupon_only | 1 | 0.0 | 0.056134 | 149.1 | 3.93 | 3.13 | -| e3b_full | 1 | 1.0 | 0.701743 | 168.9 | 0.37 | 0.28 | +| baseline | 0 | 1.0 | 0.701400 | 205.9 | 0.48 | 0.47 | +| e3a_realised_writeoff | 1 | 1.0 | 0.701400 | 122.7 | 0.36 | 0.32 | +| e3b_coupon_only_pricing | 0 | 0.0 | 0.055790 | 12.3 | 0.48 | 0.43 | + +γ note: solved on the BASELINE and held fixed across variants, so a difference in the table is attributable to the switch alone. `medium` = 25% peak-spread compression; **`aggressive` is ≈46.6%, not 50%** — see the E1 note above. -γ note: Solved on the BASELINE (0/25/50% peak-spread compression) and held fixed across variants, so a difference in the table is attributable to the writeoff switch alone. Under e3b_full the peak spread is not monotone in gamma, so these targets are not even well-defined there — see compression. +Note recorded in the results file at run time: Solved on the BASELINE (0/25/50% peak-spread compression) and held fixed across variants, so a difference in the table is attributable to the writeoff switch alone. Under e3b_full the peak spread is not monotone in gamma, so these targets are not even well-defined there — see compression. -### The headline: full writeoff inverts Live Claim 1 +### What the payoff specification is worth -Under `zeta_writeoff = 1` the loading falls **below 1** — the CB becomes *under*-compensated, receiving roughly 30% of the actuarially fair expected loss, where the paper's central claim is over-compensation. The mechanism is attributable to the denominator alone: premium income barely moves while the priced expected loss goes up by an order of magnitude. It is a repricing of the expected loss, not a change in what the CB earns. +`EL_load_D` is the expected loss per unit of default probability implied by the bond contract. Coupon-only pricing (`zeta_writeoff = 0`) puts it at `(1-rec)·delta_b/q_b`; full pricing puts it at `(1-rec)·[delta_b + (1-delta_b)q_b]/q_b`, larger by roughly `[delta_b + (1-delta_b)q_b]/delta_b ≈ 12.6` on a 12.9-quarter claim. Read the loading column with that denominator in mind: it is premium income per unit of expected loss ABSORBED, so a bigger, better-specified loss lowers it mechanically without the central bank earning any less. | variant | regime | EL PV (% Y) | premium PV (% Y) | loading | |---|---|---|---|---| -| baseline | medium | 0.00228 | 0.00909 | 4.00 | -| baseline | aggressive | 0.00391 | 0.01240 | 3.17 | -| e3a_coupon_only | medium | 0.00220 | 0.00867 | 3.93 | -| e3a_coupon_only | aggressive | 0.00382 | 0.01194 | 3.13 | -| e3b_full | medium | 0.02696 | 0.00988 | 0.37 | -| e3b_full | aggressive | 0.04542 | 0.01271 | 0.28 | +| baseline | medium | 0.27036 | 0.13022 | 0.48 | +| baseline | aggressive | 0.50132 | 0.23467 | 0.47 | +| e3a_realised_writeoff | medium | 0.09947 | 0.03559 | 0.36 | +| e3a_realised_writeoff | aggressive | 0.19282 | 0.06158 | 0.32 | +| e3b_coupon_only_pricing | medium | 0.00136 | 0.00066 | 0.48 | +| e3b_coupon_only_pricing | aggressive | 0.00305 | 0.00133 | 0.43 | ### Verification -| variant | EL_price (closed form) | EL_price (solved) | ×baseline | max SS drift | +| variant | EL_load (closed form) | EL_load (solved) | ×baseline | max SS drift | |---|---|---|---|---| -| e3a_coupon_only | 0.056134 | 0.056134 | 1.00× | 0.000e+00 | -| e3b_full | 0.701743 | 0.701743 | 12.50× | 0.000e+00 | +| e3a_realised_writeoff | 0.701400 | 0.701400 | 1.00× | 0.000e+00 | +| e3b_coupon_only_pricing | 0.055790 | 0.055790 | 0.08× | 0.000e+00 | -Both variants require a full SS + Jacobian re-solve: patching the solved SS and re-solving only the Jacobian would presume the very invariance E3a exists to test. +Both variants require a full SS + Jacobian re-solve: patching the solved SS and re-solving only the Jacobian would presume the very invariance these variants exist to test. -> **Measured SS drift is 0.000e+00 for BOTH variants**, which refines the design spec's prediction that E3b "moves the steady state". `EL_price` changes value 12.5×, but no steady-state *allocation* moves: it multiplies `def_rate`, which is 0 at SS. So it is allocation-neutral while still changing the linearised bond FOC, and hence every dynamic result. +> **Both variants must show zero SS drift.** Measured 2026-08-18: `q_b_D = 0.974906` in all three arms. Every writeoff term — priced or realised — is multiplied by `def_rate_ss = 0`, so neither switch moves an allocation. What `zeta_writeoff` does move is `EL_load_D` (0.0558 -> 0.7014, 12.6x) and hence the linearised pricing equation: peak spread on a 1pp shock goes 12.3bp -> 205.9bp. Allocation-neutral, dynamically decisive. ### Is compression targeting even well-defined? @@ -119,11 +123,11 @@ The named regimes are *defined* as 25%/50% peak-spread compression, found by bis | setting | monotone in γ? | peak @ γ=0 (bp) | min peak (bp) | γ at min | violations | |---|---|---|---|---|---| -| baseline | yes | 150.3 | 67.9 | 15.00 | 0 | -| e3a_coupon_only | yes | 149.1 | 68.5 | 15.00 | 0 | -| e3b_full | **NO** (from γ≈0.38) | 168.9 | 82.7 | 15.00 | 2 | +| baseline | yes | 205.9 | 136.7 | 15.00 | 0 | +| e3a_realised_writeoff | yes | 122.7 | 91.9 | 15.00 | 0 | +| e3b_coupon_only_pricing | yes | 12.3 | 10.1 | 15.00 | 0 | > **Under full writeoff the named-regime construction itself breaks.** Peak spread stops being monotone in γ, so "25% compression" no longer identifies a unique γ. The violations are two of 39 grid steps: a trivial one at γ≈0.39 and a large spike at γ≈3.46 (144.4 → 166.6 bp), after which the curve resumes falling. That isolated spike sits where `I − γ·A_cb` is plausibly near-singular, so read it as a linear-algebra pathology rather than economics until confirmed — but compression-targeted regimes cannot be defined under this setting, which is why every row above is evaluated at the baseline's γ held fixed. -> `psi_lambda_B = 8.5` was tuned to 150 bp with realised losses **off**. The overshoot above is a reportable fact about whether that target survives S-1 — **not** a number to re-tune away. Whether to re-tune is a separate author decision this result informs. +> `psi_lambda_B = 0` was tuned to 150 bp with realised losses **off**. The overshoot above is a reportable fact about whether that target survives S-1 — **not** a number to re-tune away. Whether to re-tune is a separate author decision this result informs. diff --git a/docs/paper_draft_results.md b/docs/paper_draft_results.md index 8137b01..cf41015 100644 --- a/docs/paper_draft_results.md +++ b/docs/paper_draft_results.md @@ -1,8 +1,8 @@ # First-draft results — tables and figures -*Generated 2026-08-05T14:15:09 from `b3983cb` · calibration `de195df2` · `BANK_SCOPE=broad` · `writeoff_enabled=0` (pure risk-premium framing, S-1 resolved 2026-08-04).* +*Generated 2026-08-18T12:33:38 from `ea23e94` · calibration `86c89892` · `BANK_SCOPE=broad` · `writeoff_enabled=0` (pure risk-premium framing, S-1 resolved 2026-08-04).* -Generated by `experiments/paper_outputs.py`. Every number is read live from the solved steady state or the cached response matrices — none is transcribed. Figures are in `experiments/paper/`, each with its caption baked into the image. +Generated by `experiments/paper_outputs.py`. Every number is read live from the solved steady state or the cached response matrices — none is transcribed. Figures are in `experiments/paper/`, each with its caption baked into the image. **The captions are derived too**: each is built by its own figure function from the arrays that figure plots, so a caption cannot contradict a table below it the way the hardcoded set did between the sticky-price conversion and 2026-08-06. ## Table 1 — Calibration and identification ledger @@ -12,11 +12,11 @@ The distinction that matters for a referee is *which* parameters are measured, w |---|---|---|---| | `theta_D` / `theta_F` | 5.511 / 6.941 | **measured** | EBA 2011 disclosure: GK-eligible assets (corporate ex-CRE + CRE + sovereign) EAD ÷ Core Tier 1 | | `delta_b_D` / `delta_b_F` | 0.0777 / 0.0568 | **measured** | EBA sovereign maturity ladder repriced at the 31-Dec-2010 market yield; modified duration 3.12y (GGB) / 4.22y (Bund) | -| `phi_bD_D` (own-book concentration) | 0.456 | **measured** | EBA 2011 bank-held sovereign book ÷ capital, broad-sector scope | -| `b_F_D` (cross-holdings) | 0.0077 | **measured** | EBA 2011 bilateral GR/DE exposures | +| `phi_bD_D` (own-book concentration) | 0.452 | **measured** | EBA 2011 bank-held sovereign book ÷ capital, broad-sector scope | +| `b_F_D` (cross-holdings) | 0.0075 | **measured** | EBA 2011 bilateral GR/DE exposures | | `n_inter_D` / `n_inter_F` | 2.138 / 1.627 | *implied* | `(Q·K + sovereign) / theta` — follows from measured leverage and the balance sheet | | `recovery_rate` | 0.30 | *external estimate* | Zettelmeyer, Trebesch & Gulati (PIIE WP13-8): 59–65% investor NPV loss in the March 2012 Greek PSI | -| `psi_lambda_B` | 8.50 | **FREE — the one amplification dial** | matched to the ~150bp 2010 GR–DE spread on a 1pp default shock; no EBA counterpart | +| `psi_lambda_B` | 0.00 | **FREE — the one amplification dial** | matched to the ~150bp 2010 GR–DE spread on a 1pp default shock; no EBA counterpart | | `def_scale` | 0.25 | free | strong-amplification choice; exceeds the 0.12–0.23 GR-2011 range | | `Delta_bD_D` / `Delta_bF_D` | 0.20 / 0.40 | **unidentified** | no EBA counterpart; the GK feasibility inequality bounds but does not pin them | | `phi_lamb` | 0.15 | free | fiscal feedback; literature (Staehr 2008) is 0.025–0.038 quarterly | @@ -26,26 +26,26 @@ The distinction that matters for a referee is *which* parameters are measured, w | Moment | Target | Model | Source | |---|---|---|---| -| Peak D–F spread, 1pp default shock | ~150 bp ann. | **150.3 bp** | 2010 GR–DE 10y spread | +| Peak D–F spread, 1pp default shock | ~150 bp ann. | **205.9 bp** | 2010 GR–DE 10y spread | | Capital–output ratio `K/Y` (annual) | 2.70 | **2.70** | conventional | | Steady-state return on capital `rk` | 0.0100 | **0.010000** | conventional quarterly | -| Bank net-worth pass-through | −1.8 to −8.6 %/100bp | **-2.25 %/100bp** | Acharya–Drechsler–Schnabl (2014 JF) bank-equity-on-sovereign-CDS elasticity, converted at three baselines | -| Steady-state D and F bond yields | equalised | 0.002491 / 0.002491 | model restriction | +| Bank net-worth pass-through | −1.8 to −8.6 %/100bp | **-5.54 %/100bp** | Acharya–Drechsler–Schnabl (2014 JF) bank-equity-on-sovereign-CDS elasticity, converted at three baselines | +| Steady-state D and F bond yields | equalised | 0.002000 / 0.002000 | model restriction | ## Table 3 — Main results | | passive | medium | aggressive | |---|---|---|---| -| backstop coefficient γ | 0.000 | 5.080 | 12.726 | -| peak D–F spread (bp ann.) | 150.3 | 112.7 | 75.2 | -| output, impact (% SS) | -0.0149 | +0.0111 | +0.0338 | -| investment, impact (% SS) | -0.7718 | -0.2903 | +0.1217 | -| bank net worth, impact (% SS) | -3.380 | -2.167 | -1.099 | -| ECB exposure PV (% of quarterly $Y_D$) | 0.0000 | 0.4265 | 1.1062 | -| priced expected loss PV (% of $Y_D$) | 0.00000 | 0.00228 | 0.00391 | -| **loading (premium ÷ expected loss)** | n/a | **4.00** | **3.17** | +| backstop coefficient γ | 0.000 | 9.989 | 19.875 | +| peak D–F spread (bp ann.) | 205.9 | 154.4 | 122.9 | +| output, impact (% SS) | -1.9742 | -0.3287 | +0.6830 | +| investment, impact (% SS) | -3.0046 | -1.6100 | -0.8053 | +| bank net worth, impact (% SS) | -11.407 | -5.541 | -2.037 | +| ECB exposure PV (% of quarterly $Y_D$) | 0.0000 | 6.0571 | 11.2618 | +| priced expected loss PV (% of $Y_D$) | 0.00000 | 0.27036 | 0.41108 | +| **loading (premium ÷ expected loss)** | n/a | **0.48** | **0.45** | -Default loading decomposition: `EL_price = 0.056134`, `psi_spread = 1.737724` → fundamental expected loss is **3.1%** of the total and the collateral-friction wedge is **96.9%**. +Sovereign-spread mechanism: expected loss on the perpetuity is `EL_load_D = 0.701400` per unit of default probability (coupon *and* continuation value, `zeta_writeoff = 1`), priced inside the GK portfolio FOC. Equilibrium peak spread is **205.9 bp** annualised. There is no separate spread parameter and no fundamental/non-fundamental share — see fig04. ## Table 4 — Distributional incidence, by income quintile @@ -53,11 +53,11 @@ PV of the consumption response over 40 quarters, % of each quintile's own steady | income quintile | passive | medium | aggressive | backstop gain | |---|---|---|---|---| -| Q1 (lowest) | +0.9472 | +1.0913 | +1.3517 | +0.4045 | -| Q2 | +0.0762 | +0.1205 | +0.2890 | +0.2128 | -| Q3 | -0.2322 | -0.2190 | -0.0820 | +0.1502 | -| Q4 | -0.4199 | -0.4256 | -0.3098 | +0.1101 | -| Q5 (highest) | -0.5949 | -0.6188 | -0.5253 | +0.0696 | +| Q1 (lowest) | -3.5823 | +2.1577 | +6.2228 | +9.8052 | +| Q2 | -3.8271 | +1.0673 | +4.7289 | +8.5560 | +| Q3 | -4.0474 | +0.4993 | +4.0050 | +8.0524 | +| Q4 | -4.2144 | +0.0905 | +3.4971 | +7.7115 | +| Q5 (highest) | -4.3746 | -0.3221 | +2.9962 | +7.3708 | > **Do not run this cut on wealth.** Binning on steady-state deposits with fixed boundaries makes the per-capita number overwhelmingly *composition*: the deposit distribution shifts across the thresholds, bin masses move by 2–3e−3 (2–3% of bin mass), and the net is a small residue of two large nearly-cancelling terms — bottom decile, PV: −41.6 consumption against −44.4 mass, netting +2.8. The arithmetic is exact and the object is well defined, but it must not be described as how poor households behaved. @@ -65,49 +65,49 @@ PV of the consumption response over 40 quarters, % of each quintile's own steady ### `fig01_transmission` -A 1pp rise in the Greek default probability widens the D–F spread 150bp and cuts bank net worth 3.4%, transmitting to the real economy almost entirely through investment (−0.77% on impact); the backstop's cushioning is concentrated in the first few quarters — by quarter four the net-worth and investment paths have converged and the spread ordering reverses, so intervention damps the initial impact and the later undershoot rather than shifting the whole path down. +A 1pp rise in the Greek default probability widens the D–F spread to a peak of 206bp, cuts bank net worth 11.4% and investment 3.0% on impact, and takes output -1.97% from steady state; the backstop's cushioning is concentrated in the opening quarters — the ordering never reverses inside the plotted window, so the backstop shifts the whole path rather than only its opening quarters. ![fig01_transmission](../experiments/paper/fig01_transmission.png) ### `fig02_loading_schedule` -KEY FIGURE — the ECB earns 4.5× the actuarially fair expected loss on a weak backstop but only 2.1× on a strong one, because the premium is a rent extracted from a balance-sheet-constrained seller and intervention relieves the very constraint that creates it: the profit self-extinguishes as the policy succeeds. +KEY FIGURE — the ECB's compensation per unit of expected loss falls monotonically from 0.53× at γ=0.34 to 0.45× at γ=20 and stays BELOW the actuarially fair benchmark of 1 throughout — the ECB is under-compensated at every intervention intensity, so the paper must NOT assert over-compensation; medium 0.48×, aggressive 0.45× at the named regimes (peak spread compresses 206bp → 123bp over the same grid). The premium is a rent extracted from a balance-sheet-constrained seller and intervention relieves the very constraint that creates it: the profit self-extinguishes as the policy succeeds. ![fig02_loading_schedule](../experiments/paper/fig02_loading_schedule.png) ### `fig03_dy_decomposition` -The crisis cuts investment sharply and is masked in the aggregate mainly by consumption (panel A), while the backstop works through a different pair — investment recovers against a net-export deterioration, each roughly four times the headline and opposite in sign (panel B) — so a near-zero ΔY reflects reallocation across very different households, not a small shock or a weak policy. +On impact the crisis is a joint contraction: consumption contributes -16.96 and investment -7.27 (×10⁻³ of D-goods) against a +3.87 net-export cushion, summing to -19.74 (panel A). The backstop works through the same margin rather than a different one: moving passive → aggressive raises ΔY by +26.57, of which consumption supplies +1.07×, with investment at +0.20× and net exports at -0.23× largely offsetting each other (panel B). So the headline ΔY is a residue of larger offsetting channels, which is why the decomposition and not the headline is the object to report. ![fig03_dy_decomposition](../experiments/paper/fig03_dy_decomposition.png) ### `fig04_spread_decomposition` -Only 3% of the sovereign default loading is fundamental expected loss; the other 97% is the collateral-friction wedge charged by a constrained intermediary, which is why the risk is priced far above fair value and why moving it to an unconstrained holder is an efficiency gain rather than a transfer. +The sovereign spread is generated by the bond's state-contingent payoff inside the intermediary's portfolio optimality condition, not by any spread parameter. Pricing the expected loss on the perpetuity — coupon *and* continuation value, 0.7014 per unit of default probability — at a required return frozen at its steady-state level already accounts for 214bp of Greek yield, against an equilibrium 190bp: the intermediary channel is a modest offset at the price while being a large amplifier for quantities. The 206bp spread exceeds the Greek yield because the German leg falls in a flight to quality. These are joint mechanisms along one equilibrium path, not separable shares of the price. ![fig04_spread_decomposition](../experiments/paper/fig04_spread_decomposition.png) ### `fig05_incidence` -As the backstop strengthens Germany's discounted exposure rises steadily while the compensation it earns per unit of expected loss falls, so the two objects the German litigation actually turned on — quantity of risk assumed and price paid for it — move in opposite directions. +As the backstop strengthens Germany's discounted exposure rises steadily — from zero to 11.26% of quarterly steady-state $Y_D$ at γ=20 — while the compensation it earns per unit of expected loss falls steadily from 0.53× to 0.45×, so the two objects the German litigation actually turned on — quantity of risk assumed and price paid for it — move in opposite directions. ![fig05_incidence](../experiments/paper/fig05_incidence.png) ### `fig06_net_effects` -Contributions to the output response quarter by quarter: the crisis is an investment collapse partly offset by consumption and a small net-export cushion, and the backstop works by shrinking the investment hole rather than by lifting output uniformly — the net path (black) is at every horizon far smaller than the components that generate it. +Contributions to the output response quarter by quarter. Without a backstop the impact quarter is a joint consumption-and-investment contraction (-19.74 ×10⁻³ of D-goods in total). The backstop works by lifting the consumption contribution in the opening quarters rather than by raising output uniformly, and beyond the impact quarter the aggregate hides most of what moves underneath it: the net path (black) is smaller in magnitude than the largest single component in 8 of the first 16 quarters. ![fig06_net_effects](../experiments/paper/fig06_net_effects.png) ### `fig07_ms_regimes` -A three-state Markov-switching model on peripheral–Bund spreads dates the ECB's intervention stance and disciplines the model's three backstop regimes — the high-spread 'hawk' state covers 2010–14 and the ergodic shares (23%/52%/25%) are what the regime-uncertainty beliefs are set to — though the pre-1999 stretch predates the ECB and reflects EMU convergence, not any policy stance. +A three-state Markov-switching model on peripheral–Bund spreads dates the ECB's intervention stance and disciplines the model's three backstop regimes: the high-spread 'hawk' state covers 2010–2014, and the ergodic shares (23% / 52% / 25%) are what the regime-uncertainty beliefs are set to — though the pre-1999 stretch predates the ECB and reflects EMU convergence, not any policy stance. Estimated from market data, so unlike every other figure here it does not move with the calibration. ![fig07_ms_regimes](../experiments/paper/fig07_ms_regimes.png) ### `fig08_deciles` -Consumption rises for every income quintile on impact — the investment collapse releases resources — then troughs around quarter five, and the trough is roughly three times deeper for the top quintile (−0.11%) than the bottom (−0.04%); discounted over 40 quarters the crisis is progressive in incidence, costing the highest-income quintile 0.59% of its consumption while the lowest gains 0.95%, and the backstop's protection is monotone in the same direction. +Consumption falls by about 2.51% in every income quintile on impact, so the distributional difference emerges only afterwards. Discounted over 40 quarters the burden of the crisis falls on the top of the income distribution, monotonically across the five quintiles: the highest-income quintile loses 4.37% of its own consumption while the lowest quintile loses 3.58% of its own consumption. The backstop's protection runs the same way, also monotone in quintile: it is worth +9.81% of consumption to the lowest quintile against +7.37% to the highest. ![fig08_deciles](../experiments/paper/fig08_deciles.png) diff --git a/docs/referee_report_2.md b/docs/referee_report_2.md new file mode 100644 index 0000000..34d0144 --- /dev/null +++ b/docs/referee_report_2.md @@ -0,0 +1,288 @@ +# Referee Report — Second Referee +## Focused on motivation, framing and contribution + +Scope note: findings about the correspondence between Sections 2–3 and the +sequence-space code are parked pending the authors' reconciliation of the two +implementations. Everything below is a framing objection that survives that +reconciliation, because it bears on the paper's argument rather than on either +solver. + +--- + +## Verdict + +The introduction is well written and its argument is legible, which is why the +objections are structural rather than cosmetic. As it stands the motivation has +three load-bearing weaknesses: the model rules out the mechanism the policy +exists to address; the chosen laboratory is the one country the instrument +excludes; and the paper's second headline question is answered with the wrong +object. Each is fixable, and none is fixed by more results. + +--- + +## M1. The model assumes away the reason the policy exists + +Section 2 states that "We model default risk as *exogenous*", with a latent +factor `s_t` (sovereign-risk state) following an AR(1) and a logistic map to the +priced probability. Two paragraphs earlier the same section says the perpetuities +are "subject to *rollover risk*". Those are incompatible. Rollover risk is a +multiplicity concept — the bad equilibrium exists because lenders expect it to. +An exogenous AR(1) has one equilibrium and no self-fulfilling component at all. +The term must go, or the mechanism must arrive. + +The framing problem is larger than the word. The introduction's own account of +OMT and TPI is a multiplicity account: instruments "designed to be effective +without being deployed", removing premia that "impaired the transmission and +singleness of monetary policy", never used and never needing to be. That is the +Calvo/Cole–Kehoe/Corsetti–Dedola story of a backstop that selects the good +equilibrium at zero cost. In a model with exogenous default risk there is no bad +equilibrium to select away from, so the instrument cannot do the thing the +introduction spends two paragraphs saying it was built to do. What the paper can +evaluate is a price-support operation acting on intermediary balance sheets. That +is a real and interesting object, but it is not what the introduction promises. + +This also reframes the paper's most graceful passage. The introduction declines +the fundamental/non-fundamental decomposition, positioning that as a considered +methodological stance — "one equilibrium object seen twice, so no share of the +premium can be labelled non-fundamental" — and as complementarity with +Bocola and Dovis (2019). A hostile reading is simpler: the non-fundamental +component has been assumed to be zero, so of course no share of the premium can +be attributed to it. Declining a decomposition you have ruled out by assumption +is not a discipline. Either endogenise the default decision — a fiscal-limit +formulation is the obvious route and is standard in this literature — or state +plainly, in the introduction, that the paper studies the fundamental-risk +transmission channel conditional on an exogenous risk process, and that the +announcement/multiplicity channel is out of scope. The second is an acceptable +paper. The current framing tries to have both. + +## M2. Greece is the one sovereign the instrument would have excluded + +"Greece is the cleanest available laboratory" is the paper's central design +choice and it is defended in one paragraph, on the grounds that the episode was +large and long. Largeness is not cleanliness. Two objections, and the second is +serious enough that a referee will not let it pass. + +**Confounding.** The Greek collapse ran alongside a troika-administered fiscal +consolidation, three official programmes, IMF conditionality, structural +conditionality across labour and product markets, an actual restructuring, +redenomination risk that was openly priced from 2011, and eventually capital +controls. The model contains none of these: no official-sector financing, no +programme, no austerity path distinct from the tax rule, no exit risk. Ascribing +a 25% output collapse to a bank–sovereign feedback in that setting requires an +argument about the other channels, and the paper does not make one. The +introduction currently uses the size of the Greek depression as evidence that the +mechanism is identifiable — "violently enough, to identify the mechanism" — which +inverts the inference. A large collapse with many simultaneous causes identifies +less, not more. + +**Eligibility.** This is the harder problem. OMT was available only under an +appropriate EFSF/ESM programme and, for a country under a full macroeconomic +adjustment programme, only once it had regained bond-market access. Greece in +2010–12 had lost market access entirely, which is precisely why it was on official +financing. TPI's published criteria are stricter still: compliance with the EU +fiscal framework, absence of severe macroeconomic imbalances, fiscal +sustainability, and sound and sustainable macroeconomic policies. Greece in +2010–12 fails all four on any reading. The paper's title promises "an evaluation +of the Transmission Protection Instrument" and its research question is "what +would a TPI-style backstop have done had it existed during the Greek episode". +The answer a referee will supply is: nothing, because it would not have been +deployed. Italy 2011–12 and 2018, or Spain 2012, are the market-access, +plausibly-eligible cases, which is precisely why Bocola (2016), Bocola–Dovis and +Bi et al. all work on Italy. + +The authors have three routes, and must take one explicitly in the introduction. +Reframe the object as a generic sovereign backstop and drop TPI from the title +and the question. Keep TPI and move the laboratory to Italy. Or keep both and +argue that the eligibility criteria are not binding for the counterfactual — but +that argument has to be made, at length, and it is not currently attempted. + +## M3. The opening paragraph promises a loop; the model delivers a chain + +Paragraph 1 sets out four legs: repricing reduces bank net worth, tightens the +leverage constraint, contracts credit, and deteriorates "the tax base that +justified the original revision". The last clause closes the circuit and is what +earns the phrase "doom loop", explicitly contrasted with "a metaphor for +contagion in general". + +With exogenous default risk, that last leg does not exist. Output and the tax +base can fall as much as they like and the default probability does not move. The +paper's transmission is a one-way chain: sovereign risk → bank net worth → +credit → activity. That is a legitimate object and much of the empirical +literature the paper cites documents exactly that leg. But the opening paragraph +describes a feedback the model cannot produce, and the phrase "doom loop" is then +used throughout on that basis. Either close the loop or rewrite paragraph 1 to +describe the chain and say why the open version is the right object to quantify. + +## M4. The motivating statistic is a model artefact, and it is off by roughly a factor of five + +"Greek banks entered the crisis holding the overwhelming majority of their own +government's paper — in the calibration below, 87%." This sits in the paper's +first paragraph, in a sentence otherwise made of facts, and reads as one. + +It is not. The 87% is an equilibrium share in a model whose only holders of +sovereign debt are two banking systems: there is no official sector, no domestic +non-bank sector, no foreign non-bank investor. It is 87% of the paper the model +issues, not 87% of Greek general government debt. The paper's own EBA input has +Greek banks holding roughly EUR 54bn of Greek sovereign exposure against a +general government debt stock of about EUR 330bn at end-2010 — on the order of +16%, and falling as official financing displaced private holders through 2011–12. +The empirical claim the sentence makes is false by roughly five times. + +The underlying point — Greek banks were *concentrated*, holding an outsized +position relative to their own thin equity — is true, is the right point, and is +supportable directly from the EBA disclosures the paper already uses. Make that +claim, with that source, and keep model shares out of the motivating paragraph. +Note also that the asymmetry of the two banking systems, which the results lean +on heavily, is a calibrated feature the introduction never mentions. + +## M5. "No free parameter appears anywhere in it" oversells + +The claim attaches to the introduction's central identity, which pins the +sovereign premium as a fraction `Δ` (pledgeability of sovereign paper relative to +capital) of the capital premium. The identity has exactly one coefficient, and +that coefficient is a calibrated number. Saying no free parameter appears in an +equation whose only parameter is free is a rhetorical move, and it is the kind a +referee reports. What is true and worth claiming is narrower and better: the +sovereign premium is not an *additive* wedge chosen to hit a spread moment, but a +proportionality whose single coefficient is a collateral primitive with an +independent interpretation. Say that, then defend `Δ` — which the introduction +never states a value for, and which the body never introduces at all. + +The accompanying swipe — "a discipline earlier versions of this model, and much +of the applied literature, did not impose" — should be cut or named. Bocola (2016) +derives the sovereign premium from intermediary first-order conditions and is +cited approvingly two paragraphs later, so "much of the applied literature" is +carrying weight it has not earned. + +## M6. "No direct lever on the spread at all" is an assumption presented as a restriction + +The introduction bills this as the paper's contribution to what backstops can and +cannot do: purchases move the spread only by moving the return on capital, so the +spread effect and the investment effect are one effect. It is a nice result and I +believe it holds in the model. But it holds *because* a single representative +constrained intermediary is the marginal holder of every asset in the economy, and +because its problem is linear in portfolio shares under a constraint that always +binds. Relax any one of those — an occasionally slack constraint, a non-bank or +official marginal holder, segmentation, or any portfolio adjustment cost on the +domestic leg — and the proportionality breaks. The model itself already carries an +adjustment cost on the cross-border legs, which is an admission that the +proportionality cannot be imposed everywhere at once. + +Presented as "a restriction on what a sovereign backstop can and cannot do", this +reads as a general theoretical claim. It is a property of a particular market +structure. State the market structure in the same sentence, and the result becomes +defensible rather than overreaching. The phrasing also risks being read as an +efficacy claim — "no direct lever on the spread at all" — when the paper's own +results have the backstop compressing the spread substantially. Direct versus +indirect is a decomposition point, not an efficacy point, and the sentence does +not currently distinguish them. + +## M7. "Who would have paid for it?" is answered with exposure, not incidence + +This is half of the stated research question, and the introduction answers it with +a capital-key share: 93% of the resulting exposure sits with the German treasury. +Exposure is not payment. On the paper's own baseline, default is priced but never +realised, so along the traced path the central bank buys distressed paper that +subsequently performs and the operation is *profitable*. On that path the German +taxpayer does not pay; the German taxpayer is paid. What a reader wants is the +expected fiscal transfer integrated over the default distribution the model +prices, together with its distribution across the two countries and — given the +paper's fourth advertised contribution — across households within them. + +"It is a transfer, it is signed and scaled" is asserted three times in the +introduction and never given a number of the right kind. Either supply the +state-contingent expected cost, or retire the second half of the research question +and the "who would have paid for it" framing with it. + +## M8. Heterogeneity is advertised, and then not used + +The fourth contribution claim is that embedding this in a heterogeneous-agent +economy "makes the distributional incidence of an unconventional policy +computable". The three findings that follow are: aggregate impulse responses, +bank sovereign concentration, and who sells to the central bank under the capital +key. Not one is distributional. A reader finishes the introduction unable to say +why the model needed heterogeneous households, and the honest answer for these +three results is that it did not — every one of them would survive a +representative-agent household block. + +This is the most easily fixed objection in the report, because the incidence +results exist: there is a quintile incidence table and two distributional figures +in the project's own results set, and none of them reach the introduction. A +two-country heterogeneous-agent model with Gertler–Karadi intermediaries and +defaultable perpetuities is an expensive object, and the introduction has to +justify the expense with a result only that object can produce. Lead with the +incidence finding, or demote the claim. + +## M9. The marginal contribution over the closest analogue is thin as stated + +Against Bi et al. — named as "the nearest published analogue" — the introduction +claims three differences: heterogeneous households, a premium from portfolio +optimality rather than a calibrated wedge, and an output contraction from the +intermediary constraint rather than a loan-in-advance requirement. The third is +contradicted by the paper's own Section 2, which installs a working-capital +requirement and states in bold that it is the only impact channel from spreads to +output. The second is at least arguably true of the antecedent literature as +well. That leaves the first, which per M8 produces none of the reported findings. + +The contribution is almost certainly real — the proportionality restriction of M6 +and the selling-side asymmetry of the results are both genuinely novel. But they +are not what the paragraph claims. Rewrite it around what the paper actually does +that its antecedents do not. + +## M10. "Unanswered by the data" overclaims against literature the paper itself cites + +"Both were designed to be effective without being deployed, and both have been. +That leaves the central quantitative question unanswered by the data." Two +paragraphs later the paper cites the announcement-window evaluations of exactly +these programmes. The OMT announcement is one of the most heavily measured policy +events in modern macroeconomics, and those measurements are data about precisely +this instrument. What the announcement studies cannot deliver is the general +equilibrium counterfactual and the distributional incidence — which is the paper's +real claim, and which it makes correctly later in the same paragraph. + +The stronger move is to stop positioning the paper as filling a vacuum and start +using those estimates as external validation. A model of a never-used instrument +has no natural target; the OMT announcement effects are the closest thing +available, and matching them would materially raise the credibility of the +counterfactual. At present the introduction advertises that none of the reported +numbers is tuned to a target, which is honest, but leaves the reader with no +independent check on any of them. + +--- + +## Smaller framing points + +1. The section titled "Empirical Analysis" contains a calibration discussion and a + solution method, and no empirical analysis. Retitle. +2. There is no conclusion, and the policy section is a title and a label. The paper + currently cannot be assessed as an evaluation of anything. +3. "Evaluation" in the title implies welfare. If welfare is secondary in this + project — and the project's own notes say it is — the title overclaims. +4. The abstract is `Lorem Ipsum`. The abstract is where M1, M2 and M8 will be + judged, so it should be written after they are resolved, not before. +5. The road map promises households, production, intermediaries, the default event + and the government. It does not mention the central bank, which is the paper's + subject. +6. The paper says the recovery rate is the 30% "implied by the 2012 restructuring", + while the body cites a 53.5% face-value cut and a 59–65% net-present-value + haircut. 30% recovery is a 70% haircut, outside the range cited as its own + authority. Fix the number or drop the appeal. + +--- + +## What would make this reviewable + +In order of importance: + +1. Choose, in the introduction, between the multiplicity framing and the exogenous + risk process, and make the paper consistent with the choice (M1, M3). +2. Defend Greece against the eligibility objection, or move the laboratory, or + drop TPI from the title and question (M2). +3. Replace the 87% with a sourced concentration statistic from the EBA + disclosures (M4). +4. Lead the contribution paragraph with a distributional result, using the + incidence output that already exists (M8, M9). +5. Answer "who would have paid" with an expected fiscal transfer integrated over + the priced default distribution, or retire the question (M7). +6. Qualify the proportionality result by the market structure that produces it, + and state and defend the pledgeability coefficient (M5, M6). diff --git a/docs/referee_report_3.md b/docs/referee_report_3.md new file mode 100644 index 0000000..4081f81 --- /dev/null +++ b/docs/referee_report_3.md @@ -0,0 +1,396 @@ +# Referee Report — Third Referee + +## On the introduction, read against Sections 2 and 3 of the Overleaf draft + +**Scope.** The introduction (`VIVA/sections/01-introduction.tex`) checked for language, +internal coherence, and consistency with the compiled paper — `02-model.tex`, +`03-calibration.tex`, `04-policy experiment.tex`. No comparison to the repository's +sequence-space implementation: Section 2 describes the coauthors' global model and the +two are separate objects. No data verification. + +The comparison draft throughout is `01-introduction.tex.bak`, which +`docs/referee_report_2.md` reviewed. + +--- + +## Disposition (2026-08-27) + +The introduction was rewritten against these findings and pushed to Overleaf. What +was actioned, and what was left: + +**Actioned in `01-introduction.tex`.** C1 — the measurement justification is replaced +by what Section 3 does: periphery/core blocs, the sovereign side disciplined to the +Greek episode, the financial block openly attributed to Bocola. C2 — Bocola credited +in the literature paragraph and on the closed-form multiplier. C3 and C4 — the two +transmission properties reordered, the constraint channel labelled asset-neutral, the +risk premium identified as the sovereign-specific one, and the barely-binding steady +state stated. C6 — a paragraph added on flexible prices, real allocations and the +absence of a policy rate. C9 — the opening claim restated as an average rather than a +bound, and the debt ratio given a base date. C10 — the recessionary default branch +demoted from theorem to a property of the solved decision rules. Plus the language +items: "the paper" the central bank buys, the credit spread named, the episodic-risk +sentence connected to the global solution method, GHH preferences added, the +`fig:decoupling` redundancy removed, and the ECB's unwarranted-premia rationale +brought adjacent to the exogeneity restriction that rules it out. + +**Actioned in `04-policy experiment.tex`.** Section title corrected to "Outright +Monetary Transactions and the TPI"; the comment header no longer reads "Conclusion". + +**Left open, deliberately.** + +- **C8, the results paragraph.** Dropped at the author's instruction, not deferred. + The introduction states its three questions and moves to the calibration. The + findings that would fill the position exist only in the sequence-space + implementation, which is a different solution of a different model from the one + Sections 2--3 describe, so importing them would create exactly the kind of + inconsistency this report is about. The finding stands against the draft: until + Section 4 exists and supplies its own numbers, the introduction reports nothing + learned. +- **C7, Section 4.** Still a heading. The introduction's three questions and the road + map both still promise it. +- **C5 and the notation ledger.** In `02-model.tex` and `03-calibration.tex`, which + are the coauthors' global-model sections; not edited here. Outstanding: the + "rollover risk" phrase that contradicts the exogenous-default framing, the + `omega_X^f`/`omega_X` and `f_X`/`f` splits, the `lambda_X`/`Lambda` and `mu`/Smolyak + collisions, the two duration formulas, the unstated recovery rate, and the empty + recessionary-default proposition. +- **Compile defects in `02-model.tex`.** `\D` and `\F` are used at lines 43, 63 and + 142 but defined nowhere — `main.tex` carries only a commented-out macro block — so + the document currently builds with five errors and those symbols drop silently from + the PDF. There is also a stray `\\` after the proof at line 288. Left for the + coauthors, since defining the macros fixes the build but fixes their notation for + them. Three `XXX` cross-references remain undefined. +- **`references.bib`.** `bi2026asset` renders as (2026) against FRBSF Working Paper + 2025-10. + +The paper compiles to 23 pages with all citations resolved; every remaining error and +undefined reference predates this edit and sits in Section 2. + +--- + +## Verdict + +**Major revision.** The introduction is a faithful and often elegant summary of Section +2's *mechanics* — the two-property transmission story, the branch-contingent kernel, the +three-way price decomposition, the priced-but-unrealised default framing all match the +model section closely, in places almost verbatim. The problems are not there. + +They are in the two places where the introduction makes a promise the rest of the paper +does not keep: it justifies the choice of Greece by a measurement argument that Section 3 +does not carry out, and it describes a policy experiment that exists nowhere in the +document. Underneath both sits a pattern of under-attribution to Bocola (2016), on whose +posterior the paper's entire financial block rests. Add to that a policy section that is a +title, an abstract that is Lorem Ipsum, and an introduction that reports no findings. + +Findings C1, C2 and C7 are load-bearing. The rest are fixable in an afternoon. + +--- + +## C1. The stated reason for choosing Greece is contradicted by the calibration section + +This is the most serious inconsistency in the paper, and it is entirely internal. + +The introduction (ll. 138–141) justifies the laboratory on measurement grounds, and does +so emphatically: + +> We calibrate to Greece and Germany over 2010–12 **for reasons of measurement**. The +> 2011 EBA disclosures report sovereign exposures, maturity ladders and bilateral +> cross-holdings bank by bank, and the March 2012 restructuring supplies a realised +> recovery rate. **Elsewhere these objects have to be assumed.** + +Section 3 assumes them anyway. The EBA is not cited in the calibration section at all. The +financial block is instead lifted wholesale from an estimated model of *Italy*: + +| Object | Section 3's source | +|---|---| +| `theta_bar` (steady-state intermediary leverage) = 5 | "posterior mean of \citet{bocola2016pass}" | +| `f` (banker payout/exit share) = 0.04 | same | +| `varsigma_bar` (steady-state intermediation wedge) = 8 bp p.a. | "following \citet{bocola2016pass}" | +| `rho_s` (persistence of the latent risk factor) = 0.95 | "posterior means from \citet{bocola2016pass}" | +| `sigma_s` (innovation s.d. of the risk factor) = 0.63 | same | +| Smolyak grid half-width on `s` | "matching the coverage in \citet{bocola2016pass}" | + +Section 3 is candid about it — "Neither has a clean accounting counterpart", and the wedge +"is the least identified parameter of the model, and we treat it as such" — which is +exactly the right register, and makes the introduction's claim look worse by contrast. + +Three of the four EBA objects the introduction advertises never appear. Sovereign +exposures: absent. Bilateral cross-holdings: absent — the cross-border position `b^F_D` +that Section 2 puts on the balance sheet is **never calibrated anywhere in the paper**. +Maturity ladders: `delta_b` = 0.056 is set to a Greek debt duration, but no ladder and no +EBA citation. The recovery rate is the fourth, and Section 3 says only "We accordingly set +the recovery rate to match that event" after quoting *two* different numbers — a 53.5% +face-value reduction and a 59–65% NPV haircut. **The recovery rate is never stated.** + +Either write the calibration the introduction promises, or rewrite the paragraph to say +what Section 3 actually does: the sovereign and fiscal side are disciplined to the Greek +episode, the intermediary block is imported from Bocola's Italian posterior, and Greece is +chosen because the default event is observed and the recovery rate realised. That second +version is still a good reason to pick Greece. It is just not the reason currently given. + +## C2. The introduction under-credits Bocola (2016) three times + +C1 is one instance of a pattern the referee will notice and dislike. + +**First**, the literature paragraph (ll. 87–90) introduces Bocola as a limitation: +"\citet{bocola2016pass} measures the pass-through from sovereign risk to lending for +Italy, **in a model with a representative household**." The reader is invited to file it +under superseded. Section 3 then takes five parameters and a grid specification from it. +You cannot distance yourself from a paper's household block and adopt its financial +posterior in the same document without saying so. + +**Second**, Section 2 labels its central analytical result +"\begin{proposition}[Closed-form multiplier, **from \cite{bocola2016pass}}]" — an explicit +attribution. The introduction (ll. 110–112) presents the same object with none: "The first +is the multiplier on the incentive constraint, **which has a closed form.**" Standing +alone in a contribution paragraph, that reads as a claim of novelty for a result the +paper's own model section credits elsewhere. + +**Third**, `mu_closed` is described in Section 2 as "the analytical heart of the +transmission mechanism", and the introduction gives it top billing. If the heart is +borrowed, say so in the introduction — it costs one clause and removes the referee's best +line of attack. + +## C3. The two transmission properties are presented in the order the calibration reverses + +The introduction gives the multiplier channel primacy — "**The first** is the multiplier +on the incentive constraint" (l. 111) — the longer treatment, and the strongest verb: "The +working-capital wedge is **the channel** through which financial conditions reach output +on impact." The risk-premium channel comes "second" (l. 119) and gets four lines. + +Section 3 then calibrates the steady state to `mu_bar` (steady-state IC multiplier) = +0.0010 and calls it, in its own words, "**a barely-binding constraint**". Section 2's +Endogenous Premiums subsection says of the other channel that "the *risk premium* operates +even when current constraints are slack", and that it is what generates "contractionary +deleveraging pressure prior to any actual default event." + +So the paper's own calibration puts the economy at the edge of slackness, where the +channel the introduction ranks first is weakest and the channel it ranks second does the +work. A referee who reads Sections 2 and 3 will reverse the ordering and ask why the +introduction did not. The honest and more interesting version is that the constraint is +barely binding at the rest point and the anticipation channel is what moves the economy — +which is also a sharper contribution claim than the one currently made. + +## C4. "Premium" is written as though sovereign-specific; in the model the constraint channel is asset-neutral + +The introduction's second-property paragraph (ll. 119–124) is consistent with Section 2 — +`Omega^{(1)} > Omega^{(0)}`, the covariance term, the three-way decomposition, all of it +matches `eq:branch-sdf` and `eq:bond-decomposition`. But the prose reads as if the +banker's discount factor is what makes *sovereigns* special, and Section 2 says something +more specific. + +`eq:foc-general` imposes `E[Omega(R_j − R)] = lambda_X mu_X` for **every** asset class — +capital, home sovereign, foreign sovereign, and the working-capital book alike. A single +divertibility parameter `lambda_X` (fraction of assets the banker can abscond with), one +constraint, one multiplier: the liquidity discount is identical across the portfolio. +Nothing in the constraint channel distinguishes sovereign paper from capital. What is +sovereign-specific is the covariance term, because only `Xi^D` (the D-sovereign per-unit +payoff) carries the survival factor `h_{t+1}`, and Section 2 states explicitly that +"$F$-bonds are safe in both states." + +Say it that way. The clean statement — the constraint channel is asset-neutral, the risk +premium is sovereign-specific, and only the second can move a *spread* — is stronger than +what the introduction currently says, and it is what licenses Section 3's calibration +step, where a measured sovereign spread is used to bound `varsigma_bar`, a *capital* +excess return. As written, that step in Section 3 arrives unmotivated. + +## C5. Section 2 says "rollover risk"; the introduction correctly says the model has none + +Section 2 opens the sovereign block with: "Both governments issue [Hatchondo / +Chatterjee] perpetuities that are subject to \emph{rollover risk}." Four lines later: +"We model default risk as \emph{exogenous}", with a latent AR(1) factor `s_t` and a +logistic priced probability. + +The introduction (ll. 149–152) gets this right and states the cost plainly: "Default risk +follows an exogenous latent factor. The government's own solvency calculus plays no part +in it, so the model admits no self-fulfilling component." + +Rollover risk *is* the self-fulfilling channel. The introduction cites +\citet{bocola2019self} two paragraphs earlier for precisely the fundamental/rollover +decomposition, so the collision is visible to any reader who reaches Section 2. Delete the +phrase from Section 2; the introduction's version is the correct one and should not be +softened to accommodate it. + +## C6. "Monetary union" is asserted in the introduction and quietly withdrawn in Section 2 + +The introduction's model paragraph opens: "We build a two-country monetary union" +(l. 97). It says nothing further about prices, inflation, nominal contracts, or monetary +policy — not one word in 158 lines. + +Section 2's third sentence: "Prices are fully flexible, so all equilibrium objects below +are real. This implies that the ``monetary union'' is modelled at the level at which it +binds real allocations, namely a single union-wide funding market for intermediary +liabilities together with real interest parity." + +Section 2's own scare quotes are the tell. A paper titled *Uncertain Unconventional +Policy*, whose object is a central bank instrument, cannot leave the reader to discover in +Section 2 that the model has no nominal side. State it in the introduction, as a modelling +choice with a defence — the mechanism is a real balance-sheet mechanism, and the nominal +block would add transmission the paper is not studying. Stated up front it is a +simplification; discovered later it looks like something withheld. + +Two smaller casualties of the same silence. Section 2 posits "a single union-wide funding +market", yet `eq:foc-general` and `eq:rwc` carry country-indexed deposit rates `r_{X,t}`, +and Section 3 solves for `r_D` and `r_F` as separate market-clearing unknowns. And the +household budget deflates the predetermined deposit return by +`P^c_{X,t−1}/P^c_{X,t}`, an inflation term, one paragraph after "all equilibrium objects +below are real" — defensible, since the consumption basket price moves with the terms of +trade `p_t` under flexible prices, but it needs the half-sentence that says so. + +## C7. The policy experiment described in the introduction exists nowhere in the paper + +Lines 133–136: + +> Into this environment we introduce a purchase rule of the TPI kind. We ask what an +> unlimited, country-specific backstop does to the equilibrium, which banking system +> supplies the paper the central bank buys, and how the resulting exposure and the +> resulting gains fall across the two countries and across households. + +Section 2 has no central bank. It ends at the fiscal rule — no purchase rule, no +Eurosystem, no capital key. It also has no market-clearing conditions and no equilibrium +definition. Section 3 calibrates no policy parameter. Section 4 is a section heading: + +``` +% 4. Conclusion +\section{Outright Money Transations/TPI} +\label{sec:policy} +``` + +Six lines, of which two are a comment header that says "Conclusion", and a title +misspelling "Transactions" — and naming the instrument *Outright Money* Transactions, +where the introduction gets it right at l. 58. The road map then promises the reader that +"\cref{sec:policy}" delivers "the backstop counterfactual and its incidence." + +The three questions at ll. 133–136 are the paper's reason for existing. Until Section 4 +exists, the introduction is writing cheques on it. + +## C8. The introduction reports no findings + +A reader reaches the road map having been told what the model contains, why Greece, and +what will be asked — and not one thing that was learned. Every sentence in the results +position is interrogative. + +`01-introduction.tex.bak` had a full results paragraph. Whatever prompted its deletion, +the replacement is an introduction that cannot be assessed: a referee cannot grade a +contribution that has not been stated, and an examiner will ask why the paper does not +want to say what it found. Restore a results paragraph, with `docs/referee_report_2.md`'s +M5 and M6 qualifications applied to the claims that overreached. + +## C9. The 50 bp statistic does opposite work in the introduction and in Section 3 + +The introduction's opening sentence rests on it: "For the first decade of the euro, Greek +and German ten-year yields traded within half a percentage point of each other", footnoted +"Mean spread of 50 basis points over 1999Q1–2007Q4." It is offered as evidence of +convergence — the thing whose unwinding the paper is about. + +Section 3 reaches for the same number for the opposite purpose: as an *upper bound* on +`varsigma_bar`, the intermediation wedge — "The Greek–German ten-year differential +averaged 50 basis points over 1999Q1–2007Q4" — and then discards it, setting +`varsigma_bar` = 8 bp on Bocola's posterior instead. + +The same statistic cannot be the paper's motivating fact and a bound the paper declines to +use. Also note the logical form of the introduction's sentence: "traded within half a +percentage point" asserts a *bound*; the footnote supplies a *mean*. A mean of 50 bp is +consistent with wide excursions. Either state the claim as an average, or defend the bound +with a range. + +Section 3's footnote on this line is broken mid-sentence and will compile as such: +"\footnote{FRED series ...; the $2010\text{Q}1$--$2012\text{Q}2$}." + +## C10. The introduction asserts a proposition Section 2 states without content or proof + +Introduction, ll. 129–131: "The recession in the default branch is itself endogenous, +arising from the same balance-sheet mechanism operating on a smaller asset base. The model +imposes no exogenous output cost of default." This tracks Section 2 almost word for word, +so as a summary it is faithful. + +But in Section 2 the claim is a formal object: + +``` +\begin{proposition} [Under standard assumptions, default is recessionary] +\end{proposition} +Proof in the appendix. [ADD] +``` + +An empty proposition body, a proof that does not exist, and no appendix containing it. The +introduction presents as settled the one result the paper has flagged as owed. Either +prove it, demote it to a numerical finding, or state it in the introduction as a property +of the solved model rather than a theorem. + +--- + +## Notation and cross-section consistency + +Purely internal; all of these will be caught on a careful read. + +| Item | Problem | +|---|---| +| `omega_X^f` vs `omega_X` | The entrant transfer is `omega_X^f` in `eq:nw-lom` and `omega_X` in `eq:bank-div` — adjacent equations — and `omega_X` again in Section 3. One object, two symbols. | +| `f_X` vs `f` | Subscripted in `eq:branch-sdf` and the banker's problem, bare in `eq:omega`, `eq:nw-lom` and `eq:ss-closedform`. | +| `delta_{X,b}` vs `delta_b` | Subscripted once, in the default subsection; bare in `eq:bond-payoff`, `eq:govt-budget` and throughout Section 3. | +| `lambda_X` vs `Lambda_{X,t,t+1}` | Divertible asset fraction and household stochastic discount factor, differing only by case, and appearing in the same display (`eq:omega` beside `eq:foc-general`). Rename one. | +| `mu_{X,t}` vs Smolyak `mu` | Section 3's solution method sets "an isotropic Smolyak level $\mu=1$" — the same glyph as the rescaled IC multiplier, which Section 2 calls the analytical heart of the model. | +| `a_{it}` vs `a_{j,t}` | Household deposits in `eq:hh-budget`; the market value of the bank's position in asset class *j* in `eq:ng-excess`. | +| `w_{X,t}` units | `eq:hh-budget` deflates it by `P^c_{X,t}`, implying nominal; `eq:labour-demand` delivers it in own-good units, implying real. One of the two is wrong. | +| Bond duration | Section 2: "approximately $1/\delta_{X,b}$ quarters". Section 3: "$(1+\bar r)/(\bar r+\delta_b)$ quarters". Two formulas for one object, never reconciled. | +| Country naming | Greece/Germany (introduction), `D`/`F` (Section 2), "Periphery"/"Core" (Section 3, which announces a grouping strategy the introduction's single-country-pair framing contradicts). | +| Section 3 title | "Empirical Analysis", for a section containing a calibration and a solution method. The introduction's road map describes the contents correctly; the title matches neither. | +| Weak vs strict IC | Introduction: depositors lend "only while the franchise is worth **more than** the divertible proceeds". `eq:IC` is weak (`\ge`). | + +## Language and copy + +- **The abstract is "Lorem Ipsum."** +- Three unresolved cross-references in Section 2 — `\cref{XXX}` (l. 35), + `Section~\ref{XXX}` (l. 150), `Section~\ref{sec:XXX}` (l. 158) — two of which point at + the bond-payoff and predetermined-rate conventions the introduction relies on. +- Section 4's comment header says "4. Conclusion" above a section titled "Outright Money + Transations/TPI". Two errors and a missing section in six lines. +- Section 3 typos and broken sentences: "Perihpery"; "broadstrokes"; "To study its + quantitative needs" (garbled); "the wealth distribution, is pinned down" (stray comma); + "Since \eqref{eq:labour-demand} is the only channel from the credit spread to output on + impact." (fragment, no main clause); "the spread of the sovereign yield over the + risk-free rate, and any measured spread is an upper bound on it" (missing verb — the + sentence never says what the spread *equals*). +- Introduction, l. 135: "which banking system supplies **the paper** the central bank + buys" — "paper" meaning sovereign bonds, in a document that calls itself the paper four + times. Use "the bonds". +- The introduction never names the object Section 2 calls "the model's *credit spread*" + and "the single instantaneous quantity that links the financial block to the production + block." It has a name; use it. +- Redundancy: ll. 46–51 and ll. 65–72 both make the no-variation-to-estimate-against + point off `fig:decoupling`, and the figure is discussed at l. 46 but placed at l. 74. +- Missed connection: "Sovereign risk inside a currency union is episodic, close to zero + for long stretches and very large for short ones" (ll. 50–51) is the argument for + Section 3's global nonlinear solution — an occasionally binding constraint interacting + with a rare event. The introduction never cashes it, and never mentions the solution + method at all, though the road map promises one. +- The introduction omits two substantive choices from its model paragraph: GHH + preferences, which remove the wealth effect on labour supply and which Section 3 leans + on for identification, and the monopolistically competitive retail layer, which under + flexible prices contributes only a constant markup. +- ll. 138–147 and ll. 149–153 remain the strongest writing in the draft — the omitted + causes of the Greek depression, the concession on TPI eligibility, and the exogeneity + restriction stated with its cost. `docs/referee_report_2.md`'s M1 and M2, answered about + as well as they can be without changing the model. Keep every line. + +--- + +## What to do + +1. **Rewrite ll. 138–141.** Say what Section 3 calibrates. Greece is the right laboratory + because the default event is observed and the recovery realised — not because the bank + block is measured, which it is not. (C1) +2. **Credit Bocola (2016) in the introduction**, once in the literature paragraph and once + on the closed-form multiplier. (C2) +3. **Swap the order of the two transmission properties**, or add the sentence that says + the constraint is barely binding at the rest point and the anticipation channel is what + moves the economy. (C3, C4) +4. **Add two sentences on the nominal side** — flexible prices, real allocations, no + policy rate, and why that is the right abstraction here. (C6) +5. **Write Section 4, or cut ll. 133–136 back to a single forward-looking sentence.** The + present text promises a counterfactual, an incidence analysis, and a + who-sells-to-the-central-bank result, none of which the document contains. (C7) +6. **Restore a results paragraph.** (C8) +7. **Delete "rollover risk" from Section 2**, fix the notation ledger, resolve the three + `XXX` references, state the recovery rate, and either prove or demote the recessionary- + default proposition. (C5, C10) diff --git a/docs/referee_report_4.md b/docs/referee_report_4.md new file mode 100644 index 0000000..97cd7cc --- /dev/null +++ b/docs/referee_report_4.md @@ -0,0 +1,750 @@ +# Referee Report — Fourth Referee + +## On Section 2, "The Model", read against Sections 1 and 3 + +**Scope.** `VIVA/sections/02-model.tex` checked for internal consistency, algebraic +correctness, completeness as a model section, and agreement with +`01-introduction.tex` and `03-calibration.tex`. Every equation in the section was +re-derived. No comparison to the repository's sequence-space implementation: +`docs/referee_report_3.md` established that Section 2 describes the coauthors' global +model and that the two are separate objects, and that finding is respected here. + +Line numbers refer to `02-model.tex` as of 2026-08-27. Equation numbers are the +compiled ones from `main.pdf` (23 pages) where I give them; otherwise I use the +labels. + +--- + +## Verdict + +**Major revision.** The algebra that is present is, with one exception, correct — I +re-derived the CES demand system, the Jermann adjustment-cost block, the +excess-return representation, Proposition 1, the bond-pricing formula, the credit +spread and the two government equations, and they all check out. Section 3's +closed-form steady state reproduces Section 2's equations exactly (`\bar\mu` = 0.000996 +against the reported 0.0010, the `\omega_X` > 0 bound at 3.8667% against the reported +3.87%). That is more than most model sections survive. + +The problems are of a different kind. **Section 2 does not define an equilibrium.** +There is no market clearing of any kind in it — no resource constraint, no bond +clearing, no deposit clearing, no labour clearing, no parity condition, no +productivity process, no household first-order condition, and no statement of how the +household continuum aggregates. What the section contains is a list of agents' +problems. A reader cannot tell what the model *is*, and cannot check that the seven +unknowns Section 3 solves for are the right seven. + +Underneath that sit two substantive defects. The bank's portfolio problem as written +is **over-determined**: four Euler equations per bank against two bond prices, with no +adjustment cost and no corner, and the cross-border position `b^F_D` (D-bank holdings +of F-government paper) is never determined by anything. And the sentence that +explains the risk-premium mechanism (l. 411) **contradicts itself and inverts the +sign** of the object it is explaining. + +Findings **M1, M2, M3** are load-bearing. **M4–M9** need decisions rather than +repairs. The rest is a long afternoon. + +Two questions the section provokes and does not answer, taken up as **M9**: default is +**not** endogenised — the sovereign's repayment decision is not modelled anywhere, and +the section says so (l. 349) while also calling the debt subject to "rollover risk" +(l. 345, see **m3**) — and there is **no central bank in the paper at all**, so the +instrument the title names has nowhere to attach. + +--- + +# Major findings + +## M1. Section 2 contains no equilibrium + +The section ends at the fiscal rule. It never states what clears. Missing, in full: + +| Object | Where it should be | Consequence of its absence | +|---|---|---| +| Goods-market clearing / resource constraint, either country | after §2.2 | `p_t` (the terms of trade) has nothing to clear; the trade balance is undefined | +| Bond-market clearing, `b^{X}_{D,t+1}+b^{X}_{F,t+1}=B_{X,t+1}` | §2.5 | the link between the government's `B_{X,t}` (total stock outstanding) and the banks' `b` holdings is never made — they are separate symbols that never meet | +| Deposit-market clearing, `\int a_{it}\,di=\mathrm{dep}_{X,t}` | §2.1 or §2.3 | `r_{X,t}` (the deposit rate) has nothing to clear; `\mathrm{dep}` is defined twice, once as the bank's residual (l. 335) and once implicitly as household saving (l. 32) | +| Labour-market clearing, `N_{X,t}=\int e_{it}n_{it}\,di` | §2.1 | `N_{X,t}` (aggregate effective hours) in \eqref{eq:production} is never connected to `n_{it}` (household `i`'s hours) in \eqref{eq:hh-objective} | +| Capital-market clearing | §2.3 | asserted in prose at l. 132 ("intermediates *all* of the economy's productive capital"), never as an equation | +| Real interest parity | §2 opening | see **M5** | +| The process for `Z_{X,t}` (country-`X` TFP) | §2.2 | `Z_{X,t}` enters \eqref{eq:production} and is a state variable in Section 3's grid, and its law of motion appears nowhere in the paper | +| The household Euler equation and the KKT conditions on `a_{i,t+1} \ge 0` | §2.1 | the household problem is posed (eqs. 1–3) and never solved | +| A definition of recursive competitive equilibrium | §2 close | — | + +Line 290 says "the equations imposed in equilibrium", which is the only occurrence of +the word. Section 3 then asserts (l. 78–80) that at each collocation point "the seven +market-clearing unknowns `[N_D, N_F, K_D', K_F', r_D, r_F, p]` are solved" — against +market-clearing conditions the paper never writes. A referee cannot verify the count, +and neither can a reader trying to replicate. + +This is the single largest thing wrong with the section, and it is also the easiest to +fix: the conditions exist in the authors' code, and they need about half a page. + +## M2. The bank's portfolio problem is over-determined, and the cross-border position is determined by nothing + +\eqref{eq:foc-general} imposes +`\E_t[\Omega_{X,t,t+1}(R_{j,t+1}-R_{X,t})] = \lambda_X\mu_{X,t}` for **every** +`j \in \mathcal{J}_X = \{K_X, b^D, b^F, L_X\}` — four conditions for the D-bank and +four for the F-bank. Both banks hold both sovereigns (l. 132, l. 136). So both banks +price both bonds: + +- D-bank, own leg: `\E_t[\Omega_D(\Xi^D_{t+1}/q^D_t - R_{D,t})] = \lambda_D\mu_{D,t}` +- F-bank, cross leg: `\E_t[\Omega_F(\tfrac{1}{p_{t+1}}\Xi^D_{t+1}/(\tfrac{1}{p_t}q^D_t) - R_{F,t})] = \lambda_F\mu_{F,t}` + +Two conditions, one price `q^D_t`. Symmetrically for `q^F_t`. `\Omega_D \ne \Omega_F` +(different countries, different consumption paths, and only D's sovereign defaults), +and the cross leg additionally carries the `p_{t+1}/p_t` conversion, so these are not +the same equation. **Generically they cannot both hold.** + +Section 2 does not notice. \eqref{eq:bond-pricing} presents `q^D_t` as determined by +D-bank optimality and `q^F_t` by F-bank optimality, and simply never writes the two +cross-border conditions down. Line 290 promises "Specialising \eqref{eq:foc-general} +to each class yields the equations imposed in equilibrium" and then delivers three of +the four classes: Capital, Sovereign bonds (own only), Working capital. **The +cross-border leg is silently dropped.** + +The consequence is not cosmetic. Nothing in the paper determines `b^F_{D,t+1}` or +`b^D_{F,t+1}`. They are on the balance sheet \eqref{eq:balance-sheet}, they enter +`\mathcal{A}_{X,t}` (total assets) and therefore the incentive constraint +\eqref{eq:IC} and the multiplier \eqref{eq:mu-closed}, they enter gross wealth +\eqref{eq:asset-payoff}, and they are absent from Section 3's seven unknowns. The +introduction (l. 99–101) rests the paper's claimed contribution on exactly this +position — "none holds the sovereign and the productive capital stock on the same +constrained balance sheet in both countries at once" — and the model section leaves it +free. + +Three ways out, and the paper must pick one and say so: + +1. **A portfolio adjustment cost on the cross-border leg**, which turns the cross + conditions into quantity restrictions and leaves the own legs to pin prices. This + is the standard fix and it is what the authors' own sequence-space implementation + does. +2. **A corner**: cross-border holdings fixed exogenously, in which case + \eqref{eq:foc-general} must be restricted to `j \in \{K_X, b^{own}, L_X\}` and + `\mathcal{J}_X` redefined, and the balance-sheet leg becomes an endowment. +3. **Segmented markets** with the cross leg priced off a separate condition. + +Whichever is chosen, `\mathcal{J}_X` at l. 189 is currently wrong. + +## M3. The sentence explaining the risk premium contradicts itself, and the sign is backwards + +Line 411, immediately after \eqref{eq:branch-sdf}: + +> Because the GHH composite is lower in the default branch, `x^{(1)} hence `\Omega^{(1)}>\Omega^{(0)}`: the bank values wealth more in the bad state. +> This is what makes the bond carry a genuine *risk premium* rather than a pure +> actuarial discount, and it is what makes `\E_t[\Omega]` in \eqref{eq:mu-closed} +> **fall** when risk rises --- tightening the constraint. + +The first half is right. The second half does not follow from it; it follows the +opposite way. By the section's own \eqref{eq:expectation}, + +``` +E_t[Omega] = (1 - pi^d_t) Omega^(0) + pi^d_t Omega^(1), +d E_t[Omega] / d pi^d = Omega^(1) - Omega^(0) > 0 +``` + +by the premise stated one clause earlier. Raising the priced default probability +shifts weight onto the branch the sentence has just declared to have the **higher** +`\Omega` (the banker's augmented stochastic discount factor). `\E_t[\Omega]` **rises**. + +This matters because \eqref{eq:mu-closed} is +`\mu = \max\{1-\E_t[\Omega]R_{X,t}n_{X,t}/(\lambda_X\mathcal{A}_{X,t}),0\}`, in which +`\partial\mu/\partial\E_t[\Omega] = -R n/(\lambda\mathcal{A}) < 0`. A higher +`\E_t[\Omega]` **loosens** the constraint. So the clause is wrong twice over: the +premise gives the opposite sign, and the conclusion drawn from it is the opposite of +what \eqref{eq:mu-closed} says. + +The clause is also unnecessary. Two paragraphs earlier (l. 288) the section already +states the correct mechanism — "A fall in sovereign bond prices reduces `n_{X,t}` +(intermediary net worth) through \eqref{eq:ng}, which *mechanically* raises +`\mu_{X,t}` (the multiplier on the incentive constraint)" — and that is the channel +that does the work. Deleting the clause after the em-dash loses nothing. + +There *is* a defensible statement in the neighbourhood, and the paper should make it +rather than assume it: `\Lambda^{(d')}=\beta(x_{X,t}/x^{(d')}_{X,t+1})^{\sigma_X}` +also contains today's composite `x_{X,t}`, which falls on impact, and if today's +contraction outweighs the expected future one then `\E_t[\Omega]` can fall on net. +That is a horse race between a level effect and a composition effect, it is +quantitative, and the section currently asserts the answer while giving the reason +that points the other way. Worth noting for the authors: because the composition +effect pushes `\E_t[\Omega]` up, the risk-premium channel *offsets* the net-worth +channel inside the multiplier. That is an interesting property of the model. It is +currently written as the reverse. + +## M4. The household heterogeneity does nothing, and Section 3's state vector confirms it + +The paper is titled "A Two-Country **Heterogeneous-Agent** Evaluation". The +introduction (l. 119–121) promises "a continuum differing in individual labour +productivity, with Greenwood--Hercowitz--Huffman preferences, a no-borrowing +constraint, and bank deposits as their only savings vehicle", and Section 2.1 +delivers exactly that: household `i`, idiosyncratic `e_{it}`, `a_{i,t+1}\ge0`. + +Then the heterogeneity is never used again. + +- Section 2 never aggregates. `C_{X,t}` appears in \eqref{eq:ces-aggregator} with no + `i`; `N_{X,t}` appears in \eqref{eq:production} with no `i`; nothing connects them + to `c_{it}`, `n_{it}`. +- Line 194 says the banker discounts "with the aggregate household stochastic discount + factor `\Lambda_{t,t+1}`", and \eqref{eq:branch-sdf} then *defines* that object as + `\beta_X(x_{X,t}/x^{(d')}_{X,t+1})^{\sigma_X}` on the **aggregate** composite. With + `a_{i,t+1}\ge0` binding for some households, `\beta(X_t/X_{t+1})^{\sigma}` is not + the marginal rate of substitution of any agent in the model, constrained or + unconstrained. It prices nothing. It is an assumption, and a substantive one, + because the *entire* risk premium in \eqref{eq:bond-decomposition} is the curvature + of this object. +- Section 3's aggregate state is `[K_D, K_F, P_D, P_F, B_D, s, Z_D]` (l. 78). **There + is no distribution in it.** A Smolyak grid over seven scalars cannot carry a wealth + distribution. + +Either (a) the aggregate block is representative-agent and the heterogeneity is +layered on ex post to compute the incidence the introduction promises — in which case +Section 2 must say so explicitly, state that the distribution does not feed back into +aggregates, and justify the SDF; or (b) it is not, and the solution method is +misspecified. As written the reader cannot tell which, and the title asserts the +answer that the state vector denies. + +If (a), note the cost: the whole point of a HANK block is that the distribution moves +aggregate demand. If it is shut off, the paper should not claim it, and the phrase +"heterogeneous-agent" in the title is doing work the model does not do. + +## M5. "A single union-wide funding market together with real interest parity" is asserted and never written, and Section 3 contradicts it + +Line 8, the paragraph that defines what a monetary union means here: + +> the "monetary union" is modelled at the level at which it binds real allocations, +> namely a single union-wide funding market for intermediary liabilities together with +> real interest parity. + +Repeated verbatim in the introduction (l. 104–106). **Neither object appears in the +model.** There is no parity condition anywhere in Section 2, and there are two deposit +rates: `r_{D,t}` and `r_{F,t}` are separate symbols throughout, and Section 3 solves +for **both** as separate unknowns (l. 78). Two rates in two markets is the definition +of a *segmented* funding market. Households, moreover, hold only domestic deposits +(l. 32, "Real deposits `a_{it}` are the only saving vehicle"), with no cross-border +household position. + +So the sentence that tells the reader what makes this a monetary union rather than two +open economies is false as stated. Either write the parity condition +(`1+r_{D,t}=(1+r_{F,t})\,p_{t+1}/p_t`, or whatever the intended form is) and drop one +unknown, or delete the claim and describe the union as what it actually is: a common +numéraire with cross-border bank holdings of sovereign paper and no other integration. +The choice changes the count in **M1**. + +## M6. Working capital is intra-period in the firm's problem and a one-period asset on the balance sheet + +Line 79: the firm "must pre-finance a fraction `\zeta_X` of its wage bill with +**intra-period** bank credit at the gross rate `1+r^{wc}_{X,t}`". The static problem +\eqref{eq:firm-problem} charges `(1+\zeta_X r^{wc}_{X,t})w_{X,t}N_{X,t}` at date `t`, +undiscounted. That is the Neumeyer–Perri convention and it is correct *for +intra-period credit*: borrow at the start of `t`, repay at the end of `t`. + +But the same loan then appears: + +- as an end-of-period-`t` **asset** in the balance sheet, `+L_{D,t}` inside + `\mathcal{A}_{D,t}` \eqref{eq:balance-sheet}; +- as a `t+1` receivable in the obligation state, + `P_{X,t+1}=(1+r_{X,t})\mathrm{dep}_{X,t}-(1+r^{wc}_{X,t})L_{X,t}` \eqref{eq:P-state}; +- as an asset class `L_X \in \mathcal{J}_X` with gross return `R_{L,t+1}=1+r^{wc}_{X,t}` + in the excess-return representation (l. 189–191), which is definitionally a + `t \to t+1` claim; +- and with a `t \to t+1` Euler equation \eqref{eq:rwc}. + +These cannot both be true. If the credit is intra-period it is not on the end-of-period +balance sheet, it does not consume incentive-constraint capacity across periods, and +`\mathcal{J}_X` has three members. If it is a `t \to t+1` asset then the firm's +date-`t` cost in \eqref{eq:firm-problem} is wrong — the repayment falls at `t+1` and +must be discounted — and the "intra-period" wording at l. 79 must go. + +This is load-bearing because `L_X` sits inside `\mathcal{A}_{X,t}`, which is the +divertible base in \eqref{eq:IC} and the denominator of \eqref{eq:mu-closed}. Its +treatment changes the multiplier, and the multiplier is the whole transmission +mechanism. + +Line 165 is also simply broken English: "Because the working-capital loan is extended +at rates the period at the rate locked at $t$". + +## M7. Equation \eqref{eq:bond-decomposition} is not a decomposition + +The algebra is right — I checked each step from \eqref{eq:euler-bond} through the +covariance identity — but the result is + +``` +q = E[Xi]/R + Cov(Omega,Xi)/(E[Omega] R) - [lambda*mu/(E[Omega] R)] * q +``` + +with `q^D_t` on **both sides**. The third term is not an additive component of the +price; it is the price itself, scaled. Solving properly gives + +``` +q = ( E[Xi] + Cov(Omega,Xi)/E[Omega] ) / ( R + lambda*mu/E[Omega] ) +``` + +which is \eqref{eq:bond-pricing} rewritten — the constraint enters the *denominator*, +not as a subtracted term. As a consequence the two terms the paper labels "expected +payoff" and "risk premium" are not the corresponding components of `q^D_t`; they are +the components of a different, higher number. + +The introduction leans on this hard: "The bond price separates into an expected +payoff, a risk premium and a constraint discount" (l. 140–141). It does not separate. +It can be *bounded* or *approximated* to first order — for the calibrated steady state +the constraint term is 1.99 bp of `q`, so a first-order treatment is numerically +harmless — but the paper should present it as such rather than as an identity. + +Two smaller things in the same passage. Line 310: "even a default-free bond trades +below its risk-neutral present value **by the factor** `\lambda\mu`" — the discount +factor is `\E_t[\Omega]R/(\E_t[\Omega]R+\lambda\mu)`, not `\lambda\mu`, which is not +even dimensionless in the right way. And the `(<0)` annotation on the risk-premium +term in \eqref{eq:bond-decomposition} is asserted; it is defensible (in the default +branch both `h_{t+1}` and `q^D_{t+1}` fall while `\Omega` rises, and within the +no-default branch a higher `s_{t+1}` does the same) but it should be shown in a line. + +## M8. The section asserts its central quantitative result and never quantifies it + +Lines 99–102: + +> Notice that equation \eqref{eq:labour-demand} is the **only** channel through which +> financial spreads, affecting `r^{wc}_{X,t}`, reach output on impact. Setting +> `\zeta_X=0` nests the model without it exactly, and doing so collapses the output +> response to a sovereign-risk shock to approximately zero even when bond prices fall +> significantly. + +Three problems. + +**(a) The claim is a result, stated in the model section, with no number and no +forward reference.** Section 4 does not exist. There is nothing for the reader to +check it against. + +**(b) "Nests exactly" is not exact.** At `\zeta_X = 0` there is no loan book, so +`L_X` leaves `\mathcal{J}_X` and leaves `\mathcal{A}_{X,t}`. Leverage `\theta_{X,t}`, +the divertible base, `\lambda_X` and `\omega_X` (all calibrated *to* `\bar\theta = 5`) +all change. It nests the transmission channel, not the model. + +**(c) A referee will compute the elasticity, and the authors should do it first.** +Combining \eqref{eq:labour-demand} with Section 3's GHH labour supply +`\chi_X n_{it}^{1/\nu_X}=(w/P^c)e_{it}` and \eqref{eq:production} at predetermined +`K_{X,t}`, holding the terms of trade fixed: + +``` +d ln N = -[ nu / (1 + alpha*nu) ] * zeta * d r_wc, d ln Y = (1-alpha) d ln N +``` + +At `\alpha=0.35`, `\nu=1`, `\zeta=1`: + +| Rise in the credit spread `\lambda\mu/\E[\Omega]` | Impact `d\ln Y` | +|---|---| +| +100 bp p.a. | −0.120% | +| +500 bp p.a. | −0.602% | +| +1000 bp p.a. | −1.204% | +| +2500 bp p.a. | −3.009% | + +The introduction opens on a 22-point output fall. Reproducing it through this channel +needs a credit spread of roughly **18,300 bp per annum**. The introduction does hedge +— "its aggregate path lies outside what the model is built to reproduce" (l. 171–172) +— and that hedge is honest, but it is in Section 1 and the reader meets the claim in +Section 2. Section 2 should state the elasticity, because it is a closed form, it +takes two lines, and every referee will derive it. + +(The Frisch elasticity `\nu_X`, `\alpha_X` and `\delta_X` are never given a value +anywhere in the paper — see **m12** — so I have used conventional ones. If `\nu_X = 2` +the impact response roughly doubles and the conclusion is unchanged.) + +## M9. There is no central bank, and the model has no slot to put one in + +Section 2's agents are: households, retailers, intermediate producers, capital +producers, one representative bank per country, and two governments. That is the whole +list. The words "central bank", "ECB", "purchase", "backstop", "TPI" and "policy" +appear **nowhere** in the section. The introduction is consistent with this — "There +is no policy rate and no inflation" (l. 106) — and the instrument enters the paper only +as a promise: "\Cref{sec:policy} adds an unlimited, country-specific backstop of the +TPI kind to this environment" (l. 154). `04-policy experiment.tex` is five lines: a +comment banner, a `\section` and a `\label`. + +Ordinarily "Section 4 is empty" is report 3's finding (its **C7**), not a model-section +finding. It becomes one here because **the two holes identified above are exactly the +two places a sovereign backstop has to attach**, and neither exists yet: + +| What a TPI purchase does | Where it enters | Status in Section 2 | +|---|---|---| +| Absorbs a quantity of `D` paper | bond-market clearing, `b^D_{D}+b^D_{F}+b^D_{CB}=B_D` | **the condition is not in the paper** (**M1**) | +| Takes it from *one of the two banking systems* | the banks' cross-border and own legs | **`b^F_D` and `b^D_F` are determined by nothing** (**M2**) | +| Relieves the incentive constraint | `\mathcal{A}_{X,t}` in \eqref{eq:IC}, hence `\mu_{X,t}` | mechanism present, entry point absent | +| Books a gain or loss, and remits it | the government budget \eqref{eq:govt-budget} | no remittance term, and no rule for who bears CB losses | +| Is funded by *something* | the union funding market | **asserted at l. 8, never written** (**M5**) | + +Which banking system supplies the bonds is one of the paper's three stated questions +(l. 154–158, "which of the two banking systems supplies the bonds bought under it"). +That question is a *portfolio* question. It is answered by the cross-border Euler +conditions — the ones \eqref{eq:foc-general} promises and l. 290 quietly omits. So +**M2 is not a tidiness complaint: it is the equation that answers the paper's own +research question**, and until it is written the policy section cannot be. + +The same goes for the fiscal side. \eqref{eq:hh-budget} taxes households lump-sum, and +\eqref{eq:bohn} makes the tax a function of the surviving debt stock. A central bank +holding `D` paper changes that stock's ownership but not its size, so under the +current rule the backstop has *no* fiscal consequence for households except through +`q^X_t`. Whether that is the intended design or an accident of the rule needs +deciding before Section 4, not during it — and it will drive the household incidence +result (see **m8**). + +A related point of framing. Section 2 opens (l. 8) by defining the monetary union as +"a single union-wide funding market … together with real interest parity", which is +the *minimum* structure that lets a union-wide institution exist. The paper then never +writes that structure down (**M5**) and never introduces the institution. As it +stands, Section 2 describes two open economies sharing a numéraire, with cross-border +bank holdings of sovereign paper and nothing else in common. That is a defensible +model. It is not yet one in which "the ECB" is a well-defined agent. + +--- + +# Moderate findings + +## m1. The section does not compile, and the failure corrupts the balance sheet + +`\D` and `\F` are used at ll. 43, 63 and 142 and defined nowhere; `main.tex`'s macro +block (ll. 7–9) is commented out. The build emits four `Undefined control sequence` +errors and LaTeX drops the symbols silently. This was flagged in +`docs/referee_report_3.md` and left "for the coauthors". It has not been fixed, and it +is worse than a warning, because line 142 is inside \eqref{eq:balance-sheet}. What the +PDF actually prints is + +``` +Q_{D,t} K_{D,t+1} + q^{D}_{t} b^{D}_{D,t+1} + p_t q_{t} b_{D,t+1} + L_{D,t} +``` + +The cross-border leg loses its issuer superscript entirely, so the two bond terms +become typographically indistinguishable and the reader has no way to recover the +`b^{issuer}_{holder}` convention — which, note, is never stated in words either. The +most important equation in the section is unreadable in the compiled paper. + +Lines 43 and 63 print "The consumption basket of country ␣ aggregates…" and "expressed +in ␣-good units". + +Fix: `\newcommand{\D}{D}` `\newcommand{\F}{F}` in `main.tex`, or replace the three +uses. Two minutes, and it should not wait for another round. + +## m2. \eqref{eq:bank-lagrangian} does not attach a multiplier to \eqref{eq:IC} + +Line 225 says "attach a multiplier `\tilde\mu_{X,t}\ge0` to \eqref{eq:IC}", i.e. to +`\varphi_{X,t}n_{X,t}\ge\lambda_X\mathcal{A}_{X,t}`. The Lagrangian written is + +``` +L = (1 + mu~) E[Omega n^g] - mu~ lambda A = E[Omega n^g] + mu~ ( E[Omega n^g] - lambda A ) +``` + +whose constraint is `\E_t[\Omega n^g_{t+1}]\ge\lambda_X\mathcal{A}_{X,t}`, not +\eqref{eq:IC}. The two coincide only *at the optimum*, via +`\varphi n = \E_t[\Omega n^g]`, which is the value function the banker is in the +middle of solving for. The manipulation is standard and the answer is right — I +verified `\partial L/\partial a_j` gives \eqref{eq:foc-general} with the rescaling +\eqref{eq:mu-rescale}, and Proposition 1 follows — but the sentence describing it is +wrong and a careful reader will stall on it. State the constraint in the +`\E_t[\Omega n^g]` form and note the equivalence. + +## m3. "Rollover risk" contradicts the default specification two lines later + +Line 345: "Both governments issue \citet{Hatchondo2009}/\citet{Chatterjee2011} +perpetuities that are subject to *rollover risk*." Line 349: "We model default risk as +*exogenous*." The introduction is explicit that this is deliberate: "The government's +repayment decision is not modelled, so there is no self-fulfilling equilibrium" +(l. 177–178). Rollover risk in this literature (Cole–Kehoe, Bocola–Dovis) *is* the +self-fulfilling kind. Neither cited paper is about rollover risk. Delete the phrase. +Flagged in report 3; still present. + +## m4. Proposition 2 is an empty environment with no proof and no appendix + +Lines 376–378: + +```latex +\begin{proposition} [Under standard assumptions, default is recessionary] +\end{proposition} +Proof in the appendix. [ADD] +``` + +The environment has no body, so the compiled paper prints "Proposition 2 (Under +standard assumptions, default is recessionary)." followed by nothing. There is no +appendix: `main.tex` has `\startappendix` immediately before `\end{document}`. The +proposition is never cross-referenced. + +The introduction has already retreated from it — "in the solved decision rules the +default state is recessionary" (l. 150–152), a numerical claim, not a theorem. Either +state and prove the proposition or delete it and cite the decision rules. Leaving a +titled, numbered, empty theorem in a submitted draft is the kind of thing that decides +a desk reject. + +## m5. \eqref{eq:expectation} hides the branch-dependent state — which is where the risk premium comes from + +The quadrature is written `\E_t[g] = (1-\pi^d_t)\E^s_t[g(0,s_{t+1})] + +\pi^d_t\E^s_t[g(1,s_{t+1})]`, as though `s_{t+1}` were the only argument. It is not. +The aggregate state is seven-dimensional, and at least three of its components are +**branch-dependent**: on default the debt stock is written down through `h_t` in +\eqref{eq:debt-lom}, and both intermediaries' obligation states `P_D`, `P_F` differ +because \eqref{eq:asset-payoff} pays `\Xi^D_{t+1}=h_{t+1}[\delta_b+(1-\delta_b)q^D_{t+1}]`. +That difference is precisely what makes `x^{(1)}\Omega^{(0)}` at l. 411 — the risk premium in +\eqref{eq:bond-decomposition} is *nothing but* that state difference. Writing +`g(d', s_{t+1})` erases it from the notation at the exact point where the reader needs +to see it. Section 3 (l. 80) is better on this than Section 2 is. + +Related, and unstated anywhere: **what is the economy after a default?** Line 369 +calls it "a single deterministic bond face value write-off", which reads as +once-and-for-all, but then the post-default economy must have a different risk process +(`\pi^d \to 0`? an absorbing state? `s` continues and default can recur?). If `s` +continues unchanged, the model prices repeated defaults on an already-written-down +stock, which is a different object from the Greek 2012 exchange the calibration is +built on. Section 2 must say which. + +## m6. \eqref{eq:mpk} is not a first-order condition of \eqref{eq:firm-problem} + +Line 87 says the firm's problem "deliver[s] the factor-demand conditions" and lists +both \eqref{eq:labour-demand} and \eqref{eq:mpk}. But \eqref{eq:firm-problem} is a max +over `N_{X,t}` alone — `K_{X,t}` is predetermined and `mpk_{X,t}K_{X,t}` is a +subtracted constant. Differentiating in `N` gives \eqref{eq:labour-demand} only. +\eqref{eq:mpk} is the **zero-profit** condition: imposing +`mc\,Y-(1+\zeta r^{wc})wN-mpk\,K=0` and substituting \eqref{eq:labour-demand} gives +`mpk\,K = mc\,Y - mc(1-\alpha)Y = mc\,\alpha Y`, which is \eqref{eq:mpk}. Correct +result, wrong derivation as described. One sentence to fix. + +## m7. \eqref{eq:nw-lom} is simultaneous in `\mathcal{A}_{X,t}` + +`n_{X,t}=(1-f)n^{g}_{X,t}+\omega^f_X\mathcal{A}_{X,t}`, where +`\mathcal{A}_{X,t}=n_{X,t}+\mathrm{dep}_{X,t}` is *end-of-period* assets, chosen after +net worth is known. So `n_t` depends on `\mathcal{A}_t` depends on `n_t`. In +Gertler–Karadi the entrant transfer is normally a fraction of the previous period's +assets, or of exiting bankers' assets, precisely to avoid this. + +It is not fatal — I verified that Section 3's `\omega_X = \mathcal{D}/\bar\theta - +(1-f)\bar\varsigma` with `\mathcal{D}=1-(1-f)(1+\bar r)` follows exactly from +\eqref{eq:nw-lom} with the contemporaneous timing, so the timing is deliberate and the +steady state is consistent. But out of steady state it is a within-period fixed point +interacting with an occasionally binding constraint, and the section should say so and +say how it is resolved in the solution. + +## m8. The government block is written symmetrically for a country that never defaults + +\eqref{eq:govt-budget}, \eqref{eq:debt-lom} and \eqref{eq:bohn} are all indexed +`X\in\{D,F\}` and all carry an **unsubscripted** `h_t` — the D-specific survival +factor, defined at l. 150 with `\varrho_D` and stated at l. 374 to be identically one +for F ("The `F`-sovereign never defaults"). As written the F government's budget +constraint applies a Greek haircut to German debt. Subscript it, or state `h^F_t\equiv1`. + +Same three equations use `\delta_b` unsubscripted while l. 345 defines +`\delta_{X,b}`; \eqref{eq:bond-payoff} uses `\delta_b` for both countries. Section 3 +gives one value, `\delta_b = 0.056`, for both. Pick one convention. + +And the whole block is uncalibrated: `G_X` (government spending), `\bar B_X` +(steady-state debt stock), `\gamma_\tau` (the Bohn elasticity) and `\bar T^\tau_X` +appear in no table and no paragraph. Section 3's "Government" paragraph gives only +`\delta_b`. The introduction opens on a debt ratio going from 105 to 175 per cent of +GDP; `\bar B_D` is the parameter that would carry that, and it has no value. Section 3 +also lists `B_D` as a state variable but not `B_F`, so German debt is apparently +fixed — never stated. + +Finally, note for Section 4: `T^{\tau}_{X,t}` is **lump-sum** in the household budget +\eqref{eq:hh-budget}. With heterogeneous, borrowing-constrained households, a lump-sum +tax is mechanically regressive in consumption terms. Since the paper's stated question +includes "how the resulting exposure and the resulting gains fall … across households" +(l. 155–158), the incidence result is going to be largely a property of the financing +rule. That needs to be confronted rather than discovered. + +## m9. Home bias is country-specific in \eqref{eq:ces-aggregator}, common in \eqref{eq:ces-D}–\eqref{eq:ces-F}, and country-specific again in Section 3 + +\eqref{eq:ces-aggregator} has `\varpi_X` and `\eta_X` — and, in the same equation, +`\varpi_X^{1/\eta}` with an **unsubscripted** `\eta` in the exponent next to +`(1-\varpi_X)^{1/\eta_X}` with a subscripted one. \eqref{eq:ces-D} and +\eqref{eq:ces-F} then use bare `\varpi` and `\eta` for both countries, which is a +*symmetry assumption*: the F price index as written, +`P^c_F=[\varpi+(1-\varpi)p_t^{\eta-1}]^{1/(1-\eta)}`, is correct only if F's home bias +equals D's. Section 3 (l. 14) then says "a home bias `\varpi_X` calibrated to match +the import penetration", country by country. Greek and German import penetration are +not equal, so the two are inconsistent. + +The demand system itself is right: I verified `IM_{D,t}=(1-\varpi)(P^c_D/p_t)^\eta C_D` +and `IM_{F,t}=(1-\varpi)(P^c_F p_t)^\eta C_F` are the correct CES demands given the +`1/\eta` weight convention and the `1/p_t` price of the D-good in F. Only the +subscripting is wrong. + +Also: \eqref{eq:ces-aggregator} is written for `C_{D,t}` over `c_{DD,t}`, `c_{FD,t}` — +aggregate on the left, lower-case on the right, no `i`. Homotheticity makes this +harmless but it should be stated once. + +## m10. Two different duration formulas + +Section 2, l. 345: "Duration is therefore approximately `1/\delta_{X,b}` quarters" — +at `\delta_b = 0.056` that is **17.9 quarters** (4.46 years). Section 3, l. 70: "its +expected duration is `(1+\bar r)/(\bar r+\delta_b)` quarters. Setting +`\delta_b = 0.056` delivers a duration of 16.9 quarters, or 4.2 years" — I get 17.0, +so the number is right modulo rounding, but it is not Section 2's formula. Since +`\delta_b` is calibrated *to* a measured Greek duration, the two formulas imply +different `\delta_b`. Use the exact one in both places. Flagged in report 3; still +present. + +## m11. The steady state is not deterministic, and Section 2 offers no expression for the sovereign spread + +Section 3 (l. 63) refers to "the deterministic steady state used for calibration". It +is not deterministic: \eqref{eq:pd} sets `\pi^d(\bar s)=0.1\%` per quarter at the rest +point, and by \eqref{eq:expectation} that probability is priced in +\eqref{eq:bond-pricing}. The steady state is a risky one. + +This has a consequence Section 3 then trips over. It calls `\bar\varsigma` "the spread +of the sovereign yield over the risk-free rate" and calibrates it to 8 bp p.a. But by +\eqref{eq:bond-pricing} the steady-state sovereign spread has **two** components: the +constraint term `\lambda\bar\mu/\bar\Omega`, which I confirm is exactly 8.00 bp p.a. +at the reported calibration, **plus** the expected-loss term +`\approx\pi^d(1-\varrho_D)`, which at `\pi^d=0.1\%` per quarter is 21–26 bp p.a. for a +recovery rate in the 0.35–0.465 range. Total: roughly **30–34 bp p.a.**, against the +50 bp measured 1999–2007. That is a defensible number and arguably a point in the +paper's favour — but Section 3 does not report it, because Section 2 never writes down +an expression for the sovereign spread. It should: two lines after +\eqref{eq:bond-pricing}, separating the expected-loss and constraint components, and +naming the object the calibration targets. + +(`\bar\varsigma` itself is never defined in Section 2. It appears for the first time +in Section 3. It *is* consistent with \eqref{eq:capital-euler}, which at a +deterministic point gives `\bar r^k-\bar r=\lambda\bar\mu/\bar\Omega` — but Section 2 +should name it.) + +## m12. The reported impulse is a 4.8-standard-deviation innovation, unremarked + +Section 3 (l. 67): "The impulse we report raises `\pi^d` on impact from 0.10% to 1.98% +per quarter." Inverting \eqref{eq:pd}, that is a move in the latent factor from +`\bar s=-6.9068` to `-3.9021`, a jump of **3.005**, against `\sigma_s=0.63`. That is a +**4.77-standard-deviation innovation** — a one-in-a-million draw under the process +\eqref{eq:s-process} the paper has just calibrated. (It is 1.49 unconditional standard +deviations, and it sits inside Section 3's `\pm4.35` Smolyak box, so the solution is +not being extrapolated. The issue is the characterisation, not the numerics.) + +Either the impulse is a sequence of innovations rather than one, or it is a large +deliberate scenario, or `\sigma_s` is too small. The paper should say which, and +report the shock in standard-deviation units as well as in probability units. + +Related and unfixable by hand-waving: **no parameter of the production or household +block is given a value anywhere in the paper.** `\alpha_X`, `\delta_X`, `\xi_X`, +`\nu_X`, `\sigma_X`, `\epsilon_X`, `\chi_X`, `\beta_X`, `n_e`, `\varpi_X`, +`\varrho_D`, `\gamma_\tau`, `G_X`, `\bar B_X` — all absent. Section 3 l. 12 says "All +specific calibration values, as well as empirical targets, can be found in the +appendix [ADD APPENDIX SECTION]", and there is no appendix. `\varrho_D` in particular +is the recovery rate, it is the one parameter the introduction says Greece uniquely +identifies (l. 162–164), and Section 3 says only "We accordingly set the recovery rate +to match that event" without giving a number. + +--- + +# Notation ledger + +Report 3 recorded `\mu`/Smolyak-`\mu`, `\lambda_X`/`\Lambda`, `\omega^f_X`/`\omega_X` +and `f_X`/`f`. All four are still live. Adding the collisions internal to Section 2, +worst first: + +| Symbol | Use A | Use B | Severity | +|---|---|---|---| +| `n_{X,t}` | **bank net worth**, \eqref{eq:ng-excess}–\eqref{eq:nw-lom} | **aggregate hours** inside the GHH composite `x_{X,t}\equiv c_{X,t}-v(n_{X,t})`, l. 401 | **severe** — same subscripts, same section, both aggregate | +| `a_{it}` | **household deposits**, \eqref{eq:hh-budget} | `a_{j,t}` = **bank asset position** `j`, \eqref{eq:ng-excess} | severe | +| `Q_{X,t}` / `q^{X}_t` | Tobin's q | bond price | severe — both appear in \eqref{eq:balance-sheet} | +| `\mu_{X,t}` | KKT multiplier | `\mu=1`, Smolyak level (§3) | high | +| `\lambda_X` | divertible fraction | `\Lambda_{X,t,t+1}`, the SDF | high | +| `\mathcal{D}` | `1-(1-f)(1+\bar r)` (§3) | `D`, the country index | high | +| `\Xi^{X}_{t+1}` | bond payoff | `\xi_X`, adjustment-cost elasticity | moderate | +| `\varrho_D` | recovery rate | `\rho_s`, risk persistence | moderate | +| `\varpi` | home bias | `\omega_X`, `\omega^f_X`, entrant transfer | moderate | +| `\sigma_X` | risk aversion | `\sigma_s`, innovation s.d. | moderate | +| `\epsilon_X` | retail elasticity | `\varepsilon_{t+1}` shock, `\varepsilon_m` quadrature node | low | +| `\delta_X` | depreciation | `\delta_b` / `\delta_{X,b}`, coupon decay | low | +| `b^{X}_{Y,t+1}` / `B_{X,t}` | bank holdings / total stock | convention never stated in words | **and destroyed by m1** | + +Two more: `V_t(n)=\varphi_{X,t}n_{X,t}` (l. 204) writes the argument as `n` on the left +and `n_{X,t}` on the right; and `\mathrm{Cov}_t` is used at ll. 423, 425, 431 where +`econpaper.sty` already defines `\Cov`. + +--- + +# Language, references and mechanics + +**Register.** "let me describe the behaviour" (l. 323) and "let us think about" +(l. 401) in a two-author paper, alongside "we" elsewhere. Pick one; in a submitted +draft it should be "we". + +**Broken or garbled sentences.** +- l. 79: "Thus the static problem is of the firm:" +- l. 165: "Because the working-capital loan is extended at rates the period at the rate locked at $t$" — unparseable. +- l. 204: "Following a standard result from \cite{gertler2011model}, we obtain that \eqref{eq:ng-excess} is linear in $n$ and the constraint below is linear in assets, the value function is linear" — needs "because … , the value function is linear". +- l. 288: "requirment"; and the sentence attributes a fall in `n_{X,t}` to \eqref{eq:ng}, which delivers `n^g_{X,t+1}` — the mechanism runs \eqref{eq:ng} → \eqref{eq:nw-lom}. +- l. 310: "This is because even a default-free bond trades below…" — the "because" does not connect to the preceding sentence. +- l. 459: "Taxes follow a modified \cite{bohn1998behavior} as a constant elasticity" — missing a noun ("rule"), and the citation is being used as one. + +**Citation commands.** `\cite` is used where `\citet` or `\citep` is needed at +ll. 105, 204, 256, 345 (twice) and 459. Line 345 currently renders as +"issue (Hatchondo and Martinez, 2009)/(Chatterjee and Eyigungor, 2011) perpetuities". +Only l. 79's `\citep{neumeyer2005business}` is right. All seven keys resolve in +`references.bib`; the "undefined citation" warnings in `main.log` are first-pass +artefacts, not real. + +**Cross-references.** Three `XXX` placeholders survive: `\cref{XXX}` (l. 35, with the +author's own reminder comment "% <-- point at your Section 1.2"), `Section~\ref{XXX}` +(l. 150), `Section~\ref{sec:XXX}` (l. 158). All three point at things the reader needs: +the predetermined-rate convention, the survival factor, and the bond payoff. + +**LaTeX mechanics.** +- Stray `\\` after `\end{proof}` (l. 287). Also `\\`+`\\` used as paragraph breaks at + ll. 178–179, 194–195, 362–363, 411–412 — use a blank line. +- l. 69: `\label{eq:mc}` sits outside any numbered environment, so it silently attaches + to the section counter. Nothing references it yet; it will misfire when something + does. +- l. 12: `\label{sec:Households}` capitalised against `sec:wc`, `sec:model`, + `sec:policy`. +- `\subparagraph` (ll. 291, 299, 311) inside `\paragraph` inside `\subsection` is four + levels deep and renders as an unnumbered run-in; consider a list. +- l. 38, l. 178: `taxes.\\` and `separately.\\` end paragraphs with `\\`, which + produces an underfull hbox warning each time. + +--- + +# What is right, and should not be touched + +Worth recording, because the list of findings is long and the section is not bad: + +- The CES demand system \eqref{eq:ces-D}–\eqref{eq:ces-F} is correct, including the + `p_t^{\eta-1}` in the F price index and the `(P^c_F p_t)^\eta` in F's import demand. +- The Jermann block is exactly right: `Q=\iota^{\xi}/(\gamma_0(1-\xi))`, the inverted + investment rate, and `\Pi^k_{X,t}` as the maximised objective. Section 3's + `\gamma_{0,X}=\delta^{\xi}/(1-\xi)`, `\gamma_{1,X}=-\delta\xi/(1-\xi)` are exactly + the coefficients that deliver `\bar Q=1` and `\bar\iota=\delta` from + \eqref{eq:capital-lom}. I verified both. +- The excess-return representation \eqref{eq:ng-excess} follows correctly from + \eqref{eq:balance-sheet} and \eqref{eq:ng}, and the `P_{X,t+1}` device genuinely is + a sufficient statistic for the liability side. It is a nice piece of construction and + the section is right to say so. +- Proposition 1 and its proof are correct, including the slack case and the + `\max\{\cdot,0\}` construction. +- \eqref{eq:bond-pricing} and \eqref{eq:rwc} follow correctly from + \eqref{eq:foc-general}. +- The logistic calibration is right: `\bar s=\log(0.001/0.999)` gives + `\pi^d(\bar s)` = 0.1000% to machine precision. +- \eqref{eq:govt-budget}, \eqref{eq:debt-lom} and the anchor + `\bar T^\tau_X=G_X+\delta_b\bar B_X(1-\bar q^X)` are mutually consistent. +- Section 3's entire GK steady state reproduces Section 2's equations. I get + `\bar\mu=0.000996` (reported 0.0010), `\bar\varsigma\bar\theta=0.1000\%` per quarter + (reported 0.10%), the `\omega_X>0` bound at 3.8667% (reported 3.87%), + `\bar\varphi=1.0255`, `\lambda_X=0.2051`. The triangularity argument at l. 46–48 is + correct and the identification point — that `\bar\mu` depends only on the product + `\bar\varsigma\bar\theta`, so leverage and the wedge are not separately identified — + is exactly the right thing to have noticed. + +--- + +# Priority + +1. **m1** — define `\D` and `\F`. Two minutes, and \eqref{eq:balance-sheet} is + currently corrupt in the compiled paper. +2. **M3** — delete or repair the clause at l. 411. One sentence, and it currently + inverts the paper's own mechanism. +3. **M1** — write the equilibrium. Half a page, from the code. +4. **M2** — decide how the cross-border position is pinned down and say so. This one + is a modelling decision, not an edit, and per **M9** it is the equation that answers + the paper's own second research question. +5. **M5**, **M6** — reconcile the union/parity claim and the working-capital timing. + **M5** is also a precondition for **M9**. +6. **M4** — decide what the household heterogeneity is for, and make the title and + Section 2 agree with Section 3's state vector. +7. **M7**, **M8** — present the decomposition as an approximation, and state the + transmission elasticity. +8. **m4** — remove or complete Proposition 2 before anyone external sees the draft. +9. Everything else. diff --git a/docs/referee_report_5.md b/docs/referee_report_5.md new file mode 100644 index 0000000..91fe650 --- /dev/null +++ b/docs/referee_report_5.md @@ -0,0 +1,409 @@ +# Referee Report — Fifth Referee + +## On the introduction, read against a full audit of Section 2 + +**Scope.** `VIVA/sections/01-introduction.tex` as of 2026-08-28, reviewed from scratch. +This report differs from `docs/referee_report_3.md` in one respect that changes most of +the findings: Section 2 has since been audited equation by equation +(`docs/referee_report_4.md`), so every claim the introduction makes about the model can +now be checked against what the model actually contains rather than against what +Section 2 says about itself. + +The draft has moved twice since report 3: that report's fixes were applied, and five +Overleaf web-editor commits on 2026-08-27 trimmed the ECB, OMT-evidence and +exogenous-default paragraphs. Lines 100–117 were rewritten by me at the author's +instruction on the same day; they are reviewed here on the same terms as everything +else. + +Line numbers refer to the current file. + +--- + +## Verdict + +**Major revision, and the problems are no longer in the prose.** + +Report 3 found an introduction that promised things Section 3 did not deliver. Those +promises have been repaired. What the Section 2 audit reveals is worse and more +specific: **the introduction's three stated research questions map one-to-one onto the +three places Section 2 is incomplete.** Not approximately — exactly. + +| Question (ll. 151–154) | What answering it requires | Status in Section 2 | +|---|---|---| +| "what the backstop does to the equilibrium" | an equilibrium | never defined; no market clearing of any kind | +| "which of the two banking systems supplies the bonds" | the cross-border Euler conditions | omitted at l. 290; `b^F_D` determined by nothing | +| "how the exposure and the gains fall … across households" | a distribution that moves aggregates | no distribution in the state vector; aggregate SDF asserted | + +An introduction is allowed to promise what a later section delivers. It is not allowed +to promise three things the model is structurally unable to produce. That is **I1**, +and it subsumes report 3's C7. + +Underneath it sits a quantitative exposure the paper has not confronted (**I2**), a set +of motivating statistics that are not model objects (**I3**), and one inconsistency +created by yesterday's edits (**I4**). + +Three findings run the other way, and are recorded as such: on the risk-premium +mechanism, on rollover risk, and on the status of the recessionary-default claim, **the +introduction is right and Section 2 is wrong**. Fix Section 2 to match the +introduction, not the reverse (**I9**). + +--- + +# Findings + +## I1. All three stated questions are currently unanswerable + +Lines 150–154: + +> \Cref{sec:policy} adds an unlimited, country-specific backstop of the TPI kind to +> this environment. It asks what the backstop does to the equilibrium, which of the two +> banking systems supplies the bonds bought under it, and how the resulting exposure +> and the resulting gains fall across the two countries and across households. + +Taking them in order. + +**"What the backstop does to the equilibrium."** Section 2 defines no equilibrium. It +contains no goods-market clearing, no bond clearing tying the banks' holdings to the +governments' outstanding stocks, no deposit clearing, no labour clearing, and no +definition of a recursive competitive equilibrium. A purchase programme is an +intervention *in* market clearing; there is nothing for it to intervene in. + +**"Which of the two banking systems supplies the bonds."** This is a portfolio +question, and it is answered by the cross-border Euler conditions. Section 2 imposes +its asset-pricing condition for every asset class, then writes out only the own-leg +conditions and silently drops both cross-border ones. The consequence is that +`b^F_D` (D-bank holdings of F government paper) and its mirror are determined by +nothing at all. The paper's second question is answered by the one equation Section 2 +omits. + +**"How the exposure and the gains fall … across households."** This needs the wealth +distribution to affect aggregates. Section 2 never aggregates the household block; the +banker's stochastic discount factor is written on the *aggregate* consumption–labour +composite, which with a binding borrowing constraint is nobody's marginal rate of +substitution; and Section 3's seven-dimensional state vector contains no distribution. +As the model is solved, the aggregate block is representative-agent. + +The third question also carries a design problem the paper has not noticed. Taxes are +lump-sum in the household budget, and the fiscal rule responds to the surviving debt +stock. A central bank that changes the *ownership* of `D` paper without changing its +quantity therefore has almost no fiscal consequence for households under the current +rule. Whatever household incidence the paper eventually reports will be a property of +that financing assumption more than of the instrument. + +Report 3 recorded Section 4 as "still a heading" and the results paragraph as dropped +at the author's instruction. That decision stands and is not re-litigated. The point +here is different: **the machinery required to answer the questions does not exist +yet**, so the gap is not a writing gap that Section 4 will close. + +## I2. The introduction opens on 22 points of output; the model's only channel is worth fractions of a point + +Lines 18–23 are the paper's motivating claim, and they are the right motivation for the +model's design: + +> By the end of 2011 Greek output had fallen 22 percentage points below its 2007Q4 +> level and investment 60 points. … The contraction ran ahead of the event that was +> supposed to cause it. + +Lines 123–126 then state the transmission channel, correctly and exclusively: + +> That common excess return is the model's credit spread, and it reaches output through +> a single equation: firms pre-finance a fraction of the wage bill, so the spread enters +> labour demand directly. + +Section 2 makes the same claim more strongly, calling labour demand "the **only** +channel through which financial spreads reach output on impact". Combining that +equation with Section 3's GHH labour supply and the production function at +predetermined capital gives a closed form for the impact response, and it is small. +At conventional parameters (`\alpha` = 0.35, Frisch elasticity `\nu` = 1, pre-financed +share `\zeta` = 1): + +| Rise in the credit spread | Impact response of output | +|---|---| +| +100 bp p.a. | −0.120% | +| +1000 bp p.a. | −1.204% | +| +2500 bp p.a. | −3.009% | + +Reproducing the 22-point fall through this channel requires a credit spread of roughly +**18,300 bp per annum**. + +The introduction does hedge, at l. 165–168: the Greek depression "had causes the model +omits" and "its aggregate path lies outside what the model is built to reproduce". That +hedge is honest and it should stay. But it is 145 lines after the claim it qualifies, +and it says the paper does not *target* the level. It does not say that the mechanism +is two orders of magnitude away from it. Those are different admissions, and a referee +who does the arithmetic will feel the difference. + +The paper should state the elasticity, in Section 2 where the exclusivity claim is +made, and decide whether the motivating fact belongs at the top of the introduction on +those terms. The honest framing is available and is arguably stronger: the model is +built to isolate and price one channel, not to account for the Greek depression. + +## I3. The motivating statistics are not model objects, and the paper later says so + +Three facts carry the introduction's motivation. None of them is something the model +matches, and in one case the paper explicitly disclaims the correspondence. + +**The debt ratio (l. 12).** "Debt rose from 105 per cent of GDP at the end of 2007 to +175 by the end of 2011." The steady-state debt stock `\bar B_X` is uncalibrated — +it appears in no table and no paragraph, Section 3's Government paragraph gives only the +coupon decay `\delta_b`, and `B_F` (F debt stock) is not even a state variable. +More fundamentally, the model's shock is a draw of the innovation to the latent risk +factor `s_t`; the debt stock plays no part in generating it. **The model cannot +represent the event the paper opens with** — a fiscal deterioration causing a +repricing. It represents the repricing alone. + +**The EBA concentration (ll. 25–28).** "Greek banks … held €54.4 billion of their own +government's paper against €22.8 billion of Core Tier 1 capital." This is the +introduction's central piece of balance-sheet evidence. Then l. 163–165 says: "no +bank-level disclosure identifies the leverage of an intermediary defined to hold an +entire capital stock; the consolidated portfolio here has no counterpart in observed +balance sheets." Both statements are defensible in isolation. Together they say that +the fact motivating the mechanism is measured on an object the model does not contain. +Nothing in the calibration targets the sovereign-to-capital ratio on the model bank's +balance sheet, so the concentration the EBA number establishes has no counterpart in +the calibrated model. + +**The 50 bp pre-crisis spread (l. 8).** This one is a missed opportunity rather than an +error. The model has a computable steady-state sovereign spread: the constraint +component is exactly 8 bp p.a. at the reported calibration, and the expected-loss +component implied by a 0.1% quarterly default probability and a Greek recovery rate is +roughly 21–26 bp p.a., for a total near **30–34 bp p.a.** against the 50 bp the paper +opens with. That is a respectable untargeted match and the paper does not claim it, +because Section 2 never writes an expression for the sovereign spread and Section 3 +conflates the total with its 8 bp constraint component. + +## I4. The paper now states the ECB's rationale and no longer says the model cannot represent it + +Lines 62–64 attribute a purpose to the instruments: + +> … exist to remove sovereign risk premia the Bank judges unwarranted by fundamentals. + +Until yesterday, ll. 173–176 closed the loop: + +> Premia unwarranted by fundamentals cannot arise here, so the model does not speak to +> that part of the ECB's stated rationale for these instruments. Endogenising the +> default decision is left for later work. + +Commit `c302a09` deleted both sentences. What remains is "Default risk follows an +exogenous latent factor. The government's repayment decision is not modelled, so there +is no self-fulfilling equilibrium. Amplification comes only from the intermediary +balance sheet, which keeps the channel identified." + +The reader is now told what the ECB says the instrument is for, and is not told that the +model cannot evaluate it on those terms. Every premium in this model is warranted by +fundamentals by construction. A paper proposing to evaluate an instrument whose stated +purpose is removing unwarranted premia must say, near the claim, that it does not model +them. The deletion also removed the only signal that endogenous default is a planned +extension. + +This is the one finding in this report created by a recent edit rather than surviving +from an earlier draft, and it is a one-sentence repair. + +## I5. "Occasionally binding" is not exercised by the calibration + +Lines 51–54 justify the solution method: + +> Sovereign risk inside a currency union is episodic … We therefore solve the model +> globally. The mechanism is an occasionally binding constraint interacting with a rare +> event, and a local approximation preserves neither. + +The constraint is not occasionally binding on the reported path. Section 3 calibrates +the steady-state multiplier to `\bar\mu` = 0.0010, which is strictly positive: the +constraint binds at the rest point. The introduction says so itself at l. 139–141 — +"The steady state is calibrated to a barely-binding constraint, so the premium is +already active at the point the experiment starts from." The experiment then raises +risk, which reduces net worth and tightens the constraint further. The `max{·,0}` +operator in the closed-form multiplier never activates on the path the paper reports. + +The global solution is well justified — by the default fork, by the branch-contingent +kernel, and by the curvature of the logistic default probability. It is not justified +by the occasional binding of a constraint that always binds. Say what actually requires +it. + +Relatedly, l. 138–139 claims the risk premium "survives when the constraint is slack". +True of the model, and never visited by the experiment. + +## I6. The contribution claim over the literature is unearned in both halves + +Lines 94–98: + +> All three work with a representative household, and none holds the sovereign and the +> productive capital stock on the same constrained balance sheet in both countries at +> once. Putting them on one balance sheet makes the incidence of a sovereign backstop +> computable: across the two countries, across the two banking systems, and across +> households. + +Two claims of novelty, each currently undelivered. + +**"In both countries at once."** The two balance sheets are linked only by the +cross-border sovereign positions, and those positions are pinned down by nothing +(**I1**). The structure is on the page; the equation that makes it operative is not. + +**"All three work with a representative household."** As solved, so does this model +(**I1**). The comparison is only available once the household block does something to +aggregates. + +One further item I could not verify and flag rather than assert: the claim that all +three cited papers use a representative household should be checked against +`bi2026asset` specifically before it survives another draft. A referee who works on +that paper will check it, and a wrong characterisation of the closest published +analogue is expensive. + +## I7. "The safe sovereign does not covary" rules out a result the paper probably wants + +Lines 135–136: + +> Domestic sovereign bonds covary with that valuation; the safe sovereign does not. + +The intended point is right: only the `D` sovereign carries default risk, so only its +payoff has the survival factor. But "does not covary" is much stronger than "does not +default", and it is false in this model. The `F` bond's payoff includes its own +continuation price, which moves with the `F` intermediary's constraint and its +stochastic discount factor. A `D` default hits the `F` bank, which holds `D` paper, +tightening `F`'s constraint and moving the `F` bond price. From the `D` bank's +perspective the cross-border position additionally carries terms-of-trade risk. + +This matters beyond precision. A flight-to-quality result — safe yields falling when +periphery risk rises — is exactly a covariance effect on the safe bond, and it is one of +the more interesting things a two-country model of this kind can produce. The sentence +as written rules it out a priori. Weaken it to the default-risk point. + +## I8. "The bond price separates into" three pieces, and it does not + +Line 136–137 restates Section 2's three-way decomposition. The underlying algebra is +correct but the result is not a decomposition: the bond price appears on both sides, +and the constraint term is the price itself scaled by a factor. Solving properly puts +the constraint in the denominator, which is the pricing equation rewritten. The two +terms labelled "expected payoff" and "risk premium" are therefore not the corresponding +components of the actual price. + +At the calibrated steady state the constraint term is about 2 bp of the price, so a +first-order treatment is numerically harmless and the economics of the sentence +survives. The word "separates" does not. + +## I9. Three places where the introduction is right and Section 2 is wrong + +Recorded so the repairs go in the correct direction. + +**The risk-premium mechanism.** Section 2 states that because the banker values wealth +more in the default branch, the expected augmented discount factor *falls* when risk +rises. It rises: raising the priced default probability shifts weight onto the branch +just declared to have the higher value. The introduction (ll. 132–135) states only the +correct half and draws no sign conclusion. **The introduction is clean here; Section 2 +needs the repair.** + +**Rollover risk.** Section 2 calls the debt "subject to rollover risk" two lines before +declaring default exogenous. The introduction (ll. 173–174) states the exogeneity +plainly and never uses the phrase. **Delete it from Section 2.** + +**Recessionary default.** Section 2 states this as a titled Proposition with an empty +body and a proof deferred to an appendix that does not exist. The introduction +(ll. 145–148) states it as a property of the solved decision rules, which is the honest +version. **Section 2 should adopt the introduction's framing**, not the other way round. + +One caveat on the introduction's version: no decision rules are reported anywhere in the +paper, so the claim is currently unverifiable by the reader. That is downstream of +Section 4. + +## I10. Not one number in the introduction comes from the model + +Every quantity in eighteen paragraphs is a data moment: 50 bp, 105 and 175 per cent, 29 +per cent, 59–65 per cent, 22 and 60 points, €54.4bn and €22.8bn. There is no +steady-state spread, no impulse magnitude, no elasticity, no loading, no welfare +number. For a quantitative paper this is unusual enough that a referee will read it as a +signal about the state of the results. + +Some of this is the deliberate decision to drop the results paragraph until Section 4 +exists, and that decision is not reopened here. But several numbers are available +*today* from Sections 2 and 3 and would cost nothing: the steady-state sovereign spread +(**I3**), the size of the impulse, and the transmission elasticity (**I2**). + +On the impulse: Section 3 reports that the experiment raises the priced quarterly +default probability from 0.10% to 1.98% on impact. Inverting the logistic, that is a +move of 3.005 in the latent factor against an innovation standard deviation of 0.63 — +a **4.8-standard-deviation draw**. It sits inside the solution grid, so the numerics are +sound, but the introduction describes the experiment only as the probability being +"positive and rising". The magnitude should be owned in the introduction, in +standard-deviation units, because a reader who finds it in Section 3 will wonder why it +was not mentioned. + +Separately: `fig:greece`, a full-page four-panel figure, is **never referenced in the +text**. `fig:decoupling` is referenced twice. Whatever the figure is for, the argument +does not currently reach for it. + +## I11. Smaller items + +- **"Disciplined throughout by the Greek episode" (l. 157).** Contradicted two sentences + later, where leverage, payout rate, intermediation wedge and risk process are + attributed to an estimated model of Italy. "Throughout" is the overclaiming the + author has been stripping elsewhere; the Greek episode disciplines two parameters. +- **The recovery rate (ll. 158–160).** The introduction leads with it as the parameter + Greece uniquely identifies. Section 3 says only "We accordingly set the recovery rate + to match that event" and never gives a number. The one Greek-identified parameter the + paper advertises is unreported. +- **Duration.** Section 2 gives the perpetuity's duration as the reciprocal of the + coupon decay (17.9 quarters); Section 3 uses a discounted formula (16.9 quarters). + Since the coupon decay is calibrated by inverting a measured Greek duration, the two + formulas imply different parameter values. Not a typo, an identification question. +- **TPI eligibility (l. 168).** "Greece in 2010–12 would also have failed TPI's + eligibility criteria" is the obvious attack on the whole exercise, and it is + currently a subordinate clause in the middle of a long paragraph. It deserves to be + met head on, because a referee will not let it pass as an aside. +- **The road map (ll. 178–180)** stops at Section 3 while the document contains a + Section 4 heading and no conclusion. Deliberate per commit `17ded8a`, but the + asymmetry will read as an oversight to anyone who has not seen that decision. + +--- + +# On lines 100–117, which I wrote + +Reviewed on the same terms. + +The paragraphs no longer track Section 2's wording and no longer assert the +union-wide funding market and real interest parity that Section 2 never writes. Those +were the defects they were written to fix, and they are fixed. + +Two criticisms stand against them. + +**"Those claims sit on a single constrained intermediary in each country" (l. 107)** +opens on the cross-border positions and builds the paragraph on them. Per **I1** those +positions are determined by nothing. The sentence is not false — the claims are on the +balance sheet — but it gives structural prominence to the least well-defined object in +the model. If **I1** is resolved by fixing the cross-border position exogenously rather +than by adding an adjustment cost, this paragraph will need rewriting. + +**"A two-country monetary union" (l. 100)** is doing work the model may not support. +By the paragraph's own account, what links the countries is goods trade and +cross-border bank holdings of sovereigns; there is no common policy rate, no common +funding market, no nominal instrument anywhere, and no monetary authority. That is a +two-country real model with financial integration. Calling it a monetary union is a +labelling choice, and the paper's policy question — the behaviour of a union-wide +central bank instrument — presumes a union with more structure than the model has. This +is the same gap as the absent central bank, seen from the introduction's side, and it +is worth confronting explicitly rather than resolving by vocabulary. + +--- + +# Priority + +1. **I4** — restore the sentence saying the model cannot speak to unwarranted premia. + One sentence, and the paper currently overstates its own scope without it. +2. **I9** — repair Section 2 in the three places where the introduction is already + right. No change to the introduction. +3. **I2** — decide how to frame the 22-point opening against a channel worth fractions + of a point, and put the elasticity in Section 2. +4. **I5**, **I7**, **I8** — three sentences that claim more than the model delivers. +5. **I3** — either connect the motivating statistics to calibrated objects or stop + presenting them as evidence about the model's bank. +6. **I1**, **I6** — not writing tasks. The introduction's questions and its contribution + claim both become true when Section 2 is completed, and not before. Until then the + introduction is writing cheques against work that has not been specified. + +--- + +# Not reviewed + +Per the author's instruction, proof-level items are excluded: notation and +sub/superscript consistency, citation-command choice, LaTeX mechanics, and the +`references.bib` metadata. Those from Section 2 are recorded in +`docs/referee_report_4.md`; the introduction has few. diff --git a/docs/superpowers/plans/2026-08-05-nominal-rigidities.md b/docs/superpowers/plans/2026-08-05-nominal-rigidities.md new file mode 100644 index 0000000..6b6ae05 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-nominal-rigidities.md @@ -0,0 +1,2013 @@ +# Nominal Rigidities Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Rotemberg price Phillips curves and nominal deposit contracts to the two-country monetary-union HANK model, keeping the steady state bit-identical, and make the sticky model the paper's new baseline. + +**Architecture:** Four new unknowns (`mc_D, pi_D, mc_F, pi_F`) and four new targets take the solver system from 23×23 to 27×27. Inflation is closed without any policy rate: the monetary-union terms-of-trade identity `p/p(−1) = (1+π_F)/(1+π_D)` pins the inflation differential off the existing `p` unknown, and a union-inflation normalisation pins the level. Markups are neutralised by subsidy so every new residual is exactly zero at the current steady state. Deposits then become nominal, which makes banks net nominal debtors and adds a Fisher debt-deflation channel. + +**Tech Stack:** Python 3.12, `sequence-jacobian` (SSJ), numpy, pytest 9.0.3. Always use `/opt/anaconda3/envs/ssj/bin/python` — the base Anaconda environment has a broken `liblapack` symlink that causes silent numerical failures. + +**Spec:** `docs/superpowers/specs/2026-08-05-nominal-rigidities-design.md` + +**Branch:** `add-nkpc` (already created, already carries the spec commits) + +--- + +## Background an engineer needs before starting + +**This model has no test suite.** The regression test is running the whole pipeline +(`/opt/anaconda3/envs/ssj/bin/python code/main.py`, ~8-12 min) and reading printed +residuals. This plan adds fast unit tests for the new algebra so you are not +waiting 10 minutes to discover a sign error. + +**How to unit-test an SSJ `@simple` block.** A `@simple`-decorated function becomes +a `SimpleBlock`. Call `blk.steady_state({...})` with a dict of every input; it +returns a dict containing inputs *and* outputs. Lags and leads (`x(-1)`, `x(+1)`) +evaluate to the same constant, so `steady_state` is really "evaluate this block at +constant values" — which is exactly what you want for algebraic identity tests, +including deliberately *off*-steady-state ones. + +```python +ss = my_block.steady_state({'x': 2.0, 'y': 0.5}) +assert ss['residual'] == 0.0 +``` + +**SSJ gotcha:** SSJ discovers a block's outputs by running a regex over the +function's *source code* looking for the last `return` line. So every `@simple` +function must have a literal `return a, b` statement, and blocks cannot be defined +inside `python -c` strings (`inspect.getsource` fails). Define them in real files. + +**Acceptance thresholds — use the MEASURED baseline below, not CLAUDE.md's list.** + +CLAUDE.md quotes `goods_mkt_D ≤ 1e−14`. That refers to the *dynamic* solve, where +`goods_mkt_D` is an explicit entry in `targets_tp` and so is driven to Newton +tolerance — it is never printed on its own. What `main.py` *does* print under +`SS goods residuals:` are the **steady-state** residuals, which sit around 4e−7. +Do not mistake one for the other and report a false failure. + +These are the actual values from a verified run of the pre-change model on +`add-nkpc` at commit `f3711bd` (Task 1, 2026-08-05). Every later task compares +against these: + +| Printed line | Baseline value | Rule | +|---|---|---| +| `SS goods residuals: goods_mkt_D` | `-4.2493506589857954e-07` | must not degrade by an order of magnitude | +| `SS goods residuals: goods_mkt_F` | `-4.1914559989475464e-07` | same | +| `SS goods residuals: ca_res_D` | `6.852157730108388e-17` | same | +| Block residual table verdict | `All residuals < 1e-8 ✓` | must still print `✓` | +| `IC_D: θ − θ_tgt` | `1.776357e-15` | OK | +| `irfs_Z_D['b_gov_D'][499]` | `-0.001701` | stability | +| `irfs_def_D['b_gov_D'][499]` | `0.000014` | stability | +| `ρ_b (partial-eq.)` | `0.8451` | target < 0.95 | +| `n_inter_D[0]` on default shock | `-3.3804%` of SS | **must stay negative** | +| `Y_D[0]` on default shock | `-0.0149%` of SS | **must stay negative** | +| TPI `max|ca_res_D|` across γ | ≤ `7.55e-08` | ≤ 1e−7 | +| TPI `max|goods_mkt_F|` across γ | ≤ `2.44e-09` | ≤ 1e−7 | + +**Sign checks:** on the default shock, `n_inter_D[0]` and `Y_D[0]` must both be +negative. Positive means a timing bug. + +**Doc hooks are enforced — all three docs, every code commit.** +`.claude/hooks/require-docs-before-commit.sh` (verified) and its git-native twin +`.githooks/pre-commit` (active: `core.hooksPath` is set to `.githooks`) deny any +commit that stages `code/**` or any `*.py` unless **all three** of +`docs/STATE.md`, `docs/PROGRESS.md` and `docs/HANDOFF.md` are staged in the same +commit. Staging only `PROGRESS.md` is not enough. + +So before each code commit, add a line to each of the three: + +- `docs/PROGRESS.md` — a changelog bullet for this specific commit. +- `docs/STATE.md` — the current state after this commit (one line is fine for + intermediate steps; Task 16 writes the real tables). +- `docs/HANDOFF.md` — where the work now stands, so an interrupted session can + resume. + +The `git add` lines in this plan already list all three. Do **not** use +`--no-verify`; the gate is deliberate project policy. + +**Long-running commands must run in the background.** `code/main.py` takes about +8–12 minutes (several T=500 Jacobian solves at ~3 min each) and +`diagnostics/regimes/regime_model.py --force` takes longer. Both exceed the Bash +tool's 10-minute maximum timeout. Run them with `run_in_background: true` and poll +the output rather than blocking, or they will be killed mid-solve and you will +mistake a timeout for a model failure. + +--- + +## One refinement to the spec, adopted here + +The spec's substitution table replaces `rdep_i` with `rdep_exante_i` in +`intermediation_P1`, `divert_bond_foc` and `divert_portfolio_adj`. This plan +achieves the same model with a strictly smaller diff by **keeping the name +`rdep_i` for the ex-ante real rate**, which is already exactly what those three +blocks mean by it. + +- `i_dep_i` — **new nominal unknown**, replaces `rdep_i` in the unknowns list. +- `rdep_i` — **now a derived block output**: the ex-ante real rate for t→t+1. +- `rdep_expost_i` — **new derived output**: the realised real rate at t on + deposits placed at t−1. + +Net effect: `intermediation_P1_{D,F}`, `divert_bond_foc_{D,F}`, +`divert_portfolio_adj`, `smart_steady_{D,F}` and `steady_auxilliary_{D,F}` are +**untouched**, and `steady_state.py`'s eight `ss['rdep_D']` / `ss['rdep_F']` reads +keep working unchanged. Only `deposit_return`, `bank_return` and `capital_fund` +change, plus one new block per country. + +--- + +## File structure + +| File | Change | Responsibility | +|---|---|---| +| `code/full_model.py` | Modify | Gains `build_block_list()` — the single definition of the model's block list. Updates `unknowns_tp` / `targets_tp` to 27×27. | +| `code/tpi.py` | Modify | Drops its duplicate block list; calls `build_block_list()` with TPI overrides. | +| `diagnostics/regimes/regime_model.py` | Modify | Same. | +| `code/equations_D.py` | Modify | Adds `price_nkpc_D`, `firm_profit_D`, `deposit_rates_D`. Modifies `labor_demand_D`, `income_D`, `deposit_return_D`, `bank_return_D`, `capital_fund_D`. | +| `code/equations_F.py` | Modify | Symmetric analogues. | +| `code/equations_global.py` | Modify | Adds `terms_of_trade`, `union_inflation`. | +| `code/calibration.py` | Modify | Adds `mu_p`, `kappa_p`, `omega_pi_D`, `pi`; retargets `mc`; renames `rdep` → `i_dep`. | +| `code/steady_state.py` | Modify | Adds new blocks to the SS block list. | +| `code/test_nkpc_blocks.py` | **Create** | Fast unit tests for all new block algebra. | +| `code/dump_irfs.py` | **Create** | Saves baseline IRFs to `.npz` so the equivalence gate is numerical, not eyeballed. | + +--- + +## Phase 0 — Refactor (behaviour-preserving) + +### Task 1: Extract a single `build_block_list()` + +`full_model.py:69`, `tpi.py:145` and `diagnostics/regimes/regime_model.py:160` +each hardcode the `create_model` block list. Six blocks are about to be added to +all three. CLAUDE.md records that a drifting duplicate model is exactly what +invalidated the retired `audit_artifacts/` harness. Do this first, as a **pure +no-op**, so the next task validates one change rather than two. + +The TPI list differs from the baseline list in four blocks: `budget_residual_D`, +`budget_residual_F`, `external_account_D` and `domestic_bond_clearing` are swapped +for `_tpi` variants defined in `tpi.py`. The factory therefore takes an +`overrides` mapping. + +**Files:** +- Modify: `code/full_model.py:36-91` +- Modify: `code/tpi.py:145-164` +- Modify: `diagnostics/regimes/regime_model.py:160-183` + +- [ ] **Step 1: Capture the baseline output** + +```bash +cd /Users/Adam/Documents/uni/phd/research/QUANTITATIVE_MODEL +/opt/anaconda3/envs/ssj/bin/python code/main.py 2>&1 | tee /tmp/nkpc_baseline_main.log +``` + +Expected: completes, prints `Done — all figures saved to:`. Takes ~8-12 min. +Confirm the log contains a `b_gov_D` stability block and both sign lines. + +- [ ] **Step 2: Add `build_block_list()` to `full_model.py`** + +Insert immediately after the import block (after line 33, before `def build_and_solve`): + +```python +def build_block_list(financial_solved_D, financial_solved_F, *, + hh_D=None, hh_F=None, overrides=None): + """The single definition of the model's block list. + + Every consumer (full_model, tpi, diagnostics/regimes) calls this. A second + copy of the list is how the retired audit_artifacts/ harness drifted into + silently testing a different model — see CLAUDE.md. + + financial_solved_D/F : the runtime-constructed GK solved blocks. + hh_D/hh_F : optionally REPLACE the household blocks with versions + carrying extra hetoutputs (experiments/e4_distribution + adds per-decile consumption). + overrides : {name: block} used by the TPI layer to swap in its + _tpi variants without keeping a second list. + """ + o = overrides or {} + hh_D = hh_extended_D if hh_D is None else hh_D + hh_F = hh_extended_F if hh_F is None else hh_F + + def pick(name, default): + return o.get(name, default) + + return [ + # Country D + deposit_return_D, tax_rule_D, hh_D, ghh_composite_D, + sdf_D, sdf_banker_D, government_default_D, financial_solved_D, + bond_return_D, bank_return_D, capital_fund_D, cap_adj_cost_inter_D, macro_pru_tax_D, + intermediation_P2_D, intermediation_P3_D, k_balance_sheet_D, + capital_adj_D, capital_producer_profit_D, + pick('budget_residual_D', budget_residual_D), + labor_D, labor_market_D, labor_demand_D, banker_div_res_D, + market_clearing_D, welfare_agg_D, + # Country F + deposit_return_F, tax_rule_F, hh_F, ghh_composite_F, + sdf_F, sdf_banker_F, government_default_F, financial_solved_F, + bond_return_F, bank_return_F, capital_fund_F, cap_adj_cost_inter_F, macro_pru_tax_F, + intermediation_P2_F, intermediation_P3_F, k_balance_sheet_F, + capital_adj_F, capital_producer_profit_F, + pick('budget_residual_F', budget_residual_F), + labor_F, labor_market_F, labor_demand_F, banker_div_res_F, + market_clearing_F, welfare_agg_F, + # Global + ces_price_D, import_demand_D, ces_price_F, import_demand_F, + trade_balance, + pick('external_account_D', external_account_D), + pick('domestic_bond_clearing', domestic_bond_clearing), + bond_yield, portfolio_level_anchors, divert_portfolio_adj, + divert_bond_foc_D, divert_bond_foc_F, global_goods_mkt, + ] +``` + +Move the two `hh_extended` imports from the bottom of the file (lines 176-178) up +into the main import block so `build_block_list` can see them, keeping the +`# noqa: F401` re-export comment for `tpi.py`. + +- [ ] **Step 3: Use the factory in `build_and_solve`** + +Replace the `ha_full = sj.create_model([...])` call (`full_model.py:69-91`) with: + +```python + ha_full = sj.create_model( + build_block_list(financial_solved_D, financial_solved_F), + name="Full 2-Country MU HANK — GHH Preferences, Flex Price & Wage, No CB", + ) +``` + +- [ ] **Step 4: Add a TPI overrides helper to `tpi.py`** + +Add near the top of `tpi.py`, after the `_tpi` block definitions: + +```python +def tpi_overrides(): + """The four blocks the TPI layer swaps into the shared block list.""" + return { + 'budget_residual_D': budget_residual_D_tpi, + 'budget_residual_F': budget_residual_F_tpi, + 'external_account_D': external_account_D_tpi, + 'domestic_bond_clearing': domestic_bond_clearing_tpi, + } +``` + +Then replace `tpi.py`'s `sj.create_model([...])` (lines 145-164) with: + +```python + from full_model import build_block_list + ha_full_tpi = sj.create_model( + build_block_list(financial_solved_D, financial_solved_F, + overrides=tpi_overrides()), + name="Full 2-Country MU HANK — TPI Extension", + ) +``` + +- [ ] **Step 5: Point `regime_model.py` at the factory** + +Replace its `return sj.create_model([...])` (lines 160-183) with: + +```python + from full_model import build_block_list + return sj.create_model( + build_block_list(financial_solved_D, financial_solved_F, + hh_D=hh_D, hh_F=hh_F, + overrides=t.tpi_overrides()), + name="Full 2-Country MU HANK — TPI Extension (regimes cache, main)", + ) +``` + +Delete the now-dead `hh_D = t.hh_extended_D if hh_D is None else hh_D` lines +immediately above it — `build_block_list` handles the `None` default itself. + +- [ ] **Step 6: Verify bit-identical** + +```bash +/opt/anaconda3/envs/ssj/bin/python code/main.py 2>&1 | tee /tmp/nkpc_refactor_main.log +diff <(grep -v 'Output directory' /tmp/nkpc_baseline_main.log) \ + <(grep -v 'Output directory' /tmp/nkpc_refactor_main.log) +``` + +Expected: **no output from `diff`**. Any difference means the refactor changed the +model — stop and find it. Do not proceed with a non-empty diff. + +- [ ] **Step 7: Commit** + +Add to `docs/PROGRESS.md` under a new dated heading: +`- Extracted build_block_list() in full_model.py; tpi.py and regime_model.py now share it. Verified bit-identical main.py output.` + +```bash +git add code/full_model.py code/tpi.py diagnostics/regimes/regime_model.py docs/STATE.md docs/PROGRESS.md docs/HANDOFF.md +git commit -m "refactor: single build_block_list() shared by full_model, tpi, regimes + +No-op. Verified main.py output is byte-identical before and after." +``` + +--- + +## SSJ library defect found in Task 9 — read before touching any Jacobian solve + +**SSJ 1.0.0 cannot solve this system with stock `Block.solve_jacobian`.** + +`CombinedBlock._jacobian` (`blocks/combined_block.py:104-119`) seeds +`total_Js = JacobianDict.identity(inputs)` from the *shock* list, visits a block +only `if (inputs & block.inputs) and (outputs & block.outputs)`, and returns +`total_Js[original_outputs & total_Js.outputs, :]`. A target reachable from no +shock is therefore silently dropped from H_Z. `Block.solve_jacobian` +(`blocks/block.py:260`) then calls `np.linalg.solve(H_U, H_Z.pack(T))` with +mismatched shapes: + +``` +ValueError: solve: Input operand 1 has a mismatch in its core dimension 0, +with gufunc signature (m,m),(m,n)->(m,n) (size 11500 is different from 13500) +``` + +`11500 = 23*500`, `13500 = 27*500`. All four new targets are pure functions of +the solver's own unknowns — `nkpc_p_res_D/F` (pi, mc), `tot_res` (p, pi_D, pi_F), +`union_pi_res` (pi_D, pi_F) — and contain no `Z_*` or `shock_def_*` symbol. + +**Fix: `full_model.solve_jacobian_padded()`**, added in Task 9. It restores the +missing rows as zeros, which is **exact, not an approximation**: `dH/dZ` at fixed +unknowns is identically zero when the shock never appears in the equation. It +mirrors `Block.solve_jacobian` line-for-line and prints the padded row names on +every solve, so it cannot go silent. + +**A 25x25 rewrite does NOT avoid this — do not attempt it.** Solving `tot_res` +and `union_pi_res` analytically for `pi_D`, `pi_F` (which has an exact closed +form, `pi_D = (1-g)/(g + omega/(1-omega))` with `g = p/p(-1)`) would make them +block outputs and drop the system to 25x25. But `nkpc_p_res_D` would still depend +only on `pi_D` — now a function of `p`, still an unknown — and `mc_D`, another +unknown. The gate at `combined_block.py:115` tests against the *shock* set, so +the two NKPC targets would still be dropped, giving 23 H_Z rows against a 25x25 +H_U. Same defect, smaller numbers, at the cost of rewriting committed work. + +**Every Jacobian call site must use the padded helper.** Task 9b converts them. + +## Phase 1 — Price rigidity (deposits still real) + +### Task 2: Markup rent — `firm_profit_{D,F}` + +With the markup in labour demand only, `w*N = mu_p*mc*(1-alpha)*Y` while the +capital return is untouched, so off steady state factor payments no longer exhaust +output and the residual has nowhere to go. That is a Walras leak of the W-1 / W-2 +class. This block routes it. + +The rent is distributed to households **in proportion to productivity `e`** +(Auclert–Rognlie–Straub), *not* lump-sum. Markups are countercyclical, so a +lump-sum rebate would hand households rising income exactly when output falls. +Distributing on `e` makes household labour-plus-profit income `(1-alpha)*Y*e` — +identical to the flexible model — so the wedge affects the firm's hiring decision +only, and because the share depends on the household's *type* rather than its +hours, the marginal wage stays `w` and `labor_market_{D,F}` is unchanged. + +**Files:** +- Create: `code/test_nkpc_blocks.py` +- Modify: `code/equations_D.py` (append after `labor_demand_D`, line 295) +- Modify: `code/equations_F.py` (append after `labor_demand_F`, line 249) + +- [ ] **Step 1: Write the failing tests** + +Create `code/test_nkpc_blocks.py`: + +```python +"""Fast algebraic tests for the nominal-rigidity blocks. + +These evaluate SSJ @simple blocks directly via .steady_state(), which is just +"evaluate at constant values" -- lags and leads collapse to the same constant. +That makes it usable for deliberately OFF-steady-state identity checks too. +""" +import os +import sys + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + + +# ── Markup rent ─────────────────────────────────────────────────────────────── + +def test_firm_profit_is_zero_at_steady_state(): + from equations_D import firm_profit_D + mu_p = 1.20 + ss = firm_profit_D.steady_state({ + 'Y_D': 1.0, 'N_D': 0.8, 'alpha_D': 0.33, + 'mu_p_D': mu_p, 'mc_D': 1.0 / mu_p, + }) + assert ss['profit_D'] == pytest.approx(0.0, abs=1e-15) + + +def test_firm_profit_restores_factor_exhaustion_off_steady_state(): + """w*N + profit must equal (1-alpha)*Y for ANY mc, so that adding the + capital share alpha*Y exhausts output exactly.""" + from equations_D import firm_profit_D, labor_demand_D + mu_p, mc, Y, N, alpha = 1.20, 0.79, 1.03, 0.81, 0.33 + + # w from labour demand at this mc (w_res_D == 0 defines w) + w = mu_p * mc * (1 - alpha) * Y / N + + ss = firm_profit_D.steady_state({ + 'Y_D': Y, 'N_D': N, 'alpha_D': alpha, 'mu_p_D': mu_p, 'mc_D': mc, + }) + assert w * N + ss['profit_D'] == pytest.approx((1 - alpha) * Y, rel=1e-14) + + # and the wage we assumed really is the one labor_demand_D implies + ld = labor_demand_D.steady_state({ + 'w_D': w, 'Y_D': Y, 'N_D': N, 'alpha_D': alpha, + 'mu_p_D': mu_p, 'mc_D': mc, + }) + assert ld['w_res_D'] == pytest.approx(0.0, abs=1e-14) + + +def test_firm_profit_F_matches_D(): + from equations_D import firm_profit_D + from equations_F import firm_profit_F + args = dict(Y=1.03, N=0.81, alpha=0.33, mu_p=1.20, mc=0.79) + d = firm_profit_D.steady_state({ + 'Y_D': args['Y'], 'N_D': args['N'], 'alpha_D': args['alpha'], + 'mu_p_D': args['mu_p'], 'mc_D': args['mc'], + }) + f = firm_profit_F.steady_state({ + 'Y_F': args['Y'], 'N_F': args['N'], 'alpha_F': args['alpha'], + 'mu_p_F': args['mu_p'], 'mc_F': args['mc'], + }) + assert d['profit_D'] == pytest.approx(f['profit_F'], rel=1e-15) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -v +``` + +Expected: FAIL — `ImportError: cannot import name 'firm_profit_D'`. + +- [ ] **Step 3: Implement `firm_profit_D`** + +Append to `code/equations_D.py` after `labor_demand_D` (line 295): + +```python +@simple +def firm_profit_D(Y_D, alpha_D, mu_p_D, mc_D): + # Markup rent. With sticky prices labour demand pays mu_p*mc*(1-alpha)*Y + # while capital still earns alpha*Y (capital_adj_D is unchanged), so off SS + # factor payments do not exhaust output. profit_D is that residual; leaving + # it unrouted is a Walras leak of the W-1/W-2 class. + # + # Distributed to households in proportion to productivity e (Auclert-Rognlie- + # Straub), NOT lump-sum: markups are countercyclical, so a lump-sum rebate + # would raise household income exactly when output falls. On the e rule, + # w*N*e + profit*e = (1-alpha)*Y*e -- identical to the flex model -- so the + # wedge affects the firm's hiring decision only, and because the share + # depends on type rather than hours the marginal wage is still w_D and + # labor_market_D is unchanged. + # + # Zero at SS, where mu_p*mc = 1. + profit_D = (1.0 - mu_p_D * mc_D) * (1.0 - alpha_D) * Y_D + return profit_D +``` + +Append the symmetric block to `code/equations_F.py` after `labor_demand_F` (line 249): + +```python +@simple +def firm_profit_F(Y_F, alpha_F, mu_p_F, mc_F): + # See firm_profit_D. + profit_F = (1.0 - mu_p_F * mc_F) * (1.0 - alpha_F) * Y_F + return profit_F +``` + +`N` is deliberately *not* in the signature — it does not appear in the expression, +and an unused input would make SSJ record a spurious DAG edge. The tests above +pass `N_D` / `N_F` in their dicts anyway; SSJ's `steady_state()` ignores extra +keys (verified), so they need no change. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -v +``` + +Expected: `test_firm_profit_is_zero_at_steady_state` and +`test_firm_profit_F_matches_D` PASS. The factor-exhaustion test still FAILS on +`labor_demand_D` not accepting `mu_p_D` / `mc_D` — that is Task 4. + +- [ ] **Step 5: Commit** + +```bash +git add code/equations_D.py code/equations_F.py code/test_nkpc_blocks.py docs/STATE.md docs/PROGRESS.md docs/HANDOFF.md +git commit -m "feat: firm_profit_D/F routes the markup rent proportional to e" +``` + +--- + +### Task 3: Price Phillips curves — `price_nkpc_{D,F}` + +Rotemberg form in producer-price inflation. The gap is written as a **ratio**, so +it is unit-free and linearises to exactly `mc_hat`; published Calvo slopes are +therefore directly usable for `kappa_p` with no steady-state rescaling. + +**Files:** +- Modify: `code/equations_D.py` (append after `firm_profit_D`) +- Modify: `code/equations_F.py` (append after `firm_profit_F`) +- Modify: `code/test_nkpc_blocks.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `code/test_nkpc_blocks.py`: + +```python +# ── Price Phillips curve ────────────────────────────────────────────────────── + +def test_price_nkpc_is_zero_at_steady_state(): + from equations_D import price_nkpc_D + mu_p = 1.20 + ss = price_nkpc_D.steady_state({ + 'pi_D': 0.0, 'mc_D': 1.0 / mu_p, 'mu_p_D': mu_p, + 'kappa_p_D': 0.0871, 'beta_D': 0.985, + }) + assert ss['nkpc_p_res_D'] == pytest.approx(0.0, abs=1e-15) + + +def test_price_nkpc_flex_limit_forces_mc_to_one_over_mu_p(): + """As kappa_p -> inf the residual/kappa_p -> -(mu_p*mc - 1), so setting the + residual to zero drives mu_p*mc -> 1, which is the competitive condition.""" + from equations_D import price_nkpc_D + mu_p = 1.20 + base = {'pi_D': 0.0, 'mu_p_D': mu_p, 'beta_D': 0.985} + off_mc = 0.79 # != 1/mu_p = 0.8333... + for kappa in (1e2, 1e4, 1e6): + ss = price_nkpc_D.steady_state({**base, 'mc_D': off_mc, 'kappa_p_D': kappa}) + implied_gap = -ss['nkpc_p_res_D'] / kappa + assert implied_gap == pytest.approx(mu_p * off_mc - 1.0, rel=1e-12) + + +def test_price_nkpc_gap_linearises_to_mc_hat(): + """d(mu_p*mc - 1)/d(mc/mc_ss) evaluated at mc_ss = 1/mu_p equals 1 for ANY + mu_p -- which is why mu_p is a free normalisation to first order.""" + from equations_D import price_nkpc_D + for mu_p in (1.05, 1.20, 1.50): + mc_ss = 1.0 / mu_p + h = 1e-7 + base = {'pi_D': 0.0, 'mu_p_D': mu_p, 'kappa_p_D': 1.0, 'beta_D': 0.985} + up = price_nkpc_D.steady_state({**base, 'mc_D': mc_ss * (1 + h)}) + dn = price_nkpc_D.steady_state({**base, 'mc_D': mc_ss * (1 - h)}) + # residual = -kappa*(gap), kappa = 1 -> d(gap)/d(mc_hat) = -d(res)/d(mc_hat) + d_gap = -(up['nkpc_p_res_D'] - dn['nkpc_p_res_D']) / (2 * h) + assert d_gap == pytest.approx(1.0, rel=1e-6) + + +def test_price_nkpc_F_matches_D(): + from equations_D import price_nkpc_D + from equations_F import price_nkpc_F + args = dict(pi=0.001, mc=0.79, mu_p=1.20, kappa=0.0871, beta=0.985) + d = price_nkpc_D.steady_state({ + 'pi_D': args['pi'], 'mc_D': args['mc'], 'mu_p_D': args['mu_p'], + 'kappa_p_D': args['kappa'], 'beta_D': args['beta'], + }) + f = price_nkpc_F.steady_state({ + 'pi_F': args['pi'], 'mc_F': args['mc'], 'mu_p_F': args['mu_p'], + 'kappa_p_F': args['kappa'], 'beta_F': args['beta'], + }) + assert d['nkpc_p_res_D'] == pytest.approx(f['nkpc_p_res_F'], rel=1e-15) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -k price_nkpc -v +``` + +Expected: FAIL — `ImportError: cannot import name 'price_nkpc_D'`. + +- [ ] **Step 3: Implement the blocks** + +Append to `code/equations_D.py`: + +```python +@simple +def price_nkpc_D(pi_D, mc_D, mu_p_D, kappa_p_D, beta_D): + # Rotemberg NK Phillips curve in D producer-price inflation. + # + # The gap is a RATIO (mu_p*mc - 1), so it is unit-free and linearises to + # exactly mc_hat for any mu_p -- published Calvo slopes are directly usable + # for kappa_p with no SS rescaling, and mu_p is a free normalisation to + # first order under the subsidy neutralisation. + # + # Subsidy-neutralised: mc_ss = 1/mu_p, so the gap and pi are both exactly + # zero at the current SS and the SS is bit-identical to the flex model. + # kappa_p -> inf recovers flexible prices (mu_p*mc = 1). + # + # Discounted at constant beta rather than SDF_D: since pi_ss = 0 the SDF + # deviation multiplies a zero, so the two are identical to first order and + # the model is solved by linearised solve_jacobian. + nkpc_p_res_D = pi_D - beta_D * pi_D(+1) - kappa_p_D * (mu_p_D * mc_D - 1.0) + return nkpc_p_res_D +``` + +Append to `code/equations_F.py`: + +```python +@simple +def price_nkpc_F(pi_F, mc_F, mu_p_F, kappa_p_F, beta_F): + # See price_nkpc_D. + nkpc_p_res_F = pi_F - beta_F * pi_F(+1) - kappa_p_F * (mu_p_F * mc_F - 1.0) + return nkpc_p_res_F +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -k price_nkpc -v +``` + +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add code/equations_D.py code/equations_F.py code/test_nkpc_blocks.py docs/STATE.md docs/PROGRESS.md docs/HANDOFF.md +git commit -m "feat: price_nkpc_D/F Rotemberg Phillips curves in PPI inflation" +``` + +--- + +### Task 4: Markup wedge in labour demand + +**Files:** +- Modify: `code/equations_D.py:292-295` +- Modify: `code/equations_F.py:246-249` + +- [ ] **Step 1: Confirm the already-written test fails** + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py::test_firm_profit_restores_factor_exhaustion_off_steady_state -v +``` + +Expected: FAIL — `labor_demand_D` does not accept `mu_p_D` / `mc_D`. + +- [ ] **Step 2: Add the flex-limit test** + +Append to `code/test_nkpc_blocks.py`: + +```python +def test_labor_demand_collapses_to_competitive_at_ss_markup(): + """At mc = 1/mu_p the condition must be exactly w = (1-alpha)Y/N, which is + what makes the steady state bit-identical to the flex model.""" + from equations_D import labor_demand_D + mu_p, Y, N, alpha = 1.20, 1.03, 0.81, 0.33 + w_competitive = (1 - alpha) * Y / N + ss = labor_demand_D.steady_state({ + 'w_D': w_competitive, 'Y_D': Y, 'N_D': N, 'alpha_D': alpha, + 'mu_p_D': mu_p, 'mc_D': 1.0 / mu_p, + }) + assert ss['w_res_D'] == pytest.approx(0.0, abs=1e-15) +``` + +- [ ] **Step 3: Modify `labor_demand_D`** + +Replace `code/equations_D.py:292-295` in full: + +```python +@simple +def labor_demand_D(w_D, Y_D, N_D, alpha_D, mu_p_D, mc_D): + # Firm FOC with a price markup: w = mu_p*mc*(1-alpha)*Y/N. + # The mu_p factor IS the production subsidy tau_s = 1 - 1/mu_p: at the SS + # markup mc = 1/mu_p this collapses to the competitive w = (1-alpha)Y/N + # identically, so the steady state is unchanged. Off SS the wedge shifts + # labour demand, which is what makes N -- and hence output -- respond to + # demand rather than being pinned by Z, K and P_CES alone. + # The rent (1 - mu_p*mc)(1-alpha)Y is routed by firm_profit_D. + w_res_D = w_D - mu_p_D * mc_D * (1 - alpha_D) * Y_D / N_D + return w_res_D +``` + +Replace `code/equations_F.py:246-249` in full: + +```python +@simple +def labor_demand_F(w_F, Y_F, N_F, alpha_F, mu_p_F, mc_F): + # See labor_demand_D. Pins the wage in ha_full (drop labor_mkt_res_F there). + w_res_F = w_F - mu_p_F * mc_F * (1 - alpha_F) * Y_F / N_F + return w_res_F +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -v +``` + +Expected: all tests pass, including +`test_firm_profit_restores_factor_exhaustion_off_steady_state`. + +- [ ] **Step 5: Commit** + +```bash +git add code/equations_D.py code/equations_F.py code/test_nkpc_blocks.py docs/STATE.md docs/PROGRESS.md docs/HANDOFF.md +git commit -m "feat: markup wedge mu_p*mc in labor_demand_D/F" +``` + +--- + +### Task 5: Global closure — `terms_of_trade` and `union_inflation` + +In a monetary union the nominal exchange rate is fixed at 1, so the terms of trade +**is** the accumulated inflation differential. That identity pins the differential +off `p`, which is already an unknown. The union-inflation normalisation pins the +level — the `phi_pi -> inf` limit of a Taylor rule, stated as an abstraction +rather than a modelled rule. + +**Files:** +- Modify: `code/equations_global.py` (append after `bond_yield`, line 44) +- Modify: `code/test_nkpc_blocks.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `code/test_nkpc_blocks.py`: + +```python +# ── Global closure ──────────────────────────────────────────────────────────── + +def test_global_residuals_zero_at_steady_state(): + from equations_global import terms_of_trade, union_inflation + tot = terms_of_trade.steady_state({'p': 0.99, 'pi_D': 0.0, 'pi_F': 0.0}) + assert tot['tot_res'] == pytest.approx(0.0, abs=1e-15) + uni = union_inflation.steady_state({'pi_D': 0.0, 'pi_F': 0.0, 'omega_pi_D': 0.071}) + assert uni['union_pi_res'] == pytest.approx(0.0, abs=1e-15) + + +def test_closure_puts_93pct_of_tot_move_into_D_deflation(): + """Solving tot_res = 0 and union_pi_res = 0 together gives + pi_D = -(1 - omega)*dlog p and pi_F = omega*dlog p. At the capital-key + omega = 0.071 that is a 93/7 split -- the internal-devaluation pattern. + Verified here by residual evaluation, not by re-deriving the algebra.""" + import math + from equations_global import terms_of_trade, union_inflation + omega = 0.071 + dlog_p = 1e-4 # small so the log-linear form is accurate + pi_D = -(1 - omega) * dlog_p + pi_F = omega * dlog_p + + uni = union_inflation.steady_state({'pi_D': pi_D, 'pi_F': pi_F, + 'omega_pi_D': omega}) + assert uni['union_pi_res'] == pytest.approx(0.0, abs=1e-18) + + # tot_res compares p/p(-1) against (1+pi_F)/(1+pi_D); steady_state() sets + # p(-1) = p, so feed the implied gross growth rate directly instead. + implied = (1 + pi_F) / (1 + pi_D) + assert math.log(implied) == pytest.approx(dlog_p, rel=1e-6) + + +def test_omega_one_half_splits_evenly(): + """Guards the calibration argument: at omega = 0.5 the adjustment splits + 50/50, which is counterfactual for GR/DE. See the spec.""" + omega = 0.5 + dlog_p = 1e-4 + assert -(1 - omega) * dlog_p == pytest.approx(-0.5 * dlog_p, rel=1e-15) + assert omega * dlog_p == pytest.approx(0.5 * dlog_p, rel=1e-15) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -k "global_residuals or closure or omega_one" -v +``` + +Expected: FAIL — `ImportError: cannot import name 'terms_of_trade'`. + +- [ ] **Step 3: Implement the blocks** + +Append to `code/equations_global.py`: + +```python +@simple +def terms_of_trade(p, pi_D, pi_F): + # p = P_F/P_D in euro producer prices. In a monetary union the nominal + # exchange rate is fixed at 1, so terms-of-trade movement IS the inflation + # differential. This pins pi_D - pi_F off an unknown that already exists. + # Zero at SS: p/p(-1) = 1 and pi_D = pi_F = 0. + tot_res = p / p(-1) - (1.0 + pi_F) / (1.0 + pi_D) + return tot_res + + +@simple +def union_inflation(pi_D, pi_F, omega_pi_D): + # The ECB stabilises union-wide producer-price inflation -- the phi_pi -> inf + # limit of a Taylor rule, stated as an abstraction and NOT a modelled rule. + # Financial contracts carry no policy rate, so no Fisher relation is needed + # to close the nominal side. + # + # With terms_of_trade this gives pi_D = -(1 - omega_pi_D)*dlog p. At the + # capital-key omega_pi_D = 0.071, 93% of any terms-of-trade adjustment is D + # producer-price deflation and 7% is F inflation -- the 2010-12 internal- + # devaluation pattern. Do NOT use model GDP weights: the model normalises + # Y_D_ss ~ Y_F_ss ~ 1, so those would give ~0.5 and split it evenly. + pi_U = omega_pi_D * pi_D + (1.0 - omega_pi_D) * pi_F + union_pi_res = pi_U + return pi_U, union_pi_res +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -v +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add code/equations_global.py code/test_nkpc_blocks.py docs/STATE.md docs/PROGRESS.md docs/HANDOFF.md +git commit -m "feat: terms_of_trade and union_inflation close the nominal side" +``` + +--- + +### Task 6: Route the rent into household income + +**Files:** +- Modify: `code/equations_D.py:64-69` +- Modify: `code/equations_F.py:60-66` + +- [ ] **Step 1: Modify `income_D`** + +Replace `code/equations_D.py:64-69` in full: + +```python +def income_D(e_grid_D, w_D, N_D, div_D, div_fund_D, profit_D, tau_D, lamb_D, P_CES_D, T_ls_D): + # div_fund_D: rebate from the passive capital fund (zero when omega_K_D=1). + # profit_D: markup rent, distributed in proportion to productivity e (see + # firm_profit_D). w_D*N_D*e + profit_D*e = (1-alpha)*Y_D*e exactly, so + # household income is identical to the flex model and the markup wedge acts + # only on the firm's hiring decision. Zero at SS. + y_pre_D = (w_D * N_D * e_grid_D + profit_D * e_grid_D + div_D + div_fund_D) / P_CES_D + z_D = lamb_D * (y_pre_D ** (1 - tau_D)) - T_ls_D + t_paid_D = y_pre_D - z_D + return z_D, t_paid_D +``` + +Replace `code/equations_F.py:60-66` in full: + +```python +def income_F(e_grid_F, w_F, N_F, div_F, div_fund_F, profit_F, tau_F, lamb_F, P_CES_F, T_ls_F): + # See income_D. profit_F is the markup rent, distributed on e; zero at SS. + y_pre_F = (w_F * N_F * e_grid_F + profit_F * e_grid_F + div_F + div_fund_F) / P_CES_F + z_F = lamb_F * (y_pre_F ** (1 - tau_F)) - T_ls_F + t_paid_F = y_pre_F - z_F + return z_F, t_paid_F +``` + +- [ ] **Step 2: Verify the hetinput picks up the new argument** + +```bash +/opt/anaconda3/envs/ssj/bin/python -c " +import sys; sys.path.insert(0, 'code') +from equations_D import hh_extended_D +assert 'profit_D' in hh_extended_D.inputs, hh_extended_D.inputs +print('profit_D wired into hh_extended_D') +" +``` + +Expected: `profit_D wired into hh_extended_D`. + +- [ ] **Step 3: Commit** + +```bash +git add code/equations_D.py code/equations_F.py docs/STATE.md docs/PROGRESS.md docs/HANDOFF.md +git commit -m "feat: markup rent enters household income proportional to e" +``` + +--- + +### Task 7: Calibration parameters + +`mc_D` and `mc_F` already exist in `calibration.py:284` at 1.0, but nothing reads +them — they are dead entries. They now become live and must be retargeted to +`1/mu_p`. + +**Files:** +- Modify: `code/calibration.py:281-284` + +- [ ] **Step 1: Replace the wage-markup / SS-real-variables block** + +Replace `code/calibration.py:281-284` in full: + +```python + # ── Wage Markups ────────────────────────────────────────────────────── + # Unchanged: wages are flexible. mu_w = 1 is the SS-neutralising device + # in labor_ss_D/F; there is no wage Phillips curve. + 'mu_w_D': 1.0, 'mu_w_F': 1.0, + + # ── Price Rigidity (Rotemberg) ──────────────────────────────────────── + # mu_p: gross price markup, epsilon_p = 6. FREE TO FIRST ORDER under the + # subsidy neutralisation -- the gap (mu_p*mc - 1) linearises to mc_hat + # for any mu_p -- so this needs no defending unless live markups are + # ever adopted. + # mc: SS real marginal cost = 1/mu_p. The production subsidy + # tau_s = 1 - 1/mu_p makes labour demand collapse to the competitive + # w = (1-alpha)Y/N at this value, so the SS is bit-identical to flex. + # kappa_p: Calvo theta_p = 0.75 at beta = 0.985, slope + # (1-theta)(1-beta*theta)/theta = 0.0871. Euro-area IPN median price + # duration ~4 quarters (Alvarez et al. 2006; Dhyne et al. 2006). + # Agrees with Bi-Foerster-Traum's implied 0.0846 to within 3%. + # pi: SS producer-price inflation, exactly zero. + 'mu_p_D': 1.20, 'mu_p_F': 1.20, + 'mc_D': 1.0 / 1.20, 'mc_F': 1.0 / 1.20, + 'kappa_p_D': 0.0871, 'kappa_p_F': 0.0871, + 'pi_D': 0.0, 'pi_F': 0.0, + + # omega_pi_D: weight on D in the union producer-price aggregate that the + # ECB is assumed to stabilise. = 1 - kappa_cb_F, the renormalised + # two-country capital key (BuBa 26.1 / BoG 2.0 of the euro-area key). + # DO NOT use model GDP weights: the model normalises Y_D_ss ~ Y_F_ss ~ 1, + # so they would give ~0.5 and split the terms-of-trade adjustment evenly + # between Greek deflation and German inflation -- counterfactual for + # 2010-12. Load-bearing twice over once deposits are nominal, since it + # scales pi_D and hence the Fisher revaluation on bank balance sheets. + 'omega_pi_D': 0.071, +``` + +- [ ] **Step 2: Verify the calibration loads and the SS markup is consistent** + +```bash +/opt/anaconda3/envs/ssj/bin/python -c " +import sys; sys.path.insert(0, 'code') +from calibration import get_calibration +c = get_calibration() +for k in ('mu_p_D','mu_p_F','mc_D','mc_F','kappa_p_D','kappa_p_F','pi_D','pi_F','omega_pi_D'): + print(f' {k} = {c[k]}') +assert abs(c['mu_p_D']*c['mc_D'] - 1.0) < 1e-15, 'subsidy neutralisation broken' +assert abs(c['mu_p_F']*c['mc_F'] - 1.0) < 1e-15, 'subsidy neutralisation broken' +print('mu_p*mc == 1 in both countries') +" +``` + +Expected: all nine printed, then `mu_p*mc == 1 in both countries`. + +- [ ] **Step 3: Commit** + +```bash +git add code/calibration.py docs/STATE.md docs/PROGRESS.md docs/HANDOFF.md +git commit -m "feat: mu_p, kappa_p, omega_pi_D calibration; mc retargeted to 1/mu_p" +``` + +--- + +### Task 8: Seed the new blocks into the steady state + +The SS solve must carry `mc`, `pi`, `profit` and the two global residuals so +`ss_final` hands them to `solve_jacobian`. All are exactly zero (or exactly +`1/mu_p`) at the current SS, so **the solved steady state must not move**. + +**Files:** +- Modify: `code/steady_state.py:1-40` (imports), `code/steady_state.py:151-162` (block list) + +- [ ] **Step 1: Add the new blocks to the SS imports** + +In `code/steady_state.py`, add to the `from equations_D import ...` list: +`firm_profit_D, price_nkpc_D`. Add to the `from equations_F import ...` list: +`firm_profit_F, price_nkpc_F`. Add to the `from equations_global import ...` list: +`terms_of_trade, union_inflation`. + +- [ ] **Step 2: Add them to the SS `create_model` list** + +In `solve_steady_state` (`code/steady_state.py:151`), extend the list. `labor_ss_D` +already sits on the line with `banker_div_D` and `government_ss_D`; add the new +blocks alongside: + +```python + hh_extended_D, smart_steady_D, market_clearing_D, steady_auxilliary_D, + banker_div_D, government_ss_D, labor_ss_D, firm_profit_D, price_nkpc_D, + hh_extended_F, smart_steady_F, market_clearing_F, steady_auxilliary_F, + banker_div_F, government_ss_F, labor_ss_F, firm_profit_F, price_nkpc_F, + ces_price_D, import_demand_D, ces_price_F, import_demand_F, + deposit_return_D, deposit_return_F, + bond_yield, + trade_balance, external_account_D, global_goods_mkt, + terms_of_trade, union_inflation, +``` + +`labor_demand_D/F` is deliberately **not** in the SS list (the SS uses +`labor_ss_D/F`), so the markup change there cannot touch the SS solve. + +- [ ] **Step 3: Verify the SS is unchanged and the new residuals are zero** + +```bash +/opt/anaconda3/envs/ssj/bin/python -c " +import sys; sys.path.insert(0, 'code') +from calibration import get_calibration +from steady_state import solve_steady_state +r = solve_steady_state(get_calibration()) +ss = r['ss_final'] if 'ss_final' in r else r['ss'] +for k in ('profit_D','profit_F','nkpc_p_res_D','nkpc_p_res_F','tot_res','union_pi_res'): + v = float(ss[k]); print(f' {k} = {v:.3e}'); assert abs(v) < 1e-12, k +print('all new SS residuals are zero') +print(f\" K_D = {float(ss['K_D']):.10f}\") +print(f\" rk_D = {float(ss['rk_D']):.10f}\") +print(f\" w_D = {float(ss['w_D']):.10f}\") +" 2>&1 | tail -20 +``` + +Expected: every new residual `< 1e-12`, then `all new SS residuals are zero`. +Record the printed `K_D`, `rk_D`, `w_D` — compare against +`/tmp/nkpc_baseline_main.log` if it reports them, or against `docs/STATE.md`'s +calibration table. **They must not have moved.** + +- [ ] **Step 4: Commit** + +```bash +git add code/steady_state.py docs/STATE.md docs/PROGRESS.md docs/HANDOFF.md +git commit -m "feat: seed mc, pi, profit and the global residuals into the SS solve" +``` + +--- + +### Task 9: Wire the 27×27 system and pass the equivalence gate + +This is the gate. At `kappa_p = 1e4` prices are effectively flexible, so the +27×27 system must reproduce the pre-change baseline. If it does not, the wiring +is wrong and nothing downstream is worth debugging. + +**Files:** +- Create: `code/dump_irfs.py` +- Modify: `code/full_model.py` (imports, `build_block_list`, `unknowns_tp`, `targets_tp`) + +- [ ] **Step 1: Create the IRF dump script** + +Create `code/dump_irfs.py`: + +```python +"""Run the pipeline through build_and_solve and save IRFs for comparison. + +Usage: + /opt/anaconda3/envs/ssj/bin/python code/dump_irfs.py OUT.npz +""" +import sys +from pathlib import Path + +import numpy as np + +sys.path.insert(0, str(Path(__file__).parent)) + +from calibration import get_calibration +from steady_state import solve_steady_state +from ic_delta_calibration import calibrate_ic_delta +from depreciation_calibration import calibrate_depreciation +from full_model import build_and_solve + +KEYS = ('Y_D', 'C_D', 'I_D', 'n_inter_D', 'K_D', 'b_gov_D', 'w_D', 'N_D', + 'p', 'q_b_D', 'spread_rb', 'Y_F', 'C_F', 'I_F', 'n_inter_F') + + +def main(out_path): + r = calibrate_depreciation(calibrate_ic_delta( + solve_steady_state(get_calibration()))) + m = build_and_solve(r) + payload = {} + for tag in ('irfs_def_D', 'irfs_Z_D'): + for k in KEYS: + if k in m[tag]: + payload[f'{tag}__{k}'] = np.asarray(m[tag][k]) + for k in ('Y_D', 'C_D', 'n_inter_D', 'K_D'): + payload[f'ss__{k}'] = np.asarray(float(m['ss_final'][k])) + np.savez(out_path, **payload) + print(f'wrote {out_path} with {len(payload)} arrays') + + +if __name__ == '__main__': + main(sys.argv[1]) +``` + +- [ ] **Step 2: Dump the pre-change baseline from `main`** + +Tasks 2–8 are all committed by now, so there are no uncommitted tracked changes +and `git checkout main` is clean. `code/dump_irfs.py` is still **untracked**, so +it survives the checkout — and it only imports `build_and_solve`, which exists on +`main`. No stash is needed. + +```bash +git status --short # must show only "?? code/dump_irfs.py" +git checkout main +/opt/anaconda3/envs/ssj/bin/python code/dump_irfs.py /tmp/nkpc_irfs_baseline.npz +git checkout add-nkpc +``` + +Expected: `wrote /tmp/nkpc_irfs_baseline.npz with ...`. If `git status` shows any +tracked modification, commit it before checking out — do not stash. + +- [ ] **Step 3: Add the new blocks to `build_block_list`** + +In `code/full_model.py`, extend the `from equations_D import (...)` list with +`price_nkpc_D, firm_profit_D`, the `from equations_F import (...)` list with +`price_nkpc_F, firm_profit_F`, and the `from equations_global import (...)` list +with `terms_of_trade, union_inflation`. + +Then in `build_block_list`, add `firm_profit_D, price_nkpc_D,` to the country-D +group (immediately after `labor_D, labor_market_D, labor_demand_D,`), add +`firm_profit_F, price_nkpc_F,` to the country-F group in the same position, and +append `terms_of_trade, union_inflation,` to the global group. + +- [ ] **Step 4: Update the solver system to 27×27** + +Replace `unknowns_tp` and `targets_tp` in `code/full_model.py:94-107`: + +```python + # ── 27×27 system ────────────────────────────────────────────────────────── + # +4 vs the flex model: mc and pi per country. mc is pinned by the price + # NKPC, pi jointly by the terms-of-trade identity and the union-inflation + # normalisation. No targets are renamed or removed -- labor_mkt_res_D/F is + # unchanged because wages stay flexible. + unknowns_tp = [ + 'K_D', 'n_inter_D', 'div_D', 'I_D', 'Q_D', 'b_gov_D', 'N_D', 'b_F_D', 'w_D', 'rdep_D', + 'mc_D', 'pi_D', + 'K_F', 'n_inter_F', 'div_F', 'I_F', 'Q_F', 'b_gov_F', 'N_F', 'b_D_F', 'w_F', 'rdep_F', + 'mc_F', 'pi_F', + 'p', 'q_b_D', 'q_b_F', + ] + targets_tp = [ + 'deposit_mkt_D', 'K_res_D', 'n_inter_val_D', 'div_res_D', + 'capital_res_D', 'q_res_D', 'b_gov_res_D', 'b_F_D_res', + 'labor_mkt_res_D', 'w_res_D', 'nkpc_p_res_D', + 'deposit_mkt_F', 'K_res_F', 'n_inter_val_F', 'div_res_F', + 'capital_res_F', 'q_res_F', 'b_gov_res_F', 'b_D_F_res', + 'labor_mkt_res_F', 'w_res_F', 'nkpc_p_res_F', + 'goods_mkt_D', 'rb_D_res', 'rb_F_res', 'tot_res', 'union_pi_res', + ] +``` + +Also update the model name on the `sj.create_model` call in `build_and_solve` to +`"Full 2-Country MU HANK — GHH Preferences, Sticky Price, Flex Wage, No CB"`. + +- [ ] **Step 5: Verify the system is square** + +`unknowns_tp` is local to `build_and_solve`, so count from the source text +directly rather than importing: + +```bash +/opt/anaconda3/envs/ssj/bin/python - <<'PY' +import ast, pathlib +tree = ast.parse(pathlib.Path('code/full_model.py').read_text()) +found = {} +for node in ast.walk(tree): + if isinstance(node, ast.Assign) and isinstance(node.value, ast.List): + for tgt in node.targets: + if isinstance(tgt, ast.Name) and tgt.id in ('unknowns_tp', 'targets_tp'): + found[tgt.id] = [e.value for e in node.value.elts] +u, t = found['unknowns_tp'], found['targets_tp'] +print('unknowns:', len(u), 'targets:', len(t)) +assert len(u) == len(t) == 27, (len(u), len(t)) +assert len(set(u)) == 27 and len(set(t)) == 27, 'duplicate entry' +for name in ('mc_D', 'pi_D', 'mc_F', 'pi_F'): + assert name in u, name +for name in ('nkpc_p_res_D', 'nkpc_p_res_F', 'tot_res', 'union_pi_res', + 'labor_mkt_res_D', 'labor_mkt_res_F'): + assert name in t, name +print('27x27 confirmed, no duplicates, all new names present') +PY +``` + +Expected: `unknowns: 27 targets: 27` then +`27x27 confirmed, no duplicates, all new names present`. + +- [ ] **Step 6: Run the equivalence gate at `kappa_p = 1e4`** + +```bash +KAPPA=1e4 /opt/anaconda3/envs/ssj/bin/python - <<'PY' +import sys, os; sys.path.insert(0, 'code') +import calibration as cal +_orig = cal.get_calibration +def patched(): + c = _orig() + c['kappa_p_D'] = c['kappa_p_F'] = float(os.environ['KAPPA']) + return c +cal.get_calibration = patched +import dump_irfs +dump_irfs.main('/tmp/nkpc_irfs_flexlimit.npz') +PY +``` + +Expected: completes and writes the npz. If the solve fails to converge, step +`KAPPA` down (3e3, 1e3, 3e2) and record the largest value that converges — a very +stiff Phillips curve can be ill-conditioned. Note the value you used. + +- [ ] **Step 7: Compare against the baseline** + +```bash +/opt/anaconda3/envs/ssj/bin/python - <<'PY' +import numpy as np +a = np.load('/tmp/nkpc_irfs_baseline.npz') +b = np.load('/tmp/nkpc_irfs_flexlimit.npz') +worst = 0.0 +for k in sorted(set(a.files) & set(b.files)): + d = float(np.max(np.abs(a[k] - b[k]))) + scale = max(float(np.max(np.abs(a[k]))), 1e-12) + rel = d / scale + worst = max(worst, rel) + flag = 'FAIL' if rel > 1e-3 else 'ok' + print(f'{flag:4s} {k:28s} max|abs diff| = {d:.3e} rel = {rel:.3e}') +print(f'\nworst relative deviation = {worst:.3e}') +PY +``` + +Expected: every line `ok`, worst relative deviation below 1e-3. This is a +*limit* comparison, not an exact one — a finite `kappa_p` cannot reproduce the flex +model to machine precision. If the worst deviation exceeds 1e-3, re-run step 6 +with a larger `KAPPA` and check whether the deviation shrinks proportionally. **If +it does not shrink with `kappa_p`, the wiring is wrong — stop and debug.** + +- [ ] **Step 8: Run the full pipeline once at the flex limit to check residuals** + +```bash +KAPPA=1e4 /opt/anaconda3/envs/ssj/bin/python - <<'PY' 2>&1 | tee /tmp/nkpc_flexlimit_main.log +import sys, os; sys.path.insert(0, 'code') +import calibration as cal +_orig = cal.get_calibration +def patched(): + c = _orig() + c['kappa_p_D'] = c['kappa_p_F'] = float(os.environ['KAPPA']) + return c +cal.get_calibration = patched +import main +main.main() +PY +grep -E "goods_mkt|ca_res|deposit_mkt|n_inter_D\[0\]|Y_D\[0\]|b_gov_D\[499\]" /tmp/nkpc_flexlimit_main.log +``` + +Expected: `goods_mkt_D` ≤ 1e−14, `goods_mkt_F` and `ca_res_D` ≤ 1e−7, +`deposit_mkt_D/F` ≤ 1e−13, `n_inter_D[0]` and `Y_D[0]` both negative. + +- [ ] **Step 9: Commit** + +```bash +git add code/full_model.py code/dump_irfs.py docs/STATE.md docs/PROGRESS.md docs/HANDOFF.md +git commit -m "feat: 27x27 sticky-price system; passes the kappa_p -> inf equivalence gate" +``` + +--- + +### Task 9b: Convert every Jacobian call site to the padded solver + +Task 9 converted `full_model.py` and `tpi.py`. **Seven call sites still use stock +`solve_jacobian` and will die with the core-dimension mismatch the moment they +see the sticky-price system.** They are not broken yet only because they have not +been re-run. + +Two of them block later tasks outright: +- `diagnostics/regimes/regime_model.py:177` — Task 15's cache rebuild, and hence + **all of E1–E4**, runs off this. +- `experiments/e4_distribution.py:255` — E4's quintile incidence. + +The rest are diagnostics off the plan's critical path but must not be left as +landmines: `diagnostics/solve_configs.py:176`, +`diagnostics/psilam_moment_sweep.py:76`, +`diagnostics/psilam_breakdown_sweep.py:83`, +`diagnostics/substitution_v2/solve_v2.py:106`, +`diagnostics/substitution_v2/exp_psilam0.py:64`. + +- [ ] **Step 1: Convert each call site** + +In each file, replace `.solve_jacobian(ss, unknowns=..., targets=..., +inputs=..., T=...)` with: + +```python +from full_model import solve_jacobian_padded +G = solve_jacobian_padded(, ss, , , , T) +``` + +Preserve each site's own variable names and any extra keyword arguments it +passes. Note `solve_jacobian_padded` takes `unknowns`, `targets`, `inputs`, `T` +positionally after `model` and `ss`. + +- [ ] **Step 2: Verify none remain** + +```bash +grep -rn "\.solve_jacobian(" --include="*.py" code experiments diagnostics \ + | grep -v solve_jacobian_padded +``` + +Expected: **no output**. + +- [ ] **Step 3: Smoke-test the two on the critical path** + +```bash +/opt/anaconda3/envs/ssj/bin/python -c " +import sys +sys.path.insert(0,'code'); sys.path.insert(0,'diagnostics/regimes'); sys.path.insert(0,'experiments') +import regime_model, e4_distribution +print('regime_model and e4_distribution import cleanly') +" +``` + +- [ ] **Step 4: Commit** (all three docs staged, as always) + +```bash +git add code experiments diagnostics docs/STATE.md docs/PROGRESS.md docs/HANDOFF.md +git commit -m "fix: route every Jacobian call site through solve_jacobian_padded + +SSJ 1.0.0 drops H_Z rows for targets reachable from no shock. Seven call sites +would have hit the core-dimension mismatch on first contact with the 27x27 +system; regime_model.py blocks the E1-E4 cache rebuild." +``` + +### Task 10: Dial `kappa_p` to 0.0871 and record the price-stickiness result + +This is the clean measure of what price stickiness alone does, with deposits still +real. It is a reportable result, not just a checkpoint — the benchmark literature +does not publish a flexible-price counterfactual. + +**Files:** none modified — `kappa_p` is already 0.0871 in `calibration.py`. + +- [ ] **Step 1: Run the full pipeline at the calibrated slope** + +```bash +/opt/anaconda3/envs/ssj/bin/python code/main.py 2>&1 | tee /tmp/nkpc_sticky_main.log +``` + +Expected: completes. + +- [ ] **Step 2: Check every acceptance threshold and sign** + +```bash +grep -E "goods_mkt|ca_res|deposit_mkt|IC-δ|IC-delta" /tmp/nkpc_sticky_main.log +grep -E "n_inter_D\[0\]|Y_D\[0\]|b_gov_D\[499\]|rho_b" /tmp/nkpc_sticky_main.log +``` + +Expected: thresholds as in Task 9 step 8; `n_inter_D[0] < 0`; `Y_D[0] < 0`; +`b_gov_D[499]` near zero; the IC-δ consistency check unchanged. +If `assert_gk_well_posed` raises, the GK block is no longer well-posed — stop and +report, do not paper over it. + +- [ ] **Step 3: Record the headline impact numbers** + +```bash +/opt/anaconda3/envs/ssj/bin/python code/dump_irfs.py /tmp/nkpc_irfs_sticky.npz +/opt/anaconda3/envs/ssj/bin/python - <<'PY' +import numpy as np +b = np.load('/tmp/nkpc_irfs_baseline.npz') +s = np.load('/tmp/nkpc_irfs_sticky.npz') +print(f"{'variable':12s} {'flex (% SS)':>14s} {'sticky (% SS)':>15s}") +for k in ('Y_D', 'C_D', 'I_D', 'n_inter_D'): + ssv = float(b[f'ss__{k}']) if f'ss__{k}' in b.files else 1.0 + flex = b[f'irfs_def_D__{k}'][0] / ssv * 100 + sticky = s[f'irfs_def_D__{k}'][0] / ssv * 100 + print(f'{k:12s} {flex:+14.4f} {sticky:+15.4f}') +PY +``` + +Expected: a four-row table. **Record it verbatim** — it goes into `docs/STATE.md` +in Task 16 and is the price-stickiness-only result. `C_D[0]` is `+0.2164%` in the +flex baseline; report whatever it is now without editorialising. + +- [ ] **Step 4: Sweep `kappa_p` for the robustness table** + +```bash +for K in 0.03 0.0871 0.2; do + echo "=== kappa_p = $K ===" + KAPPA=$K /opt/anaconda3/envs/ssj/bin/python - <<'PY' 2>&1 | grep -E "n_inter_D\[0\]|Y_D\[0\]|b_gov_D\[499\]" +import sys, os; sys.path.insert(0, 'code') +import calibration as cal +_orig = cal.get_calibration +def patched(): + c = _orig() + c['kappa_p_D'] = c['kappa_p_F'] = float(os.environ['KAPPA']) + return c +cal.get_calibration = patched +from steady_state import solve_steady_state +from ic_delta_calibration import calibrate_ic_delta +from depreciation_calibration import calibrate_depreciation +from full_model import build_and_solve +build_and_solve(calibrate_depreciation(calibrate_ic_delta( + solve_steady_state(cal.get_calibration())))) +PY +done +``` + +Expected: three blocks of output. Record which values keep `b_gov_D[499]` near +zero and both signs negative — that is the stable region for the robustness table. + +- [ ] **Step 5: Commit the recorded numbers** + +Add the two tables to `docs/PROGRESS.md` under the current date. + +```bash +git add docs/PROGRESS.md +git commit -m "docs: record price-stickiness-only impact numbers and kappa_p sweep" +``` + +--- + +## Phase 2 — Nominal deposits + +### Task 11: `deposit_rates_{D,F}` and nominal `deposit_return_{D,F}` + +`rdep_i` becomes a **derived** ex-ante real rate and `i_dep_i` becomes the nominal +unknown. This is the refinement noted at the top of the plan: because +`intermediation_P1`, `divert_bond_foc` and `divert_portfolio_adj` already mean +"ex-ante real" by `rdep_i`, they need no changes at all. + +**Files:** +- Modify: `code/equations_D.py:75-84`, `code/equations_F.py:69-76` +- Modify: `code/test_nkpc_blocks.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `code/test_nkpc_blocks.py`: + +```python +# ── Nominal deposits ────────────────────────────────────────────────────────── + +def test_deposit_rates_collapse_at_zero_inflation(): + """At pi = 0 both derived real rates must equal the nominal rate exactly -- + this is what keeps the steady state bit-identical.""" + from equations_D import deposit_rates_D + ss = deposit_rates_D.steady_state({'i_dep_D': 0.0125, 'pi_D': 0.0}) + assert ss['rdep_D'] == pytest.approx(0.0125, rel=1e-15) + assert ss['rdep_expost_D'] == pytest.approx(0.0125, rel=1e-15) + + +def test_deflation_raises_the_realised_real_deposit_rate(): + """Deflation is a windfall to depositors and a loss to banks, which hold + real assets against nominal liabilities. This is the Fisher channel; if the + sign flips, bank_return_D will amplify in the wrong direction.""" + from equations_D import deposit_rates_D + i = 0.0125 + base = deposit_rates_D.steady_state({'i_dep_D': i, 'pi_D': 0.0}) + defl = deposit_rates_D.steady_state({'i_dep_D': i, 'pi_D': -0.001}) + assert defl['rdep_expost_D'] > base['rdep_expost_D'] + assert defl['rdep_expost_D'] == pytest.approx((1 + i) / (1 - 0.001) - 1, rel=1e-14) + + +def test_deposit_return_is_unchanged_at_zero_inflation(): + """Rgross must be exactly 1 + i_dep when pi = 0 and P_CES is flat.""" + from equations_D import deposit_return_D + ss = deposit_return_D.steady_state({'i_dep_D': 0.0125, 'P_CES_D': 1.3, 'pi_D': 0.0}) + assert ss['Rgross_D'] == pytest.approx(1.0125, rel=1e-15) + + +def test_deposit_rates_F_matches_D(): + from equations_D import deposit_rates_D + from equations_F import deposit_rates_F + d = deposit_rates_D.steady_state({'i_dep_D': 0.0125, 'pi_D': -0.001}) + f = deposit_rates_F.steady_state({'i_dep_F': 0.0125, 'pi_F': -0.001}) + assert d['rdep_D'] == pytest.approx(f['rdep_F'], rel=1e-15) + assert d['rdep_expost_D'] == pytest.approx(f['rdep_expost_F'], rel=1e-15) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -k "deposit" -v +``` + +Expected: FAIL — `ImportError: cannot import name 'deposit_rates_D'`. + +- [ ] **Step 3: Implement the blocks** + +Replace `code/equations_D.py:75-84` in full: + +```python +@simple +def deposit_rates_D(i_dep_D, pi_D): + # Deposits are NOMINAL euro contracts. i_dep_D is the nominal rate and is the + # unknown that clears deposit_mkt_D -- there is no policy rate pinning it, so + # no absorber or cross-border claim is needed and external_account_D is + # untouched. + # + # rdep_D keeps its existing meaning: the EX-ANTE real rate for the t -> t+1 + # holding period, locked at t. That is exactly what intermediation_P1_D, + # divert_bond_foc_D and divert_portfolio_adj already mean by rdep_D, so those + # blocks need no changes. + # + # rdep_expost_D is the REALISED real rate at t on deposits placed at t-1. It + # contains the inflation surprise: a deflation raises the real value of the + # bank's nominal liabilities. Banks hold real assets against nominal + # liabilities, so they are net nominal debtors and this deepens the net-worth + # loss -- the Fisher-Bernanke channel. + # + # At SS pi_D = 0 and both equal i_dep_D, so the SS is bit-identical. + rdep_D = (1 + i_dep_D) / (1 + pi_D(+1)) - 1 + rdep_expost_D = (1 + i_dep_D(-1)) / (1 + pi_D) - 1 + return rdep_D, rdep_expost_D + + +@simple +def deposit_return_D(i_dep_D, P_CES_D, pi_D): + # Bundle-real gross deposit return on a NOMINAL contract. + # P_c_D = P_D * P_CES_D is the nominal CPI, so + # P_c_D(-1)/P_c_D = (P_CES_D(-1)/P_CES_D) / (1 + pi_D). + # + # T-2 is NOT reopened: the rate is still locked at t-1 (i_dep_D(-1)); only + # the deflator is period-t, which this block already did via P_CES. T-2 was + # about paying a period-t UNKNOWN rate on the t-1 deposit stock. + # + # At SS P_CES_D(-1)/P_CES_D = 1 and pi_D = 0, so Rgross_D = 1 + i_dep_D. + Rgross_D = (1 + i_dep_D(-1)) * P_CES_D(-1) / P_CES_D / (1 + pi_D) + return Rgross_D +``` + +Replace `code/equations_F.py:69-76` in full: + +```python +@simple +def deposit_rates_F(i_dep_F, pi_F): + # See deposit_rates_D. + rdep_F = (1 + i_dep_F) / (1 + pi_F(+1)) - 1 + rdep_expost_F = (1 + i_dep_F(-1)) / (1 + pi_F) - 1 + return rdep_F, rdep_expost_F + + +@simple +def deposit_return_F(i_dep_F, P_CES_F, pi_F): + # See deposit_return_D. Nominal contract; T-2 timing preserved. + Rgross_F = (1 + i_dep_F(-1)) * P_CES_F(-1) / P_CES_F / (1 + pi_F) + return Rgross_F +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -v +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add code/equations_D.py code/equations_F.py code/test_nkpc_blocks.py docs/STATE.md docs/PROGRESS.md docs/HANDOFF.md +git commit -m "feat: nominal deposit contracts; ex-ante and ex-post real rates" +``` + +--- + +### Task 12: Ex-post funding cost in `bank_return` and `capital_fund` + +These two blocks are where the bank pays for deposits it took on at t−1, so they +must use the *realised* real rate. Everything else that touches `rdep_i` is +forward-looking and stays as it is. + +**Files:** +- Modify: `code/equations_D.py:325-348` +- Modify: `code/equations_F.py:276-302` + +- [ ] **Step 1: Write the failing test** + +Append to `code/test_nkpc_blocks.py`: + +```python +def test_bank_return_uses_the_expost_rate(): + """Signature check: bank_return_D must take rdep_expost_D and must NOT take + rdep_D. Getting this backwards silently reverses the Fisher channel.""" + from equations_D import bank_return_D, capital_fund_D + for blk in (bank_return_D, capital_fund_D): + assert 'rdep_expost_D' in blk.inputs, (blk.name, blk.inputs) + assert 'rdep_D' not in blk.inputs, (blk.name, blk.inputs) + + +def test_forward_looking_blocks_still_use_rdep(): + """intermediation_P1_D and divert_bond_foc_D are ex-ante and must be + untouched -- rdep_D still means the t -> t+1 real rate.""" + from equations_D import intermediation_P1_D, divert_bond_foc_D + for blk in (intermediation_P1_D, divert_bond_foc_D): + assert 'rdep_D' in blk.inputs, (blk.name, blk.inputs) + assert 'rdep_expost_D' not in blk.inputs, (blk.name, blk.inputs) +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -k "expost_rate or forward_looking" -v +``` + +Expected: `test_bank_return_uses_the_expost_rate` FAILS; +`test_forward_looking_blocks_still_use_rdep` PASSES already. + +- [ ] **Step 3: Modify `bank_return_D`** + +In `code/equations_D.py:325-336`, change the signature `rdep_D` → `rdep_expost_D` +and replace every `rdep_D(-1)` with `rdep_expost_D`: + +```python +@simple +def bank_return_D(theta_D, rk_D, rdep_expost_D, b_D_D, b_F_D, n_inter_D, + rb_actual_D, rb_actual_F, q_b_D, q_b_F): + phi_bD_lag_D = q_b_D(-1) * b_D_D(-1) / n_inter_D(-1) + phi_bF_lag_D = q_b_F(-1) * b_F_D(-1) / n_inter_D(-1) + kappa_lag_D = theta_D(-1) - phi_bD_lag_D - phi_bF_lag_D + # T-2 fix: funding cost on the t-1 balance sheet is the rate locked at t-1. + # Under nominal deposits that realised real cost is rdep_expost_D, which + # already carries the (-1) timing internally and contains the inflation + # surprise -- the Fisher revaluation on the bank's nominal liabilities. + rn_D = (kappa_lag_D * (rk_D - rdep_expost_D) + + phi_bD_lag_D * (rb_actual_D - rdep_expost_D) + + phi_bF_lag_D * (rb_actual_F - rdep_expost_D) + + rdep_expost_D) + return rn_D +``` + +- [ ] **Step 4: Modify `capital_fund_D`** + +In `code/equations_D.py:339-348`: + +```python +@simple +def capital_fund_D(rk_D, rdep_expost_D, Q_D, K_D, omega_K_D, fund_rule_D, K_fund_D): + # Passive capital fund funded by deposits; rebates its spread on the lagged + # capital value to households. Same predetermined-rate timing as + # bank_return_D (T-2); rdep_expost_D is the realised real funding cost under + # nominal deposits. Zero when the fund is empty (omega_K_D=1, K_fund_D=0). + # fund_rule_D: 0 = fund holds (1-omega_K)·K, 1 = fund holds a constant K_fund. + K_fnd_lag_D = ((1.0 - fund_rule_D) * (1.0 - omega_K_D) * K_D(-1) + + fund_rule_D * K_fund_D) + div_fund_D = (rk_D - rdep_expost_D) * Q_D(-1) * K_fnd_lag_D + return div_fund_D +``` + +- [ ] **Step 5: Apply the identical changes to F** + +In `code/equations_F.py:276-292`, change `bank_return_F`'s signature `rdep_F` → +`rdep_expost_F` and replace all four `rdep_F(-1)` with `rdep_expost_F`. In +`code/equations_F.py:295-302`, change `capital_fund_F`'s signature `rdep_F` → +`rdep_expost_F` and replace `rdep_F(-1)` with `rdep_expost_F`. Do **not** touch +the W-2 `p(-1)/p` conversions in `bank_return_F`. + +- [ ] **Step 6: Run the tests** + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -v +/opt/anaconda3/envs/ssj/bin/python -c " +import sys; sys.path.insert(0, 'code') +from equations_F import bank_return_F, capital_fund_F +for b in (bank_return_F, capital_fund_F): + assert 'rdep_expost_F' in b.inputs and 'rdep_F' not in b.inputs, (b.name, b.inputs) +print('F blocks wired to rdep_expost_F') +" +``` + +Expected: all tests pass, then `F blocks wired to rdep_expost_F`. + +- [ ] **Step 7: Commit** + +```bash +git add code/equations_D.py code/equations_F.py code/test_nkpc_blocks.py docs/STATE.md docs/PROGRESS.md docs/HANDOFF.md +git commit -m "feat: bank_return and capital_fund pay the ex-post real deposit rate" +``` + +--- + +### Task 13: Wire nominal deposits and verify the Fisher sign + +**Files:** +- Modify: `code/calibration.py:70` +- Modify: `code/steady_state.py` (imports + SS block list) +- Modify: `code/full_model.py` (imports, `build_block_list`, `unknowns_tp`) + +- [ ] **Step 1: Rename the calibration parameter** + +Replace `code/calibration.py:70`: + +```python + # Nominal deposit rate. Deposits are nominal euro contracts; the derived + # real rates rdep_D/F (ex-ante) and rdep_expost_D/F (realised) come from + # deposit_rates_D/F. At SS pi = 0, so rdep = i_dep and the SS is + # unchanged from the real-deposit calibration. + 'i_dep_D': 0.000, 'i_dep_F': 0.000, +``` + +- [ ] **Step 2: Add `deposit_rates` to both block lists** + +In `code/steady_state.py`, add `deposit_rates_D` / `deposit_rates_F` to the +imports and place them in the SS `create_model` list immediately **before** +`deposit_return_D, deposit_return_F` (they produce `rdep_D` / `rdep_F`, which +`smart_steady_D/F` and `steady_auxilliary_D/F` consume): + +```python + deposit_rates_D, deposit_rates_F, + deposit_return_D, deposit_return_F, +``` + +In `code/full_model.py`, add `deposit_rates_D` / `deposit_rates_F` to the imports +and put each immediately before its `deposit_return_*` entry in +`build_block_list`. + +- [ ] **Step 3: Swap the unknown** + +In `code/full_model.py`'s `unknowns_tp`, replace `'rdep_D'` with `'i_dep_D'` and +`'rdep_F'` with `'i_dep_F'`. `targets_tp` is unchanged — still 27×27. + +- [ ] **Step 4: Verify the steady state has not moved** + +```bash +/opt/anaconda3/envs/ssj/bin/python -c " +import sys; sys.path.insert(0, 'code') +from calibration import get_calibration +from steady_state import solve_steady_state +r = solve_steady_state(get_calibration()) +ss = r['ss_final'] if 'ss_final' in r else r['ss'] +i, ra, rp = float(ss['i_dep_D']), float(ss['rdep_D']), float(ss['rdep_expost_D']) +print(f' i_dep_D={i:.12f} rdep_D={ra:.12f} rdep_expost_D={rp:.12f}') +assert abs(ra - i) < 1e-14 and abs(rp - i) < 1e-14, 'rates do not collapse at SS' +print(f\" K_D = {float(ss['K_D']):.10f}\") +print(f\" rk_D = {float(ss['rk_D']):.10f}\") +print(f\" w_D = {float(ss['w_D']):.10f}\") +print('SS rates collapse correctly') +" 2>&1 | tail -10 +``` + +Expected: all three rates equal, and `K_D` / `rk_D` / `w_D` identical to the +values recorded in Task 8 step 3. + +- [ ] **Step 5: Run the full pipeline** + +```bash +/opt/anaconda3/envs/ssj/bin/python code/main.py 2>&1 | tee /tmp/nkpc_nominal_main.log +grep -E "goods_mkt|ca_res|deposit_mkt|n_inter_D\[0\]|Y_D\[0\]|b_gov_D\[499\]" /tmp/nkpc_nominal_main.log +``` + +Expected: all acceptance thresholds hold; both signs negative. + +- [ ] **Step 6: Verify the Fisher sign — the gate for this phase** + +```bash +/opt/anaconda3/envs/ssj/bin/python code/dump_irfs.py /tmp/nkpc_irfs_nominal.npz +/opt/anaconda3/envs/ssj/bin/python - <<'PY' +import numpy as np +s = np.load('/tmp/nkpc_irfs_sticky.npz') # Task 10: sticky prices, real deposits +n = np.load('/tmp/nkpc_irfs_nominal.npz') # this task: + nominal deposits +ss = float(s['ss__n_inter_D']) +a = s['irfs_def_D__n_inter_D'][0] / ss * 100 +b = n['irfs_def_D__n_inter_D'][0] / ss * 100 +print(f'n_inter_D[0] real deposits: {a:+.4f}% of SS') +print(f'n_inter_D[0] nominal : {b:+.4f}% of SS') +assert b < a, 'FISHER SIGN WRONG: nominal deposits must deepen the net-worth loss' +print('Fisher channel sign OK') +for k in ('Y_D', 'C_D', 'I_D'): + v = float(s[f'ss__{k}']) if f'ss__{k}' in s.files else 1.0 + print(f'{k:6s} real {s[f"irfs_def_D__{k}"][0]/v*100:+.4f}% ' + f'nominal {n[f"irfs_def_D__{k}"][0]/v*100:+.4f}%') +PY +``` + +Expected: `Fisher channel sign OK`. **If the assertion fires, the ex-post / +ex-ante substitution in Task 12 is backwards — do not proceed.** Record the +printed `Y_D` / `C_D` / `I_D` comparison; it is the Fisher-channel result. + +- [ ] **Step 7: Commit** + +```bash +git add code/calibration.py code/steady_state.py code/full_model.py docs/STATE.md docs/PROGRESS.md docs/HANDOFF.md +git commit -m "feat: nominal deposits wired; Fisher channel deepens the net-worth loss" +``` + +--- + +## Phase 3 — Recalibration, regeneration, documentation + +### Task 14: Re-tune `psi_lambda_B` to the 150bp target + +Spread transmission now runs through both a sticky terms of trade and a Fisher +revaluation, so the amplification dial has to be re-disciplined. The moment is +**peak annualised D−F spread ≈ 150bp on a 1pp default-probability shock**. + +CLAUDE.md puts the documented breakdown region around 4–5 at `n_inter = 3.0`. +Hitting the moment is not sufficient — stability must be re-verified at whatever +value it lands on. + +**Files:** Modify `code/calibration.py` (`psi_lambda_B_D` / `psi_lambda_B_F`) + +- [ ] **Step 1: Find the current value** + +```bash +grep -n "psi_lambda_B" code/calibration.py +``` + +Record it. + +- [ ] **Step 2: Measure the spread at three candidate values** + +```bash +for PSI in 1.5 3.0 5.0; do + echo "=== psi_lambda_B = $PSI ===" + PSI=$PSI /opt/anaconda3/envs/ssj/bin/python - <<'PY' 2>&1 | tail -6 +import sys, os; sys.path.insert(0, 'code') +import numpy as np +import calibration as cal +_orig = cal.get_calibration +def patched(): + c = _orig() + c['psi_lambda_B_D'] = c['psi_lambda_B_F'] = float(os.environ['PSI']) + return c +cal.get_calibration = patched +from steady_state import solve_steady_state +from ic_delta_calibration import calibrate_ic_delta +from depreciation_calibration import calibrate_depreciation +from full_model import build_and_solve +m = build_and_solve(calibrate_depreciation(calibrate_ic_delta( + solve_steady_state(cal.get_calibration())))) +sp = m['irfs_def_D']['spread_rb'] +print(f"peak spread = {np.max(np.abs(sp)) * 400 * 100:.1f} bp annualised") +print(f"b_gov_D[499] = {m['irfs_def_D']['b_gov_D'][499]:.3e}") +PY +done +``` + +Expected: three `peak spread` readings. The `* 400 * 100` converts a quarterly +rate deviation to annualised basis points; cross-check the first reading against +the flex baseline's documented 150.3bp to confirm the scaling before trusting it. + +- [ ] **Step 3: Bisect to 150bp** + +Peak spread is monotone increasing in `psi_lambda_B`. Take the bracketing pair +from step 2 and bisect, re-running step 2's script with the midpoint, until the +peak spread is within 1bp of 150. Record every (psi, spread) pair evaluated. + +- [ ] **Step 4: Verify stability at the tuned value** + +Run the full pipeline at the tuned `psi_lambda_B`: + +```bash +/opt/anaconda3/envs/ssj/bin/python code/main.py 2>&1 | tee /tmp/nkpc_tuned_main.log +grep -E "goods_mkt|ca_res|deposit_mkt|n_inter_D\[0\]|Y_D\[0\]|b_gov_D\[499\]|rho_b" /tmp/nkpc_tuned_main.log +``` + +Expected: all thresholds hold, both signs negative, `b_gov_D[499]` near zero. +**If the tuned value sits in the 4–5 breakdown region, stop and report** — hitting +the moment inside a breakdown region is not a valid calibration. + +- [ ] **Step 5: Sweep `omega_pi_D` as the containing parameter** + +```bash +for W in 0.071 0.2 0.5; do + echo "=== omega_pi_D = $W ===" + OMEGA=$W /opt/anaconda3/envs/ssj/bin/python - <<'PY' 2>&1 | grep -E "n_inter_D\[0\]|Y_D\[0\]|b_gov_D\[499\]" +import sys, os; sys.path.insert(0, 'code') +import calibration as cal +_orig = cal.get_calibration +def patched(): + c = _orig(); c['omega_pi_D'] = float(os.environ['OMEGA']); return c +cal.get_calibration = patched +from steady_state import solve_steady_state +from ic_delta_calibration import calibrate_ic_delta +from depreciation_calibration import calibrate_depreciation +from full_model import build_and_solve +build_and_solve(calibrate_depreciation(calibrate_ic_delta( + solve_steady_state(cal.get_calibration())))) +PY +done +``` + +Expected: three blocks. Record which values stay stable — `omega_pi_D` scales +`pi_D` and hence the Fisher revaluation, so this is the containing parameter if +the Fisher channel turns out to dominate. + +- [ ] **Step 6: Commit the tuned calibration** + +```bash +git add code/calibration.py docs/STATE.md docs/PROGRESS.md docs/HANDOFF.md +git commit -m "calib: re-tune psi_lambda_B to the 150bp spread moment under sticky prices" +``` + +--- + +### Task 15: Rebuild the regime cache and regenerate all results + +**Files:** none modified — this regenerates `docs/experiments_results.md` and figures. + +- [ ] **Step 1: Rebuild the cached Jacobian response matrices** + +```bash +/opt/anaconda3/envs/ssj/bin/python diagnostics/regimes/regime_model.py --force 2>&1 | tail -20 +``` + +Expected: completes and writes new `cache_G_main_v*.npz` files. This takes a long +time (multiple Jacobian solves). The `experiments/` package reads these, so it +**must** run before `run_all.py` or the experiments will silently report the old +flex-price model. + +- [ ] **Step 2: Regenerate the standard results set** + +```bash +/opt/anaconda3/envs/ssj/bin/python experiments/run_all.py 2>&1 | tail -40 +``` + +Expected: completes and rewrites `docs/experiments_results.md`. E2 self-verifies +and asserts its dY decomposition closes at 1e−7 — since `market_clearing_D` is +untouched by this work (no Rotemberg resource cost was added), that assertion is +an independent check on the whole change. **If E2's assertion fires, something has +leaked into the resource constraint — stop and find it.** + +- [ ] **Step 3: Run the experiments' own tests** + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest experiments/ code/test_nkpc_blocks.py code/test_eba_calibration.py -v +``` + +Expected: all pass. + +- [ ] **Step 4: Regenerate figures** + +Already done by `code/main.py` in Task 14 step 4 (steps 6 and 8 write to +`outputs/`). Confirm the files are newer than the calibration change: + +```bash +ls -la outputs/ | head -20 +``` + +- [ ] **Step 5: Commit** + +```bash +git add docs/experiments_results.md outputs/ diagnostics/regimes/*.npz docs/PROGRESS.md +git commit -m "regen: rebuild regime cache and regenerate E1-E4 on the sticky model" +``` + +If the `.npz` cache files are gitignored, drop them from the `git add` — check +`diagnostics/regimes/.gitignore` first. + +--- + +### Task 16: Documentation + +The doc hooks require STATE.md, PROGRESS.md and HANDOFF.md on any commit that +stages Python. This task writes them properly rather than the one-liners used +during the phases. + +**Files:** Modify `docs/STATE.md`, `docs/PROGRESS.md`, `docs/HANDOFF.md`, +`docs/SPEC.md`, `CLAUDE.md` + +- [ ] **Step 1: `docs/STATE.md`** + +Add a new section dated 2026-08-05 containing: the new calibration rows (`mu_p`, +`kappa_p`, `mc`, `omega_pi_D`, `i_dep`, tuned `psi_lambda_B`); the three-way impact +table from Task 10 step 3 and Task 13 step 6 (flex / sticky-real-deposits / +sticky-nominal-deposits, for `Y_D[0]`, `C_D[0]`, `I_D[0]`, `n_inter_D[0]`); the +`kappa_p` and `omega_pi_D` sweeps; and the post-change Walras residuals. + +State explicitly whether `C_D[0]` changed sign. If it did not, say so plainly and +cross-reference the spec's *"The `C_D[0]` motivation, stated honestly"* section — +Bi-Foerster-Traum get consumption rising on impact too, with a Taylor rule, nominal +debt and a loan-in-advance constraint. + +- [ ] **Step 2: `docs/PROGRESS.md`** + +Consolidate the per-task one-liners into a single dated changelog entry covering +the refactor, the price NKPCs, the markup rent routing, nominal deposits, the +`psi_lambda_B` re-tune, and the regeneration. + +- [ ] **Step 3: `docs/HANDOFF.md`** + +Update the incidence paragraph at line 65. It currently says "every quintile's +consumption *rises* on impact ... must be confronted in the draft." Replace with +the post-change finding and note what was tried (sticky prices, then nominal +deposits) and what remains untried (a Sims-Wu loan-in-advance constraint; nominal +sovereign bonds). + +- [ ] **Step 4: `docs/SPEC.md`** + +Under *Key modelling choices*, add: the price Phillips curve and its +subsidy-neutralised steady state; the union-inflation normalisation as the nominal +anchor and why there is no Taylor rule; nominal deposits against real bonds as a +deliberate asymmetry that must be stated in the paper; and the markup rent's +distribution rule. + +- [ ] **Step 5: `CLAUDE.md`** + +Update the *Architecture* section to mention `build_block_list()` as the single +model definition. Add `mu_p`, `kappa_p`, `omega_pi_D` and `i_dep` to the key +modelling choices. Update the *Typical iteration* residual list to include the +four new targets. Add `code/test_nkpc_blocks.py` to *Running and testing* as the +fast unit-test entry point: +`/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py -v`. + +- [ ] **Step 6: Final verification and commit** + +```bash +/opt/anaconda3/envs/ssj/bin/python -m pytest code/test_nkpc_blocks.py experiments/ -v +/opt/anaconda3/envs/ssj/bin/python code/main.py 2>&1 | tail -30 +``` + +Expected: tests pass; pipeline completes with all thresholds held. + +```bash +git add docs/ CLAUDE.md +git commit -m "docs: sticky prices and nominal deposits become the baseline + +Records the three-way impact comparison (flex / sticky-real / sticky-nominal), +the re-tuned psi_lambda_B, the kappa_p and omega_pi_D sweeps, and states the +C_D[0] outcome plainly." +``` + +--- + +## Notes on what is deliberately NOT here + +- **Wage rigidity.** Author decision 2026-08-05. `labor_market_{D,F}` is untouched + and stays in every block list. +- **A Taylor rule.** Author decision 2026-08-05. A policy rule only bites if it + pins a real rate, which frees both deposit-market conditions and needs either a + cross-border banking claim (rewriting `external_account_D`) or an ECB reserve + asset inside the GK incentive constraint. +- **Nominal sovereign bonds.** Deliberate asymmetry; candidate follow-on spec. +- **Rotemberg resource costs.** Quadratic around `pi_ss = 0`, so first-order + irrelevant under `solve_jacobian`, and including them would inject a nonlinear + term into `goods_mkt_D` which holds at 1e−14. +- **A Sims-Wu loan-in-advance constraint.** The natural next lever if Tasks 10 and + 13 leave `Y_D[0]` implausibly small; needs its own design pass. diff --git a/docs/superpowers/specs/2026-08-05-nominal-rigidities-design.md b/docs/superpowers/specs/2026-08-05-nominal-rigidities-design.md new file mode 100644 index 0000000..93ca289 --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-nominal-rigidities-design.md @@ -0,0 +1,466 @@ +# Nominal rigidities: price Phillips curves and nominal deposits + +**Date:** 2026-08-05 +**Branch:** `add-nkpc` +**Status:** design approved, not yet implemented + +## Goal + +Add nominal price rigidity and nominal deposit contracts to the two-country +monetary-union HANK model, and make the sticky model the paper's new baseline. +The current model is fully real — `sj.create_model` is named *"Flex Price & Wage, +No CB"* (`code/full_model.py:91`) and there is no inflation variable, nominal +rate, or policy rule anywhere in `code/`. + +1. **Demand-determined output.** Today `N` is purely supply-determined: combining + the two flexible labour conditions gives `(1-alpha)Y/(N*P_CES) = + vphi*N^(1/frisch)`, a function of `Z`, `K` and `P_CES` alone. A markup wedge in + labour demand breaks that. +2. **Fisher debt deflation on bank balance sheets.** Nominal deposits against real + assets make banks net nominal debtors, so a deflation at impact raises the real + value of their liabilities and deepens the net-worth loss. This targets the + fact that `Y_D[0] = -0.0149%` is two orders of magnitude below the benchmark's + `-0.6%`. +3. **Distributional incidence** — the changed paths of output, wages, the terms of + trade and the real deposit return alter how the shock lands across the E4 + income quintiles. + +### The `C_D[0]` motivation, stated honestly + +At the live calibration `C_D[0] = +0.2164%` against `Y_D[0] = -0.0149%` and +`I_D[0] = -0.7718%` (`docs/STATE.md`, E1 passive). `docs/HANDOFF.md` already flags +this: the model's crisis is an investment bust, not a consumption bust, which is +counterfactual for Greece 2010-13. + +**This spec is not guaranteed to flip that sign, and must not be sold as if it +were.** Two forces pull opposite ways once deposits are nominal: + +- *Intertemporal substitution* — expected deflation raises the ex-ante real + deposit rate; at `eis = 0.5` households substitute out of current consumption. + Pushes `C_D[0]` **down**. +- *Impact revaluation* — households are net nominal creditors, so the same + deflation is a windfall on the existing deposit stock. Pushes `C_D[0]` **up**. + +Which dominates is quantitative. What is certain is that the channel becomes +connected at all: the model currently generates `pi_D = -0.93 * dlog p` and lets +it affect nothing. For context, Bi, Foerster and Traum (2026) get consumption +rising on impact too — with a Taylor rule, nominal debt *and* a loan-in-advance +constraint (their §4.1: "Consumption rises briefly on impact, but quickly turns +negative"). The impact sign is a property of this model class. + +## Explicitly out of scope + +**Wage rigidity (author decision, 2026-08-05).** Price stickiness only; the labour +market keeps today's flexible GHH condition `labor_market_{D,F}` unchanged. This +matches Bi-Foerster-Traum. Consequence: no hours-rationing channel, so +distributional incidence works only through the output / wage / terms-of-trade / +deposit-return path. + +**A Taylor rule (author decision, 2026-08-05).** The blocker is structural, not +effort: a policy rule only has traction if it pins a real rate, and pinning +`rdep_D`/`rdep_F` frees *both* deposit-market conditions, which then need an +absorber — either a zero-net-supply cross-border banking claim (rewriting +`external_account_D`, currently at 1e-7) or an ECB reserve asset inside the GK +incentive constraint. A deposit spread over the policy rate does not work, since +a free spread absorbs the rate one-for-one and the rule does nothing. + +**Note this is *not* what makes deposits nominal expensive.** The absorber +problem belongs to the policy rule alone. With no rule, the deposit rate stays a +free unknown and simply becomes nominal — see below. + +**Nominal sovereign bonds (author decision, 2026-08-05).** Bonds, coupons and the +external account stay real. This is a **deliberate asymmetry** that maximises +banks' Fisher exposure, and the paper must label it as such: euro-area sovereign +debt is nominal, Bi-Foerster-Traum deflate debt and net worth alike, and a referee +will ask. Making bonds nominal would touch `bond_return`, `bond_price_ss`, +`budget_residual`, `government_ss`, both bond FOCs, and `external_account_D` +(1e-7, and it carries the W-2 `p`-conversion). Candidate follow-on spec. + +Also out of scope: live steady-state markups, and a Sims-Wu loan-in-advance +constraint (see *Benchmark*). + +## Why the closure works without a policy rate + +In a monetary union the nominal exchange rate is fixed at 1, so the terms of trade +**is** the accumulated inflation differential: + +``` +p = P_F / P_D => p_t / p_{t-1} = (1 + pi_F) / (1 + pi_D) +``` + +`p` is already an unknown (target `goods_mkt_D`), so the identity pins the +inflation *differential* off an existing object. One normalisation pins the level: + +``` +omega_pi_D * pi_D + (1 - omega_pi_D) * pi_F = 0 +``` + +i.e. the ECB stabilises union-wide producer-price inflation — the `phi_pi -> inf` +limit of a Taylor rule, stated as an abstraction rather than a modelled rule. +Given the `p` path these two determine `pi_D` and `pi_F`; the Phillips curves +determine `mc_D` and `mc_F`; labour demand determines `w`; the unchanged labour- +supply condition determines `N`; the deposit markets determine `i_dep_{D,F}`; and +`goods_mkt_D` determines `p`. No redundancy. + +## Equations + +For each country `i` in `{D, F}`. + +### Price Phillips curve (new, `equations_{D,F}.py`) + +``` +nkpc_p_res_i = pi_i - beta_i * pi_i(+1) - kappa_p_i * (mu_p_i * mc_i - 1) +``` + +Rotemberg form in producer-price inflation. The gap is a **ratio**, so it is +unit-free and linearises to exactly `mc_hat`; published Calvo slopes are directly +usable for `kappa_p` with no steady-state rescaling. + +### Labour demand (changed, `labor_demand_{D,F}`) + +``` +w_res_i = w_i - mu_p_i * mc_i * (1 - alpha_i) * Y_i / N_i +``` + +The `mu_p_i` factor **is** the production subsidy `tau_s = 1 - 1/mu_p`. At +`mc_i = 1/mu_p_i` this collapses to today's competitive condition identically. + +### Markup rent (new, `firm_profit_{D,F}`) — required, not optional + +With the markup in labour demand only, factor payments stop exhausting output: +`w*N = mu_p*mc*(1-alpha)Y` while the capital return is untouched +(`capital_adj_D` keeps `mpk = alpha*Z*K^(alpha-1)*N^(1-alpha)`). Off steady state +`mu_p*mc != 1` leaves an unrouted rent, which is a Walras leak of the W-1 / W-2 +class. It **must** be routed. + +``` +profit_i = (1 - mu_p_i * mc_i) * (1 - alpha_i) * Y_i +``` + +Distributed to households **in proportion to productivity `e`** (Auclert-Rognlie- +Straub), so `income_i`'s numerator gains `profit_i * e_grid_i`. Household labour +plus profit income is then `w*N*e + profit*e = (1-alpha)*Y*e` — *identical to the +flexible model* — and factor payments exhaust output exactly. + +Three properties make this the right rule rather than the textbook lump-sum one: + +- Markups are **countercyclical** (`mc` falls in a downturn), so a lump-sum profit + rebate would hand households rising income exactly when output falls, pushing + `C_D[0]` further up — the opposite of the motivation. +- The wedge then affects the **firm's hiring decision only**, which is the channel + goal 1 wants, and leaves household income unchanged. +- The distribution is proportional to the household's *type* `e`, not to hours, so + the marginal wage is still `w_i` and **`labor_market_{D,F}` is unchanged**. + `labor_ss_{D,F}`'s `vphi` calibration is untouched (`profit = 0` at SS). + +### Nominal deposits (changed, `deposit_return_{D,F}` + substitutions) + +`rdep_{D,F}` is reinterpreted as a **nominal** rate `i_dep_{D,F}`. It remains the +free unknown clearing `deposit_mkt_{D,F}` — no absorber, no cross-border claim, +`external_account_D` untouched, count unchanged. + +``` +Rgross_i = (1 + i_dep_i(-1)) * P_CES_i(-1) / P_CES_i / (1 + pi_i) +``` + +`deposit_return_i` already has exactly this shape — a predetermined rate times a +period-t deflator — so this is a one-line change. **T-2 is not reopened:** the +rate stays locked at t-1; only the deflator becomes period-t, which the block +already does. T-2 was about using a period-t *unknown rate*. + +Two derived real rates (`@simple` outputs, **not** unknowns): + +``` +rdep_expost_i = (1 + i_dep_i(-1)) / (1 + pi_i) - 1 # realised at t on t-1 deposits +rdep_exante_i = (1 + i_dep_i) / (1 + pi_i(+1)) - 1 # locked at t, for t -> t+1 +``` + +Substitutions, following the existing timing convention exactly: + +| Block | Today | Becomes | +|---|---|---| +| `bank_return_i` | `rdep_i(-1)` | `rdep_expost_i` | +| `capital_fund_i` | `rdep_i(-1)` | `rdep_expost_i` | +| `intermediation_P1_i` | `rdep_i` | `rdep_exante_i` | +| `divert_bond_foc_i` | `rdep_i` | `rdep_exante_i` | +| `divert_portfolio_adj` | `rdep_D`, `rdep_F` | `rdep_exante_{D,F}` | +| `steady_auxilliary_i`, `smart_steady_i` | `rdep_i` | `i_dep_i` (SS only, `pi = 0`) | + +At the steady state `pi = 0` and all three rates collapse to today's `rdep`. + +Because banks hold **real** assets against **nominal** liabilities, they are net +nominal debtors: deflation raises the real value of deposits and deepens the +net-worth loss. That is the Fisher-Bernanke channel goal 2 is buying. + +### Labour supply — unchanged + +`labor_market_{D,F}` (`code/equations_D.py:287`) stays exactly as it is, remains +in all three dynamic block lists, and keeps `labor_mkt_res_{D,F}` as the target +for `N_{D,F}`. + +### Global (new, `equations_global.py`) + +``` +tot_res = p / p(-1) - (1 + pi_F) / (1 + pi_D) +union_pi_res = omega_pi_D * pi_D + (1 - omega_pi_D) * pi_F +``` + +### Discounting + +The Phillips curve discounts at constant `beta_i` rather than `SDF_i`. Because +`pi_ss = 0` the SDF deviation multiplies a zero, so the two are **identical to +first order** and the model is solved by linearised `solve_jacobian`. Immaterial; +`beta_i` is the textbook form. + +## System: 23x23 -> 27x27 + +New unknowns: `mc_D, pi_D, mc_F, pi_F`. New targets: `nkpc_p_res_D, +nkpc_p_res_F, tot_res, union_pi_res`. `rdep_{D,F}` is renamed `i_dep_{D,F}`; no +targets are renamed or removed. + +```python +unknowns_tp = [ + 'K_D','n_inter_D','div_D','I_D','Q_D','b_gov_D','N_D','b_F_D','w_D','i_dep_D','mc_D','pi_D', + 'K_F','n_inter_F','div_F','I_F','Q_F','b_gov_F','N_F','b_D_F','w_F','i_dep_F','mc_F','pi_F', + 'p','q_b_D','q_b_F', +] +targets_tp = [ + 'deposit_mkt_D','K_res_D','n_inter_val_D','div_res_D','capital_res_D','q_res_D', + 'b_gov_res_D','b_F_D_res','labor_mkt_res_D','w_res_D','nkpc_p_res_D', + 'deposit_mkt_F','K_res_F','n_inter_val_F','div_res_F','capital_res_F','q_res_F', + 'b_gov_res_F','b_D_F_res','labor_mkt_res_F','w_res_F','nkpc_p_res_F', + 'goods_mkt_D','rb_D_res','rb_F_res','tot_res','union_pi_res', +] +``` + +## Steady-state neutrality + +Markups are subsidy-neutralised and `pi_ss = 0`, so **the steady state is +bit-identical to today's**. All new residuals are *exactly* zero at it: + +| Residual | At SS | +|---|---| +| `nkpc_p_res_i` | `0 - 0 - kappa_p*(mu_p*(1/mu_p) - 1) = 0` | +| `w_res_i` | `w - 1*(1-alpha)Y/N = 0` (today's condition) | +| `profit_i` | `(1 - 1)*(1-alpha)Y = 0` | +| `tot_res` | `1 - 1 = 0` | +| `union_pi_res` | `0` | +| `rdep_expost_i`, `rdep_exante_i` | both `= i_dep_i = ` today's `rdep_i` | + +`labor_mkt_res_{D,F}` is unchanged and already zero. `K`, `rk`, `w`, `N`, the +spread, `EL_price_D`, the IC-delta check, `assert_gk_well_posed` and every Walras +residual are unchanged. `steady_state.py` needs only `mc_{D,F} = 1/mu_p_{D,F}` and +`pi_{D,F} = 0.0` seeded into `calibration_start`. + +## The flex model is the exact `kappa_p -> inf` limit + +Dividing the Phillips-curve residual by `kappa_p` and letting `kappa_p -> inf` +gives `mu_p*mc = 1`, hence today's `w_res_i` and `profit_i = 0` — identically, not +approximately. With `profit = 0`, `income_i` reverts exactly, and with `pi` driven +only by the (then flexible) terms of trade the nominal deposit terms still bite, +so the limit recovers **flex prices with nominal deposits**, not the current +`main`. See the rollout for how the gate is staged around this. + +No `STICKY` switch is needed: the flexible-price run is a calibration override +`kappa_p = 1e4` through `experiments/common.calibration_override`. No branching +inside the equations and no second code path to drift — the failure mode CLAUDE.md +records for the retired `audit_artifacts/` harness. + +## Rotemberg resource costs are omitted + +`(phi/2)*pi^2*Y` is quadratic around `pi_ss = 0`, so its derivative there is zero +and `solve_jacobian` linearises — it **cannot** move any IRF. Including it would +inject a nonlinear term into `goods_mkt_D`, which holds at 1e-14. It would matter +only for a second-order welfare exercise; E1's welfare is first-order off +`U_D = X_D / C_D_ss`. Side effect: `market_clearing_D` is untouched, so E2's +self-verifying dY decomposition must still close at 1e-7 — an independent check on +the whole change. + +## Calibration + +New parameters in `code/calibration.py`. The slope is evaluated at `beta = 0.985`; +`beta_D` and `beta_F` are separately solved SS unknowns near that value, and the +slope is a fixed constant, not a function of the solved betas. + +| Parameter | Value | Basis | +|---|---|---| +| `mu_p_D/F` | 1.20 | `epsilon_p = 6`, standard. **Free to first order** — see below | +| `kappa_p_D/F` | 0.0871 | Calvo `theta_p = 0.75`, `(1-theta)(1-beta*theta)/theta`. Euro-area IPN median price duration ~4 quarters (Alvarez et al. 2006; Dhyne et al. 2006) | +| `omega_pi_D` | 0.071 | `1 - kappa_cb_F`, the documented renormalised capital key (BuBa 26.1 / BoG 2.0) | + +### `mu_p` does not matter to first order + +Under subsidy-neutralisation the gap `mu_p*mc - 1` linearises to exactly `mc_hat` +irrespective of `mu_p` (since `mu_p*mc_ss = 1`), and labour demand's steady state +is `w = (1-alpha)Y/N` either way. `mu_p` is a free normalisation here and needs no +defending. It becomes a genuine choice only if the live-markup follow-on is taken. + +### Do not use model GDP weights for `omega_pi_D` + +The model normalises `Y_D_ss ~ Y_F_ss ~ 1`, so GDP weights would give +`omega_pi_D ~ 0.5`, implying Greek deflation forces German inflation up nearly +one-for-one — the opposite of 2010-12. Combining the two global equations gives +`pi_D = -(1 - omega_pi_D)*dlog p` and `pi_F = omega_pi_D*dlog p`, so at +`omega_pi_D = 0.071` **93% of any terms-of-trade adjustment appears as D +producer-price deflation and 7% as F inflation** — the internal-devaluation +pattern. At 0.5 it splits evenly, which is counterfactual for GR/DE. + +Bi-Foerster-Traum use 0.5/0.5, but for Italy/Germany as comparably-sized blocs and +inside a *Taylor rule*, where the weight sets a policy response rather than +allocating a given differential. Report the choice; do not adopt it. + +**This parameter is now load-bearing twice over.** It scales `pi_D`, which drives +the Fisher revaluation on bank balance sheets. Include it in the sweep. + +## Rollout + +**Step 0 — refactor first, as a pure no-op.** `full_model.py:69`, `tpi.py:145` and +`diagnostics/regimes/regime_model.py:160` each hardcode the `create_model` block +list. Extract one `build_block_list()` in `full_model.py` and point the other two +at it. **Verify `code/main.py` output is bit-identical before adding anything.** +CLAUDE.md records that a drifting duplicate model is what invalidated +`audit_artifacts/`. + +**Step 1 — sticky prices only, real deposits, equivalence gate.** Add the price +NKPCs, `firm_profit`, the two global blocks; wire 27x27; run at `kappa_p = 1e4` +with the deposit blocks still real. Must reproduce the current baseline IRFs to +solver tolerance and hold every threshold: `goods_mkt_D <= 1e-14`, `goods_mkt_F` +and `ca_res_D <= 1e-7`, `deposit_mkt_D/F <= 1e-13`. If this fails the wiring is +wrong and nothing downstream is worth debugging. Very large `kappa_p` may be +ill-conditioned; if `1e4` fails to converge, step down and record the largest +value that does. + +**Step 2 — dial `kappa_p` to 0.0871**, deposits still real. Check residuals, +doom-loop signs (`n_inter_D[0] < 0`, `Y_D[0] < 0`), stability +(`b_gov_D[499] ~ 0`), the IC-delta check, `assert_gk_well_posed`. **Record +`Y_D[0]`, `C_D[0]`, `I_D[0]`, `n_inter_D[0]` here** — this is the clean measure of +what price stickiness alone does, and it is a reportable result. + +**Step 3 — switch deposits to nominal.** One change at a time: this is where the +Fisher channel arrives, and it must be attributable. Re-run the same checks. +`n_inter_D[0]` must fall by *more* than at step 2; if it does not, the sign is +wrong somewhere in the ex-post/ex-ante substitution table. + +**Step 4 — re-tune `psi_lambda_B`** to the 150bp-per-1pp-default-shock target. +Expect a large move — see *Risks*. + +**Step 5 — regenerate.** `diagnostics/regimes/regime_model.py --force`, then +`experiments/run_all.py`, then figures. E1-E4 and the declining-loading key figure +all rebuilt; flex-price becomes an appendix comparison. + +**Step 6 — docs.** STATE.md, PROGRESS.md, HANDOFF.md (hook-enforced), plus +SPEC.md and CLAUDE.md. + +## Verification + +`code/main.py` remains the structural regression test. On top of it: + +- **Equivalence (step 1):** at `kappa_p = 1e4` with real deposits, IRFs match the + pre-change baseline to solver tolerance. +- **Steady state:** every solved SS object bit-identical to `main`'s, at every step. +- **Residual thresholds:** unchanged throughout, as listed in step 1. +- **Sign checks:** `n_inter_D[0]` and `Y_D[0]` both negative on the default shock. +- **Fisher sign (step 3):** `n_inter_D[0]` strictly more negative than at step 2. +- **E2 closure:** dY decomposition still asserts at 1e-7. +- **Sweep:** `kappa_p` in {0.03, 0.087, 0.2} and `omega_pi_D` in {0.071, 0.2, 0.5}; + report the stable region. Required robustness table regardless. +- **Report `C_D[0]` explicitly** at steps 2 and 3, whatever its sign. + +## Risks + +**The Fisher channel may dominate rather than supplement the doom loop.** +`D_supply ~ (theta-1)*n_inter ~ 9` against `n_inter ~ 3`, so a price-level surprise +is levered roughly 3x onto net worth. If `pi_D[0]` lands near -0.1%, that is on the +order of a 9% net-worth hit against today's -3.38%. Given F-1 and GK-2, treat +instability as a live possibility and be ready to report the `omega_pi_D` sweep as +the containing parameter. + +**`psi_lambda_B` will move a long way**, since spread transmission now runs through +both a sticky terms of trade and a Fisher revaluation. CLAUDE.md puts the +documented breakdown around 4-5 at `n_inter = 3.0`; step 4 must re-verify stability +at whatever value it lands on, not merely hit the moment. + +**`C_D[0]` may not change sign, or may rise further.** See *The `C_D[0]` +motivation*. Step 2 and step 3 both report it; do not commit to a claim about it in +the draft before those numbers exist. + +**Solve time grows.** 23 -> 27 unknowns at T=500; expect ~3 min to become 4-5. E3 +does two re-solves, so `--skip-e3` matters more during iteration. + +## Limitations to state in the paper + +**No policy rate.** There is no Taylor rule, so no conventional monetary +transmission and no "the ECB did not respond to Greece" experiment. The nominal +anchor is an assumed union-inflation stabilisation, not a modelled rule. + +**Sovereign bonds stay real while deposits are nominal.** A deliberate asymmetry +that maximises banks' Fisher exposure. Euro-area sovereign debt is nominal; say so. + +**Wages are flexible**, so adjustment is not shifted from wages onto hours and the +model is silent on that component of distributional incidence. + +**Steady-state markups are subsidised away**, so there is no profit-income level +effect — the markup rent is distributed proportional to `e` and nets out of +household income by construction. + +## Benchmark: Bi, Foerster and Traum (2026) + +*"Asset Purchases in a Monetary Union With Default and Liquidity Risks", FRBSF +Working Paper 2025-10, https://doi.org/10.24148/wp2025-10.* Closest published +analogue: two-country monetary union, Gertler-Karadi intermediaries, endogenous +sovereign default, cross-border sovereign holdings, targeted ECB asset purchases — +calibrated Italy/Germany 2012. + +| | Bi-Foerster-Traum | This spec | +|---|---|---| +| Price rigidity | Rotemberg, exact nonlinear (their 2.14) | Rotemberg, linear-equivalent | +| Wage rigidity | none — flexible, `chi*L^sigma_l = U_c*w` (A.9) | **none** (same) | +| SS markup | live, `theta^c = 11` -> `mc_ss = 10/11` | subsidy-neutralised, SS bit-identical | +| Markup rent | lump-sum `Pi^f` to representative household | proportional to `e`, nets out of income | +| Nominal anchor | Taylor rule, `phi_pi=1.6, phi_y=0.07, phi_r=0.85` | union-inflation normalisation, no rate | +| Union weights | 0.5 / 0.5 | `omega_pi_D = 0.071` | +| ToT identity | `rer_t/rer_{t-1} = pi*_t/pi_t` (A.80), CPI form | same relation, PPI form | +| Deposits | nominal | **nominal** (same) | +| Sovereign bonds | nominal | real (deliberate asymmetry) | +| Solution | 2nd-order perturbation (endogenous regime switching) | 1st-order SSJ | + +**The price slope agrees.** Their `psi` maps to a Calvo-equivalent slope +`(1-xi)(1-beta*xi)/xi = 0.0846` at `xi_p = 0.75, beta = 0.995`. This spec's +`kappa_p = 0.0871` at `beta = 0.985` is the same number to within 3%; the gap is +entirely the discount factor. + +**They never report a flexible-price counterfactual.** Their Table 1 decomposes +over the liquidity-risk channel, the fiscal-limit shift and the debt change — never +over price stickiness. The staged rollout here produces both that counterfactual +and a clean split between the price-stickiness and Fisher contributions, so those +are reportable output rather than merely regression tests. + +**Their nominal side does little propagation work.** Inflation moves +/-0.1% while +investment moves 9% and output 0.6% (their Figure 3); Tables 2 and 3 show inflation +at 0.00-0.02 against investment at 0.53. Their §4.1 treats inflation as an *outcome* +of the relative-price move, never as a channel. What drives their output contraction +is the **loan-in-advance constraint** (`eta^I = 0.65/0.75` of investment must be +debt-financed, their 2.10) — a real financial friction this model does not have. +Against their `-0.6%` output impact, this model's `Y_D[0] = -0.0149%` is two orders +of magnitude smaller. If steps 2 and 3 leave `Y_D[0]` implausibly small, a Sims-Wu +working-capital constraint is the natural next lever; it is **out of scope here** +and would need its own design pass. + +## Relationship to the `add-nkwpc` branch + +`add-nkwpc` (commit `2377f79`, off `08e1010`, pre-reorganisation) is a single +26-line commit adding `wage_setting_{D,F}` only. It was never wired into the model +list and has no `kappa_w` calibration. **Nothing from it is used** — this spec is +price-side only and the branch has no price-side content. Recorded so it is not +revisited without knowing what is in it: + +- It is a **real-wage** Rotemberg curve (`pi_w = w/w(-1) - 1` on the *real* wage, + explicitly "no CB needed") — a real adjustment friction, not a nominal rigidity. +- It divides the MRS by `UCE_D`, correct under separable preferences but **wrong + under the GHH preferences this model uses** — cf. `labor_ss_D` and + `labor_market_D`, neither of which contains `UCE`. +- It uses `w_D` where the existing labour condition uses `w_D / P_CES_D`, dropping + the CES bundle deflator. +- **Its stated motivation is wrong for this model.** The comment says sticky wages + stop "the household wealth effect" translating into `N`. GHH preferences have no + wealth effect on labour supply by construction. diff --git a/docs/verification_report.md b/docs/verification_report.md index 64b05f1..7bd7cff 100644 --- a/docs/verification_report.md +++ b/docs/verification_report.md @@ -4,6 +4,15 @@ **Scope:** verify fixes implemented + correct + regression-free; assess calibration. Not a re-audit. **Codebase verified:** branch `audit` (396cbd9 + 4c810e1), working tree. `main` is intentionally pre-fix. +> **THRESHOLD CORRECTION, 2026-08-18.** The `goods_mkt_D ≤ 1e−16` figures below were measured +> on the 2026-06-11 calibration and have **not** held since. Measured directly against a clean +> worktree at `91ac778` (pre-GK-refactor `main`): `goods_mkt_D = −4.2493163257550925e−07`, and +> the post-refactor branch prints `−4.2281e−07`. CLAUDE.md's acceptance threshold for this +> residual has been corrected to `1e−6`. `ca_res_D` (~1e−16) is the residual that genuinely +> reaches machine zero on the current calibration; use it, not `goods_mkt_D`, as the Walras +> regression check. The drift is pre-existing and is **not** attributable to the GK structural +> refactor — that was the point of measuring the baseline worktree. + --- ## Finding status table diff --git a/experiments/common.py b/experiments/common.py index 6a30548..de7046b 100644 --- a/experiments/common.py +++ b/experiments/common.py @@ -182,8 +182,25 @@ def named_regime_gammas(cache): Solved rather than chosen so the regimes keep their meaning across recalibrations, instead of a round number drifting into a different policy stance. Targets are spec section 7: 25% (medium), 50% (aggressive). + + ⚠ **"aggressive" no longer means 50% since the 2026-08-18 GK structural refactor.** + The closed loop has a POLE at gamma ~ 27.3, and the maximum compression reachable + below it is ~46.6%. The 50% target is only met on the far side of the singularity, + which is a different branch, not a stronger version of the same policy. Rather than + chase it there (wrong) or drop the regime (would break eight paper figures), the + aggressive regime falls back to `lottery_math.POLE_SAFETY_FRACTION * pole` = 0.75 x + pole, and this function prints the compression it actually achieves (~40.3%). 0.75 + rather than "as close to the pole as possible": the loading schedule is monotone in + gamma only up to ~0.85 x pole, and at 0.98 x pole the discounted consumption gains + reach +12% of steady-state consumption — that is the singularity talking, not the + policy. Every downstream table already reports each regime's gamma and + peak spread, so the artefacts stay self-describing; but any prose calling the + aggressive regime "50% compression" is now WRONG and must say ~46.6% or, better, + "maximum feasible". See `lottery_math.gamma_for_compression` and + `lottery_math.closed_loop_pole`. """ - from lottery_math import gamma_for_compression + from lottery_math import (gamma_for_compression, closed_loop_pole, closed_loop, + peak, CompressionInfeasible, POLE_SAFETY_FRACTION) A_def = cache["spread_rb__shock_def_D"] A_cb = cache["spread_rb__cb_buy_D"] eps = np.asarray(cache["dShock_def_D"]) @@ -191,11 +208,23 @@ def named_regime_gammas(cache): f"A_cb[0,0]={float(A_cb[0,0]):+.4e} >= 0: CB purchases WIDEN the spread, so " "compression targeting is infeasible. This is the ms-regime SA-1 pathology, " "which must be absent on main — investigate before reporting anything.") - return { - "passive": 0.0, - "medium": float(gamma_for_compression(A_def, A_cb, eps, target=0.25)), - "aggressive": float(gamma_for_compression(A_def, A_cb, eps, target=0.50)), - } + p0 = peak(closed_loop(A_def, A_cb, eps, 0.0)[0]) + out = {"passive": 0.0} + for name, target in (("medium", 0.25), ("aggressive", 0.50)): + try: + out[name] = float(gamma_for_compression(A_def, A_cb, eps, target=target)) + except CompressionInfeasible as exc: + pole = closed_loop_pole(A_cb) + assert pole is not None, exc # infeasible with no pole = a real bug + out[name] = POLE_SAFETY_FRACTION * float(pole) + got = 1.0 - peak(closed_loop(A_def, A_cb, eps, out[name])[0]) / p0 + print(f" [named_regime_gammas] {name}: {100*target:.0f}% compression is " + f"UNREACHABLE below the closed-loop pole at gamma = {pole:.2f} " + f"(max reachable there ~46.6%). Falling back to " + f"POLE_SAFETY_FRACTION x pole = {out[name]:.3f}, which achieves " + f"{100*got:.2f}% and keeps a margin from the singularity. Do not " + f"describe this regime as {100*target:.0f}% compression.") + return out def regime_irfs(cache): diff --git a/experiments/e1_backstop_schedule.py b/experiments/e1_backstop_schedule.py index ff0bd8f..c53c106 100644 --- a/experiments/e1_backstop_schedule.py +++ b/experiments/e1_backstop_schedule.py @@ -62,7 +62,7 @@ def cb_pnl(irf, cache, T_pnl=T_PNL): q_b_F_ss = float(cache["q_b_F_ss"]) delta_b_D = float(cache["delta_b_D_ss"]) delta_b_F = float(cache["delta_b_F_ss"]) - EL_price_D = float(cache["EL_price_D"]) + EL_load_D = float(cache["EL_load_D"]) disc = beta_F ** np.arange(T_pnl) cb = np.asarray(irf["cb_buy_D"])[:T_pnl] @@ -90,7 +90,7 @@ def cb_pnl(irf, cache, T_pnl=T_PNL): return { "peak_exposure": float(np.max(q_b_D_ss * cb)), "purchases_pv": float((disc * q_b_D_ss * purchases).sum()), - "el_pv": float((disc * EL_price_D * defr * q_b_D_ss * cb).sum()), + "el_pv": float((disc * EL_load_D * defr * q_b_D_ss * cb).sum()), "prem_pv": float((disc * dspr * q_b_D_ss * cb_l).sum()), "carry_ss_pv": float((disc * spread_ss * q_b_D_ss * cb_l).sum()), "mtm_pv": float((disc * (1.0 - delta_b_D) * cb_l * (dq - dq_l)).sum()), @@ -124,10 +124,24 @@ def loading_schedule(cache, gamma_max=30.0, n=60): the wedge exists because the marginal holder is balance-sheet constrained, and the backstop relieves that constraint, so intervention erodes its own profit source. The schedule, not any single point, is therefore the object. + + THE GRID STOPS BELOW THE CLOSED-LOOP POLE (2026-08-18). `(I - gamma*A_cb)` goes + singular at `gamma ~ 27.3` on the post-GK-refactor cache, and the default + `gamma_max = 30` ran straight through it: the loading spiked to 1.17 and collapsed + to 0.38 across two grid points, the peak-spread panel showed a spurious dip to 82bp, + and the caption's monotonicity test read the artefact as a real non-monotonicity. + Everything past the pole is a DIFFERENT BRANCH of the closed loop, not a stronger + version of the same policy, so it must not be plotted on the same axis. """ - from lottery_math import closed_loop + from lottery_math import closed_loop, closed_loop_pole, POLE_SAFETY_FRACTION A_def, A_cb = cache["spread_rb__shock_def_D"], cache["spread_rb__cb_buy_D"] eps = np.asarray(cache["dShock_def_D"]) + pole = closed_loop_pole(A_cb, hi=max(gamma_max, 60.0)) + if pole is not None and POLE_SAFETY_FRACTION * pole <= gamma_max: + gamma_max = POLE_SAFETY_FRACTION * pole + print(f" [loading_schedule] closed-loop pole at gamma = {pole:.2f}; " + f"capping the grid at {gamma_max:.2f} " + f"({POLE_SAFETY_FRACTION:g} x pole, lottery_math.POLE_SAFETY_FRACTION)") gammas = np.linspace(0.0, gamma_max, n) loading, peak_bp = np.full(n, np.nan), np.empty(n) for i, g in enumerate(gammas): @@ -152,8 +166,14 @@ def run(): regimes = regime_irfs(cache) payload = { "provenance": provenance(), - "gamma_selection_rule": "peak-spread compression 0/25/50% (spec section 7); " - "gamma solved, not chosen", + "gamma_selection_rule": + "gamma SOLVED for peak-spread compression, not chosen. medium = 25% (spec " + "section 7). aggressive was 50%, but since the 2026-08-18 GK structural " + "refactor that target lies beyond a closed-loop pole at gamma ~ 27.3 and is " + "unreachable; it falls back to the strongest intervention the model can " + "represent, gamma just below the pole, achieving ~46.6%. DO NOT describe the " + "aggressive regime as 50% compression -- see common.named_regime_gammas and " + "lottery_math.closed_loop_pole.", "welfare_caveat": "SECONDARY. SPEC: do not lead with welfare — it is a " "delicate decomposition-dependent object and comes out " "near-exactly zero-sum.", diff --git a/experiments/e3_writeoff_s1.py b/experiments/e3_writeoff_s1.py index 8e44833..e31f487 100644 --- a/experiments/e3_writeoff_s1.py +++ b/experiments/e3_writeoff_s1.py @@ -1,54 +1,67 @@ -"""E3 — S-1: does the sovereign default produce realised bank losses? +"""E3 — S-1 and the payoff specification. -Two nested variants, because the two switches do different things: +REBASED 2026-08-18 on the structural GK refactor. The baseline now PRICES the full +default loss (`zeta_writeoff = 1`), so the old E3 question — "what if the principal +were written down too?" — is the baseline, not a variant. Two questions remain, and +the two switches answer one each. - writeoff_enabled zeta_writeoff steady state - baseline 0 0.0 -- - E3a coupon-only 1 0.0 STRICTLY INVARIANT - E3b full writeoff 1 1.0 MOVES, via EL_price + writeoff_enabled zeta_writeoff steady state + baseline 0 1.0 -- + e3a_realised_writeoff 1 1.0 STRICTLY INVARIANT + e3b_coupon_only_pricing 0 0.0 STRICTLY INVARIANT -In bond_return_D / government_ss_D / bond_price_ss_D / budget_residual_D: +`writeoff_enabled` selects which BRANCH the impulse response traces; `zeta_writeoff` +governs what is PRICED. In bond_return_D / government_ss_D / budget_residual_D: - current_payoff = delta_b * (1 - def_rate*haircut*writeoff_enabled) - continuation = (1-delta_b)*q_b * (1 - zeta*def_rate*haircut*writeoff_enabled) + realised coupon = delta_b * (1 - def_rate*haircut*writeoff_enabled) + realised cont. = (1-delta_b)*q_b * (1 - zeta*def_rate*haircut*writeoff_enabled) -Both legs carry def_rate, which is 0 at SS, so writeoff_enabled is SS-neutral. But -zeta_writeoff ALSO appears in the EL_price anchor (code/steady_state.py:107-112), -and there it is NOT gated by writeoff_enabled: +Both legs carry def_rate, which is 0 at the steady state, so **e3a is exactly +SS-neutral** — it is the clean S-1 test of whether realised (as opposed to priced) +losses change the transmission. - EL_price = (1-recovery) * [delta_b + zeta*(1-delta_b)*q_b] / q_b +**e3b is the §12 Arm-3 diagnostic**: the pre-refactor coupon-only payoff. Measured +2026-08-18, it is ALSO exactly SS-neutral, and the reason is worth stating because it +is not obvious. zeta multiplies the continuation-value haircut `zeta*def_rate*h`, and +`def_rate_ss = 0` kills that term inside `rb_exp` just as it kills the realised one — +so the priced and realised payoffs coincide at the steady state whatever zeta is, and +`q_b_D = 0.974906` in every arm. zeta is allocation-neutral while still changing the +LINEARISED bond pricing equation, and hence every dynamic result. Expected loss per +unit of default probability falls from -At the live calibration (recovery=0.30, delta_b_D=0.0777006, q_b_D=0.968941) that -takes EL_price_D from 0.056134 to ~0.7017 — about 12.5x. EL_price is the loading's -DENOMINATOR, so this lands directly on SPEC Live Claim 1. Reported, never re-tuned -away. + (1-rec) * [delta_b + (1-delta_b) q_b] / q_b ~ 0.7014 (zeta = 1) +to (1-rec) * delta_b / q_b ~ 0.0558 (zeta = 0), -Recovery stays at 0.30 (EL-1's resolved Greek-PSI NPV value) rather than -docs/STATE.md's older recovery=0.40 suggestion, which predates EL-1 and would move -two dials at once. +a factor of ~12.6. The gap is the principal/continuation loss on a 12.9-quarter +claim, and it is what the deleted free parameter `psi_spread_D` used to stand in for. +e3b is reported to size that contribution, NOT as an economic specification. + +Recovery stays at 0.30 (EL-1's resolved Greek-PSI NPV value). """ import numpy as np from common import calibration_override, load_cache, provenance, write_results VARIANTS = { - "e3a_coupon_only": {"writeoff_enabled_D": 1.0, "writeoff_enabled_F": 1.0, - "zeta_writeoff_D": 0.0, "zeta_writeoff_F": 0.0}, - "e3b_full": {"writeoff_enabled_D": 1.0, "writeoff_enabled_F": 1.0, - "zeta_writeoff_D": 1.0, "zeta_writeoff_F": 1.0}, + "e3a_realised_writeoff": {"writeoff_enabled_D": 1.0, "writeoff_enabled_F": 1.0, + "zeta_writeoff_D": 1.0, "zeta_writeoff_F": 1.0}, + "e3b_coupon_only_pricing": {"writeoff_enabled_D": 0.0, "writeoff_enabled_F": 0.0, + "zeta_writeoff_D": 0.0, "zeta_writeoff_F": 0.0}, } +SS_INVARIANT_VARIANT = "e3a_realised_writeoff" SS_INVARIANT_KEYS = ["q_b_D_ss", "Y_D_ss", "C_D_ss", "I_D_ss", "NX_D_ss", "n_inter_D_ss", "K_D_ss", "TAX_D_ss", "P_CES_D_ss", "b_gov_D_ss", "b_D_D_ss"] -def expected_EL_price(cal, q_b_D): - """The closed form from code/steady_state.py:107-109. +def expected_EL_load(cal, q_b_D): + """The closed form behind bond_return_D's EL_load_D output. - Note this must be evaluated at the VARIANT's own solved q_b_D, not the - baseline's: EL_price feeds the bond FOC, so under E3b the price moves and the - closed form has to be checked against the price the model actually settled on. + EL_load is ENDOGENOUS now — an output of the payoff block, not the deleted + EL_price_D anchor — so this is a cross-check of the block against its algebra. + Evaluate at the VARIANT's own solved q_b_D, not the baseline's: the priced loss + feeds the GK portfolio FOC, so a variant that changes zeta moves the price too. """ return ((1.0 - cal["recovery_rate_D"]) * (cal["delta_b_D"] + cal["zeta_writeoff_D"] * (1.0 - cal["delta_b_D"]) * q_b_D) @@ -95,7 +108,7 @@ def summarise(cache, gammas): eps = np.asarray(cache["dShock_def_D"]) Y_ss, n_ss = float(cache["Y_D_ss"]), float(cache["n_inter_D_ss"]) - out = {"EL_price_D": float(cache["EL_price_D"]), + out = {"EL_load_D": float(cache["EL_load_D"]), "A_cb_impact": float(A_cb[0, 0]), "regimes": {}} for name, gamma in gammas.items(): @@ -160,7 +173,7 @@ def run(): baseline_cache = load_cache() baseline_ss = {k: float(baseline_cache[k]) for k in SS_INVARIANT_KEYS} - baseline_EL = float(baseline_cache["EL_price_D"]) + baseline_EL = float(baseline_cache["EL_load_D"]) # Solved ONCE, on the baseline, then held fixed across every variant so the # comparison changes the model without also changing the policy. @@ -168,12 +181,13 @@ def run(): payload = {"provenance": provenance(), "gammas": gammas, - "gamma_note": "Solved on the BASELINE (0/25/50% peak-spread " - "compression) and held fixed across variants, so a " - "difference in the table is attributable to the writeoff " - "switch alone. Under e3b_full the peak spread is not " - "monotone in gamma, so these targets are not even " - "well-defined there — see compression.", + "gamma_note": "Solved on the BASELINE and held fixed across variants, " + "so a difference in the table is attributable to the " + "switch alone. medium = 25% peak-spread compression; " + "aggressive is NOT 50% -- that target sits beyond a " + "closed-loop pole at gamma ~ 27.3, so it falls back to the " + "maximum feasible intervention (~46.6%). See " + "common.named_regime_gammas.", "baseline": summarise(baseline_cache, gammas), "compression": {"baseline": compression_feasible(baseline_cache)}, "variants": {}, "checks": {}} @@ -186,34 +200,38 @@ def run(): with calibration_override(**overrides): cal = calibration.get_calibration() # resolved at USE time, inside the override q_b_D = float(cache["q_b_D_ss"]) - el_expected = expected_EL_price(cal, q_b_D) - el_actual = float(cache["EL_price_D"]) + el_expected = expected_EL_load(cal, q_b_D) + el_actual = float(cache["EL_load_D"]) assert abs(el_actual - el_expected) < 1e-12, ( - f"{name}: EL_price_D={el_actual:.9f} != closed form {el_expected:.9f}. " - f"code/steady_state.py:107-109 no longer matches this experiment's model " - f"of it — reconcile before reporting.") + f"{name}: EL_load_D={el_actual:.9f} != closed form {el_expected:.9f}. " + f"code/equations_D.py bond_return_D no longer matches this experiment's " + f"model of it — reconcile before reporting.") drift = {k: float(cache[k]) - baseline_ss[k] for k in SS_INVARIANT_KEYS} max_drift = max(abs(v) for v in drift.values()) - payload["checks"][name] = {"EL_price_expected": el_expected, - "EL_price_actual": el_actual, - "EL_price_vs_baseline_ratio": el_actual / baseline_EL, + payload["checks"][name] = {"EL_load_expected": el_expected, + "EL_load_actual": el_actual, + "EL_load_vs_baseline_ratio": el_actual / baseline_EL, "max_ss_drift": max_drift, "ss_drift": drift} - if name == "e3a_coupon_only": + if name == SS_INVARIANT_VARIANT: # writeoff_enabled multiplies terms that already carry def_rate_ss = 0, - # and zeta is unchanged, so the SS must be bit-identical. Drift is a bug. + # and zeta is unchanged from baseline, so the SS must be bit-identical. assert max_drift < 1e-10, ( - f"E3a moved the steady state (max drift {max_drift:.3e}). " + f"{name} moved the steady state (max drift {max_drift:.3e}). " f"writeoff_enabled is supposed to be SS-neutral — every writeoff term " f"is multiplied by def_rate_ss=0. Investigate before reporting.") else: - # E3b DOES move the SS, through EL_price. Asserting invariance here would - # be wrong; the closed-form check above is the check that applies. What we - # do assert is that the override reached the solve at all. + # e3b is SS-neutral too (zeta multiplies def_rate_ss = 0 inside rb_exp), so + # assert BOTH: the allocation does not move, and the priced loading does. + # The second half is what proves the override actually reached the solve. + assert max_drift < 1e-10, ( + f"{name} moved the steady state (max drift {max_drift:.3e}). zeta is " + f"allocation-neutral: it multiplies def_rate_ss = 0 in both rb_exp and " + f"rb_actual. Investigate before reporting.") assert abs(el_actual - baseline_EL) > 1e-6, ( - f"E3b's EL_price ({el_actual:.9f}) is indistinguishable from baseline " + f"{name}'s EL_load ({el_actual:.9f}) is indistinguishable from baseline " f"({baseline_EL:.9f}) — the zeta override did not reach the SS solve.") write_results("e3_writeoff_s1", payload) @@ -222,7 +240,7 @@ def run(): if __name__ == "__main__": res = run() - print(f"\n{'setting':>18} {'EL_price':>10} {'peak bp (passive)':>18} " + print(f"\n{'setting':>24} {'EL_load':>10} {'peak bp (passive)':>18} " f"{'loading (medium)':>17} {'loading (aggr.)':>16}") print("-" * 84) rows = [("baseline", res["baseline"])] + list(res["variants"].items()) @@ -230,13 +248,13 @@ def run(): def ld(reg): v = r["regimes"][reg]["loading"] return "n/a" if v is None else f"{v:.2f}" - print(f"{name:>18} {r['EL_price_D']:>10.4f} " + print(f"{name:>24} {r['EL_load_D']:>10.4f} " f"{r['regimes']['passive']['peak_spread_bp_ann']:>18.1f} " f"{ld('medium'):>17} {ld('aggressive'):>16}") print("-" * 84) for name, c in res["checks"].items(): - print(f"{name}: EL_price {c['EL_price_actual']:.6f} (closed form " - f"{c['EL_price_expected']:.6f}, {c['EL_price_vs_baseline_ratio']:.2f}x " + print(f"{name}: EL_load {c['EL_load_actual']:.6f} (closed form " + f"{c['EL_load_expected']:.6f}, {c['EL_load_vs_baseline_ratio']:.2f}x " f"baseline), max SS drift {c['max_ss_drift']:.3e}") print(f"\n{'setting':>18} {'compression targeting':>22} {'peak@g=0':>10} " @@ -251,6 +269,7 @@ def ld(reg): print("Where compression targeting is INFEASIBLE the named regimes are undefined " "(no unique gamma delivers a given compression), so all rows above are " "evaluated at the BASELINE's gammas held fixed.") - print("\npsi_lambda_B was tuned to 150bp with realised losses OFF. Any overshoot " - "here is a REPORTABLE FACT about whether the target survives S-1, not a " - "number to re-tune away.") + print("\nThe baseline no longer tunes any parameter to a 150bp moment — psi_lambda_B " + "is 0 and psi_spread is deleted. Whatever peak spread these variants produce " + "is a REPORTABLE FACT about the payoff specification, not a number to re-tune " + "away.") diff --git a/experiments/e4_distribution.py b/experiments/e4_distribution.py index 5de6b72..79bb714 100644 --- a/experiments/e4_distribution.py +++ b/experiments/e4_distribution.py @@ -191,7 +191,7 @@ def build(): import tpi from calibration import get_calibration from depreciation_calibration import calibrate_depreciation - from full_model import build_and_solve + from full_model import build_and_solve, solve_jacobian_padded from ic_delta_calibration import calibrate_ic_delta from regime_model import _ss_tpi, build_tpi_model_main from steady_state import solve_steady_state @@ -252,10 +252,10 @@ def build(): T = res["T"] print(f"Solving Jacobian with {len(DECILE_AGG_D)} extra decile outputs (T={T}) ...", flush=True) - G = model.solve_jacobian(ss_tpi, unknowns=res["unknowns_tp"], - targets=res["targets_tp"], - inputs=["Z_D", "shock_def_D", "Z_F", "shock_def_F", - "cb_buy_D"], T=T) + G = solve_jacobian_padded(model, ss_tpi, res["unknowns_tp"], + res["targets_tp"], + ["Z_D", "shock_def_D", "Z_F", "shock_def_F", + "cb_buy_D"], T) out = {"T": np.array(T), "dShock_def_D": np.asarray(res["dShock_def_D"]), "decile_mass_ss": realised, "decile_c_ss": c_bin, "dep_edges": dep_edges, diff --git a/experiments/figures/fig_e1_loading_schedule.png b/experiments/figures/fig_e1_loading_schedule.png index f23118d..2ff6c9b 100644 Binary files a/experiments/figures/fig_e1_loading_schedule.png and b/experiments/figures/fig_e1_loading_schedule.png differ diff --git a/experiments/paper/fig01_transmission.png b/experiments/paper/fig01_transmission.png index 6bdb205..889b896 100644 Binary files a/experiments/paper/fig01_transmission.png and b/experiments/paper/fig01_transmission.png differ diff --git a/experiments/paper/fig02_loading_schedule.png b/experiments/paper/fig02_loading_schedule.png index bdd5313..8f5bb0e 100644 Binary files a/experiments/paper/fig02_loading_schedule.png and b/experiments/paper/fig02_loading_schedule.png differ diff --git a/experiments/paper/fig03_dy_decomposition.png b/experiments/paper/fig03_dy_decomposition.png index d8fd41e..d4d6a18 100644 Binary files a/experiments/paper/fig03_dy_decomposition.png and b/experiments/paper/fig03_dy_decomposition.png differ diff --git a/experiments/paper/fig04_spread_decomposition.png b/experiments/paper/fig04_spread_decomposition.png index 6cde94a..dfdecae 100644 Binary files a/experiments/paper/fig04_spread_decomposition.png and b/experiments/paper/fig04_spread_decomposition.png differ diff --git a/experiments/paper/fig05_incidence.png b/experiments/paper/fig05_incidence.png index c420ebc..7e1b81a 100644 Binary files a/experiments/paper/fig05_incidence.png and b/experiments/paper/fig05_incidence.png differ diff --git a/experiments/paper/fig06_net_effects.png b/experiments/paper/fig06_net_effects.png index eb90d68..2b3de13 100644 Binary files a/experiments/paper/fig06_net_effects.png and b/experiments/paper/fig06_net_effects.png differ diff --git a/experiments/paper/fig07_ms_regimes.png b/experiments/paper/fig07_ms_regimes.png index 16f4b0c..13f2b6d 100644 Binary files a/experiments/paper/fig07_ms_regimes.png and b/experiments/paper/fig07_ms_regimes.png differ diff --git a/experiments/paper/fig08_deciles.png b/experiments/paper/fig08_deciles.png index c9c92ac..0936df6 100644 Binary files a/experiments/paper/fig08_deciles.png and b/experiments/paper/fig08_deciles.png differ diff --git a/experiments/paper_outputs.py b/experiments/paper_outputs.py index 2a204b0..be59fad 100644 --- a/experiments/paper_outputs.py +++ b/experiments/paper_outputs.py @@ -4,6 +4,20 @@ response matrices. No number is transcribed. The one thing carried as literal text is a *source citation* (which paper or dataset a target came from), never a value. +**That includes the captions.** Until 2026-08-06 this module carried a module-level +`CAPTIONS` dict of literal prose with flexible-price numbers frozen into it. The +sticky-price conversion and the `psi_lambda_B` 8.5 -> 7.85 re-tune left every one of +them stale and three of them *inverted* — `fig03` asserted offsetting channels +"roughly four times the headline" when they are now ~0.25x it, `fig08` claimed +consumption rises on impact and that the lowest quintile "gains 0.95%" against a +Table 4 in the same generated document reading +0.4250, and `fig06` claimed the net +path is smaller than its components "at every horizon" when it is not in the impact +quarter. The dict is now built AT RUN TIME by `save()`: each figure hands `save()` a +caption it computed from the same arrays it just plotted, so a caption cannot +survive a recalibration that falsifies it. Directional claims ("monotone", +"reverses by quarter k", "larger than") are SELECTED from the data rather than +asserted, so a sign flip rewrites the sentence instead of lying in it. + Figure set (each caption is baked into the PNG — a caption that lives only in the LaTeX travels separately from the image and is lost the moment the file is reused): @@ -48,55 +62,39 @@ N_IRF = 40 # quarters shown in IRF panels T_PNL = 100 -CAPTIONS = { - "fig01_transmission": - "A 1pp rise in the Greek default probability widens the D–F spread 150bp and cuts " - "bank net worth 3.4%, transmitting to the real economy almost entirely through " - "investment (−0.77% on impact); the backstop's cushioning is concentrated in the " - "first few quarters — by quarter four the net-worth and investment paths have " - "converged and the spread ordering reverses, so intervention damps the initial " - "impact and the later undershoot rather than shifting the whole path down.", - "fig02_loading_schedule": - "KEY FIGURE — the ECB earns 4.5× the actuarially fair expected loss on a weak " - "backstop but only 2.1× on a strong one, because the premium is a rent extracted " - "from a balance-sheet-constrained seller and intervention relieves the very " - "constraint that creates it: the profit self-extinguishes as the policy succeeds.", - "fig03_dy_decomposition": - "The crisis cuts investment sharply and is masked in the aggregate mainly by " - "consumption (panel A), while the backstop works through a different pair — " - "investment recovers against a net-export deterioration, each roughly four times " - "the headline and opposite in sign (panel B) — so a near-zero ΔY reflects " - "reallocation across very different households, not a small shock or a weak policy.", - "fig06_net_effects": - "Contributions to the output response quarter by quarter: the crisis is an " - "investment collapse partly offset by consumption and a small net-export cushion, " - "and the backstop works by shrinking the investment hole rather than by lifting " - "output uniformly — the net path (black) is at every horizon far smaller than the " - "components that generate it.", - "fig07_ms_regimes": - "A three-state Markov-switching model on peripheral–Bund spreads dates the ECB's " - "intervention stance and disciplines the model's three backstop regimes — the " - "high-spread 'hawk' state covers 2010–14 and the ergodic shares (23%/52%/25%) are " - "what the regime-uncertainty beliefs are set to — though the pre-1999 stretch " - "predates the ECB and reflects EMU convergence, not any policy stance.", - "fig08_deciles": - "Consumption rises for every income quintile on impact — the investment collapse " - "releases resources — then troughs around quarter five, and the trough is roughly " - "three times deeper for the top quintile (−0.11%) than the bottom (−0.04%); " - "discounted over 40 quarters the crisis is progressive in incidence, costing the " - "highest-income quintile 0.59% of its consumption while the lowest gains 0.95%, " - "and the backstop's protection is monotone in the same direction.", - "fig04_spread_decomposition": - "Only 3% of the sovereign default loading is fundamental expected loss; the other " - "97% is the collateral-friction wedge charged by a constrained intermediary, which " - "is why the risk is priced far above fair value and why moving it to an " - "unconstrained holder is an efficiency gain rather than a transfer.", - "fig05_incidence": - "As the backstop strengthens Germany's discounted exposure rises steadily while " - "the compensation it earns per unit of expected loss falls, so the two objects the " - "German litigation actually turned on — quantity of risk assumed and price paid " - "for it — move in opposite directions.", -} +# Prose names for the E2 identity's components, used when a caption has to say +# which channel it picked out of the data. +COMPONENT_LABEL = {"consumption_quantity": "consumption", + "consumption_price": "the consumption deflator", + "investment": "investment", + "net_exports": "net exports"} + +# Populated at RUN TIME by save() — see the module docstring. Never edit by hand: +# a literal here is a claim that no longer has to survive the next recalibration. +CAPTIONS = {} + + +# ── Caption helpers ────────────────────────────────────────────────────────── +# +# These exist so a caption's *directional* words come from the data too. Writing +# "monotone" or "reverses by quarter four" as a literal is the same defect as +# writing "4.5x" as a literal, only harder to notice when it goes wrong. + +def _monotone(v, sign): + """True if v is strictly monotone in the given direction (NaNs dropped).""" + v = np.asarray(v, dtype=float) + v = v[~np.isnan(v)] + return bool(v.size > 1 and np.all(np.sign(np.diff(v)) == sign)) + + +def _first_quarter(mask, n): + """First quarter in [0, n) at which mask holds, or None.""" + idx = np.nonzero(np.asarray(mask)[:n])[0] + return int(idx[0]) if idx.size else None + + +def _ordinal(q): + return "the impact quarter" if q == 0 else f"quarter {q}" def _style(ax): @@ -108,9 +106,15 @@ def _style(ax): ax.axhline(0, lw=0.8, color=MUTED, zorder=1) -def save(fig, name): - """Bake the caption into the image, then write it.""" - cap = CAPTIONS[name] +def save(fig, name, caption): + """Register the DERIVED caption, bake it into the image, then write it. + + The caption is an argument, not a lookup: it must be constructed by the figure + function from the arrays it just plotted, so that it cannot outlive them. + """ + if not isinstance(caption, str) or not caption.strip(): + raise ValueError(f"{name}: save() needs a derived caption string") + CAPTIONS[name] = cap = " ".join(caption.split()) chars = int(fig.get_size_inches()[0] * 15) fig.text(0.5, -0.02, textwrap.fill(cap, width=chars), ha="center", va="top", fontsize=8, style="italic", color=MUTED) @@ -140,7 +144,51 @@ def fig01_transmission(cache, regimes): fig.suptitle("Transmission of a 1pp sovereign default-probability shock, by backstop stance", fontsize=11, color=INK, y=1.04) fig.tight_layout() - save(fig, "fig01_transmission") + save(fig, "fig01_transmission", _caption_fig01(cache, regimes)) + + +def _caption_fig01(cache, regimes): + """Impact magnitudes and the quarter at which the regime ordering reverses.""" + n = float(cache["n_inter_D_ss"]) + I = float(cache["I_D_ss"]) + Y = float(cache["Y_D_ss"]) + p, a = regimes["passive"][1], regimes["aggressive"][1] + sp_p = np.asarray(p["spread_rb"]) * BP_ANN + sp_a = np.asarray(a["spread_rb"]) * BP_ANN + nw_p, nw_a = (np.asarray(x["n_inter_D"]) * 100.0 / n for x in (p, a)) + peak = float(sp_p[:T_PNL].max()) + n0 = float(nw_p[0]) + i0 = float(np.asarray(p["I_D"])[0] * 100.0 / I) + y0 = float(np.asarray(p["Y_D"])[0] * 100.0 / Y) + + # "Reversal" = the quarter from which the aggressive path is no longer the + # better one: a wider spread, or a weaker balance sheet, than doing nothing. + q_sp = _first_quarter(sp_a > sp_p, N_IRF) + q_nw = _first_quarter(nw_a < nw_p, N_IRF) + if q_sp is None and q_nw is None: + tail = ("the ordering never reverses inside the plotted window, so the backstop " + "shifts the whole path rather than only its opening quarters") + else: + parts = [] + if q_nw is not None: + parts.append(f"the net-worth ordering reverses by {_ordinal(q_nw)}") + if q_sp is not None: + parts.append(f"the spread ordering by {_ordinal(q_sp)}") + tail = (" and ".join(parts) + " as the unaided economy overshoots on the rebound, " + "so intervention damps the impact quarter rather than shifting the whole " + "path down") + # Verbs from the signs: at this calibration both fall, but a caption that + # hardcodes "cuts" would misreport a recalibration in which they do not. + vb = lambda v: "cuts" if v < 0 else "raises" + if (n0 < 0) == (i0 < 0): + real = f"{vb(n0)} bank net worth {abs(n0):.1f}% and investment {abs(i0):.1f}%" + else: + real = (f"{vb(n0)} bank net worth {abs(n0):.1f}% and {vb(i0)} investment " + f"{abs(i0):.1f}%") + return (f"A 1pp rise in the Greek default probability widens the D–F spread to a peak of " + f"{peak:.0f}bp, {real} on impact, and takes output {y0:+.2f}% " + f"from steady state; the backstop's " + f"cushioning is concentrated in the opening quarters — {tail}.") def fig02_loading_schedule(cache, regimes, payload): @@ -179,10 +227,49 @@ def fig02_loading_schedule(cache, regimes, payload): ax.set_title("Spread compression", fontsize=10, color=INK, pad=8) fig.tight_layout() - save(fig, "fig02_loading_schedule") + save(fig, "fig02_loading_schedule", + _caption_fig02(gammas, loading, peak_bp, payload)) return gammas, loading, peak_bp +def _caption_fig02(gammas, loading, peak_bp, payload): + """The KEY claim is the DECLINE, so the schedule's own endpoints state it.""" + ok = ~np.isnan(np.asarray(loading, dtype=float)) + g_lo, g_hi = float(gammas[ok][0]), float(gammas[ok][-1]) + l_lo, l_hi = float(np.asarray(loading)[ok][0]), float(np.asarray(loading)[ok][-1]) + falling = _monotone(loading, -1) + arr = np.asarray(loading)[ok] + above_one = bool(np.all(arr > 1.0)) + below_one = bool(np.all(arr < 1.0)) + + named = {k: v["loading"] for k, v in payload["regimes"].items() + if v["loading"] is not None} + named_txt = ("; " + ", ".join(f"{k} {v:.2f}×" for k, v in named.items()) + + " at the named regimes") if named else "" + shape = ("falls monotonically" if falling else + "falls on net but not monotonically" if l_hi < l_lo else + "does NOT fall — the self-extinguishing-premium claim fails at this " + "calibration and must not be asserted") + # Three cases, not two. The old two-branch version assumed the loading STARTS above + # 1 and asked only whether it crosses; since the 2026-08-18 payoff repair it starts + # at 0.53 and is below 1 throughout, which the "crossing below ... before the grid + # ends" wording described backwards. + floor = (" and stays above the actuarially fair benchmark of 1 throughout" + if above_one else + " and stays BELOW the actuarially fair benchmark of 1 throughout — the ECB " + "is under-compensated at every intervention intensity, so the paper must " + "NOT assert over-compensation" if below_one else + ", crossing the actuarially fair benchmark of 1 within the grid") + peak_txt = (f"peak spread compresses {peak_bp[0]:.0f}bp → {peak_bp[-1]:.0f}bp " + f"over the same grid") + return (f"KEY FIGURE — the ECB's compensation per unit of expected loss {shape} from " + f"{l_lo:.2f}× at γ={g_lo:.2f} to {l_hi:.2f}× at γ={g_hi:.0f}{floor}" + f"{named_txt} ({peak_txt}). The premium is a rent extracted from a " + f"balance-sheet-constrained seller and intervention relieves the very " + f"constraint that creates it: the profit self-extinguishes as the policy " + f"succeeds.") + + def fig03_dy_decomposition(cache, regimes): """Two panels, because the shock and the policy work through DIFFERENT channels. @@ -238,7 +325,53 @@ def fig03_dy_decomposition(cache, regimes): fig.suptitle("Components of the output response, on impact", fontsize=11, color=INK, y=1.03) fig.tight_layout() - save(fig, "fig03_dy_decomposition") + save(fig, "fig03_dy_decomposition", _caption_fig03(comps, names)) + + +def _caption_fig03(comps, names): + """Both panels, stated from the impact contributions actually plotted. + + Under flexible prices panel B's investment and net-export channels were each + several times the headline ΔY and opposite in sign, so the caption's job was to + warn against leading with the headline. Under sticky prices the ordering is + REVERSED — consumption carries almost the whole of ΔY and the other channels are + a quarter of it — so the sentence is selected, not adjusted. + """ + lo, hi = names[0], names[-1] + A = comps[lo] + B = {k: comps[hi][k] - comps[lo][k] for k in comps[lo]} + tot = B["__total__"] + r = {k: (B[k] / tot if tot != 0 else np.nan) + for k in ("consumption_quantity", "investment", "net_exports")} + + lead = max(r, key=lambda k: abs(r[k])) + lead_name = COMPONENT_LABEL[lead] + others = [k for k in ("consumption_quantity", "investment", "net_exports") if k != lead] + # "at {x}x" rather than a verb, so the sentence stays grammatical whichever + # component the data picks out as the leading one (plural "net exports" + # included). + other_txt = " and ".join(f"{COMPONENT_LABEL[k]} at {r[k]:+.2f}×" for k in others) + # Whether the two secondary channels offset is a claim about signs, so read it. + other_txt += (" largely offsetting each other" + if np.sign(r[others[0]]) != np.sign(r[others[1]]) + else " pulling the same way") + + residue = abs(tot) < max(abs(B[k]) for k in ("investment", "net_exports", + "consumption_quantity")) + verdict = ("the headline ΔY is a residue of larger offsetting channels, which is " + "why the decomposition and not the headline is the object to report" + if residue else + "the headline ΔY is no longer a residue of larger offsetting channels — " + "it is now the largest object in the decomposition — but the remaining " + "channels still work against each other, so the decomposition is still " + "what should be reported") + return (f"On impact the crisis is a joint contraction: consumption contributes " + f"{A['consumption_quantity']:+.2f} and investment {A['investment']:+.2f} " + f"(×10⁻³ of D-goods) against a {A['net_exports']:+.2f} net-export cushion, " + f"summing to {A['__total__']:+.2f} (panel A). The backstop works through the " + f"same margin rather than a different one: moving {lo} → {hi} raises ΔY by " + f"{tot:+.2f}, of which {lead_name} supplies {r[lead]:+.2f}×, with " + f"{other_txt} (panel B). So {verdict}.") def fig06_net_effects(cache, regimes, n_q=16): @@ -254,10 +387,12 @@ def fig06_net_effects(cache, regimes, n_q=16): ("consumption_quantity", "consumption (quantity)", "#c87941"), ("consumption_price", "consumption (price)", "#A62B22")] q = np.arange(n_q) + by_regime = {} fig, axes = plt.subplots(1, 3, figsize=(14.5, 4.2), sharey=True) for ax, (name, (_g, irf)) in zip(axes, regimes.items()): comps, _r = decompose_dY(irf, ss) + by_regime[name] = (comps, np.asarray(irf["Y_D"])) pos = np.zeros(n_q) neg = np.zeros(n_q) for key, label, colour in parts: @@ -277,7 +412,45 @@ def fig06_net_effects(cache, regimes, n_q=16): fig.suptitle("Net decomposition of the output response, quarter by quarter", fontsize=11, color=INK, y=1.03) fig.tight_layout() - save(fig, "fig06_net_effects") + save(fig, "fig06_net_effects", _caption_fig06(by_regime, parts, n_q)) + + +def _caption_fig06(by_regime, parts, n_q): + """"Smaller than its components" is COUNTED, not asserted. + + The previous literal said the net path is "at every horizon far smaller than + the components that generate it". Under sticky prices that is false in the + impact quarter of the passive regime, where consumption and investment move + the same way and ΔY is the largest bar on the panel. The claim is therefore + stated as the count of quarters in which it actually holds. + """ + keys = [k for k, _lab, _c in parts] + comps, dY = by_regime["passive"] + dY = dY[:n_q] * 1e3 + mat = np.array([np.asarray(comps[k])[:n_q] * 1e3 for k in keys]) + biggest = np.abs(mat).max(axis=0) + n_small = int((np.abs(dY) < biggest).sum()) + + inv = np.asarray(comps["investment"])[:n_q] + nx = np.asarray(comps["net_exports"])[:n_q] + q_inv = _first_quarter(inv > 0, n_q) + q_nx = _first_quarter(nx < 0, n_q) + turns = [] + if q_inv is not None: + turns.append(f"the investment contribution turns positive from {_ordinal(q_inv)} " + f"as the capital stock is run down") + if q_nx is not None: + turns.append(f"net exports flip from cushion to drag at {_ordinal(q_nx)}") + turn_txt = ("; thereafter " + ", and ".join(turns)) if turns else "" + + return (f"Contributions to the output response quarter by quarter. Without a backstop " + f"the impact quarter is a joint consumption-and-investment contraction " + f"({dY[0]:+.2f} ×10⁻³ of D-goods in total){turn_txt}. The backstop works by " + f"lifting the consumption contribution in the opening quarters rather than by " + f"raising output uniformly, and beyond the impact quarter the aggregate hides " + f"most of what moves underneath it: the net path (black) is smaller in " + f"magnitude than the largest single component in {n_small} of the first " + f"{n_q} quarters.") def fig07_ms_regimes(): @@ -336,7 +509,43 @@ def fig07_ms_regimes(): fontsize=9.5, color=INK, pad=8) fig.tight_layout() - save(fig, "fig07_ms_regimes") + save(fig, "fig07_ms_regimes", _caption_fig07(dates, modal, erg, order, ecb)) + + +def _caption_fig07(dates, modal, erg, order, ecb): + """Empirical, hence MODEL-INDEPENDENT — but still derived from the npz. + + Nothing in this caption moves when the model is recalibrated: the estimates come + from Empirics/outputs/ms_regime_COMPOSITE.npz, not from the solve. It is derived + anyway so a re-estimation of the Markov-switching model cannot leave it stale. + """ + shares = " / ".join(f"{erg[order[k]] * 100:.0f}%" for k in range(3)) + # Longest contiguous run of the modal hawk state, and whether any of it + # predates the ECB — the caveat the figure's dashed line marks. + spans, start = [], None + for i, m in enumerate(modal): + if m == 2 and start is None: + start = i + elif m != 2 and start is not None: + spans.append((start, i - 1)) + start = None + if start is not None: + spans.append((start, len(modal) - 1)) + if spans: + s0, s1 = max(spans, key=lambda s: s[1] - s[0]) + yrs = (str(dates[s0])[:4], str(dates[s1])[:4]) + hawk_txt = (f"the high-spread 'hawk' state covers {yrs[0]}–{yrs[1]}" + if yrs[0] != yrs[1] else f"the high-spread 'hawk' state covers {yrs[0]}") + else: + hawk_txt = "the high-spread 'hawk' state is never modal" + pre = any(dates[s0_] < ecb for s0_, _s1 in spans) + caveat = (" — though the pre-1999 stretch predates the ECB and reflects EMU " + "convergence, not any policy stance" if pre else "") + return (f"A three-state Markov-switching model on peripheral–Bund spreads dates the " + f"ECB's intervention stance and disciplines the model's three backstop regimes: " + f"{hawk_txt}, and the ergodic shares ({shares}) are what the regime-uncertainty " + f"beliefs are set to{caveat}. Estimated from market data, so unlike every other " + f"figure here it does not move with the calibration.") def fig08_deciles(): @@ -366,12 +575,15 @@ def fig08_deciles(): A_def, A_cb = d["spread_rb__shock_def_D"], d["spread_rb__cb_buy_D"] mass_ss, c_ss = d["qnt_mass_ss"], d["qnt_c_ss"] - # gamma solved on THIS cache, so the regimes mean the same thing they do - # everywhere else in the paper (0/25/50% peak-spread compression). - from lottery_math import gamma_for_compression - gam = {"passive": 0.0, - "medium": float(gamma_for_compression(A_def, A_cb, eps, target=0.25)), - "aggressive": float(gamma_for_compression(A_def, A_cb, eps, target=0.50))} + # gamma solved on THIS cache, so the regimes mean the same thing they do everywhere + # else in the paper. Routed through common.named_regime_gammas rather than calling + # gamma_for_compression directly: since 2026-08-18 the aggressive target is + # unreachable below the closed-loop pole and falls back to the maximum feasible + # intervention, and a second copy of the solve here would silently skip that + # handling and raise. One definition of the regimes, not two. + from common import named_regime_gammas as _nrg + gam = _nrg({"spread_rb__shock_def_D": A_def, "spread_rb__cb_buy_D": A_cb, + "dShock_def_D": eps}) H, beta = 40, float(d["beta_D_ss"]) disc = beta ** np.arange(H) @@ -436,44 +648,152 @@ def path(k, cb): fig.suptitle("Distributional incidence by steady-state income quintile", fontsize=11, color=INK, y=1.03) fig.tight_layout() - save(fig, "fig08_deciles") + save(fig, "fig08_deciles", _caption_fig08(paths, pv, N_QNT, H)) return paths, pv, gam -def fig04_spread_decomposition(cache, ss_tl): - el, ps = float(ss_tl["EL_price_D"]), float(ss_tl["psi_spread_D"]) - total = el + ps - s_el, s_ps = 100 * el / total, 100 * ps / total - - fig, ax = plt.subplots(figsize=(9, 2.9)) - # 0.6pt surface gap between the segments so the boundary reads as a division - # rather than a colour change. - ax.barh([0], [s_el], color=BLUE, zorder=3, height=0.42, - label="fundamental expected loss", edgecolor="white", lw=1.2) - ax.barh([0], [s_ps], left=[s_el], color=ORANGE, zorder=3, height=0.42, - label="collateral-friction wedge", edgecolor="white", lw=1.2) - # The small segment cannot hold an inside label at 3% of the width — annotate - # it above with a leader instead of clipping text against the axis. - ax.annotate(f"{s_el:.1f}%", xy=(s_el / 2, 0.21), xytext=(s_el / 2, 0.52), - ha="center", fontsize=9.5, color=INK, weight="bold", - arrowprops=dict(arrowstyle="-", color=MUTED, lw=0.8)) - ax.text(s_el + s_ps / 2, 0, f"{s_ps:.1f}%", ha="center", va="center", - fontsize=13, color="white", weight="bold") - ax.set_xlim(0, 100) - ax.set_ylim(-0.45, 0.78) - ax.set_yticks([]) - ax.spines[["top", "right", "left"]].set_visible(False) - ax.spines["bottom"].set_color(MUTED) +def _caption_fig08(paths, pv, n_qnt, H): + """The instance that made this defect visible: the literal caption said the + lowest quintile "gains 0.95%" and the highest loses 0.59%, against a Table 4 in + the same generated document reading +0.4250 and −0.9073. It also claimed every + quintile's consumption RISES on impact, which the sticky-price solution reverses. + Both facts are now read off `paths` and `pv` — the arrays panel A and panel B + are drawn from. + """ + imp = paths["passive"][:, 0] + p_lo, p_hi = float(pv["passive"][0]), float(pv["passive"][-1]) + gain = np.asarray(pv["aggressive"]) - np.asarray(pv["passive"]) + g_lo, g_hi = float(gain[0]), float(gain[-1]) + + # Impact response: near-identical across quintiles at this calibration, so say + # so only if the spread across bins is genuinely small relative to the level. + uniform = float(imp.max() - imp.min()) < 0.1 * abs(float(imp.mean())) + if uniform: + vb = "falls" if imp.mean() < 0 else "rises" + imp_txt = (f"Consumption {vb} by about {abs(float(imp.mean())):.2f}% in every " + f"income quintile on impact, so the distributional difference emerges " + f"only afterwards") + else: + imp_txt = (f"On impact the consumption response already differs across the " + f"distribution, from {imp[0]:+.2f}% in the lowest quintile to " + f"{imp[-1]:+.2f}% in the highest") + + mono_pv = _monotone(pv["passive"], -1) + mono_gain = _monotone(gain, -1) + burden = (", monotonically across the five quintiles" if mono_pv else + ", though not monotonically across the quintiles") + prot = ("The backstop's protection runs the same way, also monotone in quintile:" + if mono_gain else + "The backstop's protection runs the same way but is not monotone in quintile:") + + def _side(v, who): + return (f"the {who} quintile loses {abs(v):.2f}% of its own consumption" + if v < 0 else f"the {who} quintile gains {v:.2f}%") + + return (f"{imp_txt}. Discounted over {H} quarters the burden of the crisis falls on the " + f"top of the income distribution{burden}: {_side(p_hi, 'highest-income')} " + f"while {_side(p_lo, 'lowest')}. {prot} it is worth {g_lo:+.2f}% of " + f"consumption to the lowest quintile against {g_hi:+.2f}% to the highest.") + + +def fig04_spread_decomposition(cache, ss_tl, regimes): + """Spread path against the expected-loss pricing it starts from. + + REWRITTEN 2026-08-18. The previous version was a two-segment bar splitting the + default loading into `EL_price_D` and `psi_spread_D`, captioned "x% fundamental / + y% non-fundamental". Both objects are deleted, and the split was not + interpretable in the first place: `psi_spread_D` was a free parameter absorbing + the principal-loss term that `zeta_writeoff = 0` had left out of the bond payoff, + so the "friction share" was mostly a calibration artefact. In a linearised + equilibrium model there is no such thing as a fundamental/non-fundamental share of + an endogenous price anyway — every channel operates simultaneously. + + What replaces it is a MECHANISM chart with no share arithmetic. Three series, all in + the same units (annualised bp of yield), all read off the same solved path: + + 1. **Direct expected-loss pricing of the Greek yield.** The bond price path implied + by the realised `def_rate_D` path when the intermediary's required excess return + is held at its steady-state value `s0 = Delta_bD_D * (rk_ss - rdep_ss)`. Solved by + backward recursion on the model's OWN pricing condition, + + q_t = (1 - h*d_{t+1}) * [delta_b + (1-delta_b)*q_{t+1}] / (1 + s0), + + terminating at `q_ss`, then read as `delta_b*(1/q_t - 1)`. Forward-consistent, so + it correctly reflects that the shock decays at `rho_def`. + 2. **Equilibrium Greek yield** — the same object with the intermediary's required + return free to move. + 3. **Equilibrium GR–DE spread**, which additionally contains the German leg. + + UNITS TRAP THIS FIXES. A first draft plotted `EL_load_D * def_rate_D`, which is a + ONE-PERIOD expected capital-loss rate, against `spread_rb`, which is a per-period + COUPON-EQUIVALENT yield. They are not commensurate: it read 331bp against a 206bp + spread, i.e. an "amplification factor" of 0.62, which is an artefact of the mismatch + and not a mechanism. The recursion above puts both on the yield measure. + + WHAT THE CHART ACTUALLY SHOWS, which is not what the old figure claimed. Direct + expected-loss pricing accounts for essentially the whole Greek yield response + (213.8bp against an equilibrium 189.7bp on impact). The intermediary channel is a + modest OFFSET at the price — general-equilibrium movement in `rk_D` and `rdep_D` + lowers the required return slightly — while being a large amplifier for QUANTITIES + (`n_inter_D` -11.4%, `Y_D` -1.97%). The spread exceeds the Greek yield because the + German leg falls (flight to quality), not because of amplification on the Greek leg. + """ + irf = regimes["passive"][1] + H, T = 40, 200 + t = np.arange(H) + q_ss = float(ss_tl["q_b_D"]) + db = float(ss_tl["delta_b_D"]) + h = 1.0 - float(ss_tl["recovery_rate_D"]) + s0 = db * (1.0 / q_ss - 1.0) # SS required excess return (rdep_ss = 0) + d = np.asarray(irf["def_rate_D"])[:T] # deviation == level, def_rate_ss = 0 + + q = np.full(T + 1, q_ss) + for i in range(T - 1, -1, -1): + dn = d[i + 1] if i + 1 < T else 0.0 + q[i] = (1.0 - h * dn) * (db + (1.0 - db) * q[i + 1]) / (1.0 + s0) + y0 = db * (1.0 / q_ss - 1.0) + direct = (db * (1.0 / q[:H] - 1.0) - y0) * BP_ANN + y_D = np.asarray(irf["rb_D"])[:H] * BP_ANN + spread = np.asarray(irf["spread_rb"])[:H] * BP_ANN + + fig, ax = plt.subplots(figsize=(9, 3.8)) + ax.fill_between(t, 0, direct, color=BLUE, alpha=0.28, zorder=1) + ax.plot(t, direct, color=BLUE, lw=1.6, ls="--", zorder=3, + label="Greek yield under direct expected-loss pricing\n" + "(required return held at steady state)") + ax.plot(t, y_D, color=INK, lw=2.2, zorder=4, label="equilibrium Greek yield") + ax.plot(t, spread, color=ORANGE, lw=2.0, zorder=4, + label="equilibrium GR–DE spread (adds the German leg)") + ax.set_xlim(0, H - 1) + ax.set_xlabel("quarters after the 1pp Greek default-probability shock", + fontsize=9, color=MUTED) + ax.set_ylabel("bp, annualised", fontsize=9, color=MUTED) + ax.spines[["top", "right"]].set_visible(False) + ax.spines[["bottom", "left"]].set_color(MUTED) ax.tick_params(colors=MUTED, labelsize=8) - ax.set_xlabel("share of the total default loading (%)", fontsize=9, color=MUTED) - ax.legend(frameon=False, fontsize=8.5, labelcolor=INK, ncol=2, - loc="lower center", bbox_to_anchor=(0.5, -0.62)) - ax.set_title(f"Default loading per unit of default probability = " - f"EL_price {el:.4f} + ψ_spread {ps:.4f}", - fontsize=9.5, color=INK, pad=12) + ax.legend(frameon=False, fontsize=8, labelcolor=INK, loc="upper right") + ax.set_title("Where the Greek spread comes from: the bond payoff, priced by the " + "intermediary", fontsize=9.5, color=INK, pad=10) fig.tight_layout() - save(fig, "fig04_spread_decomposition") - return el, ps + peak_sp = float(spread.max()) + save(fig, "fig04_spread_decomposition", + _caption_fig04(float(ss_tl["EL_load_D"]), peak_sp, float(direct.max()), + float(y_D.max()))) + return float(ss_tl["EL_load_D"]), peak_sp + + +def _caption_fig04(el_load, peak_sp, peak_dir, peak_yD): + """Derived from the solved path, not from any parameter.""" + return (f"The sovereign spread is generated by the bond's state-contingent payoff " + f"inside the intermediary's portfolio optimality condition, not by any spread " + f"parameter. Pricing the expected loss on the perpetuity — coupon *and* " + f"continuation value, {el_load:.4f} per unit of default probability — at a " + f"required return frozen at its steady-state level already accounts for " + f"{peak_dir:.0f}bp of Greek yield, against an equilibrium {peak_yD:.0f}bp: the " + f"intermediary channel is a modest offset at the price while being a large " + f"amplifier for quantities. The {peak_sp:.0f}bp spread exceeds the Greek yield " + f"because the German leg falls in a flight to quality. These are joint " + f"mechanisms along one equilibrium path, not separable shares of the price.") def fig05_incidence(cache, payload, gammas): @@ -524,7 +844,29 @@ def fig05_incidence(cache, payload, gammas): fig.suptitle("The German ledger: exposure rises, compensation per unit falls", fontsize=11, color=INK, y=1.03) fig.tight_layout() - save(fig, "fig05_incidence") + save(fig, "fig05_incidence", _caption_fig05(gammas, expo, load)) + + +def _caption_fig05(gammas, expo, load): + """The whole claim is a pair of directions, so take both from the schedules.""" + expo = np.asarray(expo, dtype=float) + load = np.asarray(load, dtype=float) + ok = ~np.isnan(load) + g_hi = float(np.asarray(gammas)[-1]) + up = _monotone(expo, +1) + down = _monotone(load, -1) + opposed = up and down + verdict = ("move in opposite directions" if opposed else + "do NOT move in opposite directions at this calibration — check before " + "asserting the German-ledger reading") + return (f"As the backstop strengthens Germany's discounted exposure rises " + f"{'steadily' if up else 'non-monotonically'} — from zero to " + f"{expo[-1]:.2f}% of quarterly steady-state $Y_D$ at γ={g_hi:.0f} — while the " + f"compensation it earns per unit of expected loss " + f"{'falls steadily' if down else 'moves non-monotonically'} from " + f"{load[ok][0]:.2f}× to {load[ok][-1]:.2f}×, so the two objects the German " + f"litigation actually turned on — quantity of risk assumed and price paid for " + f"it — {verdict}.") # ── Tables ─────────────────────────────────────────────────────────────────── @@ -559,7 +901,11 @@ def tables(cache, payload, ss_tl, el, ps, dist=None): f"S-1 resolved 2026-08-04).*", "", "Generated by `experiments/paper_outputs.py`. Every number is read live from the " "solved steady state or the cached response matrices — none is transcribed. " - "Figures are in `experiments/paper/`, each with its caption baked into the image.", + "Figures are in `experiments/paper/`, each with its caption baked into the image. " + "**The captions are derived too**: each is built by its own figure function from " + "the arrays that figure plots, so a caption cannot contradict a table below it " + "the way the hardcoded set did between the sticky-price conversion and " + "2026-08-06.", "", "## Table 1 — Calibration and identification ledger", "", "The distinction that matters for a referee is *which* parameters are measured, " @@ -617,9 +963,11 @@ def tables(cache, payload, ss_tl, el, ps, dist=None): ("n/a" if payload["regimes"][k]["loading"] is None else f"**{payload['regimes'][k]['loading']:.2f}**") for k in payload["regimes"]) + " |", "", - f"Default loading decomposition: `EL_price = {el:.6f}`, `psi_spread = {ps:.6f}` → " - f"fundamental expected loss is **{100 * el / (el + ps):.1f}%** of the total and the " - f"collateral-friction wedge is **{100 * ps / (el + ps):.1f}%**.", "", + f"Sovereign-spread mechanism: expected loss on the perpetuity is `EL_load_D = " + f"{el:.6f}` per unit of default probability (coupon *and* continuation value, " + f"`zeta_writeoff = 1`), priced inside the GK portfolio FOC. Equilibrium peak " + f"spread is **{ps:.1f} bp** annualised. There is no separate spread parameter and " + f"no fundamental/non-fundamental share — see fig04.", "", "## Table 4 — Distributional incidence, by income quintile", "", "PV of the consumption response over 40 quarters, % of each quintile's own " "steady-state consumption. Bins are cut on the **exogenous income state**, whose " @@ -662,17 +1010,34 @@ def main(): fig01_transmission(cache, regimes) gammas, _loading, _peak = fig02_loading_schedule(cache, regimes, payload) fig03_dy_decomposition(cache, regimes) - el, ps = fig04_spread_decomposition(cache, ss_tl) + el, ps = fig04_spread_decomposition(cache, ss_tl, regimes) fig05_incidence(cache, payload, gammas) fig06_net_effects(cache, regimes) fig07_ms_regimes() _paths, _pv, _gam = fig08_deciles() dist = {"pv": _pv} - # Coverage: captions and emitted figures must match exactly. + # Coverage: captions and emitted figures must match exactly. Since CAPTIONS is + # now filled by save(), this also proves every figure supplied a DERIVED caption + # rather than silently shipping without one. emitted = {f[:-4] for f in os.listdir(PAPER_DIR) if f.endswith(".png")} assert emitted == set(CAPTIONS), (emitted - set(CAPTIONS), set(CAPTIONS) - emitted) + # Prose-vs-table guard. fig01's caption and Table 3 quote the same impact + # numbers by two different routes — the cache directly, and e1.run()'s payload. + # Nothing previously checked that rendered prose agreed with rendered tables, + # which is how eight figures came to carry claims their own tables refuted. + _n_cap = float(np.asarray(regimes["passive"][1]["n_inter_D"])[0] + * 100.0 / float(cache["n_inter_D_ss"])) + _n_tbl = payload["regimes"]["passive"]["impact"]["n_inter_D_pct_ss"] + assert abs(_n_cap - _n_tbl) < 1e-9, ( + f"fig01's caption says bank net worth moves {_n_cap:+.4f}% on impact while " + f"Table 3 says {_n_tbl:+.4f}% — the figure and the table are no longer reading " + f"the same solve. Do not publish this document.") + # Table 4 and fig08's caption are the SAME object (dist['pv'] is _pv), so they + # agree by construction rather than by check. + assert dist["pv"] is _pv + doc = tables(cache, payload, ss_tl, el, ps, dist=dist) print(f"Figures -> {PAPER_DIR} ({len(emitted)}, captions baked in)") print(f"Tables -> {doc}") diff --git a/experiments/results/e1_backstop_schedule.json b/experiments/results/e1_backstop_schedule.json index ffc35da..89e637c 100644 --- a/experiments/results/e1_backstop_schedule.json +++ b/experiments/results/e1_backstop_schedule.json @@ -1,36 +1,36 @@ { "provenance": { - "generated": "2026-08-05T14:13:36", - "git_sha": "b3983cb", + "generated": "2026-08-18T12:31:32", + "git_sha": "ea23e94", "git_dirty": true, - "cal_fingerprint": "de195df2", + "cal_fingerprint": "86c89892", "cache_schema": 3, "BANK_SCOPE": "broad", "EBA_CALIBRATION": true, - "psi_lambda_B_D": 8.5, + "psi_lambda_B_D": 0.0, "mv_rule_D": 0.0, "recovery_rate_D": 0.3, "writeoff_enabled_D": 0.0, - "zeta_writeoff_D": 0.0, + "zeta_writeoff_D": 1.0, "phi_lamb_D": 0.15, "delta_b_D": 0.07770055706859433 }, - "gamma_selection_rule": "peak-spread compression 0/25/50% (spec section 7); gamma solved, not chosen", + "gamma_selection_rule": "gamma SOLVED for peak-spread compression, not chosen. medium = 25% (spec section 7). aggressive was 50%, but since the 2026-08-18 GK structural refactor that target lies beyond a closed-loop pole at gamma ~ 27.3 and is unreachable; it falls back to the strongest intervention the model can represent, gamma just below the pole, achieving ~46.6%. DO NOT describe the aggressive regime as 50% compression -- see common.named_regime_gammas and lottery_math.closed_loop_pole.", "welfare_caveat": "SECONDARY. SPEC: do not lead with welfare \u2014 it is a delicate decomposition-dependent object and comes out near-exactly zero-sum.", "regimes": { "passive": { "gamma": 0.0, - "peak_spread_bp_ann": 150.30634590442185, + "peak_spread_bp_ann": 205.87042801449053, "impact": { - "Y_D_pct_ss": -0.014942910216657817, - "C_D_pct_ss": 0.21635033089777483, - "I_D_pct_ss": -0.7717951666942222, - "n_inter_D_pct_ss": -3.3804095383737423, - "K_D_pct_ss": -0.017293928735185343 + "Y_D_pct_ss": -1.9741762806656946, + "C_D_pct_ss": -2.5107502561285773, + "I_D_pct_ss": -3.0046296835178623, + "n_inter_D_pct_ss": -11.407334960944704, + "K_D_pct_ss": -0.06726530000604572 }, "trough": { - "Y_D_pct_ss": -0.014942910216657817, - "n_inter_D_pct_ss": -3.3804095383737423 + "Y_D_pct_ss": -1.9741762806656946, + "n_inter_D_pct_ss": -11.407334960944704 }, "a5_1_exposure_pv_pct_Y": 0.0, "a5_1_expected_loss_pv_pct_Y": 0.0, @@ -40,246 +40,246 @@ "mtm_pv_pct_Y": 0.0, "carry_ss_pv_pct_Y": 0.0, "loading": null, - "welfare_W_D_secondary": 0.03988813025260926, - "welfare_W_F_secondary": 0.04034907866498298 + "welfare_W_D_secondary": -2.6843678601674306, + "welfare_W_F_secondary": 0.1721829651118925 }, "medium": { - "gamma": 5.079798909719102, - "peak_spread_bp_ann": 112.72975942499305, + "gamma": 9.989373271010116, + "peak_spread_bp_ann": 154.40282102077668, "impact": { - "Y_D_pct_ss": 0.01107062826928443, - "C_D_pct_ss": 0.304049542612273, - "I_D_pct_ss": -0.29028461509803505, - "n_inter_D_pct_ss": -2.1672414829333886, - "K_D_pct_ss": -0.006504525634604118 + "Y_D_pct_ss": -0.3287499369660586, + "C_D_pct_ss": 0.07922947653206867, + "I_D_pct_ss": -1.609993084044884, + "n_inter_D_pct_ss": -5.541404513508157, + "K_D_pct_ss": -0.03604326629667808 }, "trough": { - "Y_D_pct_ss": 0.0023634152801282155, - "n_inter_D_pct_ss": -2.1672414829333886 + "Y_D_pct_ss": -0.3287499369660586, + "n_inter_D_pct_ss": -5.541404513508157 }, - "a5_1_exposure_pv_pct_Y": 0.42649761301674904, - "a5_1_expected_loss_pv_pct_Y": 0.0022755755177235226, - "a5_1_greek_fiscal_saving_pv": -0.0015409998799423542, - "peak_exposure_pct_Y": 1.38714650703372, - "premium_pv_pct_Y": 0.009093587286078345, - "mtm_pv_pct_Y": 0.03424715477101813, - "carry_ss_pv_pct_Y": 1.2363323403846125e-16, - "loading": 3.996170294174871, - "welfare_W_D_secondary": 0.06456099376293112, - "welfare_W_F_secondary": 0.018921772680493016 + "a5_1_exposure_pv_pct_Y": 6.0570905352562106, + "a5_1_expected_loss_pv_pct_Y": 0.2703589099615343, + "a5_1_greek_fiscal_saving_pv": -0.0136944332404844, + "peak_exposure_pct_Y": 3.7592071384084442, + "premium_pv_pct_Y": 0.13021894894985664, + "mtm_pv_pct_Y": 0.08679593246861175, + "carry_ss_pv_pct_Y": -1.640953146807401e-13, + "loading": 0.4816521451739236, + "welfare_W_D_secondary": -1.5568374163956196, + "welfare_W_F_secondary": 0.07785043128450872 }, "aggressive": { - "gamma": 12.72601367381867, - "peak_spread_bp_ann": 75.15317295827965, + "gamma": 19.875, + "peak_spread_bp_ann": 122.91203594590507, "impact": { - "Y_D_pct_ss": 0.033769292271885125, - "C_D_pct_ss": 0.3854756057744613, - "I_D_pct_ss": 0.12170544630835509, - "n_inter_D_pct_ss": -1.0988157952017568, - "K_D_pct_ss": 0.002727103519131661 + "Y_D_pct_ss": 0.6829587035021203, + "C_D_pct_ss": 1.6963047342467106, + "I_D_pct_ss": -0.8052973214972285, + "n_inter_D_pct_ss": -2.0374711489447597, + "K_D_pct_ss": -0.01802836676403823 }, "trough": { - "Y_D_pct_ss": 3.6450008536580966e-05, - "n_inter_D_pct_ss": -1.0988157952017568 + "Y_D_pct_ss": -0.17888166161604255, + "n_inter_D_pct_ss": -2.0374711489447597 }, - "a5_1_exposure_pv_pct_Y": 1.1061638595977343, - "a5_1_expected_loss_pv_pct_Y": 0.003914458747579844, - "a5_1_greek_fiscal_saving_pv": -0.004651071845333929, - "peak_exposure_pct_Y": 2.316738087705519, - "premium_pv_pct_Y": 0.012397842482955569, - "mtm_pv_pct_Y": 0.05962931017288611, - "carry_ss_pv_pct_Y": 3.182835272040681e-16, - "loading": 3.1671920136138016, - "welfare_W_D_secondary": 0.10562770145835651, - "welfare_W_F_secondary": -0.030950849995792774 + "a5_1_exposure_pv_pct_Y": 11.261752126940214, + "a5_1_expected_loss_pv_pct_Y": 0.41108294924028094, + "a5_1_greek_fiscal_saving_pv": -0.020881953144410204, + "peak_exposure_pct_Y": 5.953938358215376, + "premium_pv_pct_Y": 0.18461821497946754, + "mtm_pv_pct_Y": 0.12308180340848604, + "carry_ss_pv_pct_Y": -3.001264470910414e-13, + "loading": 0.44910209805748197, + "welfare_W_D_secondary": 0.2017520732504983, + "welfare_W_F_secondary": -0.02386580020555438 } }, "loading_schedule": { "gamma": [ 0.0, - 0.5084745762711864, - 1.0169491525423728, - 1.5254237288135593, - 2.0338983050847457, - 2.542372881355932, - 3.0508474576271185, - 3.559322033898305, - 4.067796610169491, - 4.576271186440678, - 5.084745762711864, - 5.59322033898305, - 6.101694915254237, - 6.610169491525424, - 7.11864406779661, - 7.627118644067796, - 8.135593220338983, - 8.64406779661017, - 9.152542372881356, - 9.661016949152541, - 10.169491525423728, - 10.677966101694915, - 11.1864406779661, - 11.694915254237287, - 12.203389830508474, - 12.711864406779661, - 13.220338983050848, - 13.728813559322033, - 14.23728813559322, - 14.745762711864407, - 15.254237288135592, - 15.762711864406779, - 16.271186440677965, - 16.779661016949152, - 17.28813559322034, - 17.796610169491526, - 18.305084745762713, - 18.813559322033896, - 19.322033898305083, - 19.83050847457627, - 20.338983050847457, - 20.847457627118644, - 21.35593220338983, - 21.864406779661017, - 22.3728813559322, - 22.881355932203387, - 23.389830508474574, - 23.89830508474576, - 24.406779661016948, - 24.915254237288135, - 25.423728813559322, - 25.93220338983051, - 26.440677966101696, - 26.94915254237288, - 27.457627118644066, - 27.966101694915253, - 28.47457627118644, - 28.983050847457626, - 29.491525423728813, - 30.0 + 0.336864406779661, + 0.673728813559322, + 1.0105932203389831, + 1.347457627118644, + 1.684322033898305, + 2.0211864406779663, + 2.358050847457627, + 2.694915254237288, + 3.031779661016949, + 3.36864406779661, + 3.705508474576271, + 4.0423728813559325, + 4.379237288135593, + 4.716101694915254, + 5.052966101694915, + 5.389830508474576, + 5.726694915254237, + 6.063559322033898, + 6.400423728813559, + 6.73728813559322, + 7.0741525423728815, + 7.411016949152542, + 7.747881355932203, + 8.084745762711865, + 8.421610169491526, + 8.758474576271187, + 9.095338983050848, + 9.432203389830509, + 9.76906779661017, + 10.10593220338983, + 10.442796610169491, + 10.779661016949152, + 11.116525423728813, + 11.453389830508474, + 11.790254237288135, + 12.127118644067796, + 12.463983050847457, + 12.800847457627118, + 13.137711864406779, + 13.47457627118644, + 13.811440677966102, + 14.148305084745763, + 14.485169491525424, + 14.822033898305085, + 15.158898305084746, + 15.495762711864407, + 15.832627118644067, + 16.16949152542373, + 16.50635593220339, + 16.843220338983052, + 17.180084745762713, + 17.516949152542374, + 17.853813559322035, + 18.190677966101696, + 18.527542372881356, + 18.864406779661017, + 19.201271186440678, + 19.53813559322034, + 19.875 ], "loading": [ null, - 4.509052819478987, - 4.458445686660772, - 4.405556167295593, - 4.35064740488409, - 4.294007295324711, - 4.235940839876058, - 4.176760730182356, - 4.116777485460598, - 4.056290296285778, - 3.9955794108549285, - 3.934900520609964, - 3.8744812500108052, - 3.8145195870309556, - 3.7551839270006107, - 3.6966143344190128, - 3.638924631407189, - 3.5822049699207286, - 3.5265246137351216, - 3.4719347283974713, - 3.4184710425453066, - 3.366156297457387, - 3.3150024423568496, - 3.265012561886263, - 3.216182541327541, - 3.168502486780964, - 3.121957923704812, - 3.0765307996586744, - 3.0322003171122565, - 2.98894362074605, - 2.946736361458052, - 2.9055531567522, - 2.8653679646054364, - 2.826154385458916, - 2.78788590474468, - 2.750536086379728, - 2.714078725940838, - 2.6784879707634026, - 2.6437384129636654, - 2.6098051603399997, - 2.5766638892385485, - 2.544290882746164, - 2.512663056976038, - 2.481757977718432, - 2.4515538693228662, - 2.422029617344001, - 2.3931647662088174, - 2.364939512936822, - 2.337334697759496, - 2.3103317923324886, - 2.2839128861086095, - 2.258060671336266, - 2.232758427062984, - 2.2079900024535393, - 2.1837397996742784, - 2.1599927565478314, - 2.1367343291427043, - 2.1139504744300956, - 2.091627633113225, - 2.06975271271248 + 0.5291116633040699, + 0.5272065556381359, + 0.5253201080400276, + 0.5234521587925137, + 0.5216024011751159, + 0.5197690786751127, + 0.5179656957687009, + 0.5161678395345803, + 0.5143910577669307, + 0.5126326903031017, + 0.510892402479479, + 0.5091700491552403, + 0.5074655306452781, + 0.5057787651068869, + 0.5041096807453812, + 0.5024582131461361, + 0.5008243043066208, + 0.49920790234091456, + 0.4976089614920198, + 0.49602744230630547, + 0.4944633119071098, + 0.4929165443392921, + 0.49138712097252657, + 0.48987503095922164, + 0.4883802717474428, + 0.48690284965205116, + 0.48544278048932116, + 0.4840000902820179, + 0.4825748160435547, + 0.48116700665154294, + 0.4797767238229218, + 0.47840404320499186, + 0.4770490555991517, + 0.4757118683370749, + 0.4743926068325337, + 0.4730914163362529, + 0.4718084639261841, + 0.47054394077165645, + 0.4692980647172249, + 0.4680710832410262, + 0.4668632768534804, + 0.4656749630157513, + 0.4645065006742137, + 0.4633582955281091, + 0.4622308061737932, + 0.46112455130198754, + 0.46004011816625956, + 0.4589781725942617, + 0.4579394708816687, + 0.45692487399720144, + 0.4559353646422825, + 0.45497206786011507, + 0.4540362760891834, + 0.45312947982372076, + 0.4522534054046173, + 0.45141006195622263, + 0.4506018001625729, + 0.4498313865222999, + 0.44910209805748197 ], "peak_spread_bp_ann": [ - 150.30634590442185, - 146.3307359424627, - 142.3627851250667, - 138.4187868804567, - 134.51478093171042, - 130.666167667402, - 126.88726902242303, - 123.19091864851511, - 119.58814185579077, - 116.0879587150119, - 112.69731751000143, - 109.4211452818337, - 106.2624898601912, - 103.22272329018179, - 100.30177802259723, - 97.49839219144434, - 94.81034659102517, - 92.23468208456903, - 89.76789131228117, - 87.40608243294483, - 85.14511527350501, - 82.9807118799056, - 80.90854431136584, - 78.92430283489654, - 77.0237476495816, - 75.20274704458751, - 73.45730457090482, - 71.78357744945909, - 70.17788808687048, - 68.63673024646606, - 67.15677113616535, - 65.73485042942504, - 64.36797702921316, - 63.053324214456374, - 61.78822366918864, - 60.570158782095724, - 59.39675751397142, - 58.26578505886248, - 57.17513646796124, - 56.122829360778226, - 55.10699681334478, - 54.12588048622266, - 53.177824034308784, - 52.26126682453029, - 51.37473797549154, - 50.51685072410615, - 49.68629711759877, - 48.881843024424704, - 48.10232345425165, - 47.34663817482775, - 46.61374761207856, - 45.90266901891587, - 45.21247289787363, - 44.54227966264925, - 43.891256523860285, - 43.25861458474531, - 42.64360613305928, - 42.04552211605028, - 41.46368978605686, - 40.897470504969256 + 205.87042801449053, + 203.64174093176214, + 201.4560129475699, + 199.31210691971447, + 197.20891987126598, + 195.14537093216168, + 193.12027677257552, + 191.13392748484014, + 189.18306633147492, + 187.2682384200107, + 185.38829990290648, + 183.54235460663548, + 181.72955139524137, + 179.94906953929137, + 178.2001151172054, + 176.48191932733323, + 174.79373732215382, + 173.1348472392415, + 171.5045493301434, + 169.9021651509168, + 168.32703679881854, + 166.77852618749344, + 165.25601435634002, + 163.75890081127594, + 162.2866028949297, + 160.8385551847056, + 159.41420891748882, + 158.01303143988937, + 156.63450568313422, + 155.27812966178888, + 153.94341599563123, + 152.62989145410538, + 151.33709652288059, + 150.06458499217274, + 148.81192356659872, + 147.57869149647615, + 146.36448023062505, + 145.1688930909233, + 143.99154496903662, + 142.83206204601137, + 141.690081535673, + 140.56525145312102, + 139.4572304099929, + 138.36568743868241, + 137.2903018482654, + 136.23076311565586, + 135.18677081639635, + 134.15803460069142, + 133.1442742217032, + 132.14521962502602, + 131.16061111063516, + 130.19019958170207, + 129.2337468987125, + 128.29102636265216, + 127.36182335809261, + 126.44593619651734, + 125.54317721307365, + 124.6533741875975, + 123.77637218518394, + 122.91203594590515 ] } } \ No newline at end of file diff --git a/experiments/results/e2_dy_decomposition.json b/experiments/results/e2_dy_decomposition.json index 1d1e235..4105f7c 100644 --- a/experiments/results/e2_dy_decomposition.json +++ b/experiments/results/e2_dy_decomposition.json @@ -1,17 +1,17 @@ { "provenance": { - "generated": "2026-08-03T12:08:12", - "git_sha": "b3d5889", + "generated": "2026-08-18T12:30:46", + "git_sha": "ea23e94", "git_dirty": true, - "cal_fingerprint": "de195df2", + "cal_fingerprint": "86c89892", "cache_schema": 3, "BANK_SCOPE": "broad", "EBA_CALIBRATION": true, - "psi_lambda_B_D": 8.5, + "psi_lambda_B_D": 0.0, "mv_rule_D": 0.0, "recovery_rate_D": 0.3, "writeoff_enabled_D": 0.0, - "zeta_writeoff_D": 0.0, + "zeta_writeoff_D": 1.0, "phi_lamb_D": 0.15, "delta_b_D": 0.07770055706859433 }, @@ -20,154 +20,154 @@ "regimes": { "passive": { "gamma": 0.0, - "max_abs_residual": 6.754579534584693e-17, - "dY_impact_pct_ss": -0.014942910216657817, - "dY_trough_pct_ss": -0.014942910216657817, - "dY_pv": 0.0028676000843844878, + "max_abs_residual": 7.903833837419327e-17, + "dY_impact_pct_ss": -1.9741762806656946, + "dY_trough_pct_ss": -1.9741762806656946, + "dY_pv": -0.04915021419228573, "components_pv": { - "consumption_quantity": -0.0003749961439858758, - "consumption_price": 5.0877243328866585e-05, - "investment": 0.003050281861435693, + "consumption_quantity": -0.02712666492257098, + "consumption_price": 0.002960107531533955, + "investment": -0.034630979789525056, "government": 0.0, "portfolio_cost": 0.0, "macropru_tax": 0.0, - "net_exports": 0.00014143712360581 + "net_exports": 0.009647322988276495 }, "components_impact": { - "consumption_quantity": 0.001462421132203461, - "consumption_price": 0.0001763421089795903, - "investment": -0.0018677443034000173, + "consumption_quantity": -0.016964052668682554, + "consumption_price": 0.000619833547498386, + "investment": -0.007269180988741625, "government": 0.0, "portfolio_cost": 0.0, "macropru_tax": 0.0, - "net_exports": 7.95519600503203e-05 + "net_exports": 0.0038716373032688955 }, "paths": { "consumption_quantity": [ - 0.001462421132203461, - 0.00047477867235798605, - -0.00010855776245865113, - -0.000422050058674539, - -0.0005605620820314034, - -0.0005900481932623674, - -0.0005555594988796396, - -0.00048719352776104534, - -0.0004044757967389724, - -0.00031956821017430727, - -0.00023959364748531446, - -0.00016830247586222297, - -0.00010725420476835472, - -5.6641550979327926e-05, - -1.5855755912993068e-05, - 1.6134378091834407e-05, - 4.053556746434969e-05, - 5.857550054222404e-05, - 7.14118156103695e-05, - 8.008389383470212e-05, - 8.549260452738909e-05, - 8.839758168540212e-05, - 8.94248967312036e-05, - 8.908029927442498e-05, - 8.776483911293243e-05, - 8.579086296493956e-05, - 8.339719925128709e-05, - 8.076289258882604e-05, - 7.801920654943146e-05, - 7.525983889245952e-05, - 7.254942962785181e-05, - 6.993051702347428e-05, - 6.742913088622432e-05, - 6.50592209085441e-05, - 6.282611055121008e-05, - 6.072915061953039e-05, - 5.8763726081811516e-05, - 5.692274789717137e-05, - 5.5197740532217057e-05, - 5.357961646986438e-05 + -0.016964052668682554, + -0.0017239056102202727, + 0.0015404824522387373, + 0.0016992927749014002, + 0.001218481306552758, + 0.0007294713205793006, + 0.00035638280420456495, + 9.512848254858917e-05, + -8.221750889523548e-05, + -0.0002014396567543854, + -0.0002816632325601479, + -0.00033607362194949504, + -0.0003734961495726982, + -0.00039976046849434134, + -0.0004186881174548689, + -0.0004327725065872101, + -0.0004436328640807586, + -0.0004523158196464789, + -0.00045949532627979044, + -0.00046560394825800655, + -0.0004709176613124397, + -0.0004756116414201387, + -0.00047979693865434766, + -0.0004835440161495358, + -0.00048689780514536555, + -0.000489887491845314, + -0.0004925328762213896, + -0.0004948484221141684, + -0.0004968457505813181, + -0.0004985351285234827, + -0.0004999263227064651, + -0.0005010290576528626, + -0.0005018532342585925, + -0.0005024090091592165, + -0.0005027067946101722, + -0.0005027572137332085, + -0.0005025710332621561, + -0.0005021590883897103, + -0.0005015322085581683, + -0.0005007011490867117 ], "consumption_price": [ - 0.0001763421089795903, - -6.816881017578879e-06, - -9.499819067615091e-05, - -0.0001253004006769533, - -0.00012258541412859929, - -0.00010303351451168174, - -7.677795881742544e-05, - -4.9829645635661545e-05, - -2.546292280258905e-05, - -5.191546754715604e-06, - 1.0555180424150172e-05, - 2.1974345163366564e-05, - 2.9588054050976645e-05, - 3.405105002983739e-05, - 3.60340851329852e-05, - 3.615816033119263e-05, - 3.4962172969650133e-05, - 3.2891681468587954e-05, - 3.0300297625261074e-05, - 2.7457982498912416e-05, - 2.456258264446576e-05, - 2.175229821345919e-05, - 1.9117691133356864e-05, - 1.671249315705478e-05, - 1.4562894238256769e-05, - 1.26752403371703e-05, - 1.1042215814479925e-05, - 9.647666742229208e-06, - 8.470257785722138e-06, - 7.486161701822682e-06, - 6.67096901813744e-06, - 6.000985094352029e-06, - 5.454057792471556e-06, - 5.010054626702806e-06, - 4.6510855227547614e-06, - 4.361547192889292e-06, - 4.128047928390611e-06, - 3.939257269124485e-06, - 3.785713334553967e-06, - 3.6596113126377913e-06 + 0.000619833547498386, + 0.0005810385274043079, + 0.00042741323490391725, + 0.00029139298689851833, + 0.00019389918598614538, + 0.00012927509534491055, + 8.813292939575949e-05, + 6.267508708487049e-05, + 4.73366814673534e-05, + 3.836660074669369e-05, + 3.3304165903796104e-05, + 3.0562025579635134e-05, + 2.913088521287355e-05, + 2.8379523498317104e-05, + 2.792183383428032e-05, + 2.7529302422706916e-05, + 2.707371225283592e-05, + 2.6489837257058483e-05, + 2.575128727119487e-05, + 2.4854947006385192e-05, + 2.3811009851075474e-05, + 2.263667399186111e-05, + 2.1352220545414994e-05, + 1.997862458614684e-05, + 1.8536152204914223e-05, + 1.7043591351664284e-05, + 1.5517883362016694e-05, + 1.3974000285226384e-05, + 1.2424967029428024e-05, + 1.0881964505995473e-05, + 9.354474176613068e-06, + 7.850439852148683e-06, + 6.376432399323238e-06, + 4.937809085394022e-06, + 3.5388629829060524e-06, + 2.1829601390954255e-06, + 8.726636757929634e-07, + -3.901551007653449e-07, + -1.604217975770947e-06, + -2.7687526119733474e-06 ], "investment": [ - -0.0018677443034000173, - -0.0004649145951973444, - 0.000323829089471659, - 0.000714848252948532, - 0.0008577898102111881, - 0.0008541724102087704, - 0.0007712463435243431, - 0.0006522199169655633, - 0.0005237213905008852, - 0.0004011693274959686, - 0.00029259068180137384, - 0.0002012950129575159, - 0.00012771451473654355, - 7.064579856965024e-05, - 2.806558089971146e-05, - -2.3514008634428207e-06, - -2.2919374847732612e-05, - -3.576648407069759e-05, - -4.275010323017026e-05, - -4.542921990980634e-05, - -4.507112268833854e-05, - -4.267704864168358e-05, - -3.901670451300926e-05, - -3.466532764122625e-05, - -3.003951588414803e-05, - -2.542975451424613e-05, - -2.1028684649088195e-05, - -1.695486972538896e-05, - -1.327224254019614e-05, - -1.0005645149076489e-05, - -7.15297554798616e-06, - -4.694477492242329e-06, - -2.5996853480504756e-06, - -8.324861905126389e-07, - 6.452992430414168e-07, - 1.8714778285193365e-06, - 2.8819083657095392e-06, - 3.709563766491042e-06, - 4.3840530408788475e-06, - 4.931464821047081e-06 + -0.007269180988741625, + -0.004237756542405225, + -0.002723311805637203, + -0.0018747854105553646, + -0.001365639892105407, + -0.0010496980118163363, + -0.0008510371660985171, + -0.0007257691424798769, + -0.0006469100871100165, + -0.0005973652281679727, + -0.000566175454610715, + -0.000546298707080573, + -0.0005332261404127383, + -0.0005240941708132604, + -0.0005171067587779329, + -0.000511158426600268, + -0.0005055876892264894, + -0.0005000161907033778, + -0.000494244096770865, + -0.00048818221541090405, + -0.00048180803475877455, + -0.0004751376063779941, + -0.0004682077664307314, + -0.00046106500735918583, + -0.0004537586710238857, + -0.00044633695769728875, + -0.00043884472991035034, + -0.00043132243192841994, + -0.00042380568647230985, + -0.00041632529481344356, + -0.0004089074676779128, + -0.0004015741782620713, + -0.00039434357018887546, + -0.00038723037934807966, + -0.0003802463448313201, + -0.0003734005947243634, + -0.0003666999996621659, + -0.00036014949161103176, + -0.0003537523479917712, + -0.0003475104427414441 ], "government": [ 0.0, @@ -296,241 +296,241 @@ 0.0 ], "net_exports": [ - 7.95519600503203e-05, - -9.50311243763044e-05, - -0.00016065287990215532, - -0.0001641280716241511, - -0.0001358224203437048, - -9.467911836279855e-05, - -5.187699289815015e-05, - -1.3455061000859636e-05, - 1.7832345443240183e-05, - 4.123980097065111e-05, - 5.7169869890928205e-05, - 6.660849261111004e-05, - 7.076954277252607e-05, - 7.08805617747132e-05, - 6.806434777666198e-05, - 6.32825059449883e-05, - 5.7317886536713256e-05, - 5.078003019603244e-05, - 4.412293779630296e-05, - 3.766820382207599e-05, - 3.162934236084647e-05, - 2.6134861328495883e-05, - 2.1248759087503813e-05, - 1.6987913918599113e-05, - 1.3336323255697142e-05, - 1.0256401359505308e-05, - 7.697670666434597e-06, - 5.60323663912544e-06, - 3.914441501209e-06, - 2.5740657422709367e-06, - 1.52840262068674e-06, - 7.284815738959414e-07, - 1.3066745939153456e-07, - -3.0318277370440593e-07, - -6.058641326141481e-07, - -8.051600246754232e-07, - -9.242903280374617e-07, - -9.82412530400368e-07, - -9.95134905080127e-07, - -9.75014213274908e-07 + 0.0038716373032688955, + 0.0013304353701034826, + 0.0005240558335643511, + 0.0002526806614860129, + 0.0001542787429891867, + 0.00011639113134433667, + 0.00010204680888274027, + 9.789078108325016e-05, + 9.84339402998947e-05, + 0.00010109664445870078, + 0.00010453972278444447, + 0.00010803304803333697, + 0.00011118131845919217, + 0.00011378561435034797, + 0.00011576413094081513, + 0.00011710468020503877, + 0.0001178349188137262, + 0.00011800387018272087, + 0.0001176706686335262, + 0.0001168976843988306, + 0.0001157462190281089, + 0.00011427411743192344, + 0.00011253459047588703, + 0.00011057568468780118, + 0.00010844016398773267, + 0.00010616568680223083, + 0.00010378515479654668, + 0.00010132713430850617, + 9.881628948814752e-05, + 9.62738008685724e-05, + 9.371775741660559e-05, + 9.116351674877698e-05, + 8.86240327510736e-05, + 8.611015166841474e-05, + 8.363087793314103e-05, + 8.119361113095887e-05, + 7.880435610703671e-05, + 7.646790866051921e-05, + 7.41880193046234e-05, + 7.196753742794135e-05 ] }, "dY_path": [ - -0.00014942910216657817, - -9.198392823321221e-05, - -4.037974356532098e-05, - 3.3697219728571505e-06, - 3.881989370749769e-05, - 6.641158407193688e-05, - 8.703189292912734e-05, - 0.00010174168256802162, - 0.00011161501640253795, - 0.00011764937153759947, - 0.00012072208463112023, - 0.00012157537486976933, - 0.00012081790679169358, - 0.00011893585939483746, - 0.00011630825789636417, - 0.00011322364350456398, - 0.00010989625212298299, - 0.00010648072813614801, - 0.00010308494780176168, - 9.97808602458813e-05, - 9.661340684435727e-05, - 9.360769258567482e-05, - 9.077464243905543e-05, - 8.811537870885345e-05, - 8.562454072273272e-05, - 8.329275014736901e-05, - 8.110840108312027e-05, - 7.905892624479534e-05, - 7.713166329616924e-05, - 7.531442118747738e-05, - 7.359582571868023e-05, - 7.19655061994761e-05, - 7.041417079002956e-05, - 6.89336065710284e-05, - 6.751663118439132e-05, - 6.615701561626356e-05, - 6.484939204787295e-05, - 6.358915640239126e-05, - 6.237237200256877e-05, - 6.119567839026925e-05 + -0.019741762806656946, + -0.004050188255117723, + -0.00023136028493020823, + 0.0003685810127305668, + 0.00020101934342269602, + -7.456046454776938e-05, + -0.000304474623615445, + -0.0004700747917632461, + -0.0005833569742380107, + -0.0006593416397169816, + -0.0007099947984825744, + -0.0007437772554171085, + -0.0007664100863133726, + -0.0007816895014588955, + -0.0007921089114577526, + -0.0007992969505597598, + -0.000804311922240704, + -0.0008078383029100835, + -0.0008103174671459357, + -0.0008120335322636563, + -0.000813168467192045, + -0.0008138384563743581, + -0.0008141178940637696, + -0.0008140547142347621, + -0.000813680159976611, + -0.0008130151713887166, + -0.0008120745679731674, + -0.0008108697194488734, + -0.0008094101805360452, + -0.0008077046579623577, + -0.0008057615587911619, + -0.0008035892793140203, + -0.0008011963392970723, + -0.0007985914277534754, + -0.0007957833985254481, + -0.0007927812371875261, + -0.0007895940131414721, + -0.0007862308264409769, + -0.0007827007552211029, + -0.0007790128070121816 ] }, "medium": { - "gamma": 5.079798909719102, - "max_abs_residual": 1.2962992224779812e-16, - "dY_impact_pct_ss": 0.01107062826928443, - "dY_trough_pct_ss": 0.006503345476517193, - "dY_pv": 0.003959063523903593, + "gamma": 9.989373271010116, + "max_abs_residual": 8.592302216947623e-17, + "dY_impact_pct_ss": -0.3287499369660586, + "dY_trough_pct_ss": -0.3287499369660586, + "dY_pv": -0.032774205522912166, "components_pv": { - "consumption_quantity": -0.0001504977703135832, - "consumption_price": 0.00012620131103367114, - "investment": 0.003758260451216295, + "consumption_quantity": 0.0046015175237591735, + "consumption_price": 0.0021812795574096345, + "investment": -0.04280514644941183, "government": 0.0, "portfolio_cost": 0.0, "macropru_tax": 0.0, - "net_exports": 0.00022509953196743276 + "net_exports": 0.003248143845330962 }, "components_impact": { - "consumption_quantity": 0.002055224387722709, - "consumption_price": -0.0003415705589177501, - "investment": -0.0007024887685372447, + "consumption_quantity": 0.0005353192773838877, + "consumption_price": 3.67432985131133e-05, + "investment": -0.0038950993470989554, "government": 0.0, "portfolio_cost": 0.0, "macropru_tax": 0.0, - "net_exports": -0.000900458777574948 + "net_exports": 3.553740154140731e-05 }, "paths": { "consumption_quantity": [ - 0.002055224387722709, - 0.0008429612704631925, - 8.890781153019441e-05, - -0.00034910706366164153, - -0.0005744378025930824, - -0.0006609169747648496, - -0.0006605386214490447, - -0.0006092805302299807, - -0.0005314893991705225, - -0.00044316727487796805, - -0.00035441274209764166, - -0.0002712184781400031, - -0.00019678259664507436, - -0.00013245295560397637, - -7.839834937801101e-05, - -3.4077605236457474e-05, - 1.439179856756237e-06, - 2.9256336555968633e-05, - 5.0514652823121534e-05, - 6.630708311210153e-05, - 7.763144124949856e-05, - 8.536790854508854e-05, - 9.027267989226379e-05, - 9.298162483389508e-05, - 9.401970061385107e-05, - 9.381323124409117e-05, - 9.270316805407448e-05, - 9.095815181373101e-05, - 8.878668333328644e-05, - 8.634803940582708e-05, - 8.376179026572187e-05, - 8.111591443542632e-05, - 7.847359067156952e-05, - 7.587879251670307e-05, - 7.336083124183458e-05, - 7.093799657508842e-05, - 6.862043794177566e-05, - 6.64124165721955e-05, - 6.431404381582438e-05, - 6.232260525958963e-05 + 0.0005353192773838877, + 0.0032002274697876345, + 0.0031092209043806053, + 0.002478052386382991, + 0.0018696063506607693, + 0.0013900107981317422, + 0.0010307603337353858, + 0.000762822764559694, + 0.0005600088282481347, + 0.0004028358137013641, + 0.0002777012681367775, + 0.0001753361694729537, + 8.945571612885847e-05, + 1.5795943444549498e-05, + -4.8555627688723154e-05, + -0.00010560844510231232, + -0.00015676798848197687, + -0.0002030355885467766, + -0.0002451417280401505, + -0.000283633291807856, + -0.0003189313035349792, + -0.00035136976931577046, + -0.00038122148476905666, + -0.000408715088189646, + -0.0004340465822086805, + -0.0004573872856946376, + -0.000478889354080736, + -0.0004986896166560626, + -0.0005169122712787338, + -0.0005336708203416254, + -0.0005490695011591937, + -0.0005632043775753268, + -0.0005761642030854634, + -0.0005880311259187008, + -0.0005988812803082037, + -0.000608785293096696, + -0.0006178087263400123, + -0.0006260124705368447, + -0.0006334530985817768, + -0.0006401831873909339 ], "consumption_price": [ - -0.0003415705589177501, - -0.0003114447790282581, - -0.0002456049139720336, - -0.0001685254020501068, - -9.41307236977622e-05, - -2.9673660830657284e-05, - 2.187350283094905e-05, - 6.014162703273345e-05, - 8.622099621706556e-05, - 0.00010190968174278469, - 0.00010923836634833139, - 0.00011018726077809848, - 0.00010653313079185969, - 9.978044915727187e-05, - 9.114543318587695e-05, - 8.15709117086852e-05, - 7.175737836570534e-05, - 6.220073181278803e-05, - 5.323081703404612e-05, - 4.504736083284159e-05, - 3.775159475268209e-05, - 3.137293853263436e-05, - 2.589073966179629e-05, - 2.125144185717441e-05, - 1.7381751399288695e-05, - 1.4198430390321594e-05, - 1.1615330756869233e-05, - 9.54823221440447e-06, - 7.917978465065914e-06, - 6.652330181071193e-06, - 5.686878136780075e-06, - 4.96529093995954e-06, - 4.439111560169501e-06, - 4.067265813891176e-06, - 3.815403982426615e-06, - 3.655163110789838e-06, - 3.563411194656081e-06, - 3.5215142140034624e-06, - 3.514651725952747e-06, - 3.5311955322078484e-06 + 3.67432985131133e-05, + -3.334538370000608e-05, + -9.171042319027427e-05, + -0.00012010183804900696, + -0.00012396823747788148, + -0.00011187736581302383, + -9.077239333583073e-05, + -6.545120992380776e-05, + -3.8995272668558016e-05, + -1.3279395766975942e-05, + 1.0625494523794443e-05, + 3.2168151332112505e-05, + 5.112558034567216e-05, + 6.747653302891159e-05, + 8.13191177210654e-05, + 9.281772280671426e-05, + 0.00010216928563222983, + 0.00010958225546278211, + 0.00011526380907098653, + 0.00011941236796321745, + 0.0001222135155420529, + 0.00012383807448544085, + 0.00012444152147755573, + 0.00012416420810487137, + 0.00012313205240724301, + 0.00012145748569471291, + 0.00011924051502618961, + 0.00011656981320924635, + 0.00011352378358515652, + 0.00011017157020439924, + 0.00010657399860200629, + 0.00010278444132941596, + 9.884960771717352e-05, + 9.481026033836638e-05, + 9.070186224533585e-05, + 8.655515985976974e-05, + 8.239670672583618e-05, + 7.824933334663815e-05, + 7.413256813216641e-05, + 7.006301418252046e-05 ], "investment": [ - -0.0007024887685372447, - 0.0002625482638270375, - 0.0007312924095928096, - 0.0008953228188917093, - 0.0008828322441540735, - 0.0007778326201522726, - 0.0006335143917619854, - 0.000481978240249069, - 0.00034122144463147265, - 0.00022007246216718042, - 0.00012162140982397487, - 4.555816711812065e-05, - -1.0272141505929207e-05, - -4.8864216496503354e-05, - -7.343018509560525e-05, - -8.70369302575529e-05, - -9.241566424661648e-05, - -9.188208674502354e-05, - -8.732442828831937e-05, - -8.023042063701479e-05, - -7.173410764768162e-05, - -6.267030934104382e-05, - -5.3629266015518476e-05, - -4.500723281103157e-05, - -3.705094218139965e-05, - -2.989519927146437e-05, - -2.3593690057568544e-05, - -1.814354683014745e-05, - -1.350444330567351e-05, - -9.613065853213485e-06, - -6.39378741803248e-06, - -3.766299814120075e-06, - -1.6508652127584055e-06, - 2.8254357147574667e-08, - 1.3407295073580994e-06, - 2.3490879398891327e-06, - 3.10833935296065e-06, - 3.6660758904073753e-06, - 4.062878247252596e-06, - 4.332901992623853e-06 + -0.0038950993470989554, + -0.0025384891463560108, + -0.0018034519329617276, + -0.00138567154096723, + -0.0011468501056874792, + -0.001014456527209238, + -0.0009464655225561038, + -0.0009171608389623084, + -0.0009104233172547351, + -0.0009160993486449101, + -0.0009278362327413694, + -0.0009417245186072035, + -0.0009554289309853176, + -0.000967621933528053, + -0.0009776153831698821, + -0.0009851205026225266, + -0.0009900921816213837, + -0.0009926287018816667, + -0.000992907679018203, + -0.0009911454208452308, + -0.0009875716551523343, + -0.0009824142996004673, + -0.0009758906327301137, + -0.0009682025335874606, + -0.0009595343164441476, + -0.0009500521794607273, + -0.0009399046100196093, + -0.00092922332332245, + -0.0009181244718765653, + -0.0009067099663487181, + -0.0008950688111279087, + -0.0008832783978689547, + -0.0008714057255331092, + -0.0008595085309362625, + -0.0008476363234459978, + -0.0008358313236326688, + -0.0008241293095133954, + -0.0008125603761080573, + -0.0008011496149655495, + -0.0007899177205905317 ], "government": [ 0.0, @@ -659,241 +659,241 @@ 0.0 ], "net_exports": [ - -0.000900458777574948, - -0.0006841726519524708, - -0.0004609969093986351, - -0.00025888547777417893, - -9.04938992588679e-05, - 4.042186972333195e-05, - 0.00013534241182473593, - 0.0001985153942662085, - 0.00023535210371412087, - 0.0002514424245112213, - 0.0002519898763718698, - 0.00024152692640926068, - 0.00022381091771951872, - 0.00020182823281603442, - 0.00017785981870987446, - 0.00015357603208483337, - 0.00013014072792181256, - 0.00010831249672781405, - 8.853637321180684e-05, - 7.102290040925358e-05, - 5.581377798156151e-05, - 4.283469530642526e-05, - 3.193661081818294e-05, - 2.2927070604091786e-05, - 1.5593251029978137e-05, - 9.718319511011626e-06, - 5.0925317592689944e-06, - 1.5202855709782803e-06, - -1.1758491613128378e-06, - -3.1532942280123906e-06, - -4.549162849952265e-06, - -5.4809861703862395e-06, - -6.048081433823411e-06, - -6.333278167459208e-06, - -6.404799452017551e-06, - -6.31815817536309e-06, - -6.117975943578248e-06, - -5.839667976214236e-06, - -5.510963308624883e-06, - -5.15324801924397e-06 + 3.553740154140731e-05, + -0.0005347963333700693, + -0.0006339841275007261, + -0.0005895294506975439, + -0.0005031667300601707, + -0.00040794858737469195, + -0.0003152734054999909, + -0.00022925898711346603, + -0.00015133522745503782, + -8.180826612802436e-05, + -2.0464482628220523e-05, + 3.31601961985653e-05, + 7.964880886201058e-05, + 0.00011962809537108118, + 0.00015372621956770232, + 0.0001825482692143547, + 0.00020666290492001417, + 0.00022659612801984426, + 0.00024282935792723707, + 0.00025579974870778053, + 0.00026590194851357245, + 0.00027349075561020075, + 0.00027888411677701966, + 0.00028236619097583284, + 0.00028419038593082145, + 0.0002845822908019697, + 0.0002837424337291018, + 0.0002818488240937067, + 0.0002790592702497766, + 0.0002755134806591348, + 0.00027133496186710975, + 0.00026663272913176754, + 0.000261502846116851, + 0.00025602980913119115, + 0.0002502877901302754, + 0.00024434175177060293, + 0.0002382484470683463, + 0.00023205731528471108, + 0.0002258112846026338, + 0.0002195474911186301 ] }, "dY_path": [ - 0.0001107062826928443, - 0.00010989210330959793, - 0.00011359839775220564, - 0.00011880487540579081, - 0.00012376981860433447, - 0.00012766385428009737, - 0.00013019168496855802, - 0.0001313547313180383, - 0.00013130514539212728, - 0.0001302572935432149, - 0.00012843691044650004, - 0.00012605387616546933, - 0.00012328931036034856, - 0.00012029150987278594, - 0.00011717671742213566, - 0.0001140324082995081, - 0.00011092162189766449, - 0.00010788747835154043, - 0.0001049574147806518, - 0.0001021469237171723, - 9.94627063360498e-05, - 9.690523304309708e-05, - 9.447076435671546e-05, - 9.21529044841331e-05, - 8.99437608617127e-05, - 8.783478187396354e-05, - 8.581734051265264e-05, - 8.388312276897105e-05, - 8.202436933136677e-05, - 8.023400950567383e-05, - 7.850571813450898e-05, - 7.68339193908734e-05, - 7.521375558514577e-05, - 7.364103452027956e-05, - 7.211216527960076e-05, - 7.062408945040191e-05, - 6.917421254581009e-05, - 6.776033870039344e-05, - 6.638061048039715e-05, - 6.503345476517193e-05 + -0.003287499369660586, + 9.359660636153004e-05, + 0.0005800744207278655, + 0.0003827495566692141, + 9.562127743524676e-05, + -0.00014427168226519816, + -0.000321750987656525, + -0.00044904827143997403, + -0.0005407449891301961, + -0.0006083511968385505, + -0.0006599739527089764, + -0.0007010600016035829, + -0.000735198825648778, + -0.0007647213616834656, + -0.0007911256735698806, + -0.0008153629557037919, + -0.00083802797955113, + -0.0008594859069458172, + -0.0008799562400601318, + -0.0008995665959820758, + -0.000918387494631668, + -0.0009364552388206139, + -0.0009537864792445737, + -0.0009703872226963903, + -0.0009862584603147853, + -0.0010013996886586768, + -0.0010158110153450375, + -0.001029494302675604, + -0.0010424536893203441, + -0.001054695735826822, + -0.001066229351817993, + -0.0010770656049831039, + -0.0010872174747845467, + -0.0010966995873853953, + -0.001105527951378586, + -0.0011137197050990074, + -0.0011212928820592044, + -0.0011282661980135437, + -0.0011346588608125458, + -0.0011404904026803113 ] }, "aggressive": { - "gamma": 12.72601367381867, - "max_abs_residual": 2.26706674266719e-16, - "dY_impact_pct_ss": 0.033769292271885125, - "dY_trough_pct_ss": 0.004323732339885298, - "dY_pv": 0.005022978654971665, + "gamma": 19.875, + "max_abs_residual": 1.6566609195578508e-16, + "dY_impact_pct_ss": 0.6829587035021203, + "dY_trough_pct_ss": -0.154241538787857, + "dY_pv": -0.025901021598861762, "components_pv": { - "consumption_quantity": 0.0008897757848687297, - "consumption_price": 0.00016308545078041982, - "investment": 0.003920147731029582, + "consumption_quantity": 0.028832147191539606, + "consumption_price": 0.0013112479099320628, + "investment": -0.0539328869619838, "government": 0.0, "portfolio_cost": 0.0, "macropru_tax": 0.0, - "net_exports": 4.996968829333592e-05 + "net_exports": -0.002111529738349631 }, "components_impact": { - "consumption_quantity": 0.0026056242645631222, - "consumption_price": -0.0007954832731645461, - "investment": 0.00029452718006621925, + "consumption_quantity": 0.011461196821012334, + "consumption_price": -0.00032632383581705885, + "investment": -0.0019482773573808385, "government": 0.0, "portfolio_cost": 0.0, "macropru_tax": 0.0, - "net_exports": -0.00176697524874602 + "net_exports": -0.0023570085927932098 }, "paths": { "consumption_quantity": [ - 0.0026056242645631222, - 0.0012601618253261925, - 0.00039621314153685187, - -0.00013062794331565854, - -0.0004264909076924639, - -0.0005679279070949206, - -0.0006095036547860448, - -0.000589543755224734, - -0.0005344772220977645, - -0.0004621057883647626, - -0.00038404836619145434, - -0.00030755593308669823, - -0.00023684825982356051, - -0.00017408795518835098, - -0.00012008218742866008, - -7.478116554242736e-05, - -3.762625145645899e-05, - -7.787962381419587e-06, - 1.567572569093045e-05, - 3.371801829503881e-05, - 4.724198720586127e-05, - 5.7066784188253205e-05, - 6.391232471619982e-05, - 6.839595679793096e-05, - 7.103651098466031e-05, - 7.226253359080229e-05, - 7.242254806003009e-05, - 7.179593689728907e-05, - 7.0603563729511e-05, - 6.90176202149547e-05, - 6.717043257100145e-05, - 6.516212823709578e-05, - 6.306716847979469e-05, - 6.093981600535739e-05, - 5.881864097344849e-05, - 5.6730183530252614e-05, - 5.4691892923870107e-05, - 5.271445747851887e-05, - 5.080362965506934e-05, - 4.896163837608873e-05 + 0.011461196821012334, + 0.006419099082468408, + 0.004262160625487857, + 0.003141428028960587, + 0.0024517083555770044, + 0.0019752732279911004, + 0.0016215508798586417, + 0.0013460553037729387, + 0.0011237214034188702, + 0.000939040521736663, + 0.0007819002647893194, + 0.0006455167269224965, + 0.0005252267926398217, + 0.000417774615079435, + 0.0003208410758188888, + 0.0002327403161837467, + 0.00015221785167020618, + 7.831473533956482e-05, + 1.0276269899820209e-05, + -5.250732974230079e-05, + -0.00011054139555773908, + -0.00016425472963471893, + -0.00021401831716731537, + -0.00026015805380023925, + -0.00030296382106082545, + -0.0003426960863020237, + -0.00037959072300964875, + -0.00041386256457210443, + -0.0004457080970910048, + -0.0004753075674461604, + -0.000502826684369888, + -0.0005284180322354004, + -0.0005522222766957763, + -0.0005743692121214424, + -0.0005949786836529369, + -0.000614161407833421, + -0.0006320197103191865, + -0.000648648194063105, + -0.0006641343479689962, + -0.0006785591033258937 ], "consumption_price": [ - -0.0007954832731645461, - -0.0006138006103629854, - -0.0004343297224301297, - -0.00027332716120185733, - -0.00013799623149585795, - -3.0281605564461277e-05, - 5.104491228197291e-05, - 0.00010893035531273528, - 0.0001470701149512355, - 0.00016929381377012398, - 0.00017920191857313494, - 0.00017997453513944804, - 0.000174293965464999, - 0.00016433813666304594, - 0.00015181551721690583, - 0.0001380213092795344, - 0.0001239017194786483, - 0.00011011812105669022, - 9.710639399248637e-05, - 8.512906029614989e-05, - 7.43193527392371e-05, - 6.471733864567987e-05, - 5.6298722327839397e-05, - 4.899720993778869e-05, - 4.272142571884138e-05, - 3.7367351749407274e-05, - 3.2827178303901594e-05, - 2.899534265664632e-05, - 2.577241700427876e-05, - 2.3067391950199033e-05, - 2.0798795832460915e-05, - 1.8894996740156797e-05, - 1.7293954712611043e-05, - 1.5942625818906738e-05, - 1.4796166541199195e-05, - 1.3817044752983266e-05, - 1.2974130959509458e-05, - 1.224181865214215e-05, - 1.159920411796789e-05, - 1.1029342570420587e-05 + -0.00032632383581705885, + -0.0004200860376167555, + -0.000423366359401682, + -0.000388225223347736, + -0.00033646611630965234, + -0.0002785480648569592, + -0.00021999393146874866, + -0.00016383430949452963, + -0.00011169757969526085, + -6.437419056401364e-05, + -2.2143551150046505e-05, + 1.5024503194310425e-05, + 4.734242475602935e-05, + 7.512337775029282e-05, + 9.872938294038555e-05, + 0.00011853870836317252, + 0.000134925871361896, + 0.00014824986668217778, + 0.00015884768672836164, + 0.00016703118539892656, + 0.0001730860640860561, + 0.00017727216483399055, + 0.0001798245243497259, + 0.00018095484720414607, + 0.00018085318621898698, + 0.00017968969228028387, + 0.00017761634527742676, + 0.0001747686134286069, + 0.0001712670129084767, + 0.00016721855513448706, + 0.00016271807831943935, + 0.00015784946535885833, + 0.00015268675313528091, + 0.00014729513982633406, + 0.00014173189747757688, + 0.00013604719731183458, + 0.00013028485515186915, + 0.00012448300402036148, + 0.00011867470055078792, + 0.00011288847135559547 ], "investment": [ - 0.00029452718006621925, - 0.0009353855776877004, - 0.0011615447540680567, - 0.0011466183686604202, - 0.0010041589771174714, - 0.0008066609907712416, - 0.0005982562372701115, - 0.0004039455470430992, - 0.00023619156725251993, - 9.953972770422224e-05, - -6.204953000879759e-06, - -8.384617706188159e-05, - -0.00013740857174381494, - -0.0001712691783643915, - -0.00018964511043924467, - -0.00019631919957672461, - -0.0001945197854017501, - -0.00018689617718359698, - -0.00017554984412493737, - -0.00016209471734968665, - -0.00014772942039562527, - -0.00013331088367128175, - -0.00011942322365178523, - -0.00010643874545828435, - -9.456986996049963e-05, - -8.391196350175081e-05, - -7.4477712795117e-05, - -6.622402785817961e-05, - -5.907258280519228e-05, - -5.29250987832227e-05, - -4.7674389896324195e-05, - -4.321207212709818e-05, - -3.9433701779570504e-05, - -3.624197826108144e-05, - -3.354852459981796e-05, - -3.127465222180049e-05, - -2.935142551172806e-05, - -2.771926609526884e-05, - -2.632727535162571e-05, - -2.5132404774143442e-05 + -0.0019482773573808385, + -0.0016088943342006936, + -0.001346941398560663, + -0.0011905724398866633, + -0.0011163048847522724, + -0.0010965097164899113, + -0.0011097320320956254, + -0.0011411975826121884, + -0.0011812704116994062, + -0.001223900536485955, + -0.0012654329427603268, + -0.001303775063174508, + -0.0013378400674629461, + -0.001367174377817767, + -0.001391714920650528, + -0.0014116306968469656, + -0.0014272208120426851, + -0.0014388496992073984, + -0.0014469065504979944, + -0.0014517802315300733, + -0.0014538445201447766, + -0.0014534499905687014, + -0.0014509200173496923, + -0.0014465493761641793, + -0.001440604474802276, + -0.0014333245390202596, + -0.0014249233059578372, + -0.0014155909478778571, + -0.0014054960630197426, + -0.0013947876368082094, + -0.0013835969171277515, + -0.0013720391735925852, + -0.0013602153268206672, + -0.0013482134433669708, + -0.0013361100981248027, + -0.0013239716099575402, + -0.001311855158563404, + -0.0012998097914623929, + -0.0012878773302085244, + -0.0012760931846884133 ], "government": [ 0.0, @@ -1022,89 +1022,89 @@ 0.0 ], "net_exports": [ - -0.00176697524874602, - -0.001279972603857259, - -0.0008481722069431087, - -0.0004880372552825555, - -0.00020207875576689212, - 1.4379231476422319e-05, - 0.00016978993571444776, - 0.00027411618711094943, - 0.00033740569541223234, - 0.00036896611852430325, - 0.0003769451165440847, - 0.00036817597503288895, - 0.00034818846388572, - 0.0003213134147968019, - 0.00029083410589779185, - 0.0002591535110676121, - 0.00022795841333322247, - 0.0001983696483605626, - 0.00017107327313261412, - 0.00014643099393330336, - 0.00012457035930298334, - 0.0001054565382710059, - 8.894801851125454e-05, - 7.483871991553093e-05, - 6.288897305797789e-05, - 5.284759792850845e-05, - 4.44670233920243e-05, - 3.751308521875358e-05, - 3.177085181576938e-05, - 2.704756489442525e-05, - 2.317355085892479e-05, - 2.000176229434276e-05, - 1.7406446934818147e-05, - 1.5281310439021086e-05, - 1.353743548452832e-05, - 1.2101139267600288e-05, - 1.0911890496818289e-05, - 9.920361552355434e-06, - 9.08665837986637e-06, - 8.3787472264916e-06 + -0.0023570085927932098, + -0.0017273391981675996, + -0.001397379284362996, + -0.001162773334392929, + -0.0009657832637851532, + -0.0007906739563583692, + -0.0006331810722068594, + -0.0004918591204320031, + -0.00036578078576715746, + -0.00025399198340029166, + -0.00015545024179649882, + -6.906754647345437e-05, + 6.246529973055601e-06, + 7.154747796617271e-05, + 0.00012783484762927591, + 0.0001760402316020543, + 0.00021702237684551883, + 0.0002515669611880461, + 0.0002803890333029427, + 0.00030413652415802643, + 0.0003233946649392008, + 0.0003386908197397143, + 0.0003504992567130742, + 0.0003592457441690466, + 0.0003653119541468619, + 0.0003690396074512619, + 0.0003707343086530181, + 0.0003706690559118032, + 0.0003690874385682123, + 0.0003662065429987611, + 0.0003622195883947951, + 0.00035729831486593707, + 0.00035159514530849994, + 0.000345245140610396, + 0.00033836776629269006, + 0.00033106848769435117, + 0.0003234402097887347, + 0.00031556457636022447, + 0.0003075131419617947, + 0.000299348428780145 ] }, "dY_path": [ - 0.00033769292271885126, - 0.00030177418879380295, - 0.00027525596623144345, - 0.0002546260088603972, - 0.00023759308216219363, - 0.0002228307095882666, - 0.0002095874304803614, - 0.0001974483342420458, - 0.00018619015551822696, - 0.00017569387163387533, - 0.00016589371592483533, - 0.00015674840002374922, - 0.0001482255977832959, - 0.00014029441790706782, - 0.00013292232524680356, - 0.00012607445522800517, - 0.00011971409595367954, - 0.00011380362985222358, - 0.0001083055486910941, - 0.00010318335517478988, - 9.840227885244311e-05, - 9.392977743365166e-05, - 8.97358419035006e-05, - 8.579314119297183e-05, - 8.207703980096925e-05, - 7.856551976696816e-05, - 7.523903696084093e-05, - 7.208033691451567e-05, - 6.90742497443635e-05, - 6.62074782763565e-05, - 6.346838936605448e-05, - 6.084681514448876e-05, - 5.8333868347635684e-05, - 5.592177400219427e-05, - 5.360371839935174e-05, - 5.1373715329029596e-05, - 4.92264888684642e-05, - 4.715737158774413e-05, - 4.516221680126459e-05, - 4.323732339885298e-05 + 0.006829587035021203, + 0.0026627795124833445, + 0.0010944735831625047, + 0.0003998570313332682, + 3.3154090729934255e-05, + -0.00019045850971416723, + -0.00034135615591254136, + -0.00045083570876589235, + -0.0005350273737429233, + -0.0006032261887135714, + -0.0006611264709175205, + -0.0007123013795311862, + -0.0007590243200940269, + -0.0008027289070218155, + -0.000844309614262004, + -0.0008843114406979982, + -0.0009230547121650931, + -0.0009607181359975813, + -0.0009973935605668725, + -0.0010331198517155235, + -0.0010679051866770931, + -0.001101741735629784, + -0.0011346145534541494, + -0.0011665068385911823, + -0.0011974031554973549, + -0.001227291325590656, + -0.0012561633750370002, + -0.0012840158431096829, + -0.0013108497086339738, + -0.0013366701061211664, + -0.001361485934783435, + -0.0013853094256031703, + -0.0014081557050726512, + -0.0014300423750516797, + -0.00145098911800743, + -0.0014710173327848151, + -0.0014901498039419736, + -0.0015084104051448924, + -0.0015258238356649681, + -0.00154241538787857 ] } } diff --git a/experiments/results/e3_writeoff_s1.json b/experiments/results/e3_writeoff_s1.json index 9a25fd7..a234b24 100644 --- a/experiments/results/e3_writeoff_s1.json +++ b/experiments/results/e3_writeoff_s1.json @@ -1,56 +1,56 @@ { "provenance": { - "generated": "2026-08-03T12:32:16", - "git_sha": "de03a8e", + "generated": "2026-08-18T12:06:02", + "git_sha": "ea23e94", "git_dirty": true, - "cal_fingerprint": "de195df2", + "cal_fingerprint": "86c89892", "cache_schema": 3, "BANK_SCOPE": "broad", "EBA_CALIBRATION": true, - "psi_lambda_B_D": 8.5, + "psi_lambda_B_D": 0.0, "mv_rule_D": 0.0, "recovery_rate_D": 0.3, "writeoff_enabled_D": 0.0, - "zeta_writeoff_D": 0.0, + "zeta_writeoff_D": 1.0, "phi_lamb_D": 0.15, "delta_b_D": 0.07770055706859433 }, "gammas": { "passive": 0.0, - "medium": 5.079798909719102, - "aggressive": 12.72601367381867 + "medium": 9.989373271010116, + "aggressive": 25.97 }, "gamma_note": "Solved on the BASELINE (0/25/50% peak-spread compression) and held fixed across variants, so a difference in the table is attributable to the writeoff switch alone. Under e3b_full the peak spread is not monotone in gamma, so these targets are not even well-defined there \u2014 see compression.", "baseline": { - "EL_price_D": 0.05613386543752716, - "A_cb_impact": -0.018889149864477355, + "EL_load_D": 0.7013999999999925, + "A_cb_impact": -0.004397083388715891, "regimes": { "passive": { "gamma": 0.0, - "peak_spread_bp_ann": 150.30634590442185, - "Y_D_impact_pct_ss": -0.014942910216657817, - "n_inter_D_impact_pct_ss": -3.3804095383737423, + "peak_spread_bp_ann": 205.87042801449053, + "Y_D_impact_pct_ss": -1.9741762806656946, + "n_inter_D_impact_pct_ss": -11.407334960944704, "expected_loss_pv_pct_Y": 0.0, "premium_pv_pct_Y": 0.0, "loading": null }, "medium": { - "gamma": 5.079798909719102, - "peak_spread_bp_ann": 112.72975942499305, - "Y_D_impact_pct_ss": 0.01107062826928443, - "n_inter_D_impact_pct_ss": -2.1672414829333886, - "expected_loss_pv_pct_Y": 0.0022755755177235226, - "premium_pv_pct_Y": 0.009093587286078345, - "loading": 3.996170294174871 + "gamma": 9.989373271010116, + "peak_spread_bp_ann": 154.40282102077668, + "Y_D_impact_pct_ss": -0.3287499369660586, + "n_inter_D_impact_pct_ss": -5.541404513508157, + "expected_loss_pv_pct_Y": 0.2703589099615343, + "premium_pv_pct_Y": 0.13021894894985664, + "loading": 0.4816521451739236 }, "aggressive": { - "gamma": 12.72601367381867, - "peak_spread_bp_ann": 75.15317295827965, - "Y_D_impact_pct_ss": 0.033769292271885125, - "n_inter_D_impact_pct_ss": -1.0988157952017568, - "expected_loss_pv_pct_Y": 0.003914458747579844, - "premium_pv_pct_Y": 0.012397842482955569, - "loading": 3.1671920136138016 + "gamma": 25.97, + "peak_spread_bp_ann": 109.87043454482074, + "Y_D_impact_pct_ss": 1.1470465088832194, + "n_inter_D_impact_pct_ss": -0.7068381084835311, + "expected_loss_pv_pct_Y": 0.5013182365182419, + "premium_pv_pct_Y": 0.23466863741295219, + "loading": 0.46810313353604305 } } }, @@ -100,54 +100,54 @@ 15.0 ], "peak_spread_bp_ann": [ - 150.30634590442185, - 147.2990716769984, - 144.2939040184335, - 141.29790826402055, - 138.31811710159087, - 135.36145249891297, - 132.43462468495142, - 129.54402183190152, - 126.69560257415893, - 123.89480124110673, - 121.1464528576843, - 118.4547419318315, - 115.82317618403468, - 113.25458399075073, - 110.75113258977633, - 108.31436307330353, - 105.9452378048277, - 103.64419600384014, - 101.41121368819188, - 99.24586479799368, - 97.14738102478088, - 95.11470854830574, - 93.14656048708389, - 91.24146437149685, - 89.39780434342744, - 87.61385808035055, - 85.88782864761393, - 84.21787161615761, - 82.60211785998574, - 81.03869248260553, - 79.52573032655242, - 78.06138850473943, - 76.64385636424062, - 75.27136325784332, - 73.94218446024375, - 72.65464552693514, - 71.40712535635535, - 70.19805818090691, - 69.02593468053705, - 67.88930238399558 + 205.87042801449053, + 203.3293246462571, + 200.84401077880818, + 198.41280510565036, + 196.03408031849557, + 193.7062003602614, + 191.4288986324153, + 189.1980270200708, + 187.01430248501998, + 184.87590062985976, + 182.78150292898968, + 180.7298589027375, + 178.7197667626565, + 176.75006856478998, + 174.81964763635656, + 172.92742661888389, + 171.07236576079418, + 169.25346135065723, + 167.4697442509962, + 165.72027851474786, + 164.00416007482062, + 162.3205155007242, + 160.66850081800084, + 159.04730038708803, + 157.45612583887635, + 155.894215064634, + 154.3608312583393, + 152.85526200977492, + 151.37681844706117, + 149.92483442763222, + 148.49866577699754, + 147.09768957503988, + 145.72130349002555, + 144.36892516101577, + 143.0399916299937, + 141.73395882571347, + 140.4503011022033, + 139.18851083591008, + 137.9480980868653, + 136.7285903309471 ], "first_violation_gamma": null, "n_violations": 0, - "peak_at_gamma0_bp": 150.30634590442185, - "min_peak_bp": 67.88930238399558, + "peak_at_gamma0_bp": 205.87042801449053, + "min_peak_bp": 136.7285903309471, "gamma_at_min_peak": 15.0 }, - "e3a_coupon_only": { + "e3a_realised_writeoff": { "monotone_decreasing": true, "gamma_grid": [ 0.0, @@ -192,55 +192,55 @@ 15.0 ], "peak_spread_bp_ann": [ - 149.0668467666693, - 146.22569018401356, - 143.37645046552268, - 140.52621515708063, - 137.6820733831892, - 134.8510611217428, - 132.04007423362856, - 129.2557636264456, - 126.50442585095537, - 123.79190055561936, - 121.1234836249744, - 118.50386180645097, - 115.93707158688233, - 113.42648238137842, - 110.97480199578469, - 108.58410092780969, - 106.25585135985199, - 103.99097655771574, - 101.789906669657, - 99.65263746088029, - 97.57878918235032, - 95.56766345549003, - 93.61829668704839, - 91.72950907270175, - 89.8999486887529, - 88.12813050923494, - 86.41247043088104, - 84.75131455523048, - 83.14296408119439, - 81.58569621749649, - 80.07778154538087, - 78.6174982584449, - 77.20314368679645, - 75.83304348326499, - 74.50555881477452, - 73.21909186546203, - 71.97208992193961, - 70.76304827660543, - 69.590512153013, - 68.45307782833885 + 122.72088047986382, + 121.71051962843686, + 120.71430371517967, + 119.73198355087419, + 118.76331256860965, + 117.80802678643344, + 116.86607216652578, + 115.93691249054667, + 115.02050877953737, + 114.11661191929717, + 113.22500169506229, + 112.34546422428215, + 111.47779020382853, + 110.62177450417883, + 109.77721599978925, + 108.94391746188018, + 108.1216854724761, + 107.31033034724075, + 106.50966606264885, + 105.71951018569771, + 104.93968380536046, + 104.17001146541362, + 103.41032109844915, + 102.66044396098324, + 101.92021456960696, + 101.18947063816334, + 100.46805301593211, + 99.75580562682224, + 99.05257540956778, + 98.35821225893348, + 97.67256896792395, + 96.9955011710064, + 96.3268672883432, + 95.66652847103741, + 95.01434854739782, + 94.37019397021712, + 93.7339337650712, + 93.10543947964045, + 92.48458513405325, + 91.8712471722557 ], "first_violation_gamma": null, "n_violations": 0, - "peak_at_gamma0_bp": 149.0668467666693, - "min_peak_bp": 68.45307782833885, + "peak_at_gamma0_bp": 122.72088047986382, + "min_peak_bp": 91.8712471722557, "gamma_at_min_peak": 15.0 }, - "e3b_full": { - "monotone_decreasing": false, + "e3b_coupon_only_pricing": { + "monotone_decreasing": true, "gamma_grid": [ 0.0, 0.38461538461538464, @@ -284,127 +284,127 @@ 15.0 ], "peak_spread_bp_ann": [ - 168.93177755629583, - 169.61277507505991, - 168.66851908306904, - 166.9752036000209, - 164.80071602654215, - 162.2094049986996, - 159.10109717490107, - 154.9100246148256, - 144.43683024251547, - 166.6273809812974, - 154.80040109836057, - 150.12878208215554, - 146.40206978862105, - 142.98052131162785, - 139.7050688409527, - 136.524455175035, - 133.4215726627391, - 130.39195313730272, - 127.43595679313948, - 124.5555529778706, - 121.75291337147762, - 119.02980445219454, - 116.38735756674689, - 113.82602267554418, - 111.34561002157804, - 108.94537032110753, - 106.62408754655394, - 104.38017086569556, - 102.21173915410705, - 100.11669529732329, - 98.0927895856145, - 96.13767260888002, - 94.24893859747041, - 92.4241603687415, - 90.66091707199168, - 88.95681586121182, - 87.30950851591132, - 85.7167039043608, - 84.17617705715325, - 82.68577550066189 + 12.33868147879263, + 12.27018055194768, + 12.20226748870213, + 12.134936479375957, + 12.068181620190135, + 12.001997844018444, + 11.936378684547131, + 11.871318895337017, + 11.806812893344533, + 11.742855163088096, + 11.679440240079192, + 11.616562708956598, + 11.554217203048943, + 11.492398404210098, + 11.431101042719202, + 11.370319897192685, + 11.310049794491725, + 11.25028560961888, + 11.191022265602738, + 11.132254733369269, + 11.073978031600348, + 11.016187226579238, + 10.958877432024114, + 10.902043808909264, + 10.84568156527547, + 10.78978595602895, + 10.734352282730242, + 10.679375893373358, + 10.624852182154886, + 10.570776589234995, + 10.517144600489228, + 10.463951747252892, + 10.411193606057298, + 10.358865798359234, + 10.306963990263581, + 10.255483892239345, + 10.204421258830072, + 10.153771888358337, + 10.10353162262516, + 10.053696346604593 ], - "first_violation_gamma": 0.38461538461538464, - "n_violations": 2, - "peak_at_gamma0_bp": 168.93177755629583, - "min_peak_bp": 82.68577550066189, + "first_violation_gamma": null, + "n_violations": 0, + "peak_at_gamma0_bp": 12.33868147879263, + "min_peak_bp": 10.053696346604593, "gamma_at_min_peak": 15.0 } }, "variants": { - "e3a_coupon_only": { - "EL_price_D": 0.05613386543752716, - "A_cb_impact": -0.01893646629019237, + "e3a_realised_writeoff": { + "EL_load_D": 0.7013999999999925, + "A_cb_impact": -0.003662984738264572, "regimes": { "passive": { "gamma": 0.0, - "peak_spread_bp_ann": 149.0668467666693, - "Y_D_impact_pct_ss": -0.014872922195301624, - "n_inter_D_impact_pct_ss": -3.3725555736156285, + "peak_spread_bp_ann": 122.72088047986382, + "Y_D_impact_pct_ss": -0.7970798113762646, + "n_inter_D_impact_pct_ss": -6.18899449990734, "expected_loss_pv_pct_Y": 0.0, "premium_pv_pct_Y": 0.0, "loading": null }, "medium": { - "gamma": 5.079798909719102, - "peak_spread_bp_ann": 112.91289373572302, - "Y_D_impact_pct_ss": 0.010363465538216663, - "n_inter_D_impact_pct_ss": -2.1976721913858137, - "expected_loss_pv_pct_Y": 0.002202439059159868, - "premium_pv_pct_Y": 0.00866586412289881, - "loading": 3.934666926132544 + "gamma": 9.989373271010116, + "peak_spread_bp_ann": 100.48786152528533, + "Y_D_impact_pct_ss": 0.031243305858771366, + "n_inter_D_impact_pct_ss": -3.334623811574779, + "expected_loss_pv_pct_Y": 0.0994666239570081, + "premium_pv_pct_Y": 0.03559348887768675, + "loading": 0.35784354049325257 }, "aggressive": { - "gamma": 12.72601367381867, - "peak_spread_bp_ann": 75.71502841336199, - "Y_D_impact_pct_ss": 0.03292462825031287, - "n_inter_D_impact_pct_ss": -1.1364199100666166, - "expected_loss_pv_pct_Y": 0.0038200382276959606, - "premium_pv_pct_Y": 0.011941430928579497, - "loading": 3.1259977562533243 + "gamma": 25.97, + "peak_spread_bp_ann": 77.08535738430909, + "Y_D_impact_pct_ss": 0.9001673209051243, + "n_inter_D_impact_pct_ss": -0.39927764495856477, + "expected_loss_pv_pct_Y": 0.19281520255438822, + "premium_pv_pct_Y": 0.061582748898847094, + "loading": 0.3193874138709378 } } }, - "e3b_full": { - "EL_price_D": 0.7017434754895111, - "A_cb_impact": -0.02158385591990817, + "e3b_coupon_only_pricing": { + "EL_load_D": 0.055790389948008515, + "A_cb_impact": -0.002385720558625707, "regimes": { "passive": { "gamma": 0.0, - "peak_spread_bp_ann": 168.93177755629583, - "Y_D_impact_pct_ss": -0.011999710852482292, - "n_inter_D_impact_pct_ss": -3.4799875802285865, + "peak_spread_bp_ann": 12.33868147879263, + "Y_D_impact_pct_ss": -0.12057295041189345, + "n_inter_D_impact_pct_ss": -0.6981577883718075, "expected_loss_pv_pct_Y": 0.0, "premium_pv_pct_Y": 0.0, "loading": null }, "medium": { - "gamma": 5.079798909719102, - "peak_spread_bp_ann": 142.29145370566098, - "Y_D_impact_pct_ss": 0.009016964878320703, - "n_inter_D_impact_pct_ss": -2.767772539528327, - "expected_loss_pv_pct_Y": 0.02695796593541321, - "premium_pv_pct_Y": 0.009876384785818905, - "loading": 0.3663623883745931 + "gamma": 9.989373271010116, + "peak_spread_bp_ann": 10.735877723130022, + "Y_D_impact_pct_ss": -0.02274766379103581, + "n_inter_D_impact_pct_ss": -0.38746899990928035, + "expected_loss_pv_pct_Y": 0.0013613004619915312, + "premium_pv_pct_Y": 0.0006577927470658292, + "loading": 0.4832090823678287 }, "aggressive": { - "gamma": 12.72601367381867, - "peak_spread_bp_ann": 92.26721246917202, - "Y_D_impact_pct_ss": 0.033558572778292786, - "n_inter_D_impact_pct_ss": -1.4833505943172207, - "expected_loss_pv_pct_Y": 0.04542480071547009, - "premium_pv_pct_Y": 0.012711272774031397, - "loading": 0.27983111810774297 + "gamma": 25.97, + "peak_spread_bp_ann": 8.78656041935721, + "Y_D_impact_pct_ss": 0.0961818377266989, + "n_inter_D_impact_pct_ss": -0.01563317094414377, + "expected_loss_pv_pct_Y": 0.0030549307045721385, + "premium_pv_pct_Y": 0.0013260328931101709, + "loading": 0.4340631658602187 } } } }, "checks": { - "e3a_coupon_only": { - "EL_price_expected": 0.05613386543752716, - "EL_price_actual": 0.05613386543752716, - "EL_price_vs_baseline_ratio": 1.0, + "e3a_realised_writeoff": { + "EL_load_expected": 0.7013999999999925, + "EL_load_actual": 0.7013999999999925, + "EL_load_vs_baseline_ratio": 1.0, "max_ss_drift": 0.0, "ss_drift": { "q_b_D_ss": 0.0, @@ -420,10 +420,10 @@ "b_D_D_ss": 0.0 } }, - "e3b_full": { - "EL_price_expected": 0.7017434754895111, - "EL_price_actual": 0.7017434754895111, - "EL_price_vs_baseline_ratio": 12.501249825214686, + "e3b_coupon_only_pricing": { + "EL_load_expected": 0.055790389948008515, + "EL_load_actual": 0.055790389948008515, + "EL_load_vs_baseline_ratio": 0.07954147412034376, "max_ss_drift": 0.0, "ss_drift": { "q_b_D_ss": 0.0, diff --git a/experiments/run_all.py b/experiments/run_all.py index 8731447..88e058b 100644 --- a/experiments/run_all.py +++ b/experiments/run_all.py @@ -45,7 +45,13 @@ def _fmt_loading(v): def _render_e1(e1, L): L += ["## E1 — Backstop schedule", "", _stamp(e1["provenance"]), "", - f"γ selection: {e1['gamma_selection_rule']}.", "", + "γ selection: γ is **solved** for peak-spread compression, not chosen. " + "`medium` = 25% (spec section 7). **`aggressive` is NOT 50%**: since the " + "2026-08-18 GK structural refactor that target lies beyond a closed-loop pole " + "at γ ≈ 27.3, so it falls back to the strongest intervention the model can " + "represent — γ just below the pole, achieving **≈46.6%**. See " + "`common.named_regime_gammas` / `lottery_math.closed_loop_pole`.", "", + f"Rule recorded in the results file at run time: {e1['gamma_selection_rule']}", "", "| regime | γ | peak spread (bp ann) | Y_D[0] (% SS) | C_D[0] (% SS) " "| I_D[0] (% SS) | n_inter_D[0] (% SS) | loading |", "|---|---|---|---|---|---|---|---|"] @@ -124,12 +130,31 @@ def _render_e2(e2, L): L.append("| **dY[0] total** | " + " | ".join( f"**{e2['regimes'][r]['dY_path'][0]:+.3e}**" for r in e2["regimes"]) + " |") - L += ["", "> **The headline output number is the residue of two much larger " - "offsetting channels.** Passive → aggressive, `dY[0]` moves by ~+4.9e−04 while " - "investment moves ~+2.2e−03 and net exports ~−1.9e−03 — each roughly 4× the " - "headline and opposite in sign. This confirms `docs/SPEC.md`'s standing caution " - "as a measured property of this calibration. **Report the decomposition, not " - "the headline ΔY.**", "", + # Computed live, never hardcoded. This caption asserted "+4.9e-04 / +2.2e-03 / + # -1.9e-03, each roughly 4x the headline" until 2026-08-06 — flex-price numbers that + # outlived the sticky-price re-tune. Under sticky prices the ordering REVERSES (the + # headline becomes the larger object), so a hardcoded caption here does not merely go + # stale, it states the opposite of what the table above it shows. + _names = list(e2["regimes"]) + _lo, _hi = e2["regimes"][_names[0]], e2["regimes"][_names[-1]] + _dY = _hi["dY_path"][0] - _lo["dY_path"][0] + _dI = _hi["components_impact"]["investment"] - _lo["components_impact"]["investment"] + _dNX = (_hi["components_impact"]["net_exports"] + - _lo["components_impact"]["net_exports"]) + _big = max(abs(_dI), abs(_dNX)) + _residue = abs(_dY) < _big + _lead = ("**The headline output number is the residue of larger offsetting " + "channels.**" if _residue else + "**The headline output number is no longer a residue of larger offsetting " + "channels — it now exceeds each of them.**") + _rel = (f"each roughly {_big / abs(_dY):.1f}x the headline and opposite in sign" + if _residue else + f"the largest single channel is {_big / abs(_dY):.2f}x the headline") + L += ["", f"> {_lead} {_names[0]} → {_names[-1]}, `dY[0]` moves by " + f"{_dY:+.2e} while investment moves {_dI:+.2e} and net exports {_dNX:+.2e} — " + f"{_rel}. **Report the decomposition, not the headline ΔY** — the channels " + "still offset, and `docs/SPEC.md`'s standing caution is about their " + "cancellation, not about which term happens to be largest.", "", "> `government`, `portfolio_cost` and `macropru_tax` are **verified** zero, not " "merely uncached: `G_D` is constant and absent from the Jacobian, `Phi_D` has no " "Jacobian column (the portfolio adjustment cost is quadratic about its anchor, " @@ -140,31 +165,43 @@ def _render_e2(e2, L): def _render_e3(e3, L): L += ["## E3 — S-1 writeoff", "", _stamp(e3["provenance"]), "", - "`writeoff_enabled` is steady-state-neutral: every writeoff term is multiplied " - "by `def_rate_ss = 0`. `zeta_writeoff` is **not** — it enters the `EL_price` " - "anchor at `code/steady_state.py:107-112` *ungated by* `writeoff_enabled`, and " - "`EL_price` is the loading's denominator. S-1 is therefore two nested variants, " - "not one switch.", "", - "| setting | `writeoff_enabled` | `zeta_writeoff` | EL_price_D | peak spread, " + "The two switches answer different questions. `writeoff_enabled` selects which " + "BRANCH the impulse response traces and is steady-state-neutral: every realised " + "writeoff term is multiplied by `def_rate_ss = 0`. `zeta_writeoff` governs what " + "is PRICED — whether a default writes down the perpetuity's continuation value " + "alongside its coupon. Both are SS-neutral, for the same reason: every writeoff " + "term is multiplied by `def_rate_ss = 0`, inside `rb_exp` as well as inside " + "`rb_actual`. So `zeta_writeoff` is allocation-neutral while still changing the " + "linearised pricing equation, and hence every dynamic result. Since the " + "2026-08-18 refactor the " + "baseline is `zeta_writeoff = 1`; `e3b_coupon_only_pricing` is the §12 Arm-3 " + "diagnostic showing what the pre-refactor coupon-only payoff was worth.", "", + "| setting | `writeoff_enabled` | `zeta_writeoff` | EL_load_D | peak spread, " "passive (bp ann) | loading (medium) | loading (aggressive) |", "|---|---|---|---|---|---|---|"] - flags = {"baseline": ("0", "0.0"), "e3a_coupon_only": ("1", "0.0"), - "e3b_full": ("1", "1.0")} + flags = {"baseline": ("0", "1.0"), "e3a_realised_writeoff": ("1", "1.0"), + "e3b_coupon_only_pricing": ("0", "0.0")} for name, r in [("baseline", e3["baseline"])] + list(e3["variants"].items()): we, ze = flags.get(name, ("?", "?")) - L.append(f"| {name} | {we} | {ze} | {r['EL_price_D']:.6f} | " + L.append(f"| {name} | {we} | {ze} | {r['EL_load_D']:.6f} | " f"{r['regimes']['passive']['peak_spread_bp_ann']:.1f} | " f"{_fmt_loading(r['regimes']['medium']['loading'])} | " f"{_fmt_loading(r['regimes']['aggressive']['loading'])} |") - L += ["", f"γ note: {e3['gamma_note']}", "", - "### The headline: full writeoff inverts Live Claim 1", "", - "Under `zeta_writeoff = 1` the loading falls **below 1** — the CB becomes " - "*under*-compensated, receiving roughly 30% of the actuarially fair expected " - "loss, where the paper's central claim is over-compensation. The mechanism is " - "attributable to the denominator alone: premium income barely moves while the " - "priced expected loss goes up by an order of magnitude. It is a repricing of " - "the expected loss, not a change in what the CB earns.", "", + L += ["", "γ note: solved on the BASELINE and held fixed across variants, so a " + "difference in the table is attributable to the switch alone. `medium` = 25% " + "peak-spread compression; **`aggressive` is ≈46.6%, not 50%** — see the E1 note " + "above.", "", + f"Note recorded in the results file at run time: {e3['gamma_note']}", "", + "### What the payoff specification is worth", "", + "`EL_load_D` is the expected loss per unit of default probability implied by " + "the bond contract. Coupon-only pricing (`zeta_writeoff = 0`) puts it at " + "`(1-rec)·delta_b/q_b`; full pricing puts it at " + "`(1-rec)·[delta_b + (1-delta_b)q_b]/q_b`, larger by roughly " + "`[delta_b + (1-delta_b)q_b]/delta_b ≈ 12.6` on a 12.9-quarter claim. Read the " + "loading column with that denominator in mind: it is premium income per unit of " + "expected loss ABSORBED, so a bigger, better-specified loss lowers it " + "mechanically without the central bank earning any less.", "", "| variant | regime | EL PV (% Y) | premium PV (% Y) | loading |", "|---|---|---|---|---|"] for name, r in [("baseline", e3["baseline"])] + list(e3["variants"].items()): @@ -175,19 +212,20 @@ def _render_e3(e3, L): f"{v['premium_pv_pct_Y']:.5f} | {_fmt_loading(v['loading'])} |") L += ["", "### Verification", "", - "| variant | EL_price (closed form) | EL_price (solved) | ×baseline | max SS drift |", + "| variant | EL_load (closed form) | EL_load (solved) | ×baseline | max SS drift |", "|---|---|---|---|---|"] for name, c in e3["checks"].items(): - L.append(f"| {name} | {c['EL_price_expected']:.6f} | {c['EL_price_actual']:.6f} | " - f"{c['EL_price_vs_baseline_ratio']:.2f}× | {c['max_ss_drift']:.3e} |") + L.append(f"| {name} | {c['EL_load_expected']:.6f} | {c['EL_load_actual']:.6f} | " + f"{c['EL_load_vs_baseline_ratio']:.2f}× | {c['max_ss_drift']:.3e} |") L += ["", "Both variants require a full SS + Jacobian re-solve: patching the solved " - "SS and re-solving only the Jacobian would presume the very invariance E3a " - "exists to test.", "", - "> **Measured SS drift is 0.000e+00 for BOTH variants**, which refines the " - "design spec's prediction that E3b \"moves the steady state\". `EL_price` " - "changes value 12.5×, but no steady-state *allocation* moves: it multiplies " - "`def_rate`, which is 0 at SS. So it is allocation-neutral while still " - "changing the linearised bond FOC, and hence every dynamic result.", ""] + "SS and re-solving only the Jacobian would presume the very invariance these " + "variants exist to test.", "", + "> **Both variants must show zero SS drift.** Measured 2026-08-18: `q_b_D = " + "0.974906` in all three arms. Every writeoff term — priced or realised — is " + "multiplied by `def_rate_ss = 0`, so neither switch moves an allocation. What " + "`zeta_writeoff` does move is `EL_load_D` (0.0558 -> 0.7014, 12.6x) and hence " + "the linearised pricing equation: peak spread on a 1pp shock goes 12.3bp -> " + "205.9bp. Allocation-neutral, dynamically decisive.", ""] comp = e3.get("compression") if comp: @@ -212,7 +250,11 @@ def _render_e3(e3, L): "be defined under this setting, which is why every row above is evaluated " "at the baseline's γ held fixed.", ""] - L += ["> `psi_lambda_B = 8.5` was tuned to 150 bp with realised losses **off**. The " + # Read the amplification live: this caption hardcoded "8.5" until 2026-08-06 and + # silently outlived the sticky-price re-tune to 7.85, asserting a stale number in + # a GENERATED document. Every parameter quoted in prose must come from provenance. + L += [f"> `psi_lambda_B = {e3['provenance']['psi_lambda_B_D']:g}` was tuned to 150 bp " + "with realised losses **off**. The " "overshoot above is a reportable fact about whether that target survives S-1 — " "**not** a number to re-tune away. Whether to re-tune is a separate author " "decision this result informs.", ""]