From d55cb0210c5f939f68f1ffdc2b23aaa9250c8afa Mon Sep 17 00:00:00 2001 From: Polichinl Date: Fri, 24 Jul 2026 11:23:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(summarize):=20tower-tip=20reads=20the=20to?= =?UTF-8?q?p=20floor=20=E2=80=94=20tip=5Fmass=200.25=20+=20MAP-containment?= =?UTF-8?q?=20law=20(ADR-019=20Amendment=203)=20=E2=86=92=20v1.9.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tower-tip MAP is now the median of the TOP floor of the published tower (tip_mass default 0.5 → 0.25), matching the design intent the name always promised. Evidence (research/map_hdi/tip_mass_study.py: 1000-replicate accuracy/stability battery, duplicate-capture frontier, the real-cell C-44 gate): the 0.5 shorth carried a structural rightward bias that does not shrink with S; 0.25 roughly halves it, beats the shorth on RMSE at pooled S, reads zero-inflated cells exactly, and passes the real-cell zero-stack gate with margin (masses <= 0.15 resurrect the C-44 signal loss; 0.20 has zero margin). New MAP-containment law in assert_summarizer_contract: every floor holding more than half the tip floor's draws (a mass-m floor spans floor(m*S)+1 draws; asymptotically m > tip_mass/2 = 12.5%) provably contains the tip — wider floors by nesting, narrower qualifying floors by the sub-window trim argument. All published bands (50/90/95/99) qualify; the unguaranteed region shrinks vs 0.5. Also: research/figures/ — permanent seeded generators for the PRN06 tower figures (upright tower: 99% base at the bottom narrowing to the red 25% top floor with the tip on it; degenerate zero-width bands rendered as open diamonds), reports/plots/ as the gitignored output home, and the provenance/ placement research note (plotting lives under research/, never src/ — the sibling charters exclude it six times over). Governance: ADR-019 Amendment 3; Summarize CIC §3 + header amendment; CHANGELOG [1.9.0]; README banner + chronicle (banner guard green). Consumer note: published MAPs shift toward the mode on skewed cells — the intended C-32 direction. CONFORMANCE_FLOOR stays 1.0.0. Gate: ruff / mypy (3.10+3.11) / pytest 100% line+branch / validate_docs all green. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + CHANGELOG.md | 27 ++ README.md | 7 +- ...19_coherent_posterior_summary_hdi_tower.md | 26 +- docs/CICs/Summarize.md | 18 +- pyproject.toml | 2 +- research/figures/NOTE.md | 111 ++++++++ research/figures/make_tower_figs.py | 242 ++++++++++++++++++ research/map_hdi/tip_mass_study.py | 129 ++++++++++ src/views_frames_summarize/config.py | 12 +- src/views_frames_summarize/conformance.py | 26 ++ src/views_frames_summarize/tower_point.py | 12 +- tests/test_summarize_config.py | 5 +- tests/test_summarize_tower.py | 12 +- uv.lock | 2 +- 15 files changed, 614 insertions(+), 20 deletions(-) create mode 100644 research/figures/NOTE.md create mode 100644 research/figures/make_tower_figs.py create mode 100644 research/map_hdi/tip_mass_study.py diff --git a/.gitignore b/.gitignore index c91489e..d23f7b3 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ research/**/.cache/ research/**/results.tsv research/**/run.log research/**/audit_plots/ + +# generated figures (research/figures/ generators; deliverable copies travel with the deliverable) +reports/plots/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a7903cf..257ca4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to `views-frames` are documented here. The format is based o [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/) as governed in `GOVERNANCE.md`. +## [1.9.0] — 2026-07-24 + +**The tower-tip MAP reads the top floor (ADR-019 Amendment 3): `tip_mass` 0.5 → 0.25.** +Behavior change to `tower_point`/`summarize_tower` outputs, shipped MINOR per the C-44/C-45 +precedent (estimator amendment with ADR evidence); `CONFORMANCE_FLOOR` stays `1.0.0`. + +### Changed +- **`tip_mass` default 0.5 → 0.25 (the top-quartile floor).** The tower-tip MAP is now the + median of the **top floor of the published tower**, matching the design intent the name + always promised. Evidence (`research/map_hdi/tip_mass_study.py`, 1000-replicate battery + + duplicate-capture frontier + the real-cell C-44 gate): the 0.5 shorth carried a structural + rightward bias that does not shrink with sample count; 0.25 roughly halves it, beats the + shorth on RMSE at pooled S, reads zero-inflated cells exactly, and passes the real-cell + zero-stack gate with margin (masses ≤ 0.15 resurrect the C-44 signal loss; 0.20 has zero + margin). Consumer note: published MAPs shift toward the mode on skewed cells — the intended + C-32 direction. + +### Added +- **MAP-containment law** in `assert_summarizer_contract`: every floor holding more than half + the tip floor's draws (asymptotically mass > `tip_mass`/2 = 12.5%) provably contains the + tip — wider floors by nesting, narrower qualifying floors by the sub-window trim argument. + All published bands (50/90/95/99) qualify; the unguaranteed region *shrinks* versus the old + default (was: everything below 0.5). +- `research/map_hdi/tip_mass_study.py` — the committed evidence trail for the amendment. +- `research/figures/` — permanent, seeded generators for the PRN06 tower figures (overlay + + detail, upright-tower rendering), with `reports/plots/` as the gitignored output home. + ## [1.8.1] — 2026-07-02 **Falsification-audit hardening (four-axis audit 2026-07-02; register C-67/C-68/C-69/C-70).** diff --git a/README.md b/README.md index be9c706..969540d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > containers (`FeatureFrame`, `PredictionFrame`, and their anticipated siblings) > that every other repo depends on and that depends on nothing internal. > -> **Status:** **v1.8.0 — frozen API, published to PyPI** (frozen since v1.0.0, ADR-018; the +> **Status:** **v1.9.0 — frozen API, published to PyPI** (frozen since v1.0.0, ADR-018; the > v1.1 surface is > purely additive — the coherent posterior summary, ADR-019; v1.2.0 rebuilt the tower > `outside-in`, C-44; v1.3.0 makes the tower summary distribution-agnostic — no magnitude @@ -15,7 +15,10 @@ > sibling package **`views_frames_reconcile`** — forecast reconciliation, ADR-023; v1.8.0 > adds the native point-country broadcast + the self-describing reconciliation mode, > the three showcase notebooks, and the frames-family hardening pass — Reconcile.md CIC, -> ADR-025 immutability-by-convention, the adversarial red-test batch). This +> ADR-025 immutability-by-convention, the adversarial red-test batch; v1.9.0 moves the +> tower-tip MAP to the **top floor** of the published tower — `tip_mass` 0.25 with the +> **MAP-containment law** in the conformance suite, ADR-019 Amendment 3 — and adds the +> `research/figures/` tower-figure toolkit). This > README is the design > bible; the contract it specifies is realised in `src/views_frames/` (index, frames, > io, conformance suite) plus the `src/views_frames_summarize/` sibling package diff --git a/docs/ADRs/019_coherent_posterior_summary_hdi_tower.md b/docs/ADRs/019_coherent_posterior_summary_hdi_tower.md index b61e2e8..362b408 100644 --- a/docs/ADRs/019_coherent_posterior_summary_hdi_tower.md +++ b/docs/ADRs/019_coherent_posterior_summary_hdi_tower.md @@ -23,6 +23,28 @@ > `tip_mass`-floor density, and the magnitude rule is now an **optional, off-by-default** > `config['zero_cutoff']` (read live) for count consumers that want it. The summary family is > now **distribution-agnostic**. See the *Decided properties* below. +> +> **Amendment 3 (2026-07-24 — `tip_mass` 0.5 → 0.25, the top-quartile floor).** The design +> intent was always a **tower-tip** MAP — the point read off an *upper* floor of the tower — +> but the shipped default read the 0.5 (shorth) floor, whose median carries a **structural +> bias away from the mode** on skewed shapes that does not shrink with sample count +> (measured: +0.12 to +2.0 across the shape battery; the narrow-floor bias is roughly half, +> everywhere). A simulation study (`research/map_hdi/tip_mass_study.py`: 1000-replicate +> accuracy/stability sweep, a duplicate-capture frontier, and the real-cell C-44 gate) +> settled the choice: floors of mass ≤ 0.15 **resurrect the C-44 zero-stack signal loss on +> the real faoapi cells** (a floor's median is safe only while a duplicate stack is under +> half its draws), 0.20 passes with zero margin, and **0.25 passes with margin** while +> beating the shorth on RMSE at pooled sample counts and reading zero-inflated cells +> exactly. Decided: `tip_mass = 0.25`. With it comes the **MAP-containment law**, now +> asserted in `conformance.py`: every floor holding more than half the tip floor's draws +> (a floor of mass `m` spans `floor(m·S)+1` draws; asymptotically `m > tip_mass/2 = 12.5%`) +> provably contains the tip — wider floors by nesting, narrower qualifying floors because a +> contiguous sub-window longer than half its parent cannot trim away the parent's median. +> Floors below that threshold carry no containment guarantee and are below platform sample +> resolution (the published bands 50/90/95/99 all qualify under both old and new defaults; +> the change *shrinks* the unguaranteed region from mass < 0.25 to mass ≤ ~0.125). +> Consumer note: published MAPs shift toward the mode on skewed cells — the intended +> direction (the C-32 lineage). The full shrinking-limit MAP remains #89. --- @@ -80,7 +102,7 @@ new surface (MINOR under ADR-018); the frozen estimators (`map_estimate`, `hdi`, **independent of the other requested masses** (the reproducibility guarantee that resolves C-33). - `tower_point(frame) -> (N, …, 1)` frame. The "tower tip": the median of the configurable - **`tip_mass`** floor's samples (default 0.5 — the "shorth"); zero-inflation is read off + **`tip_mass`** floor's samples (default 0.25 — the top-quartile floor, Amendment 3); zero-inflation is read off that floor's density (an optional `zero_cutoff` magnitude rule is off by default — C-45). Unbinned and median-based, so it carries **none** of `map_estimate`'s histogram tie-break bias (mitigates C-32), and — reading a *mass-aware* floor rather than @@ -185,7 +207,7 @@ decide); cross-repo adoption (consumers adopt when ready). - The bimodality flag is a heuristic with deliberately limited recall on *ambiguous* (overlapping) mixtures, unequal-weight splits, and a tall-narrow mode beside a spread one — documented as a conservative trade, not a formal test, and **registered as C-34**. -- `tower_point` reads a fixed `tip_mass` floor (default 50% — the shorth), so it is **not** +- `tower_point` reads a fixed `tip_mass` floor (default 25% — the top-quartile floor, Amendment 3), so it is **not** a consistency guarantee to the true mode; a fully-principled convergent mode remains #89. - `map_estimate` remains in the frozen surface (biased), now with a documented better alternative — a residual a naïve consumer can still step on. diff --git a/docs/CICs/Summarize.md b/docs/CICs/Summarize.md index 958597a..da90167 100644 --- a/docs/CICs/Summarize.md +++ b/docs/CICs/Summarize.md @@ -22,6 +22,13 @@ > additively in v1.6.0** (`src/views_frames_summarize/expected_shortfall.py`, > `tests/test_summarize_expected_shortfall.py`; register C-55/C-56 Resolved). Best-case ships **no > code** (a low quantile + `exceedance(0)`). The §3/§4/§6/§8–§11 entries marked *(ADR-022)* are live. +> +> **Amendment (2026-07-24, ADR-019 Amendment 3).** `tip_mass` default **0.5 → 0.25** (the +> top-quartile floor — the tower-tip MAP now reads the top floor of the published tower), with +> the **MAP-containment law** added to `assert_summarizer_contract`: every floor holding more +> than half the tip floor's draws provably contains the tip. Simulation evidence in +> `research/map_hdi/tip_mass_study.py`; behavior change to `tower_point`/`summarize_tower` +> outputs (MAPs shift toward the mode on skewed cells — the intended C-32 direction). --- @@ -95,11 +102,18 @@ re-derive (ADR-017). HDI with no nesting constraint, use the frozen `hdi`; use the tower when you need a *coherent, reproducible* family of bands. - `tower_point(frame)` → a `(N, …, 1)` frame: the **tower tip**, the median of the - configurable **`tip_mass`** floor (default 0.5 — the shorth). Zero-inflation is read off + configurable **`tip_mass`** floor (default 0.25 — the top-quartile floor, the top floor of + the published tower; ADR-019 Amendment 3, evidence `research/map_hdi/tip_mass_study.py`). + Zero-inflation is read off that floor's density (a zero-majority row reads 0; the optional `zero_cutoff` magnitude rule is off by default — C-45). Unbinned and median-based, so it carries **none** of `map_estimate`'s histogram tie-break bias (mitigates C-32) **and** is robust to minority - duplicates (C-44). + duplicates (C-44 — a floor's median is safe while a duplicate stack is under half its + draws; at 0.25 that tolerates the real-cell zero-stacks with margin). **MAP-containment + law** (asserted in the conformance suite): every floor holding more than half the tip + floor's draws — asymptotically mass > `tip_mass`/2 = 12.5% — provably contains the tip; + all published bands (50/90/95/99) qualify. Narrower floors carry no containment + guarantee and are below platform sample resolution. It is **not** a consistency-guaranteed mode; pair it with `bimodality`. **Caveat (the semantic shift — read before adopting over a histogram MAP):** on right-skewed / zero-inflated / multi-cluster posteriors `tower_point` returns the **densest** mode, which diff --git a/pyproject.toml b/pyproject.toml index faeb7b2..f781b53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "views-frames" -version = "1.8.1" +version = "1.9.0" description = "The VIEWS platform data-contract layer: immutable array+identifier frames (numpy only, root of the dependency DAG)." authors = [ { name = "Simon Polichinel von der Maase", email = "simmaa@prio.org" }, diff --git a/research/figures/NOTE.md b/research/figures/NOTE.md new file mode 100644 index 0000000..a5b75f2 --- /dev/null +++ b/research/figures/NOTE.md @@ -0,0 +1,111 @@ +# Research note — the tower figures (PRN06 provenance, method, and where plotting lives) + +This note records what the two published tower figures show, exactly how they were made, +how they were nearly lost, and the placement decision for figure tooling in this repo. + +## The figures + +FAO Project 02 **Pre-Release Note 06** (2026-06-24) embeds two figures: + +- **`tower_overlay`** — "The HDI tower over the empirical forecast distribution + (synthetic, n = 2000)": a 2×2 zoo (right-skewed, near-symmetric, zero-inflated, + bimodal), each panel a grey histogram with the full 25-floor nested HDI tower in blue + and the tower-tip MAP as a red dashed line. Tips: 1.28 / 5.89 / 0.00 / 2.56 [bimodal + flag]. +- **`tower_detail`** — one right-skewed posterior with the full tower and the 50/90/95% + floors highlighted. Tip: 1.51. + +## The method (and a misreading to guard against) + +Both figures call the **installed public API directly** — `hdi_tower`, `tower_point`, +`bimodality` — with no bespoke derivation. `tower_point` is (`tower_point.py`): + +```python +tower = _dense_tower(srt, ks) # outside-in nested tower +tip = _median_in(srt, tower[:, t, 0], tower[:, t, 1]) # median of the tip_mass floor +``` + +i.e. **the median of the draws inside the `tip_mass` floor of the outside-in tower**. +Since ADR-019 Amendment 3 (2026-07-24), `tip_mass` = **0.25** — the top-quartile floor, +the top floor of the published tower — replacing the original 0.5 ("shorth") default, +whose median carried a structural rightward bias on skewed shapes (evidence: +`research/map_hdi/tip_mass_study.py`; the change also brought the MAP-containment law — +every floor holding more than half the tip floor's draws provably contains the MAP). +It is *not* derived from any central/equal-tailed interval. + +**The v1 rendering actively invited that misreading (fixed in v2/v3, 2026-07-24).** +The as-published figures drew credible level *ascending*, which put the widest (99%) +floors at the **top** and the narrow floors at the bottom — an upside-down tower — and +placed the tip marker at level 0.5, i.e. **mid-structure: visually a "tower-middle +MAP"**, directly contradicting the "tower-*tip*" name. v2 inverted the vertical +arrangement (99% base at the bottom, floors narrowing upward) but drew the sub-50% +internal grid as a spire *above* the 50% floor — un-topping the tower and again making +the tip read as mid-structure. **v3 is the correct rendering:** the published tower +**ends at the `tip_mass` floor — its top floor** (50% at the time; **25% since ADR-019 +Amendment 3**) — with the MAP marker sitting on it, so "the tower-tip MAP is the median +of the top HDI" is literally what the picture shows. Floors below `tip_mass` are the canonical grid's internal machinery and are not +drawn. (The *narrowest-internal-floor* median — the shrinking-HDI-limit mode — remains +the **deferred #89 redesign**: the tip-mass study showed masses ≤ 0.15 resurrect the +C-44 zero-stack signal loss on real cells, which is what fixed the choice at 0.25.) +A smaller cue inherent to the method remains: on right-skewed shapes the tip-floor +median sits slightly right of the histogram peak (lognormal(0.5, 0.55): analytic mode +≈ 1.22; tip 1.38 on the seed-7 realization). v2/v3 also fixed twin-axis label +collisions between panels, the unreadable 0.92–0.99 floor slab, the endpoint "curtain" +verticals, and the detail figure's annotation collision. The rendering fixes preserved +the published values; the **Amendment 3 method change updated them** (see *Reproduce*). +The "central 50% is path-dependent" defect in `research/map_hdi/NOTE.md` describes the +**pre-C-44 / old-faoapi** behavior the tower *replaced*; these figures postdate that fix +(generated 19 minutes after the v1.3.0 research-note commit `96be471`) and — verified +2026-07-24 — `git diff 96be471..HEAD` over the five tower-family modules is **empty**, +so the published figures match the current (v1.8.x) method byte-for-byte. + +## Provenance (and the lesson) + +The original generators were **ephemeral scratchpad scripts** run on 2026-06-24 +08:00:15 / 08:00:58 CEST (matching the PDFs' embedded CreationDates to the second). +They were never committed anywhere; when their provenance was questioned on 2026-07-24, +no filesystem search could find them — they survived only inside a session transcript, +from which they were recovered verbatim. `make_tower_figs.py` in this directory is that +recovery made permanent (same seeds — overlay 7, detail 3 — same shapes, same styling; +a rerun reproduces the published tip values exactly). + +**Lesson:** any figure that lands in a deliverable gets a committed, seeded generator +the same day. Ephemeral plotting scripts are provenance debt. + +## Where plotting lives in this repo (the placement decision) + +**Figure tooling lives under `research/`, never under `src/`.** Considered and +rejected: a `src/views_frames_plotting` sibling package. Grounds: the sibling charters +exclude plotting explicitly and repeatedly (ADR-017:59, ADR-023:64 + :188 +"failure-mode to watch", Summarize CIC §2/§11, Reconcile CIC §2); the wheel is +numpy-only and `pyproject.toml` already rules that matplotlib is "notebook-only … +never imported by the package"; ADR-018 would freeze plot styling — the least stable +kind of code — onto the most frozen release unit (a CCP/SAP inversion); and C-52's +accretion guard names exactly this camel's nose. The native precedent followed instead: +`research/map_hdi/audit*.py`, `notebooks/_synthetic.py`, `scripts/` — dev/research +tooling outside the wheel, matplotlib via the `[docs]` extra. + +**Standard output location:** `reports/plots/` (gitignored — generated artifacts stay +out of git; a deliverable's copy travels with the deliverable, as PRN06's correctly +does in its own `figures/`). + +**Escalation path (recorded once, to close the question):** if cross-repo demand for +these plots ever materializes, they graduate to a separate *unfrozen* distribution +(the D-11 pattern) or to views-reporting, the platform's presentation layer — never +into this wheel. + +## Reproduce + +```bash +uv run --extra docs python research/figures/make_tower_figs.py # both, into reports/plots/ +uv run --extra docs python research/figures/make_tower_figs.py --which overlay --figdir /tmp/x +``` + +Expected (tip_mass = 0.25, ADR-019 Amendment 3): overlay tips `[1.38, 5.94, 0.0, 2.13]` +(4th panel bimodal-flagged), detail tip `1.47`. (The as-published PRN06 v1 figures carried +the pre-amendment 0.5-floor tips `[1.28, 5.89, 0.0, 2.56]` / `1.51`.) + +**PRN06 swap status:** the note still embeds the v1 figures (upside-down tower, +pre-amendment 0.5-floor tips); the v1 generators remain recoverable from the session +transcript if ever needed. Once the current figures are approved, copy them into the +note's `figures/` and recompile. diff --git a/research/figures/make_tower_figs.py b/research/figures/make_tower_figs.py new file mode 100644 index 0000000..e4d7b59 --- /dev/null +++ b/research/figures/make_tower_figs.py @@ -0,0 +1,242 @@ +"""Tower-figure generators — the PRN06 overlay + detail plots (v2, tower upright). + +Provenance: the figures of FAO Project 02 Pre-Release Note 06 (`tower_overlay.pdf`, +`tower_detail.pdf`, 2026-06-24) were generated by ephemeral scratchpad scripts, +recovered verbatim from a session transcript on 2026-07-24, and made permanent here +(see `research/figures/NOTE.md`). + +v2 (2026-07-24) corrects the as-published rendering, values unchanged: the original +drew credible level ascending, putting the WIDE floors at the top — an upside-down +tower whose "tip" marker sat mid-structure (a *tower-middle* MAP, visually). v2 +inverts the vertical arrangement so the tower stands upright: 99% base at the bottom, +floors narrowing upward, the ``tip_mass`` (50%) floor as the top of the published +tower with the MAP marker at its tip, and the sub-50% floors as a faint spire +converging on the MAP above it. Also fixed: twin-axis label collisions between +panels, the endpoint "curtain" verticals, and the detail figure's annotation +collision. + +The tips/towers come from the installed `views_frames_summarize` public API — +`tower_point` (median of the ``tip_mass`` floor — 0.25, the top floor of the +published tower, since ADR-019 Amendment 3), `hdi_tower` +(outside-in nested floors on the canonical grid), `bimodality`. Seeds are fixed +(overlay: 7, detail: 3), so reruns reproduce the published values exactly: +see NOTE.md for the current expected tip values (tip_mass = 0.25, ADR-019 Am. 3). + +Run (matplotlib rides the [docs] extra; never a package dependency): + + uv run --extra docs python research/figures/make_tower_figs.py + uv run --extra docs python research/figures/make_tower_figs.py \ + --figdir path/ --which overlay + +Outputs default to ``reports/plots/`` (gitignored — generated artifacts stay out of +git; a deliverable's copy travels with the deliverable). Plotting deliberately lives +under ``research/``, NOT ``src/``: the frames wheel is numpy-only and its charters +exclude plotting six times over (ADR-017, ADR-023, Summarize/Reconcile CIC §2). If +cross-repo demand for these plots ever materializes, they graduate to a separate +unfrozen distribution or views-reporting — never into this wheel. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import matplotlib +import numpy as np + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +import views_frames as vf +import views_frames_summarize as vfs +from views_frames_summarize import config + +FLOORS = np.asarray(config.canonical_floors()) +PUBLISHED = {0.25: "#c0392b", 0.5: "#2e86c1", 0.9: "#7d3c98", 0.95: "#117a65", + 0.99: "#1f4e79"} +TOWER_C = "#1f4e79" # deep blue (faint internal floors) +TIP_C = "#c0392b" # red + + +def _index(n: int) -> vf.SpatioTemporalIndex: + return vf.SpatioTemporalIndex( + time=np.arange(n, dtype=np.int64), + unit=np.arange(100, 100 + n, dtype=np.int32), + level=vf.SpatialLevel.PGM, + ) + + +def _frame(draws: np.ndarray) -> vf.PredictionFrame: + return vf.PredictionFrame(np.asarray(draws, np.float32)[None, :], _index(1)) + + +def _tower_and_tip(draws: np.ndarray) -> tuple[np.ndarray, float, float]: + f = _frame(draws) + tower = vfs.hdi_tower(f, masses=tuple(float(m) for m in FLOORS))[0] # (F, 2) + tip = float(vfs.tower_point(f).values.reshape(-1)[0]) + flag = float(vfs.bimodality(f).reshape(-1)[0]) + return tower, tip, flag + + +def _draw_upright_tower(ax2, tower: np.ndarray, tip: float, *, label_bands: bool): + """Draw the published tower standing upright on an inverted credible-mass axis. + + The axis is inverted so mass DECREASES upward: the 99% base sits at the bottom + and the tower narrows upward, ending at the ``tip_mass`` (50%) floor — the TOP + floor of the published tower. The tip marker sits on that top floor: the + tower-tip MAP is the median of the top HDI, exactly as the name promises. + Floors below ``tip_mass`` (the canonical grid's fine machinery) are NOT drawn — + rendering them as a spire above the 50% floor would un-top the tower and make + the tip read as mid-structure (the v2 mistake). + """ + tip_mass = float(config.get("tip_mass")) + ax2.set_ylim(tip_mass - 0.06, 1.03) + ax2.invert_yaxis() # mass 1.0 at the bottom (base), tip_mass at the top (tip) + for m, (lo, hi) in zip(FLOORS, tower, strict=True): + if m < tip_mass - 1e-9: + continue # internal grid below the tip floor: machinery, not the tower + if any(np.isclose(m, pm) for pm in PUBLISHED): + c = PUBLISHED[min(PUBLISHED, key=lambda pm: abs(pm - m))] + if hi - lo <= 1e-9: + # a degenerate band (e.g. the tip floor collapsed onto the zero atom + # of a zero-inflated cell): zero width renders invisibly — and the + # tip dot sits at the same point, hiding any filled marker. Draw an + # OPEN diamond around the spot so the collapsed band stays legible. + ax2.plot([lo], [m], marker="D", ms=11, zorder=8, + markerfacecolor="none", markeredgecolor=c, + markeredgewidth=1.4) + else: + ax2.plot([lo, hi], [m, m], color=c, lw=3.0, solid_capstyle="round", + zorder=5) + if label_bands: + ax2.text(hi, m, f" {int(round(m * 100))}%", va="center", ha="left", + fontsize=7.5, color=c, fontweight="bold") + else: + ax2.plot([lo, hi], [m, m], color=TOWER_C, lw=1.1, alpha=0.35, + solid_capstyle="round", zorder=3) + # the tip: marker ON TOP of the published tower (the tip_mass floor) + ax2.axvline(tip, color=TIP_C, lw=1.6, ls="--", zorder=6) + ax2.plot([tip], [tip_mass], marker="o", color=TIP_C, ms=7, zorder=7, + markeredgecolor="white", markeredgewidth=0.8) + + +def overlay_cases(n: int = 2000, seed: int = 7) -> list[tuple[str, np.ndarray]]: + """The four-shape zoo of the PRN06 overlay (seed 7 reproduces the note).""" + rng = np.random.default_rng(seed) + n_zero = int(0.45 * n) + return [ + ("Right-skewed unimodal", rng.lognormal(0.5, 0.55, n)), + ("Near-symmetric unimodal", rng.normal(6.0, 1.2, n)), + ( + "Zero-inflated + positive body", + np.concatenate([np.zeros(n_zero), rng.lognormal(0.7, 0.5, n - n_zero)]), + ), + ( + "Bimodal (assumption violated)", + np.concatenate( + [rng.normal(2.0, 0.6, n // 2), rng.normal(9.0, 0.8, n - n // 2)] + ), + ), + ] + + +def make_overlay(figdir: Path, n: int = 2000, seed: int = 7) -> None: + """The 2x2 upright-tower-over-histogram overlay across the four-shape zoo.""" + cases = overlay_cases(n, seed) + fig, axes = plt.subplots(2, 2, figsize=(11.5, 8.4), layout="constrained") + + for k, (ax, (title, draws)) in enumerate(zip(axes.ravel(), cases, strict=True)): + draws = np.asarray(draws, np.float32) + tower, tip, flag = _tower_and_tip(draws) + + ax.hist( + draws, bins=60, density=True, color="#e3e6ea", edgecolor="#c8cdd4", + linewidth=0.3, zorder=1, + ) + ax.set_xlabel("forecast value") + ax.set_title(title, fontsize=11, fontweight="bold") + ax.tick_params(axis="y", labelcolor="#777") + if k % 2 == 0: # outer-left panels only — no mid-figure label collisions + ax.set_ylabel("empirical density", color="#777") + + ax2 = ax.twinx() + _draw_upright_tower(ax2, tower, tip, label_bands=(k == 0)) + ax2.tick_params(axis="y", labelcolor=TOWER_C) + if k % 2 == 1: # outer-right panels only + ax2.set_ylabel("credible mass (99% base ↓ → 25% tip ↑)", + color=TOWER_C) + + tag = f"MAP (tip) = {tip:.2f}" + (" [bimodal flag]" if flag >= 0.5 else "") + ax2.text( + 0.98, 0.97, tag, transform=ax.transAxes, ha="right", va="top", + fontsize=8.5, color=TIP_C, + bbox={"boxstyle": "round,pad=0.25", "fc": "white", "ec": TIP_C, + "alpha": 0.9}, + ) + + fig.suptitle( + f"The HDI tower over the empirical forecast distribution (synthetic, $n={n}$)", + fontsize=12.5, fontweight="bold", + ) + fig.savefig(figdir / "tower_overlay.pdf") + fig.savefig(figdir / "tower_overlay.png", dpi=150) + plt.close(fig) + tips = [round(_tower_and_tip(np.asarray(c[1], np.float32))[1], 2) for c in cases] + print("wrote tower_overlay.pdf/.png; tips:", tips) + + +def make_detail(figdir: Path, n: int = 2000, seed: int = 3) -> None: + """One right-skewed posterior with the upright tower + published bands labeled.""" + rng = np.random.default_rng(seed) + draws = rng.lognormal(0.6, 0.5, n).astype(np.float32) + tower, tip, _ = _tower_and_tip(draws) + + fig, ax = plt.subplots(figsize=(8.6, 4.8), layout="constrained") + ax.hist(draws, bins=70, density=True, color="#e3e6ea", edgecolor="#c8cdd4", + lw=0.3) + ax.set_xlabel("forecast value") + ax.set_ylabel("empirical density", color="#777") + ax.tick_params(axis="y", labelcolor="#777") + + ax2 = ax.twinx() + _draw_upright_tower(ax2, tower, tip, label_bands=True) + ax2.set_ylabel("credible mass (99% base ↓ → 25% tip ↑)", color=TOWER_C) + ax2.tick_params(axis="y", labelcolor=TOWER_C) + # annotation in the clear space beside the spire, not across the floors + tip_mass = float(config.get("tip_mass")) + ax2.annotate( + f"MAP (tip) = {tip:.2f}", xy=(tip, tip_mass), xytext=(0.97, 0.72), + textcoords=ax.transAxes, ha="right", va="center", fontsize=9.5, + color=TIP_C, fontweight="bold", + arrowprops={"arrowstyle": "->", "color": TIP_C, "lw": 1.0, + "shrinkA": 4, "shrinkB": 6}, + ) + ax.set_title( + "Nested HDI tower and tower-tip MAP on one empirical posterior (synthetic)", + fontsize=11, fontweight="bold", + ) + fig.savefig(figdir / "tower_detail.pdf") + fig.savefig(figdir / "tower_detail.png", dpi=150) + plt.close(fig) + print("wrote tower_detail.pdf/.png; tip", round(tip, 2)) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--figdir", type=Path, default=Path("reports/plots"), + help="output directory (default: reports/plots — gitignored)", + ) + parser.add_argument( + "--which", choices=("overlay", "detail", "all"), default="all", + ) + args = parser.parse_args() + args.figdir.mkdir(parents=True, exist_ok=True) + if args.which in ("overlay", "all"): + make_overlay(args.figdir) + if args.which in ("detail", "all"): + make_detail(args.figdir) + + +if __name__ == "__main__": + main() diff --git a/research/map_hdi/tip_mass_study.py b/research/map_hdi/tip_mass_study.py new file mode 100644 index 0000000..deb58b1 --- /dev/null +++ b/research/map_hdi/tip_mass_study.py @@ -0,0 +1,129 @@ +"""Tip-mass study: which UPPER floor of the nested tower should define the MAP? + +Question (Simon, 2026-07-24): the tip must be the median of a NARROW upper floor of +the constrained-nested tower — top 5% preferred, 10/15/20/25 acceptable, "absolutely +not higher than 25%". Because the tower is nested (C-44 outside-in), the narrow floor +is contained in its parents, so the tip is bounded — the open questions are purely +empirical: + + (1) Monte-Carlo stability: how much does tip(m) wobble across independent draws of + the same posterior, at realistic sample counts (S = 32 / 100 / 1024)? + (2) Accuracy: bias/RMSE of tip(m) against the known true mode, per shape. + (3) Duplicate robustness (the C-44 concern, properly scoped to the nested tower): + does a minority duplicate stack INSIDE the body attract a narrow floor more + than a wide one? (An outside attractor is shed by nesting for every m — check.) + +Method: R independent replicate rows per (shape, S); one `tower_point` call per +tip_mass m (config live-read, restored after) gives R tips vectorized. m ∈ +{0.05, 0.10, 0.15, 0.20, 0.25} + 0.50 as the status-quo reference. + +Run: uv run python research/map_hdi/tip_mass_study.py +""" + +from __future__ import annotations + +import numpy as np + +import views_frames as vf +import views_frames_summarize as vfs +from views_frames_summarize import config + +R = 1000 # replicates per cell +MASSES = [0.05, 0.10, 0.15, 0.20, 0.25, 0.50] +SAMPLE_COUNTS = [32, 100, 1024] +RNG = np.random.default_rng(20260724) + + +def _frame(mat: np.ndarray) -> vf.PredictionFrame: + n = mat.shape[0] + idx = vf.SpatioTemporalIndex( + time=np.arange(n, dtype=np.int64), + unit=np.arange(100, 100 + n, dtype=np.int32), + level=vf.SpatialLevel.PGM, + ) + return vf.PredictionFrame(np.asarray(mat, np.float32), idx) + + +def tips_for_mass(mat: np.ndarray, m: float) -> np.ndarray: + """R tips (one per row) with tip_mass=m; config restored afterwards.""" + old = config.TOWER_CONFIG["tip_mass"] + try: + config.TOWER_CONFIG["tip_mass"] = m + return vfs.tower_point(_frame(mat)).values.reshape(-1).astype(np.float64) + finally: + config.TOWER_CONFIG["tip_mass"] = old + + +# --- shapes with known true modes ------------------------------------------------- +def shapes(s: int) -> dict[str, tuple[np.ndarray, float]]: + out = {} + out["lognormal (skewed)"] = ( + RNG.lognormal(0.5, 0.55, size=(R, s)), + float(np.exp(0.5 - 0.55**2)), # 1.218 + ) + out["gamma k=2 θ=5"] = (RNG.gamma(2.0, 5.0, size=(R, s)), 5.0) + out["normal (symmetric)"] = (RNG.normal(6.0, 1.2, size=(R, s)), 6.0) + zi = RNG.lognormal(0.7, 0.5, size=(R, s)) + mask = RNG.random(size=(R, s)) < 0.45 + out["zero-inflated 45%"] = (np.where(mask, 0.0, zi), 0.0) + return out + + +def main() -> None: + k_note = {s: {m: int(np.floor(m * s)) for m in MASSES} for s in SAMPLE_COUNTS} + print("draws inside the tip floor k = floor(m*S):") + for s in SAMPLE_COUNTS: + print(f" S={s:5d}: " + " ".join(f"m={m:.2f}→k={k_note[s][m]:4d}" + for m in MASSES)) + print() + + # ---- (1)+(2) stability & accuracy --------------------------------------------- + print("=" * 88) + print("STABILITY & ACCURACY (per cell: bias = mean(tip)−mode, SD across " + f"{R} replicates, RMSE)") + print("=" * 88) + for s in SAMPLE_COUNTS: + for name, (mat, mode) in shapes(s).items(): + line = f"S={s:5d} {name:22s} mode={mode:6.3f} | " + cells = [] + for m in MASSES: + t = tips_for_mass(mat, m) + bias = t.mean() - mode + sd = t.std() + rmse = np.sqrt(((t - mode) ** 2).mean()) + cells.append(f"m={m:.2f}: {bias:+.3f}/{sd:.3f}/{rmse:.3f}") + print(line) + for c in cells: + print(" " * 12 + c) + print("-" * 88) + + # ---- (3) duplicate robustness (C-44, nested-tower scoped) ---------------------- + print("=" * 88) + print("DUPLICATE ROBUSTNESS — minority duplicate stack (15% of draws at one " + "value); median |tip_dup − tip_clean| across replicates") + print("attractor INSIDE the body (at the clean q80) vs OUTSIDE (beyond q99.9×1.5)") + print("=" * 88) + for s in [32, 100, 1024]: + base = RNG.lognormal(0.5, 0.55, size=(R, s)) + n_dup = max(1, int(0.15 * s)) + q80 = np.quantile(base, 0.80, axis=1, keepdims=True) + far = np.quantile(base, 0.999, axis=1, keepdims=True) * 1.5 + for label, attractor in [("inside(q80)", q80), ("outside(far)", far)]: + dup = base.copy() + dup[:, :n_dup] = attractor # replace a minority block with the stack + line = f"S={s:5d} {label:13s} | " + cells = [] + for m in MASSES: + t_clean = tips_for_mass(base, m) + t_dup = tips_for_mass(dup, m) + disp = float(np.median(np.abs(t_dup - t_clean))) + snapped = float(np.mean(np.abs(t_dup - attractor.ravel()) < 1e-6)) + cells.append(f"m={m:.2f}: disp={disp:6.3f} snap={snapped:4.0%}") + print(line) + for c in cells: + print(" " * 12 + c) + print("-" * 88) + + +if __name__ == "__main__": + main() diff --git a/src/views_frames_summarize/config.py b/src/views_frames_summarize/config.py index 3349939..fe04f2a 100644 --- a/src/views_frames_summarize/config.py +++ b/src/views_frames_summarize/config.py @@ -28,9 +28,15 @@ [round(0.05 * i, 2) for i in range(1, 19)] # 0.05 … 0.90 + [0.92, 0.94, 0.95, 0.96, 0.97, 0.98, 0.99] # fine high-mass tail ), - # The tower-tip reads the median of the floor at this mass — the "shorth". 0.5 is - # the maximally-robust choice: a duplicate would need ~half the draws to hijack it. - "tip_mass": 0.5, + # The tower-tip reads the median of the floor at this mass — the top floor of the + # published tower. 0.25 (the top-quartile floor; ADR-019 amendment 2026-07-24, + # evidence in research/map_hdi/tip_mass_study.py): lower bias than the 0.5 shorth + # everywhere, RMSE-superior at pooled S, exact on zero-inflated cells, and it + # passes the real-cell C-44 gate with margin — the tip floor's median is safe as + # long as a duplicate stack is under half the floor (k > 2·duplicates; at S=32, + # k=8 tolerates the observed 2-3-zero stacks). Containment law: every HDI of mass + # > tip_mass/2 provably contains the tip (asserted in conformance.py). + "tip_mass": 0.25, # Optional magnitude zero rule, OFF by default (``None``). When set to a float, a # row whose every draw is <= it collapses to 0 — a *count* opt-in (sub-1 ⇒ 0). # Left ``None`` for any non-count target: zero-inflation is otherwise handled by the diff --git a/src/views_frames_summarize/conformance.py b/src/views_frames_summarize/conformance.py index 2bfe456..0c23177 100644 --- a/src/views_frames_summarize/conformance.py +++ b/src/views_frames_summarize/conformance.py @@ -126,6 +126,32 @@ def _assert_tower_contract(frame: AnyFrame, n: int) -> None: assert (tip.values[..., 0] >= tlo - 1e-6).all(), "tip below the tip_mass floor" assert (tip.values[..., 0] <= thi + 1e-6).all(), "tip above the tip_mass floor" + # MAP-containment law (ADR-019 amendment 2026-07-24). Wider-than-tip_mass floors + # contain the tip by nesting. Below tip_mass, a nested floor still contains it + # whenever it holds MORE THAN HALF the tip floor's draws: a contiguous sub-window + # longer than half the parent cannot trim away the parent's middle draw(s), and + # the tip is their median/average. A floor of mass m spans floor(m·S)+1 draws + # (the `_ks` value counts inter-draw steps), so the exact condition is + # 2·(floor(m·S)+1) > floor(tip_mass·S)+1 — asymptotically mass > tip_mass/2. + # Floors below it carry NO guarantee and are below platform sample resolution + # (see tower_point.py / research/map_hdi/tip_mass_study.py). + s_count = int(frame.values.shape[-1]) + n_tip = int(np.floor(tip_mass * s_count)) + 1 + guaranteed = tuple( + float(m) + for m in config.canonical_floors() + if 2 * (int(np.floor(float(m) * s_count)) + 1) > n_tip + ) + law_tower = hdi_tower(frame, masses=guaranteed) + for j, m in enumerate(guaranteed): + glo, ghi = law_tower[..., j, 0], law_tower[..., j, 1] + assert (tip.values[..., 0] >= glo - 1e-6).all(), ( + f"MAP-containment violated: tip below the {m:.2f} floor" + ) + assert (tip.values[..., 0] <= ghi + 1e-6).all(), ( + f"MAP-containment violated: tip above the {m:.2f} floor" + ) + # Reproducibility law: the 50% HDI is independent of the other requested masses. just_50 = hdi_tower(frame, masses=(0.5,)) assert np.array_equal(just_50[..., 0, :], tower[..., 0, :]), ( diff --git a/src/views_frames_summarize/tower_point.py b/src/views_frames_summarize/tower_point.py index 7bfd82a..e7fe400 100644 --- a/src/views_frames_summarize/tower_point.py +++ b/src/views_frames_summarize/tower_point.py @@ -2,15 +2,21 @@ `tower_point` is the "most likely single value" we report to a consumer: the median of the draws inside the **`tip_mass` floor** of the nested tower (config-driven, default -0.5 — the "shorth"). Zero-inflation is handled by that floor's density (a zero-majority -row reads 0); the optional, off-by-default magnitude cutoff applies too if set (C-45). +0.25 — the top-quartile floor, i.e. the top floor of the published tower; ADR-019 +amendment 2026-07-24, evidence in `research/map_hdi/tip_mass_study.py`). Zero-inflation +is handled by that floor's density (a zero-majority row reads 0); the optional, +off-by-default magnitude cutoff applies too if set (C-45). Containment law: every HDI +whose floor holds more than half the tip floor's draws provably contains the tip +(asymptotically, mass > `tip_mass`/2 — a nested contiguous window longer than half +the parent cannot trim away the parent's median); narrower floors carry no guarantee +and are below platform sample resolution. Asserted in ``conformance.py``. It is a *new* estimator, deliberately distinct from the frozen `map_estimate` (ADR-018): `map_estimate` is a binned histogram mode with a zero-*mass*-fraction rule and a lowest-index tie-break that is directionally biased on right-skewed, zero-inflated, low-sample posteriors (register C-32). The tower tip is unbinned — it reads the median of a mass-aware floor — so it carries no histogram tie-break **and** is robust to -minority duplicated draws (register C-44; a lonely outlier cannot define a 50%-mass +minority duplicated draws (register C-44; a lonely outlier cannot define a 25%-mass floor). On a genuinely multi-peaked row the tip is, like any point, ambiguous; pair it with `bimodality` to detect that case rather than collapse it silently. """ diff --git a/tests/test_summarize_config.py b/tests/test_summarize_config.py index 6ebea98..aba2a98 100644 --- a/tests/test_summarize_config.py +++ b/tests/test_summarize_config.py @@ -21,7 +21,10 @@ def test_green_shipped_config_is_complete(): def test_green_get_returns_the_set_value(): - assert config.get("tip_mass") == 0.5 + # tip_mass 0.25: the top-quartile floor (ADR-019 amendment 2026-07-24; evidence + # in research/map_hdi/tip_mass_study.py — the 0.5 shorth carried a structural + # rightward bias; 0.25 passes the real-cell C-44 gate with margin). + assert config.get("tip_mass") == 0.25 assert config.get("zero_cutoff") is None # magnitude rule off by default (C-45) assert config.get("row_block") == (1 << 16) diff --git a/tests/test_summarize_tower.py b/tests/test_summarize_tower.py index d841dad..074b28f 100644 --- a/tests/test_summarize_tower.py +++ b/tests/test_summarize_tower.py @@ -285,12 +285,14 @@ def test_beige_all_equal_row(): assert bimodality(pf)[0, 0] == 0.0 -def test_beige_tiny_sample_tip_is_tip_mass_shorth(): - # S=5, tip_mass=0.5 → the 50% floor is the shortest 3 of [2,4,6,8,10] = (2,4,6), - # whose median is 4.0 (NOT the global median 6.0 — the tip is the shorth, not the - # row median). Narrow floors (k<=0) collapse to a real sample and stay nested. +def test_beige_tiny_sample_tip_is_tip_mass_floor_median(): + # S=5, tip_mass=0.25 → the 25% floor spans floor(0.25·5)+1 = 2 draws: the + # shortest 2-window of [2,4,6,8,10] under the nesting chain is (2,4), whose + # median is 3.0 (NOT the global median 6.0 — the tip is the tip_mass-floor + # median, not the row median). Narrow floors collapse to a real sample and + # stay nested. pf = _pf([[2.0, 4.0, 6.0, 8.0, 10.0]]) - assert abs(_tp([2.0, 4.0, 6.0, 8.0, 10.0]) - 4.0) < 1e-6 + assert abs(_tp([2.0, 4.0, 6.0, 8.0, 10.0]) - 3.0) < 1e-6 tower = hdi_tower(pf, masses=(0.05, 0.5)) assert tower[0, 0, 0] >= tower[0, 1, 0] - 1e-6 # 5% nested in 50% assert tower[0, 0, 1] <= tower[0, 1, 1] + 1e-6 diff --git a/uv.lock b/uv.lock index bde9d42..24a03de 100644 --- a/uv.lock +++ b/uv.lock @@ -3274,7 +3274,7 @@ wheels = [ [[package]] name = "views-frames" -version = "1.8.1" +version = "1.9.0" source = { editable = "." } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },