diff --git a/CLAUDE.md b/CLAUDE.md index 25d0a7e..997487c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,11 +34,19 @@ 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 - `deposit_mkt_D/F` ≤ 1e−13 +- `nkpc_p_res_D/F`, `tot_res`, `union_pi_res` — exactly `0.000000e+00` at SS **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 @@ -62,8 +70,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 @@ -80,11 +117,20 @@ The paper's standard results set. **`code/` is deliberately untouched by this pa - `experiments/e3_writeoff_s1.py` — the S-1 writeoff variants. - `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 +153,24 @@ 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. +- **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. This is what generates MTM capital losses on bank balance sheets. - **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 +181,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. | +| **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 3.82/2.90 → 3.77/2.87), but adding `zeta_writeoff=1` takes `EL_price_D` 0.0561→0.7017 (12.5×) and takes the loading to **2.46 (medium) / 0.26 (aggressive)**. **Re-measured on the sticky-price model 2026-08-06: the inversion is now only PARTIAL — medium holds above 1 at 2.46, and only the aggressive regime falls below at 0.26.** The flex-price model had both below 1 (0.37/0.28); do not quote those. Full writeoff 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 under strong intervention**, 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. | +| **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.** `psi_lambda_B_D/F = 7.85` — re-tuned 2026-08-06 (was 8.5) because sticky prices and the Fisher channel pushed peak spread to 162bp; bisected back to **150.14bp**, within 1bp of the paper's moment. `n_inter = 2.138/1.627`, `omega_K = 1.0`, `phi_lamb = 0.15`, `mv_rule = 0`, `delta_b = 0.0777/0.0568` (measured maturity ladder), `recovery_rate = 0.30` (EL-1), `EL_price_D = 0.056134`. **New with `add-nkpc`:** `mu_p_D/F = 1.20`, `mc_D/F = 1/1.20` (subsidy neutralisation — this is what 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 3%), `pi_D/F = 0.0`, `omega_pi_D = 0.071`, `i_dep_D/F = 0.000` (renamed from `rdep_D/F`, which is now solved). Current dynamics on a 1pp default shock: peak spread **150.0bp**, `Y_D[0] = −0.5064%`, `C_D[0] = −0.5103%`, `n_inter_D[0] = −4.2962%`, TPI loading **3.82/2.90** declining. The `psi_lambda_B` breakdown region is ~14–18 at this scope, so 7.85 has ample margin and lowering it moves *away* from the pole. | | **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: `−4.2962%` and `−0.5064%` of SS. 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. @@ -160,5 +216,7 @@ See `docs/STATE.md` for the full calibration table. Key tensions: | `docs/bank_cal_review.md` | bank-cal branch analysis; calibration porting roadmap | | `docs/verification_report.md` | Post-fix numerical verification with residual tables | | `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/code/calibration.py b/code/calibration.py index 412fa38..e65f38f 100644 --- a/code/calibration.py +++ b/code/calibration.py @@ -67,7 +67,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 +80,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 @@ -146,8 +156,50 @@ 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. + 'psi_lambda_B_D': 2.92 if EBA_CALIBRATION else 3.0, + 'psi_lambda_B_F': 2.92 if EBA_CALIBRATION else 3.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), @@ -247,6 +299,29 @@ def eba_or(key, pre_eba): 'zeta_writeoff_D': 0.0, 'zeta_writeoff_F': 0.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 @@ -278,10 +353,37 @@ def eba_or(key, pre_eba): 'psi_bF_D': 0.5, 'psi_bD_F': 0.5, # ── 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, 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..1da2ed4 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 @@ -251,14 +282,54 @@ def bond_return_D(def_rate_D, recovery_rate_D, q_b_D, delta_b_D, zeta_writeoff_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,11 +361,57 @@ 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 intermediation_IC_D(nu_K_D, nu_bD_D, nu_bF_D, eta_D, @@ -323,28 +440,32 @@ 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 diff --git a/code/equations_F.py b/code/equations_F.py index fea45e7..510d080 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 @@ -220,12 +226,40 @@ def bond_return_F(def_rate_F, recovery_rate_F, q_b_F, delta_b_F, zeta_writeoff_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,12 +277,25 @@ 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 @@ -273,7 +320,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,21 +331,26 @@ 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 diff --git a/code/equations_global.py b/code/equations_global.py index aedb1e6..49f7ecf 100644 --- a/code/equations_global.py +++ b/code/equations_global.py @@ -44,6 +44,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 diff --git a/code/full_model.py b/code/full_model.py index bc16a4e..6e2db51 100644 --- a/code/full_model.py +++ b/code/full_model.py @@ -11,9 +11,10 @@ intermediation_P2_D, banker_div_res_D, intermediation_P3_D, government_default_D, divert_bond_foc_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, @@ -22,17 +23,123 @@ intermediation_P2_F, banker_div_res_F, intermediation_P3_F, government_default_F, divert_bond_foc_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, 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, 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, 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, divert_portfolio_adj, + divert_bond_foc_D, divert_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) @@ -66,44 +173,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 +210,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), @@ -172,7 +271,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..4fe6074 100644 --- a/code/steady_state.py +++ b/code/steady_state.py @@ -8,19 +8,22 @@ 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, + 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, + 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, 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 @@ -154,13 +157,15 @@ def solve_steady_state(calibration_start): 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, 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} diff --git a/code/test_nkpc_blocks.py b/code/test_nkpc_blocks.py new file mode 100644 index 0000000..2a0dac1 --- /dev/null +++ b/code/test_nkpc_blocks.py @@ -0,0 +1,279 @@ +"""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 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, sorted(blk.inputs)) + assert 'rdep_expost_D' not in blk.inputs, (blk.name, sorted(blk.inputs)) + + +# ── 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..b135144 100644 --- a/code/tpi.py +++ b/code/tpi.py @@ -101,6 +101,16 @@ def external_account_D_tpi(NX_D, q_b_D, q_b_F, b_F_D, b_D_F, rb_actual_F, rb_act 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 +152,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 +174,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.") 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/regime_model.py b/diagnostics/regimes/regime_model.py index 8b1b250..d742e56 100644 --- a/diagnostics/regimes/regime_model.py +++ b/diagnostics/regimes/regime_model.py @@ -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): diff --git a/diagnostics/regimes/regimes_log.md b/diagnostics/regimes/regimes_log.md index fe8d16a..af00347 100644 --- a/diagnostics/regimes/regimes_log.md +++ b/diagnostics/regimes/regimes_log.md @@ -570,3 +570,189 @@ 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'] 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..3f83978 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,8 +1,99 @@ # Handoff Notes +## Open decision: Live Claim 5 + +The MS-disciplined shock persistence fixed the crisis dynamics (cumulative Y over +40q: −0.049 -> −2.542) but flattened the loading schedule from a 66% decline to +19%, with the floor at 4.59 rather than 1.49. The self-extinguishing-premium +framing needs re-deriving against what a 19% decline actually supports before it +goes in the draft. See docs/STATE.md. + ## Where to start -- Working branch: `main`. Production entry point: `code/main.py` (orchestrates +- **FIRST: regenerate the downstream artefacts. They are stale.** `rho_def` was + disciplined at **0.9408** and `psi_lambda_B` re-tuned **7.85 → 2.92** on + 2026-08-06 (see `docs/STATE.md` → *`rho_def` disciplined by the MS regime + estimate*). `code/main.py` is verified against the new calibration, but E1–E4 + and every paper artefact still reflect the old one. 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.60% fundamental / 91.40% collateral friction** + (`EL_price_D = 0.056134`, `psi_spread_D = 0.596959`), a ratio of 10.63:1. It + was 3.4% / 96.6% (28.6:1). 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 +125,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 +153,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 +186,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 +206,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 +257,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 +405,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..fef76af 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -14,6 +14,387 @@ and `.githooks/pre-commit` (terminal commits; enable with --- +## 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..dd1a530 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -1,9 +1,678 @@ # 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:** `add-nkpc` | **Date:** 2026-08-06 | **Status:** **Sticky prices with nominal deposit contracts are the baseline.** 27×27 solver system; `rho_def` promoted to the calibration and disciplined at **0.9408** by the repo's own Markov-switching estimate; `psi_lambda_B` re-tuned to **2.92** to hold the 150bp spread moment. EBA calibration remains LIVE (`EBA_CALIBRATION=True`, `BANK_SCOPE="broad"`). **E1–E4 and the paper artefacts are now STALE** — they were regenerated against `psi_lambda_B = 7.85` / `rho_def = 0.80`. + +## `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 +738,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 +971,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 +1164,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 +1198,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/experiments_results.md b/docs/experiments_results.md index c38ec35..f054523 100644 --- a/docs/experiments_results.md +++ b/docs/experiments_results.md @@ -1,20 +1,20 @@ # 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-06T17:39:25 from `ec4f862` · **working tree DIRTY** · calibration `14989c17` · scope `broad` · `psi_lambda_B=2.92` · `mv_rule=0` · `recovery_rate=0.3` · `writeoff_enabled=0` · `zeta_writeoff=0`* 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-06T17:33:26 from `ec4f862` · **working tree DIRTY** · calibration `14989c17` · scope `broad` · `psi_lambda_B=2.92` · `mv_rule=0` · `recovery_rate=0.3` · `writeoff_enabled=0` · `zeta_writeoff=0`* γ selection: peak-spread compression 0/25/50% (spec section 7); gamma solved, not chosen. | 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 | 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 | ### A5-1 — three separate objects @@ -23,14 +23,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 | 1.5331 | 0.00518 | -0.005042 | +| aggressive | 4.0737 | 0.01058 | -0.011981 | > **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.51, 30.00]: loading falls from **5.65** to **4.59**. Monotone decreasing: **YES**. The self-extinguishing premium is the *decline*, so the schedule — not any single point — is the object. @@ -42,50 +42,50 @@ 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 | -0.3646 | -0.0491 | +| medium | -0.3211 | -0.1050 | +| aggressive | -0.0729 | -0.2794 | ## 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-06T17:33:25 from `ec4f862` · **working tree DIRTY** · calibration `14989c17` · scope `broad` · `psi_lambda_B=2.92` · `mv_rule=0` · `recovery_rate=0.3` · `writeoff_enabled=0` · `zeta_writeoff=0`* 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 | -0.7502 | -0.7502 | -2.287e-02 | +4.426e-04 | -2.775e-03 | -3.868e-05 | 1.09e-16 | +| medium | +0.1834 | -0.0623 | -2.440e-02 | -6.677e-04 | +9.284e-03 | +5.538e-04 | 1.14e-16 | +| aggressive | +1.1336 | -0.0843 | -2.940e-02 | -3.174e-03 | +2.549e-02 | +8.038e-04 | 1.07e-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 | -4.741e-03 | +5.030e-03 | +1.514e-02 | +| consumption_price | +2.063e-04 | -1.097e-04 | -4.461e-04 | +| investment | -4.140e-03 | -2.102e-03 | -1.230e-04 | | 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 | +1.173e-03 | -9.835e-04 | -3.236e-03 | +| **dY[0] total** | **-7.502e-03** | **+1.834e-03** | **+1.134e-02** | -> **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 +1.88e-02 while investment moves +4.02e-03 and net exports -4.41e-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-06T17:33:27 from `ec4f862` · **working tree DIRTY** · calibration `14989c17` · scope `broad` · `psi_lambda_B=2.92` · `mv_rule=0` · `recovery_rate=0.3` · `writeoff_enabled=0` · `zeta_writeoff=0`* `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, 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 | 0.0 | 0.056134 | 150.1 | 5.48 | 5.17 | +| e3a_coupon_only | 1 | 0.0 | 0.056134 | 160.6 | 5.84 | 5.53 | +| e3b_full | 1 | 1.0 | 0.701743 | 446.2 | 1.35 | 2.51 | γ 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. @@ -95,12 +95,12 @@ Under `zeta_writeoff = 1` the loading falls **below 1** — the CB becomes *unde | 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.00518 | 0.02839 | 5.48 | +| baseline | aggressive | 0.01058 | 0.05471 | 5.17 | +| e3a_coupon_only | medium | 0.00527 | 0.03079 | 5.84 | +| e3a_coupon_only | aggressive | 0.01065 | 0.05895 | 5.53 | +| e3b_full | medium | 0.13577 | 0.18359 | 1.35 | +| e3b_full | aggressive | 36.51334 | 91.60948 | 2.51 | ### Verification @@ -119,11 +119,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 | 150.1 | 55.5 | 15.00 | 0 | +| e3a_coupon_only | yes | 160.6 | 57.6 | 15.00 | 0 | +| e3b_full | **NO** (from γ≈7.31) | 446.2 | -975.7 | 8.85 | 8 | > **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 = 2.92` 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..07a84aa 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-06T17:43:32 from `ec4f862` · calibration `14989c17` · `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 @@ -16,7 +16,7 @@ The distinction that matters for a referee is *which* parameters are measured, w | `b_F_D` (cross-holdings) | 0.0077 | **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` | 2.92 | **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. | **150.1 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 | +| Bank net-worth pass-through | −1.8 to −8.6 %/100bp | **-4.18 %/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 | ## 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 | 3.103 | 8.973 | +| peak D–F spread (bp ann.) | 150.1 | 112.6 | 75.0 | +| output, impact (% SS) | -0.7502 | +0.1834 | +1.1336 | +| investment, impact (% SS) | -1.7107 | -0.8688 | -0.0508 | +| bank net worth, impact (% SS) | -6.271 | -2.922 | +0.469 | +| ECB exposure PV (% of quarterly $Y_D$) | 0.0000 | 1.5331 | 4.0737 | +| priced expected loss PV (% of $Y_D$) | 0.00000 | 0.00518 | 0.01058 | +| **loading (premium ÷ expected loss)** | n/a | **5.48** | **5.17** | -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%**. +Default loading decomposition: `EL_price = 0.056134`, `psi_spread = 0.596959` → fundamental expected loss is **8.6%** of the total and the collateral-friction wedge is **91.4%**. ## 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) | +0.6447 | +3.0172 | +6.0428 | +5.3981 | +| Q2 | -0.1367 | +1.7457 | +4.2615 | +4.3982 | +| Q3 | -0.5444 | +1.1487 | +3.4554 | +3.9998 | +| Q4 | -0.8339 | +0.7328 | +2.8947 | +3.7286 | +| Q5 (highest) | -1.1203 | +0.3182 | +2.3299 | +3.4502 | > **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 150bp, cuts bank net worth 6.3% and investment 1.7% on impact, and takes output -0.75% from steady state; the backstop's cushioning is concentrated in the opening quarters — the spread ordering by quarter 22 as the unaided economy overshoots on the rebound, so intervention damps the impact quarter rather than shifting the whole path down. ![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 5.65× at γ=0.51 to 4.59× at γ=30 and stays above the actuarially fair benchmark of 1 throughout; medium 5.48×, aggressive 5.17× at the named regimes (peak spread compresses 150bp → 35bp 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 -4.74 and investment -4.14 (×10⁻³ of D-goods) against a +1.17 net-export cushion, summing to -7.50 (panel A). The backstop works through the same margin rather than a different one: moving passive → aggressive raises ΔY by +18.84, of which consumption supplies +1.06×, with investment at +0.21× 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. +Only 8.6% of the sovereign default loading is fundamental expected loss (EL_price = 0.0561); the other 91.4% is the collateral-friction wedge charged by a constrained intermediary (ψ_spread = 0.5970, linear in the one free amplification parameter). That 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. ![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.23% of quarterly steady-state $Y_D$ at γ=30 — while the compensation it earns per unit of expected loss falls steadily from 5.65× to 4.59×, 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 (-7.50 ×10⁻³ of D-goods in total); thereafter net exports flip from cushion to drag at quarter 2. 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 9 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 0.70% 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 1.12% of its own consumption while the lowest quintile gains 0.64%. The backstop's protection runs the same way, also monotone in quintile: it is worth +5.40% of consumption to the lowest quintile against +3.45% to the highest. ![fig08_deciles](../experiments/paper/fig08_deciles.png) 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/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..3243969 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..3669e98 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..e79a725 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..e91b3d9 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..beafa43 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..1d4fc7e 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..3954c65 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..3559e4b 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..a14a47c 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,40 @@ 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) + above_one = bool(np.all(np.asarray(loading)[ok] > 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") + floor = (" and stays above the actuarially fair benchmark of 1 throughout" + if above_one else + ", crossing below the actuarially fair benchmark of 1 before the grid ends") + 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 +316,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 +378,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 +403,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 +500,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(): @@ -436,10 +636,54 @@ 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 _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): el, ps = float(ss_tl["EL_price_D"]), float(ss_tl["psi_spread_D"]) total = el + ps @@ -472,10 +716,30 @@ def fig04_spread_decomposition(cache, ss_tl): f"EL_price {el:.4f} + ψ_spread {ps:.4f}", fontsize=9.5, color=INK, pad=12) fig.tight_layout() - save(fig, "fig04_spread_decomposition") + save(fig, "fig04_spread_decomposition", _caption_fig04(el, ps, s_el, s_ps)) return el, ps +def _caption_fig04(el, ps, s_el, s_ps): + """The split is a ratio of two SOLVED steady-state objects, so derive it. + + Total default loading per unit of default probability, from the bond-pricing + FOC (`code/equations_D.py:566`): EL_price_D + psi_spread_D. The first is the + fundamental expected loss, `(1-recovery)*delta_b/q_b`; the second is the + collateral-friction wedge, `lambda_gk * psi_lambda_B / (beta_inter * Omega)` + (`code/steady_state.py:104`) and is therefore LINEAR in `psi_lambda_B`. The + literal "3% / 97%" here was computed at `psi_lambda_B = 8.5` and had to be + re-derived when the sticky-price re-tune moved it to 7.85; it is now read off + the same two numbers the bar is drawn from and the title prints. + """ + return (f"Only {s_el:.1f}% of the sovereign default loading is fundamental expected " + f"loss (EL_price = {el:.4f}); the other {s_ps:.1f}% is the collateral-friction " + f"wedge charged by a constrained intermediary (ψ_spread = {ps:.4f}, linear in " + f"the one free amplification parameter). That is why the risk is priced far " + f"above fair value, and why moving it to an unconstrained holder is an " + f"efficiency gain rather than a transfer.") + + def fig05_incidence(cache, payload, gammas): """Germany's ledger: exposure (quantity of risk) against loading (price paid).""" Y_ss = float(cache["Y_D_ss"]) @@ -524,7 +788,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 +845,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, " @@ -669,10 +959,27 @@ def main(): _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..96c6148 100644 --- a/experiments/results/e1_backstop_schedule.json +++ b/experiments/results/e1_backstop_schedule.json @@ -1,13 +1,13 @@ { "provenance": { - "generated": "2026-08-05T14:13:36", - "git_sha": "b3983cb", + "generated": "2026-08-06T17:42:03", + "git_sha": "ec4f862", "git_dirty": true, - "cal_fingerprint": "de195df2", + "cal_fingerprint": "14989c17", "cache_schema": 3, "BANK_SCOPE": "broad", "EBA_CALIBRATION": true, - "psi_lambda_B_D": 8.5, + "psi_lambda_B_D": 2.92, "mv_rule_D": 0.0, "recovery_rate_D": 0.3, "writeoff_enabled_D": 0.0, @@ -20,17 +20,17 @@ "regimes": { "passive": { "gamma": 0.0, - "peak_spread_bp_ann": 150.30634590442185, + "peak_spread_bp_ann": 150.09385909543687, "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": -0.7501872110077656, + "C_D_pct_ss": -0.7014451983637122, + "I_D_pct_ss": -1.7107020042484526, + "n_inter_D_pct_ss": -6.2710479408736495, + "K_D_pct_ss": -0.038332396761863466 }, "trough": { - "Y_D_pct_ss": -0.014942910216657817, - "n_inter_D_pct_ss": -3.3804095383737423 + "Y_D_pct_ss": -0.7501872110077656, + "n_inter_D_pct_ss": -6.2710479408736495 }, "a5_1_exposure_pv_pct_Y": 0.0, "a5_1_expected_loss_pv_pct_Y": 0.0, @@ -40,58 +40,58 @@ "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": -0.364590286990928, + "welfare_W_F_secondary": -0.04911059002100359 }, "medium": { - "gamma": 5.079798909719102, - "peak_spread_bp_ann": 112.72975942499305, + "gamma": 3.1033104489324614, + "peak_spread_bp_ann": 112.57039429444241, "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.1834050293246882, + "C_D_pct_ss": 0.7440916349083527, + "I_D_pct_ss": -0.8687686890174924, + "n_inter_D_pct_ss": -2.9216993194092673, + "K_D_pct_ss": -0.019466853957614178 }, "trough": { - "Y_D_pct_ss": 0.0023634152801282155, - "n_inter_D_pct_ss": -2.1672414829333886 + "Y_D_pct_ss": -0.06228287799405587, + "n_inter_D_pct_ss": -2.9216993194092673 }, - "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": 1.5331070061176595, + "a5_1_expected_loss_pv_pct_Y": 0.00518017000375735, + "a5_1_greek_fiscal_saving_pv": -0.0050417342917128925, + "peak_exposure_pct_Y": 0.846226541961562, + "premium_pv_pct_Y": 0.02838918214014709, + "mtm_pv_pct_Y": 0.016655938884829045, + "carry_ss_pv_pct_Y": 4.354011511635898e-16, + "loading": 5.480357231433616, + "welfare_W_D_secondary": -0.3211101112369088, + "welfare_W_F_secondary": -0.10495680672893058 }, "aggressive": { - "gamma": 12.72601367381867, - "peak_spread_bp_ann": 75.15317295827965, + "gamma": 8.973235110170208, + "peak_spread_bp_ann": 75.04692955100413, "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": 1.1336134341700963, + "C_D_pct_ss": 2.2400456299210245, + "I_D_pct_ss": -0.05082030525779431, + "n_inter_D_pct_ss": 0.4687621429182317, + "K_D_pct_ss": -0.0011387512844802051 }, "trough": { - "Y_D_pct_ss": 3.6450008536580966e-05, - "n_inter_D_pct_ss": -1.0988157952017568 + "Y_D_pct_ss": -0.08673074284395689, + "n_inter_D_pct_ss": 0.05579902262919417 }, - "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": 4.073650993140121, + "a5_1_expected_loss_pv_pct_Y": 0.010580099729909797, + "a5_1_greek_fiscal_saving_pv": -0.011981305459399032, + "peak_exposure_pct_Y": 1.6312450515034933, + "premium_pv_pct_Y": 0.054710999916973126, + "mtm_pv_pct_Y": 0.03285796658880834, + "carry_ss_pv_pct_Y": 1.147369366642376e-15, + "loading": 5.1711232704457295, + "welfare_W_D_secondary": -0.07294075833287433, + "welfare_W_F_secondary": -0.2793807557412091 } }, "loading_schedule": { @@ -159,127 +159,127 @@ ], "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 + 5.649791588186778, + 5.614691142343552, + 5.58055321319079, + 5.547341119605205, + 5.5150198338288705, + 5.483556050115532, + 5.452918212710921, + 5.423076508806199, + 5.394002832661639, + 5.365670727196485, + 5.338055309071231, + 5.311133182751554, + 5.284882348336238, + 5.2592821071416065, + 5.234312968232476, + 5.209956558326562, + 5.186195536810627, + 5.163013517012581, + 5.1403949943812375, + 5.118325281834115, + 5.096790452235425, + 5.075777287750726, + 5.055273235679072, + 5.035266370274716, + 5.015745360026904, + 4.996699439856826, + 4.97811838770625, + 4.959992505025191, + 4.942312600709691, + 4.925069978091071, + 4.908256424631105, + 4.891864204030833, + 4.875886050512809, + 4.860315165086021, + 4.84514521364927, + 4.830370326832065, + 4.815985101512328, + 4.801984603987565, + 4.788364374811174, + 4.775120435338784, + 4.76224929606139, + 4.749747966833502, + 4.737613969135936, + 4.725845350545255, + 4.714440701615913, + 4.703399175417571, + 4.692720510010103, + 4.682405054183105, + 4.672453796836875, + 4.662868400438568, + 4.6536512390529055, + 4.64480544152227, + 4.636334940459184, + 4.6282445278170306, + 4.6205399179258935, + 4.613227819022772, + 4.606316014474249, + 4.599813455089913, + 4.593730364163539 ], "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 + 150.09385909543687, + 142.88903292716353, + 136.13585159641073, + 129.80580861633155, + 123.87177063180087, + 118.30797437390126, + 113.09001807392515, + 108.1948463793164, + 103.99860783575437, + 100.14020329608346, + 96.49436100944517, + 93.0482402705564, + 89.78964431676658, + 86.70702055006687, + 83.86450960118441, + 81.28455830516133, + 78.83125044024109, + 76.49736214013353, + 74.27604469150396, + 72.1608188988272, + 70.14556651782871, + 68.22451939625712, + 66.40996709128751, + 64.74729055016711, + 63.1558276699172, + 61.63178288215551, + 60.171569720538514, + 58.7718025354825, + 57.42928776141393, + 56.14101493227486, + 54.939374262670526, + 53.79980168210735, + 52.702679326319476, + 51.64595133442326, + 50.627672691342454, + 49.64600381158407, + 48.70623708716039, + 47.82730674343765, + 46.97766420265518, + 46.156018876144884, + 45.361145535164226, + 44.591881148421024, + 43.84938528539371, + 43.15356288171549, + 42.478642339478235, + 41.82378695466129, + 41.18819973356847, + 40.57112155725304, + 39.97182940589604, + 39.404183715121356, + 38.856693987451735, + 38.32403524894819, + 37.80567454237015, + 37.30110291470226, + 36.80983439898609, + 36.3314050402268, + 35.87772181527085, + 35.43736551770101, + 35.007935561151, + 34.58908524880862 ] } } \ No newline at end of file diff --git a/experiments/results/e2_dy_decomposition.json b/experiments/results/e2_dy_decomposition.json index 1d1e235..15575b2 100644 --- a/experiments/results/e2_dy_decomposition.json +++ b/experiments/results/e2_dy_decomposition.json @@ -1,13 +1,13 @@ { "provenance": { - "generated": "2026-08-03T12:08:12", - "git_sha": "b3d5889", + "generated": "2026-08-06T17:33:25", + "git_sha": "ec4f862", "git_dirty": true, - "cal_fingerprint": "de195df2", + "cal_fingerprint": "14989c17", "cache_schema": 3, "BANK_SCOPE": "broad", "EBA_CALIBRATION": true, - "psi_lambda_B_D": 8.5, + "psi_lambda_B_D": 2.92, "mv_rule_D": 0.0, "recovery_rate_D": 0.3, "writeoff_enabled_D": 0.0, @@ -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": 1.093959992037874e-16, + "dY_impact_pct_ss": -0.7501872110077656, + "dY_trough_pct_ss": -0.7501872110077656, + "dY_pv": -0.025236897238739994, "components_pv": { - "consumption_quantity": -0.0003749961439858758, - "consumption_price": 5.0877243328866585e-05, - "investment": 0.003050281861435693, + "consumption_quantity": -0.002775301240498164, + "consumption_price": -3.868460811229618e-05, + "investment": -0.02286549380354283, "government": 0.0, "portfolio_cost": 0.0, "macropru_tax": 0.0, - "net_exports": 0.00014143712360581 + "net_exports": 0.0004425824134131123 }, "components_impact": { - "consumption_quantity": 0.001462421132203461, - "consumption_price": 0.0001763421089795903, - "investment": -0.0018677443034000173, + "consumption_quantity": -0.004741422289085538, + "consumption_price": 0.0002062541078137403, + "investment": -0.004139898850281255, "government": 0.0, "portfolio_cost": 0.0, "macropru_tax": 0.0, - "net_exports": 7.95519600503203e-05 + "net_exports": 0.0011731949214754053 }, "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.004741422289085538, + 0.0008858698468155174, + 0.0018575995970825233, + 0.0016396244677207806, + 0.0012201237859976164, + 0.0008498586977132164, + 0.0005698895827991019, + 0.00036843748747232807, + 0.00022543109579599183, + 0.00012361622233513707, + 5.023005637686893e-05, + -3.6614682548399427e-06, + -4.417841158905347e-05, + -7.547119437631623e-05, + -0.00010034432790932244, + -0.00012069061798288788, + -0.00013778799049457284, + -0.00015250105084333153, + -0.00016541735351189812, + -0.00017693841625893595, + -0.00018734069069120714, + -0.0001968166679449357, + -0.0002055024267950812, + -0.00021349599614999462, + -0.0002208696659654127, + -0.0002276783009853424, + -0.0002339649559357356, + -0.00023976464589672337, + -0.00024510686259118233, + -0.00025001724941140993, + -0.00025451871524242417, + -0.00025863217505600546, + -0.0002623770433321797, + -0.0002657715637472314, + -0.0002688330295735885, + -0.0002715779306941252, + -0.000274022051597207, + -0.00027618053708071134, + -0.00027806793694670756, + -0.0002796982372186279 ], "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.0002062541078137403, + 0.00016353128348645956, + 8.650407572344361e-05, + 2.6149174285698673e-05, + -1.18551929514533e-05, + -3.234392474254785e-05, + -4.10901731185715e-05, + -4.2625309466397765e-05, + -4.0086095439148326e-05, + -3.551499663573621e-05, + -3.018477349929431e-05, + -2.4852726871739497e-05, + -1.9940813852589825e-05, + -1.5658122031258272e-05, + -1.2082231365923797e-05, + -9.212443146904515e-06, + -7.004088406537464e-06, + -5.390255286643142e-06, + -4.295242035031188e-06, + -3.6426337830233492e-06, + -3.3599122524827154e-06, + -3.3808569661226907e-06, + -3.6465714949304006e-06, + -4.105677972546087e-06, + -4.714027672813365e-06, + -5.434149654329322e-06, + -6.234578507740802e-06, + -7.0891486693975615e-06, + -7.976306943839267e-06, + -8.878471430162466e-06, + -9.781450277393442e-06, + -1.0673924692245024e-05, + -1.1546995357498457e-05, + -1.2393788543457468e-05, + -1.320911679168592e-05, + -1.3989188510818727e-05, + -1.473136078341661e-05, + -1.5433929933972e-05, + -1.609595482353729e-05, + -1.6717108327022627e-05 ], "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.004139898850281255, + -0.002532542850243048, + -0.0016734696458019318, + -0.0011754113627290535, + -0.0008738183618412184, + -0.0006880208410960384, + -0.0005733828526871242, + -0.0005031629127243261, + -0.0004606392521175876, + -0.00043516349870222884, + -0.0004199288389982641, + -0.00041060610829676196, + -0.0004044764538641665, + -0.0003998661315952168, + -0.00039577630484262274, + -0.00039163972603532067, + -0.0003871612549835412, + -0.00038221387811008574, + -0.0003767713694570145, + -0.0003708649798441571, + -0.0003645559854566651, + -0.0003579186548050039, + -0.00035102995955004043, + -0.00034396362133525297, + -0.000336786933524572, + -0.0003295593255555181, + -0.00032233198566863685, + -0.0003151480975627726, + -0.0003080434100763272, + -0.00030104696609699094, + -0.0002941818848244328, + -0.0002874661347763355, + -0.00028091326239498604, + -0.00027453305824426264, + -0.0002683321533663666, + -0.00026231454489044107, + -0.0002564820538847141, + -0.00025083472055689607, + -0.00024537114284114145, + -0.00024008876464389747 ], "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.0011731949214754053, + 0.00014658789748156557, + -0.00015528898439328337, + -0.00022646496684391066, + -0.00022367991064372298, + -0.00019794110585780666, + -0.0001665037271684376, + -0.00013546895688058307, + -0.00010704081557687393, + -8.194353010362507e-05, + -6.028646271699838e-05, + -4.190715422377953e-05, + -2.6521650208638537e-05, + -1.3800298293715351e-05, + -3.4072736962347127e-06, + 4.978105840027222e-06, + 1.1650583936367313e-05, + 1.6874467796468603e-05, + 2.0882806028267074e-05, + 2.3878462891356048e-05, + 2.603626476637191e-05, + 2.7505702204321544e-05, + 2.8413773997137067e-05, + 2.8867749760344884e-05, + 2.895776503685178e-05, + 2.8759203188683124e-05, + 2.8334834143446834e-05, + 2.7736699820753613e-05, + 2.700775502150944e-05, + 2.6183283896317858e-05, + 2.52921157237901e-05, + 2.4357664029142223e-05, + 2.3398811873576345e-05, + 2.2430663998061026e-05, + 2.1465184073655907e-05, + 2.0511733125227972e-05, + 1.9577523315280682e-05, + 1.8667999537460778e-05, + 1.7787159614983327e-05, + 1.693782239919899e-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.007501872110077657, + -0.0013365538224594537, + 0.00011534504261072695, + 0.0002638973124335107, + 0.0001107703205611677, + -6.844717398315895e-05, + -0.0002110871701750148, + -0.00031281969159901014, + -0.00038233506733750865, + -0.0004290058031064562, + -0.0004601700188376755, + -0.0004810274576471373, + -0.0004951173295144115, + -0.000504795746296528, + -0.0005116101378141043, + -0.0005165646813250573, + -0.0005203027499482887, + -0.0005232307164435934, + -0.0005256011589756703, + -0.0005275675669947474, + -0.0005292203236339589, + -0.0005306104775117334, + -0.000531765183842922, + -0.0005326975456974724, + -0.0005334128621259518, + -0.0005339125730064992, + -0.0005341966859686604, + -0.0005342651923081369, + -0.0005341188245898224, + -0.0005337594030422444, + -0.0005331899346204613, + -0.0005324145704954455, + -0.0005314384892110729, + -0.0005302677465368762, + -0.0005289091156579779, + -0.0005273699309701539, + -0.0005256579429500607, + -0.0005237811880341142, + -0.0005217478749963954, + -0.0005195662877903606 ] }, "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": 3.1033104489324614, + "max_abs_residual": 1.1405806854547507e-16, + "dY_impact_pct_ss": 0.1834050293246882, + "dY_trough_pct_ss": -0.06228287799405587, + "dY_pv": -0.015228351375601846, "components_pv": { - "consumption_quantity": -0.0001504977703135832, - "consumption_price": 0.00012620131103367114, - "investment": 0.003758260451216295, + "consumption_quantity": 0.009284479646775971, + "consumption_price": 0.0005537683836409493, + "investment": -0.02439889904655336, "government": 0.0, "portfolio_cost": 0.0, "macropru_tax": 0.0, - "net_exports": 0.00022509953196743276 + "net_exports": -0.0006677003594655624 }, "components_impact": { - "consumption_quantity": 0.002055224387722709, - "consumption_price": -0.0003415705589177501, - "investment": -0.0007024887685372447, + "consumption_quantity": 0.005029691087923311, + "consumption_price": -0.0001096848075380938, + "investment": -0.002102420227422331, "government": 0.0, "portfolio_cost": 0.0, "macropru_tax": 0.0, - "net_exports": -0.000900458777574948 + "net_exports": -0.000983535759716036 }, "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.005029691087923311, + 0.00346016342393144, + 0.0024272622535394185, + 0.0017217564439796352, + 0.0012272287014048005, + 0.0008745428156045071, + 0.0006193044096212767, + 0.0004318104343346389, + 0.000291831650550973, + 0.00018545029448222865, + 0.00010304200323649506, + 3.7930951000863654e-05, + -1.4541419946222548e-05, + -5.7639804399645514e-05, + -9.366860432999583e-05, + -0.00012426735030424948, + -0.00015061396697099315, + -0.00017356505837132306, + -0.00019375267890041107, + -0.00021165021761748265, + -0.00022761801994120942, + -0.00024193527467989535, + -0.0002548221252513839, + -0.00026645507810988696, + -0.0002769779509929512, + -0.0002865097553332892, + -0.0002951504049189049, + -0.0003029848745145413, + -0.0003100862643404857, + -0.00031651809301050377, + -0.0003223360399186794, + -0.00032758929069307517, + -0.00033232159294747476, + -0.00033657209649644857, + -0.0003403760298186639, + -0.00034376525034620004, + -0.00034676869665891085, + -0.0003494127636278367, + -0.00035172161624366936, + -0.0003537174540609544 ], "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 + -0.0001096848075380938, + -0.00015493203673518172, + -0.00016321878290231125, + -0.00015076317668750154, + -0.00012750205387094914, + -9.960648812353876e-05, + -7.085388681305891e-05, + -4.3466402626184685e-05, + -1.8655918813906872e-05, + 3.013736668703256e-06, + 2.1384376623951543e-05, + 3.654203372301706e-05, + 4.871085880725432e-05, + 5.818427952813245e-05, + 6.528144373843039e-05, + 7.032052478485823e-05, + 7.360319395480702e-05, + 7.540637370126118e-05, + 7.597862906696787e-05, + 7.553942087827369e-05, + 7.428006943370205e-05, + 7.236567341317222e-05, + 6.993749132668586e-05, + 6.711547965963945e-05, + 6.400080532169531e-05, + 6.0678226191676396e-05, + 5.721828229649057e-05, + 5.367927238503787e-05, + 5.010901145196824e-05, + 4.654637717393439e-05, + 4.302265992526456e-05, + 3.956273417772495e-05, + 3.6186070013824195e-05, + 3.29076030904015e-05, + 2.9738480295295555e-05, + 2.668669692546923e-05, + 2.375763967881953e-05, + 2.0954548201147535e-05, + 1.8278906435262783e-05, + 1.5730773627541687e-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.002102420227422331, + -0.0014883061159099101, + -0.001102675683912656, + -0.0008676127678735545, + -0.0007298602497206854, + -0.0006535130393330589, + -0.0006148368220168977, + -0.0005983825154291258, + -0.0005942454757149863, + -0.0005961971795526851, + -0.0006004222989983683, + -0.0006046731276739866, + -0.000607709298948451, + -0.0006089256978279489, + -0.0006081088558215139, + -0.0006052780874847191, + -0.000600583248217155, + -0.0005942399478282692, + -0.0005864893230630694, + -0.000577573705947987, + -0.0005677226815289409, + -0.0005571457944864957, + -0.0005460293796690839, + -0.0005345359120501883, + -0.000522804851766025, + -0.0005109543078225238, + -0.0004990830845006397, + -0.0004872728431413214, + -0.0004755902241225486, + -0.00046408884341692927, + -0.0004528111205716713, + -0.00044178992132397484, + -0.0004310500138460993, + -0.00042060934656041823, + -0.0004104801603120309, + -0.00040066995007232245, + -0.00039118229209487824, + -0.00038201755211076066, + -0.00037317348920084116, + -0.0003646457687577147 ], "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 + -0.000983535759716036, + -0.0008242388764978547, + -0.0006799538331431812, + -0.000548093938503568, + -0.00042902972439218175, + -0.0003234867603469908, + -0.00023154787733928176, + -0.0001526698095243096, + -8.590623688049124e-05, + -3.0099574891587654e-05, + 1.597931401367497e-05, + 5.3541324333769295e-05, + 8.372967403179625e-05, + 0.00010759189219223988, + 0.00012606903718213506, + 0.0001399941754391773, + 0.00015009672567283688, + 0.00015701005666879668, + 0.00016128054922389713, + 0.0001633768710007336, + 0.00016369907632804643, + 0.00016258720826049745, + 0.00016032914663383606, + 0.00015716764832094407, + 0.00015330661640011186, + 0.00014891663400215776, + 0.00014413980484224873, + 0.00013909395709202005, + 0.00013387627741289287, + 0.00012856644237754976, + 0.00012322930976211007, + 0.00011791722668796686, + 0.00011267200558739106, + 0.0001075266128086477, + 0.00010250660904020195, + 9.763137588101194e-05, + 9.291515859162987e-05, + 8.836795114520603e-05, + 8.399624616692162e-05, + 7.980366925057557e-05 ] }, "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.001834050293246882, + 0.000992686394788468, + 0.00048141395358125967, + 0.00015528656091499628, + -5.916332657907364e-05, + -0.0002020634721990666, + -0.0002979341765479411, + -0.0003627082932450167, + -0.0004069759808582973, + -0.0004378327232933383, + -0.0004600166051242206, + -0.00047665881861632563, + -0.0004898101860555877, + -0.0005007893305072393, + -0.0005104269792309471, + -0.0005192307375649165, + -0.0005274972955605119, + -0.0005353885758295485, + -0.0005429828236725965, + -0.0005503076316864524, + -0.0005573615557083816, + -0.0005641281874927062, + -0.0005705848669599373, + -0.0005767078621795058, + -0.0005824753810371716, + -0.0005878692029619702, + -0.0005928754022807974, + -0.0005974844881788144, + -0.0006016911995981649, + -0.0006054941168759589, + -0.0006088951908029896, + -0.0006118992511513628, + -0.0006145135311923535, + -0.0006167472271578083, + -0.0006186111007952009, + -0.0006201171276120402, + -0.0006212781904833406, + -0.0006221078163922366, + -0.000622619952842315, + -0.0006228287799405587 ] }, "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": 8.973235110170208, + "max_abs_residual": 1.0711917464156784e-16, + "dY_impact_pct_ss": 1.1336134341700963, + "dY_trough_pct_ss": -0.08431702313779946, + "dY_pv": -0.006280677608664726, "components_pv": { - "consumption_quantity": 0.0008897757848687297, - "consumption_price": 0.00016308545078041982, - "investment": 0.003920147731029582, + "consumption_quantity": 0.025488523667173378, + "consumption_price": 0.0008038332628754958, + "investment": -0.029399266306184432, "government": 0.0, "portfolio_cost": 0.0, "macropru_tax": 0.0, - "net_exports": 4.996968829333592e-05 + "net_exports": -0.0031737682325292714 }, "components_impact": { - "consumption_quantity": 0.0026056242645631222, - "consumption_price": -0.0007954832731645461, - "investment": 0.00029452718006621925, + "consumption_quantity": 0.015141599519181564, + "consumption_price": -0.00044606260998847526, + "investment": -0.0001229851387238622, "government": 0.0, "portfolio_cost": 0.0, "macropru_tax": 0.0, - "net_exports": -0.00176697524874602 + "net_exports": -0.0032364174287683387 }, "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.015141599519181564, + 0.006401248868457051, + 0.0033305763408441685, + 0.0021178539051284495, + 0.0015380325827668165, + 0.001193859810231541, + 0.0009520182490997158, + 0.0007643613468056705, + 0.0006110849561974828, + 0.00048240719158291914, + 0.0003725449131635729, + 0.00027761763164356495, + 0.00019481301013738193, + 0.00012201745762574416, + 5.76037920140428e-05, + 2.990779505107886e-07, + -5.0907139536698465e-05, + -9.682899599117758e-05, + -0.00013813236082948436, + -0.0001753688963703985, + -0.00020900148115209104, + -0.00023942329116875794, + -0.00026697177816248, + -0.00029193909491038626, + -0.0003145801471510346, + -0.00033511887576608826, + -0.00035375316702103215, + -0.0003706587227723551, + -0.00038599216626556283, + -0.0003998935798903348, + -0.00041248860886674996, + -0.00042389022820500183, + -0.00043420024372683514, + -0.00044351057789612, + -0.0004519043782953236, + -0.00045945697894491034, + -0.0004662367388840511, + -0.00047230577735125196, + -0.0004777206208617055, + -0.0004825327745565363 ], "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.00044606260998847526, + -0.000508776120810145, + -0.00045794618491082714, + -0.0003787082691870239, + -0.00029764142453646714, + -0.00022278363942219512, + -0.000156256117871138, + -9.825899500826943e-05, + -4.8352814661659015e-05, + -5.88347612008848e-06, + 2.986539389959738e-05, + 5.961039051603595e-05, + 8.403938523311619e-05, + 0.00010379653000024627, + 0.00011947479235436599, + 0.0001316130208323433, + 0.00014069597498609447, + 0.00014715627162513226, + 0.00015137749045896536, + 0.00015369789969627744, + 0.0001544144579336877, + 0.00015378684864564144, + 0.00015204138138880636, + 0.00014937466603903778, + 0.00014595701256813048, + 0.0001419355340976811, + 0.0001374369486677733, + 0.00013257008839798125, + 0.00012742813331680422, + 0.00012209059141582776, + 0.00011662504796052245, + 0.00011108870701157174, + 0.00010552974709886553, + 9.998851147704905e-05, + 9.44985516953362e-05, + 8.908754149814276e-05, + 8.377807638731833e-05, + 7.858837255304108e-05, + 7.353287735700519e-05, + 6.862280215461895e-05 ], "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.0001229851387238622, + -0.00048235377710199477, + -0.0005630508211574641, + -0.0005862347922864377, + -0.0006091006064874314, + -0.0006408981561501449, + -0.0006789233907007854, + -0.0007188549731199638, + -0.0007572932248791863, + -0.0007920743406122945, + -0.0008220315012993453, + -0.0008466922691619707, + -0.0008660339779981135, + -0.0008803053346803444, + -0.0008899062551560674, + -0.0008953087147570095, + -0.000897006556448204, + -0.0008954848282045724, + -0.0008912019823859527, + -0.0008845803373882485, + -0.0008760019981517077, + -0.0008658081720427807, + -0.0008543004637086079, + -0.0008417433012572206, + -0.0008283669542596072, + -0.0008143707714971253, + -0.0007999264041412229, + -0.0007851808826843511, + -0.0007702594816081791, + -0.0007552683422960304, + -0.0007402968460204066, + -0.0007254197422409158, + -0.0007106990450026116, + -0.0006961857140471257, + -0.000681921139047274, + -0.0006679384459358192, + -0.0006542636438636969, + -0.0006409166302229905, + -0.0006279120697746069, + -0.0006152601624601723 ], "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.0032364174287683387, + -0.0019227260951050322, + -0.0013440782174606726, + -0.001016659379163329, + -0.0007856943850698319, + -0.0006012388585866881, + -0.0004462565603285751, + -0.0003140037509191492, + -0.00020098071151516427, + -0.00010475330957250881, + -2.330279381061208e-05, + 4.515962734728682e-05, + 0.00010224678671492675, + 0.00014940964919151655, + 0.00018794669006113476, + 0.000219012928571969, + 0.00024363054913890963, + 0.0002627005188822988, + 0.00027701437637121746, + 0.0002872654759457047, + 0.00029405970353957946, + 0.0002979254477594963, + 0.0002993226373824666, + 0.0002986508771855205, + 0.00029625675755540556, + 0.00029244037602010615, + 0.000287461115056147, + 0.0002815427387600362, + 0.00027487788020158727, + 0.0002676319875555586, + 0.00025994679042897476, + 0.0002519433422296584, + 0.0002437246886156422, + 0.00023537820630230793, + 0.00022697765151508944, + 0.00021858495316991856, + 0.0002102517819809299, + 0.00020202092301016973, + 0.00019392747582536662, + 0.00018599990348409974 ] }, "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.011336134341700962, + 0.0034873928754397794, + 0.0009655011173152178, + 0.00013625146449163422, + -0.0001544038333269679, + -0.0002710608439274748, + -0.00032941781980075564, + -0.0003667563722417522, + -0.00039554179485841966, + -0.00042030393472196364, + -0.0004429239880467475, + -0.0004643046196550461, + -0.0004849347959126545, + -0.0005050816978628519, + -0.0005248809807265279, + -0.0005443836874021911, + -0.0005635871718599159, + -0.0005824570336883548, + -0.0006009424763852353, + -0.0006189858581166397, + -0.0006365293178305094, + -0.0006535191668063662, + -0.0006699082230997767, + -0.0006856568529430454, + -0.0007007333312871004, + -0.0007151137371454151, + -0.0007287815074383151, + -0.0007417267782987157, + -0.0007539456343553597, + -0.0007654393432150073, + -0.0007762136164976959, + -0.0007862779212046987, + -0.0007956448530149489, + -0.0008043295741638844, + -0.0008123493141321881, + -0.0008197229302126714, + -0.0008264705243795016, + -0.0008326131120110296, + -0.0008381723374539318, + -0.0008431702313779945 ] } } diff --git a/experiments/results/e3_writeoff_s1.json b/experiments/results/e3_writeoff_s1.json index 9a25fd7..ca356f7 100644 --- a/experiments/results/e3_writeoff_s1.json +++ b/experiments/results/e3_writeoff_s1.json @@ -1,13 +1,13 @@ { "provenance": { - "generated": "2026-08-03T12:32:16", - "git_sha": "de03a8e", + "generated": "2026-08-06T17:33:27", + "git_sha": "ec4f862", "git_dirty": true, - "cal_fingerprint": "de195df2", + "cal_fingerprint": "14989c17", "cache_schema": 3, "BANK_SCOPE": "broad", "EBA_CALIBRATION": true, - "psi_lambda_B_D": 8.5, + "psi_lambda_B_D": 2.92, "mv_rule_D": 0.0, "recovery_rate_D": 0.3, "writeoff_enabled_D": 0.0, @@ -17,40 +17,40 @@ }, "gammas": { "passive": 0.0, - "medium": 5.079798909719102, - "aggressive": 12.72601367381867 + "medium": 3.1033104489324614, + "aggressive": 8.973235110170208 }, "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, + "A_cb_impact": -0.01821206232594977, "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": 150.09385909543687, + "Y_D_impact_pct_ss": -0.7501872110077656, + "n_inter_D_impact_pct_ss": -6.2710479408736495, "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": 3.1033104489324614, + "peak_spread_bp_ann": 112.57039429444241, + "Y_D_impact_pct_ss": 0.1834050293246882, + "n_inter_D_impact_pct_ss": -2.9216993194092673, + "expected_loss_pv_pct_Y": 0.00518017000375735, + "premium_pv_pct_Y": 0.02838918214014709, + "loading": 5.480357231433616 }, "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": 8.973235110170208, + "peak_spread_bp_ann": 75.04692955100413, + "Y_D_impact_pct_ss": 1.1336134341700963, + "n_inter_D_impact_pct_ss": 0.4687621429182317, + "expected_loss_pv_pct_Y": 0.010580099729909797, + "premium_pv_pct_Y": 0.054710999916973126, + "loading": 5.1711232704457295 } } }, @@ -100,51 +100,51 @@ 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 + 150.09385909543687, + 144.60132799627723, + 139.37127239169374, + 134.39113795022354, + 129.64881989864637, + 125.13266296725483, + 120.83146048616804, + 116.73445242592906, + 112.83132223984023, + 109.11219242530528, + 105.7504003989377, + 102.73798444503402, + 99.85237487899975, + 97.08779300987038, + 94.43866893769, + 91.89964350219032, + 89.46556882227198, + 87.13150755237764, + 84.89273098957713, + 82.89003372999126, + 80.96310980589507, + 79.10809608477071, + 77.32186727980246, + 75.60142114296413, + 73.94387725093135, + 72.34647519707488, + 70.80657228503924, + 69.32164081158945, + 67.88926501830527, + 66.51907066194197, + 65.25111648877636, + 64.02459616414554, + 62.83782729277442, + 61.6891973559627, + 60.57716176106108, + 59.50024177339565, + 58.45702236365505, + 57.446149999282994, + 56.466330404338365, + 55.525753491879215 ], "first_violation_gamma": null, "n_violations": 0, - "peak_at_gamma0_bp": 150.30634590442185, - "min_peak_bp": 67.88930238399558, + "peak_at_gamma0_bp": 150.09385909543687, + "min_peak_bp": 55.525753491879215, "gamma_at_min_peak": 15.0 }, "e3a_coupon_only": { @@ -192,51 +192,51 @@ 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 + 160.58393831842176, + 154.58167581417547, + 148.87100144991552, + 143.43790258942855, + 138.26885380637745, + 133.35081901963375, + 128.67125276000087, + 124.21810025644646, + 119.97979611338772, + 115.94526143019021, + 112.10389928735985, + 108.58166892959072, + 105.46357240344334, + 102.47898029912774, + 99.62166295809787, + 96.88562403270072, + 94.26510231088344, + 91.7545719047418, + 89.3487409649573, + 87.04254908964636, + 84.8398188903443, + 82.85624163676829, + 80.94777523543266, + 79.11108157360599, + 77.34295911056735, + 75.64034111660938, + 74.00029327656155, + 72.42001076839522, + 70.8968149172931, + 69.42814951529739, + 68.01157688658341, + 66.64477376872762, + 65.35486380098949, + 64.14035855944813, + 62.965303594676904, + 61.828074234348584, + 60.727117117591625, + 59.660947697011586, + 58.62814769913358, + 57.62736256882264 ], "first_violation_gamma": null, "n_violations": 0, - "peak_at_gamma0_bp": 149.0668467666693, - "min_peak_bp": 68.45307782833885, + "peak_at_gamma0_bp": 160.58393831842176, + "min_peak_bp": 57.62736256882264, "gamma_at_min_peak": 15.0 }, "e3b_full": { @@ -284,118 +284,118 @@ 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 + 446.21100681253625, + 428.1039721586011, + 405.6093338439824, + 382.8079453423104, + 360.8325105255078, + 340.0729795937403, + 320.66100915258806, + 302.6202983271312, + 285.924401150262, + 270.522763964639, + 256.3541676699121, + 243.3547158607691, + 231.73644684679647, + 221.99016180956, + 213.17348230896962, + 205.30789391403133, + 198.4578750542229, + 193.4655738447334, + 190.35564883050822, + 194.63570111958302, + 264.24357465345065, + 404.99903311559837, + 844.4407329656557, + -975.7059609392644, + 67.22740825738936, + 93.81622055398437, + 100.24879450327482, + 101.60886870706294, + 101.04277778455193, + 99.60792088825905, + 97.75314376346147, + 95.71186329752527, + 93.91600617197327, + 92.02816380615356, + 90.10544647138659, + 88.18318207547631, + 86.28354189538342, + 84.4204544405781, + 82.60253601389995, + 80.83490522853613 ], - "first_violation_gamma": 0.38461538461538464, - "n_violations": 2, - "peak_at_gamma0_bp": 168.93177755629583, - "min_peak_bp": 82.68577550066189, - "gamma_at_min_peak": 15.0 + "first_violation_gamma": 7.307692307692308, + "n_violations": 8, + "peak_at_gamma0_bp": 446.21100681253625, + "min_peak_bp": -975.7059609392644, + "gamma_at_min_peak": 8.846153846153847 } }, "variants": { "e3a_coupon_only": { "EL_price_D": 0.05613386543752716, - "A_cb_impact": -0.01893646629019237, + "A_cb_impact": -0.018807467128205302, "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": 160.58393831842176, + "Y_D_impact_pct_ss": -0.8142824199536273, + "n_inter_D_impact_pct_ss": -6.781860204705727, "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": 3.1033104489324614, + "peak_spread_bp_ann": 119.6966108974623, + "Y_D_impact_pct_ss": 0.17396248027417954, + "n_inter_D_impact_pct_ss": -3.2192032134826793, + "expected_loss_pv_pct_Y": 0.005273027212459834, + "premium_pv_pct_Y": 0.030794399961813552, + "loading": 5.839985025878173 }, "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": 8.973235110170208, + "peak_spread_bp_ann": 78.51944180477868, + "Y_D_impact_pct_ss": 1.1659136667820151, + "n_inter_D_impact_pct_ss": 0.33884984410138996, + "expected_loss_pv_pct_Y": 0.01065319158916125, + "premium_pv_pct_Y": 0.05894963301498659, + "loading": 5.533518525562143 } } }, "e3b_full": { "EL_price_D": 0.7017434754895111, - "A_cb_impact": -0.02158385591990817, + "A_cb_impact": -0.03501327126664988, "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": 446.21100681253625, + "Y_D_impact_pct_ss": -1.83539546076612, + "n_inter_D_impact_pct_ss": -15.195991326429702, "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": 3.1033104489324614, + "peak_spread_bp_ann": 284.82704697187813, + "Y_D_impact_pct_ss": 0.43187121342217716, + "n_inter_D_impact_pct_ss": -7.29463081538677, + "expected_loss_pv_pct_Y": 0.13576568373525874, + "premium_pv_pct_Y": 0.1835911509350659, + "loading": 1.35226476885769 }, "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": 8.973235110170208, + "peak_spread_bp_ann": -39.693005252222704, + "Y_D_impact_pct_ss": 1.949666283652823, + "n_inter_D_impact_pct_ss": 50.961986875425715, + "expected_loss_pv_pct_Y": 36.51334127536912, + "premium_pv_pct_Y": 91.60948041692019, + "loading": 2.5089317278864693 } } } diff --git a/experiments/run_all.py b/experiments/run_all.py index 8731447..690f282 100644 --- a/experiments/run_all.py +++ b/experiments/run_all.py @@ -124,12 +124,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, " @@ -212,7 +231,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.", ""]