views-frames 1.1.1__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {views_frames-1.1.1 → views_frames-1.2.0}/PKG-INFO +13 -10
  2. {views_frames-1.1.1 → views_frames-1.2.0}/README.md +12 -9
  3. {views_frames-1.1.1 → views_frames-1.2.0}/pyproject.toml +1 -1
  4. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/bimodality.py +26 -20
  5. views_frames-1.2.0/src/views_frames_summarize/config.py +89 -0
  6. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/conformance.py +10 -4
  7. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/summarize_tower.py +19 -12
  8. views_frames-1.2.0/src/views_frames_summarize/tower.py +224 -0
  9. views_frames-1.2.0/src/views_frames_summarize/tower_point.py +59 -0
  10. views_frames-1.1.1/src/views_frames_summarize/tower.py +0 -192
  11. views_frames-1.1.1/src/views_frames_summarize/tower_point.py +0 -46
  12. {views_frames-1.1.1 → views_frames-1.2.0}/.gitignore +0 -0
  13. {views_frames-1.1.1 → views_frames-1.2.0}/LICENSE +0 -0
  14. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/__init__.py +0 -0
  15. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/_typing.py +0 -0
  16. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/_validation.py +0 -0
  17. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/conformance/__init__.py +0 -0
  18. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/feature_frame.py +0 -0
  19. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/index.py +0 -0
  20. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/io/__init__.py +0 -0
  21. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/io/arrow.py +0 -0
  22. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/io/npz.py +0 -0
  23. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/metadata.py +0 -0
  24. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/prediction_frame.py +0 -0
  25. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/protocols.py +0 -0
  26. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/py.typed +0 -0
  27. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/spatial_level.py +0 -0
  28. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames/target_frame.py +0 -0
  29. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/__init__.py +0 -0
  30. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/_common.py +0 -0
  31. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/aggregate.py +0 -0
  32. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/collapse.py +0 -0
  33. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/interval.py +0 -0
  34. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/point.py +0 -0
  35. {views_frames-1.1.1 → views_frames-1.2.0}/src/views_frames_summarize/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: views-frames
3
- Version: 1.1.1
3
+ Version: 1.2.0
4
4
  Summary: The VIEWS platform data-contract layer: immutable array+identifier frames (numpy only, root of the dependency DAG).
5
5
  Project-URL: Homepage, https://github.com/views-platform/views-frames
6
6
  Project-URL: Repository, https://github.com/views-platform/views-frames
@@ -30,8 +30,9 @@ Description-Content-Type: text/markdown
30
30
  > containers (`FeatureFrame`, `PredictionFrame`, and their anticipated siblings)
31
31
  > that every other repo depends on and that depends on nothing internal.
32
32
  >
33
- > **Status:** **v1.1.1 — frozen API** (frozen since v1.0.0, ADR-018; the v1.1.0 surface
34
- > is purely additive — the coherent posterior summary, ADR-019; v1.1.1 is a docs patch). This README is the design
33
+ > **Status:** **v1.2.0 — frozen API** (frozen since v1.0.0, ADR-018; the v1.1 surface is
34
+ > purely additive — the coherent posterior summary, ADR-019; v1.2.0 rebuilds the tower
35
+ > `outside-in` and makes it config-driven, register C-44). This README is the design
35
36
  > bible; the contract it specifies is realised in `src/views_frames/` (index, frames,
36
37
  > io, conformance suite) plus the `src/views_frames_summarize/` sibling package
37
38
  > (sample-axis summarization — `collapse`/MAP/HDI/quantiles, the coherent-tower estimators
@@ -95,14 +96,16 @@ Both are numpy-only. For the subtler cm↔pgm surface — a time-varying
95
96
  `aggregate_distributions` (`HDI(sum) ≠ sum(HDI)`) — see
96
97
  [`examples/cross_level.py`](examples/cross_level.py).
97
98
 
98
- **Which estimator? (two coherent paths, v1.1.0).** Each frozen estimator has a
99
+ **Which estimator? (two coherent paths, v1.2.0).** Each frozen estimator has a
99
100
  coherent-tower sibling (ADR-019): `map_estimate` ↔ `tower_point` (an unbinned
100
- median-of-the-narrowest-floor point, free of `map_estimate`'s histogram tie-break bias),
101
- and `hdi`/`quantiles` `hdi_tower(masses=…)` (HDIs nested **by construction** and
102
- reproducible a mass's interval is identical regardless of which others you request),
103
- with `summarize_tower` returning all three in one pass. Use the **frozen** estimators for
104
- parity with existing pipelines; use the **tower** path when you need coherent, reproducible
105
- bands plus a matching point.
101
+ median-of-the-`tip_mass`-floor point — the "shorth", free of `map_estimate`'s histogram
102
+ tie-break bias **and** robust to minority duplicated draws, register C-44), and
103
+ `hdi`/`quantiles` `hdi_tower(masses=…)` (HDIs built **outside-in**, nested **by
104
+ construction** and reproducible a mass's interval is identical regardless of which others
105
+ you request), with `summarize_tower` returning all three in one pass. The tower's tunables
106
+ live in `views_frames_summarize.config` (fail-loud, no silent defaults). Use the **frozen**
107
+ estimators for parity with existing pipelines; use the **tower** path when you need coherent,
108
+ reproducible bands plus a matching, duplicate-robust point.
106
109
 
107
110
  **Reading the `bimodality` flag.** It is a *deliberately conservative* heuristic, **not** a
108
111
  formal multimodality test — tuned for **zero false positives** at the cost of recall on
@@ -4,8 +4,9 @@
4
4
  > containers (`FeatureFrame`, `PredictionFrame`, and their anticipated siblings)
5
5
  > that every other repo depends on and that depends on nothing internal.
6
6
  >
7
- > **Status:** **v1.1.1 — frozen API** (frozen since v1.0.0, ADR-018; the v1.1.0 surface
8
- > is purely additive — the coherent posterior summary, ADR-019; v1.1.1 is a docs patch). This README is the design
7
+ > **Status:** **v1.2.0 — frozen API** (frozen since v1.0.0, ADR-018; the v1.1 surface is
8
+ > purely additive — the coherent posterior summary, ADR-019; v1.2.0 rebuilds the tower
9
+ > `outside-in` and makes it config-driven, register C-44). This README is the design
9
10
  > bible; the contract it specifies is realised in `src/views_frames/` (index, frames,
10
11
  > io, conformance suite) plus the `src/views_frames_summarize/` sibling package
11
12
  > (sample-axis summarization — `collapse`/MAP/HDI/quantiles, the coherent-tower estimators
@@ -69,14 +70,16 @@ Both are numpy-only. For the subtler cm↔pgm surface — a time-varying
69
70
  `aggregate_distributions` (`HDI(sum) ≠ sum(HDI)`) — see
70
71
  [`examples/cross_level.py`](examples/cross_level.py).
71
72
 
72
- **Which estimator? (two coherent paths, v1.1.0).** Each frozen estimator has a
73
+ **Which estimator? (two coherent paths, v1.2.0).** Each frozen estimator has a
73
74
  coherent-tower sibling (ADR-019): `map_estimate` ↔ `tower_point` (an unbinned
74
- median-of-the-narrowest-floor point, free of `map_estimate`'s histogram tie-break bias),
75
- and `hdi`/`quantiles` `hdi_tower(masses=…)` (HDIs nested **by construction** and
76
- reproducible a mass's interval is identical regardless of which others you request),
77
- with `summarize_tower` returning all three in one pass. Use the **frozen** estimators for
78
- parity with existing pipelines; use the **tower** path when you need coherent, reproducible
79
- bands plus a matching point.
75
+ median-of-the-`tip_mass`-floor point — the "shorth", free of `map_estimate`'s histogram
76
+ tie-break bias **and** robust to minority duplicated draws, register C-44), and
77
+ `hdi`/`quantiles` `hdi_tower(masses=…)` (HDIs built **outside-in**, nested **by
78
+ construction** and reproducible a mass's interval is identical regardless of which others
79
+ you request), with `summarize_tower` returning all three in one pass. The tower's tunables
80
+ live in `views_frames_summarize.config` (fail-loud, no silent defaults). Use the **frozen**
81
+ estimators for parity with existing pipelines; use the **tower** path when you need coherent,
82
+ reproducible bands plus a matching, duplicate-robust point.
80
83
 
81
84
  **Reading the `bimodality` flag.** It is a *deliberately conservative* heuristic, **not** a
82
85
  formal multimodality test — tuned for **zero false positives** at the cost of recall on
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "views-frames"
7
- version = "1.1.1"
7
+ version = "1.2.0"
8
8
  description = "The VIEWS platform data-contract layer: immutable array+identifier frames (numpy only, root of the dependency DAG)."
9
9
  authors = [
10
10
  { name = "Simon Polichinel von der Maase", email = "simmaa@prio.org" },
@@ -26,11 +26,10 @@ from __future__ import annotations
26
26
  import numpy as np
27
27
  from numpy.typing import NDArray
28
28
 
29
- from views_frames_summarize._common import ROW_BLOCK, AnyFrame, block_apply
29
+ from views_frames_summarize import config
30
+ from views_frames_summarize._common import AnyFrame, block_apply
30
31
  from views_frames_summarize.tower import _zero_mask
31
32
 
32
- _SMOOTH = 3 # moving-average window over bins; tames sparse-histogram flicker
33
-
34
33
 
35
34
  def _coarse_counts(flat: NDArray[np.float32], bins: int) -> NDArray[np.intp]:
36
35
  """Per-row histogram counts ``(rows, bins)`` over each row's ``[min, max]``.
@@ -49,24 +48,32 @@ def _coarse_counts(flat: NDArray[np.float32], bins: int) -> NDArray[np.intp]:
49
48
 
50
49
 
51
50
  def _bimodal_block(
52
- block: NDArray[np.float32], bins: int, prominence: float, min_mass: float
51
+ block: NDArray[np.float32],
52
+ bins: int,
53
+ prominence: float,
54
+ min_mass: float,
55
+ smooth: int,
53
56
  ) -> NDArray[np.float32]:
54
- """Flag (0/1) per row of a block: ≥ 2 separated regions each holding enough mass."""
57
+ """Flag (0/1) per row of a block: ≥ 2 separated regions each holding enough mass.
58
+
59
+ ``smooth`` is the moving-average window width (the 3-tap normalization; config
60
+ ``bimodality_smooth``). It tames sparse-histogram flicker.
61
+ """
55
62
  rows = block.shape[0]
56
63
  counts = _coarse_counts(block, bins).astype(np.float64)
57
64
 
58
- # Light moving-average smoothing (window _SMOOTH) over the bins. Normalise each
65
+ # Light moving-average smoothing (window ``smooth``) over the bins. Normalise each
59
66
  # position by the number of *real* bins in its window — the two edge bins have one
60
67
  # fewer neighbour, so dividing them by the full window would deflate an edge peak
61
68
  # (e.g. the zero atom, which always lands in bin 0) and hide an atom+bump bimodal.
62
69
  pad = np.zeros((rows, 1))
63
70
  padded = np.concatenate([pad, counts, pad], axis=1)
64
71
  window_sum = padded[:, :-2] + padded[:, 1:-1] + padded[:, 2:]
65
- divisor = np.full(bins, _SMOOTH, dtype=np.float64)
66
- divisor[0] = divisor[-1] = _SMOOTH - 1 # edges contribute one fewer real bin
67
- smooth = window_sum / divisor
72
+ divisor = np.full(bins, smooth, dtype=np.float64)
73
+ divisor[0] = divisor[-1] = smooth - 1 # edges contribute one fewer real bin
74
+ smoothed = window_sum / divisor
68
75
 
69
- significant = smooth >= prominence * smooth.max(axis=1, keepdims=True)
76
+ significant = smoothed >= prominence * smoothed.max(axis=1, keepdims=True)
70
77
  prev = np.concatenate(
71
78
  [np.zeros((rows, 1), dtype=bool), significant[:, :-1]], axis=1
72
79
  )
@@ -87,27 +94,26 @@ def _bimodal_block(
87
94
  return flag
88
95
 
89
96
 
90
- def bimodality(
91
- frame: AnyFrame,
92
- *,
93
- bins: int = 16,
94
- prominence: float = 0.40,
95
- min_mass: float = 0.15,
96
- block_rows: int = ROW_BLOCK,
97
- ) -> NDArray[np.float32]:
97
+ def bimodality(frame: AnyFrame) -> NDArray[np.float32]:
98
98
  """Per-row bimodality flag over the sample axis → ``(N, …, 1)`` of ``0.0``/``1.0``.
99
99
 
100
100
  Conservative heuristic (see module docstring): ≥ 2 separated density regions, each
101
101
  holding ≥ ``min_mass`` of the samples, after coarse binning + light smoothing.
102
- Aligned to ``frame.index``.
102
+ Aligned to ``frame.index``. Tunables (``bins``, ``prominence``, ``min_mass``,
103
+ ``smooth``, row-block) come from ``config`` — no silent defaults (ADR-009).
103
104
  """
104
105
  values = frame.values
105
106
  lead = values.shape[:-1]
106
107
  s = values.shape[-1]
108
+ bins = int(config.get("bimodality_bins"))
109
+ prominence = float(config.get("bimodality_prominence"))
110
+ min_mass = float(config.get("bimodality_min_mass"))
111
+ smooth = int(config.get("bimodality_smooth"))
112
+ block_rows = int(config.get("row_block"))
107
113
  flat = np.ascontiguousarray(values).reshape(-1, s)
108
114
 
109
115
  def _block(block: NDArray[np.float32]) -> NDArray[np.float32]:
110
- return _bimodal_block(block, bins, prominence, min_mass)
116
+ return _bimodal_block(block, bins, prominence, min_mass, smooth)
111
117
 
112
118
  out = block_apply(flat, block_rows, _block)
113
119
  return np.asarray(out, dtype=np.float32).reshape(lead)[..., np.newaxis]
@@ -0,0 +1,89 @@
1
+ """Tower-family tunables (ADR-019) — a fail-loud config dict, no silent defaults.
2
+
3
+ views-models style: a plain dict of tunables, validated at entry, with **no defaults
4
+ anywhere downstream**. ADR-008 (explicit failure) + ADR-009 ("No semantic defaults may
5
+ exist silently"): a tower-family function reads each parameter from here, and a missing
6
+ key raises a ``ValueError`` naming it rather than falling back to a hidden literal.
7
+
8
+ ``masses`` is **not** here — it stays a per-call argument (it is *what* a caller asks
9
+ for, not a tunable). The frozen estimators (``map_estimate``/``hdi``/``quantiles``,
10
+ ADR-018) are **out of scope**; this config governs only the tower family
11
+ (``hdi_tower``/``tower_point``/``bimodality``/``summarize_tower``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Final
17
+
18
+ import numpy as np
19
+ from numpy.typing import NDArray
20
+
21
+ # The single source of truth for every tower-family tunable. Edit values here; the
22
+ # algorithm code carries no fallback literals (ADR-009).
23
+ TOWER_CONFIG: Final[dict[str, Any]] = {
24
+ # The fixed canonical mass grid: a 5% body + a fine high-mass tail to 0.99. Built
25
+ # from rounded literals — never ``np.arange`` (which drifts ~1 ulp across numpy
26
+ # versions and would make every pinned interval non-reproducible; register C-24).
27
+ "canonical_floors": tuple(
28
+ [round(0.05 * i, 2) for i in range(1, 19)] # 0.05 … 0.90
29
+ + [0.92, 0.94, 0.95, 0.96, 0.97, 0.98, 0.99] # fine high-mass tail
30
+ ),
31
+ # The tower-tip reads the median of the floor at this mass — the "shorth". 0.5 is
32
+ # the maximally-robust choice: a duplicate would need ~half the draws to hijack it.
33
+ "tip_mass": 0.5,
34
+ # Raw-count zero rule: a row whose every draw is <= this collapses to 0 (the quiet
35
+ # cell). A *count* rule, distinct from ``map_estimate``'s zero-mass-fraction rule.
36
+ "zero_cutoff": 1.0,
37
+ # Bimodality detector (coarse histogram + light smoothing; see bimodality.py).
38
+ "bimodality_bins": 16,
39
+ "bimodality_prominence": 0.40,
40
+ "bimodality_min_mass": 0.15,
41
+ "bimodality_smooth": 3,
42
+ # Row-block size for the memory-bounded estimators (register C-22/C-25).
43
+ "row_block": 1 << 16,
44
+ }
45
+
46
+ # Every key a tower-family function may read. The completeness check is keyed off this,
47
+ # so adding a reader without a corresponding key fails the test suite, not production.
48
+ REQUIRED_KEYS: Final[frozenset[str]] = frozenset(
49
+ {
50
+ "canonical_floors",
51
+ "tip_mass",
52
+ "zero_cutoff",
53
+ "bimodality_bins",
54
+ "bimodality_prominence",
55
+ "bimodality_min_mass",
56
+ "bimodality_smooth",
57
+ "row_block",
58
+ }
59
+ )
60
+
61
+
62
+ def validate_config(cfg: dict[str, Any]) -> None:
63
+ """Raise ``ValueError`` listing any missing required key (ADR-009 completeness)."""
64
+ missing = REQUIRED_KEYS - cfg.keys()
65
+ if missing:
66
+ raise ValueError(
67
+ f"tower config is missing required key(s): {sorted(missing)}; "
68
+ "no semantic defaults may exist silently (ADR-009)"
69
+ )
70
+
71
+
72
+ def get(key: str, cfg: dict[str, Any] | None = None) -> Any:
73
+ """Fail-loud accessor: raise naming the key rather than returning a default."""
74
+ cfg = TOWER_CONFIG if cfg is None else cfg
75
+ if key not in cfg:
76
+ raise ValueError(
77
+ f"tower config key {key!r} is not set; no silent default (ADR-008/009). "
78
+ f"known keys: {sorted(cfg)}"
79
+ )
80
+ return cfg[key]
81
+
82
+
83
+ def canonical_floors(cfg: dict[str, Any] | None = None) -> NDArray[np.float64]:
84
+ """The fixed grid as a float64 array (rounded literals; never ``np.arange``)."""
85
+ return np.asarray(get("canonical_floors", cfg), dtype=np.float64)
86
+
87
+
88
+ # Import-time guarantee: the shipped config is itself complete.
89
+ validate_config(TOWER_CONFIG)
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import numpy as np
11
11
 
12
+ from views_frames_summarize import config
12
13
  from views_frames_summarize._common import AnyFrame
13
14
  from views_frames_summarize.bimodality import bimodality
14
15
  from views_frames_summarize.collapse import collapse
@@ -69,10 +70,15 @@ def _assert_tower_contract(frame: AnyFrame, n: int) -> None:
69
70
  "tower uppers must be non-decreasing"
70
71
  )
71
72
 
72
- # Tip-in-narrowest law: the point lies inside the narrowest requested floor.
73
- lo0, hi0 = tower[..., 0, 0], tower[..., 0, 1]
74
- assert (tip.values[..., 0] >= lo0 - 1e-6).all(), "tip below the narrowest floor"
75
- assert (tip.values[..., 0] <= hi0 + 1e-6).all(), "tip above the narrowest floor"
73
+ # Tip-in-tip_mass-floor law (ADR-019, outside-in redesign): the point is the median
74
+ # of the configured ``tip_mass`` floor, so it lies inside that floor. (It is *not*
75
+ # tied to the narrowest *requested* floor any longer a caller may request a
76
+ # narrower or wider band than ``tip_mass``.)
77
+ tip_mass = float(config.get("tip_mass"))
78
+ tip_floor = hdi_tower(frame, masses=(tip_mass,))
79
+ tlo, thi = tip_floor[..., 0, 0], tip_floor[..., 0, 1]
80
+ assert (tip.values[..., 0] >= tlo - 1e-6).all(), "tip below the tip_mass floor"
81
+ assert (tip.values[..., 0] <= thi + 1e-6).all(), "tip above the tip_mass floor"
76
82
 
77
83
  # Reproducibility law: the 50% HDI is independent of the other requested masses.
78
84
  just_50 = hdi_tower(frame, masses=(0.5,))
@@ -4,7 +4,7 @@
4
4
  the canonical tower **once**, deriving the tower-tip point, the pinned nested HDIs, and
5
5
  the bimodality flag together. It returns exactly what the three composable functions
6
6
  (`tower_point`, `hdi_tower`, `bimodality`) return — the bundle is purely an efficiency
7
- collapse of the three, not a different computation.
7
+ collapse of the three, not a different computation. All tunables come from `config`.
8
8
  """
9
9
 
10
10
  from __future__ import annotations
@@ -15,14 +15,16 @@ from typing import NamedTuple
15
15
  import numpy as np
16
16
  from numpy.typing import NDArray
17
17
 
18
- from views_frames_summarize._common import ROW_BLOCK, AnyFrame, rebuild
18
+ from views_frames_summarize import config
19
+ from views_frames_summarize._common import AnyFrame, rebuild
19
20
  from views_frames_summarize.bimodality import _bimodal_block
20
21
  from views_frames_summarize.tower import (
21
22
  _CANONICAL_FLOORS,
22
23
  _dense_tower,
23
24
  _ks,
25
+ _median_in,
24
26
  _pin,
25
- _tip,
27
+ _tip_floor_index,
26
28
  _zero_mask,
27
29
  )
28
30
 
@@ -46,11 +48,6 @@ class TowerSummary(NamedTuple):
46
48
  def summarize_tower(
47
49
  frame: AnyFrame,
48
50
  masses: Sequence[float] = (0.5, 0.9, 0.99),
49
- *,
50
- bins: int = 16,
51
- prominence: float = 0.40,
52
- min_mass: float = 0.15,
53
- block_rows: int = ROW_BLOCK,
54
51
  ) -> TowerSummary:
55
52
  """Point + nested HDIs + bimodality flag in one sort-once pass → `TowerSummary`."""
56
53
  values = frame.values
@@ -58,7 +55,12 @@ def summarize_tower(
58
55
  s = values.shape[-1]
59
56
  ks = _ks(s)
60
57
  pin = _pin(masses)
61
- k0 = int(ks[0])
58
+ t = _tip_floor_index(s)
59
+ bins = int(config.get("bimodality_bins"))
60
+ prominence = float(config.get("bimodality_prominence"))
61
+ min_mass = float(config.get("bimodality_min_mass"))
62
+ smooth = int(config.get("bimodality_smooth"))
63
+ block_rows = int(config.get("row_block"))
62
64
  flat = np.ascontiguousarray(values).reshape(-1, s)
63
65
  rows = flat.shape[0]
64
66
 
@@ -71,14 +73,19 @@ def summarize_tower(
71
73
  srt = np.sort(block, axis=-1)
72
74
  zero = _zero_mask(block)
73
75
 
74
- sel = _dense_tower(srt, ks)[:, pin, :]
76
+ tower = _dense_tower(srt, ks)
77
+ sel = tower[:, pin, :]
75
78
  sel[zero] = 0.0
76
- tip = np.where(zero, np.float32(0.0), _tip(srt, k0))
79
+ tip = np.where(
80
+ zero, np.float32(0.0), _median_in(srt, tower[:, t, 0], tower[:, t, 1])
81
+ )
77
82
 
78
83
  stop = start + block.shape[0]
79
84
  point_flat[start:stop] = tip
80
85
  intervals_flat[start:stop] = sel
81
- bimodal_flat[start:stop] = _bimodal_block(block, bins, prominence, min_mass)
86
+ bimodal_flat[start:stop] = _bimodal_block(
87
+ block, bins, prominence, min_mass, smooth
88
+ )
82
89
 
83
90
  point = rebuild(frame, point_flat.reshape(lead)[..., np.newaxis])
84
91
  intervals = intervals_flat.reshape(*lead, pin.shape[0], 2)
@@ -0,0 +1,224 @@
1
+ """The constrained-nested HDI tower (ADR-019) — interval estimates over the sample axis.
2
+
3
+ `hdi_tower` returns, for each requested mass, the highest-density interval read out of
4
+ a **fixed canonical tower** built **outside-in**: the widest floor is the shortest
5
+ interval holding its mass, then each *narrower* floor is the shortest interval
6
+ **contained in** its wider parent. Nesting is guaranteed **by construction** (register
7
+ C-33), and — crucially — the build is **robust to minority duplicated draws** (register
8
+ C-44): a lonely outlier (a couple of exact zeros, a stray pair) is shed by the
9
+ well-determined wide floors, and the containment constraint forbids any narrower floor
10
+ from re-selecting a window that pokes outside its parent. Because the tower is always
11
+ built on the same fixed `_CANONICAL_FLOORS` grid — requested masses are *pinned* to the
12
+ nearest floor, never inserted — "the 50% HDI" is identical regardless of which other
13
+ masses a caller asks for (reproducibility).
14
+
15
+ The construction is vectorized over the sample axis and runs in row-blocks (register
16
+ C-22/C-25): one sort per block, never a whole-grid sorted copy. A raw-count zero
17
+ short-circuit (`max(row) <= zero_cutoff` → the whole summary collapses to 0) kills the
18
+ quiet cells cheaply.
19
+
20
+ All tunables (the grid, zero cutoff, row-block size) come from `config` with **no silent
21
+ defaults** (ADR-009). The private engine (`_dense_tower`, `_median_in`, `_pin`,
22
+ `_zero_mask`, `_CANONICAL_FLOORS`) is shared by `tower_point`, `bimodality`, and the
23
+ single-pass `summarize_tower` bundle.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections.abc import Sequence
29
+ from typing import Final
30
+
31
+ import numpy as np
32
+ from numpy.typing import NDArray
33
+
34
+ from views_frames_summarize import config
35
+ from views_frames_summarize._common import AnyFrame, block_apply
36
+
37
+ # The fixed canonical mass grid + the zero cutoff, sourced from the single config (no
38
+ # silent defaults — ADR-009). Kept as module names so the engine and its siblings share
39
+ # one source of truth.
40
+ _CANONICAL_FLOORS: Final[NDArray[np.float64]] = config.canonical_floors()
41
+ _ZERO_CUTOFF: Final[float] = float(config.get("zero_cutoff"))
42
+
43
+
44
+ def _ks(sample_count: int) -> NDArray[np.intp]:
45
+ """The per-floor window size ``k = floor(mass * S)`` for every canonical floor."""
46
+ return np.asarray(np.floor(_CANONICAL_FLOORS * sample_count), dtype=np.intp)
47
+
48
+
49
+ def _pin(masses: Sequence[float]) -> NDArray[np.intp]:
50
+ """Index of the nearest canonical floor for each requested mass.
51
+
52
+ Deterministic: ``argmin`` of the distance, breaking ties on the lowest index. A
53
+ mass at the midpoint between two floors (e.g. ``0.075`` between ``0.05`` and
54
+ ``0.10``) therefore pins **down** to the lower floor — unambiguous and
55
+ reproducible. Fails loud (ADR-008) on a mass outside ``(0, 1)`` rather than silently
56
+ pinning a nonsense value to the nearest floor and returning a plausible interval.
57
+ """
58
+ m = np.asarray(masses, dtype=np.float64)
59
+ if m.size == 0 or not np.all((m > 0.0) & (m < 1.0)):
60
+ raise ValueError(
61
+ f"masses must each be in the open interval (0, 1); got {masses!r}"
62
+ )
63
+ dist = np.abs(_CANONICAL_FLOORS[None, :] - m[:, None])
64
+ return np.asarray(np.argmin(dist, axis=1), dtype=np.intp)
65
+
66
+
67
+ def _zero_mask(block: NDArray[np.float32]) -> NDArray[np.bool_]:
68
+ """Rows whose maximum draw is at/below the zero cutoff — the quiet cells."""
69
+ return np.asarray(block.max(axis=-1) <= _ZERO_CUTOFF, dtype=np.bool_)
70
+
71
+
72
+ def _in_range_span(
73
+ srt: NDArray[np.float32], lo: NDArray[np.float32], hi: NDArray[np.float32]
74
+ ) -> tuple[NDArray[np.intp], NDArray[np.intp]]:
75
+ """``(first, count)`` of the contiguous run of samples within ``[lo, hi]`` per row.
76
+
77
+ ``lo``/``hi`` are sample values (a floor's bounds), so the in-range samples form a
78
+ contiguous run; at least one is always in range (the floor was built from samples).
79
+ """
80
+ inside = (srt >= lo[:, None]) & (srt <= hi[:, None])
81
+ first = np.argmax(inside, axis=1) # index of the first in-range sample
82
+ count = inside.sum(axis=1)
83
+ return np.asarray(first, dtype=np.intp), np.asarray(count, dtype=np.intp)
84
+
85
+
86
+ def _median_in(
87
+ srt: NDArray[np.float32], lo: NDArray[np.float32], hi: NDArray[np.float32]
88
+ ) -> NDArray[np.float32]:
89
+ """Per-row **median value** of the samples within ``[lo, hi]`` (averaged for an even
90
+ count). Used for the *tip point estimate*, which may fall between two samples.
91
+ """
92
+ first, count = _in_range_span(srt, lo, hi)
93
+ a = first + (count - 1) // 2
94
+ b = first + count // 2
95
+ va = np.take_along_axis(srt, a[:, None], axis=1)[:, 0]
96
+ vb = np.take_along_axis(srt, b[:, None], axis=1)[:, 0]
97
+ return np.asarray((va + vb) * 0.5, dtype=np.float32)
98
+
99
+
100
+ def _mid_sample_in(
101
+ srt: NDArray[np.float32], lo: NDArray[np.float32], hi: NDArray[np.float32]
102
+ ) -> NDArray[np.float32]:
103
+ """Per-row **middle sample** (a real draw) of the run within ``[lo, hi]``.
104
+
105
+ Used for a ``k <= 0`` *floor*, whose bounds must be actual samples so that the next
106
+ narrower floor's containment (and `_median_in`) stays well-defined — an averaged
107
+ median could be a value no draw equals, breaking the contiguous-run assumption.
108
+ """
109
+ first, count = _in_range_span(srt, lo, hi)
110
+ mid = first + (count - 1) // 2
111
+ return np.asarray(
112
+ np.take_along_axis(srt, mid[:, None], axis=1)[:, 0], dtype=np.float32
113
+ )
114
+
115
+
116
+ def _shortest_seed(
117
+ srt: NDArray[np.float32], k: int
118
+ ) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
119
+ """The widest floor (no parent): the shortest interval holding ``k + 1`` samples.
120
+
121
+ Leftmost tie-break (``np.argmin`` returns the first). ``k <= 0`` degenerates to the
122
+ per-row median point; ``k >= n-1`` is the whole row (the widest floor of a small S).
123
+ """
124
+ n = srt.shape[-1]
125
+ if k <= 0:
126
+ v = srt[:, n // 2]
127
+ return v.copy(), v.copy()
128
+ if k >= n - 1:
129
+ return srt[:, 0].copy(), srt[:, -1].copy()
130
+ widths = srt[:, k:] - srt[:, : n - k]
131
+ i = np.argmin(widths, axis=-1)
132
+ lo = np.take_along_axis(srt, i[:, None], axis=-1)[:, 0]
133
+ hi = np.take_along_axis(srt, (i + k)[:, None], axis=-1)[:, 0]
134
+ return lo, hi
135
+
136
+
137
+ def _shortest_contained_in(
138
+ srt: NDArray[np.float32],
139
+ k: int,
140
+ parent_lo: NDArray[np.float32],
141
+ parent_hi: NDArray[np.float32],
142
+ ) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
143
+ """Shortest ``k+1``-sample window ``[lo, hi]`` with ``parent_lo <= lo`` and
144
+ ``hi <= parent_hi`` — the outside-in nesting step.
145
+
146
+ This is what makes the tower nested *and* robust to minority duplicates: a window
147
+ straddling an outlier cannot fit in a parent that already shed it. Always feasible
148
+ — the parent's own first ``k+1`` samples are a candidate (the parent holds at least
149
+ ``k+1`` samples, since floors are non-decreasing in mass and built widest-first).
150
+ ``k <= 0`` collapses to the in-parent median point.
151
+ """
152
+ n = srt.shape[-1]
153
+ if k <= 0:
154
+ v = _mid_sample_in(srt, parent_lo, parent_hi)
155
+ return v, v
156
+ starts = srt[:, : n - k]
157
+ ends = srt[:, k:]
158
+ inside = (starts >= parent_lo[:, None]) & (ends <= parent_hi[:, None])
159
+ widths = np.where(inside, ends - starts, np.inf)
160
+ i = np.argmin(widths, axis=-1)
161
+ lo = np.take_along_axis(starts, i[:, None], axis=-1)[:, 0]
162
+ hi = np.take_along_axis(ends, i[:, None], axis=-1)[:, 0]
163
+ return lo, hi
164
+
165
+
166
+ def _dense_tower(srt: NDArray[np.float32], ks: NDArray[np.intp]) -> NDArray[np.float32]:
167
+ """Build the full constrained-nested tower over a sorted block → ``(rows, F, 2)``.
168
+
169
+ Built **outside-in**: the widest floor (last index) first, then each narrower floor
170
+ contained in its wider parent. Each floor is written at its natural ascending index,
171
+ so the output layout, the ``[:, pin, :]`` readout, and reproducibility are unchanged
172
+ from the published contract — only the *fill order* runs widest→narrowest.
173
+ """
174
+ rows = srt.shape[0]
175
+ f = ks.shape[0]
176
+ out = np.empty((rows, f, 2), dtype=np.float32)
177
+ plo: NDArray[np.float32] | None = None
178
+ phi: NDArray[np.float32] | None = None
179
+ for j in range(f - 1, -1, -1): # widest → narrowest
180
+ k = int(ks[j])
181
+ if plo is None or phi is None:
182
+ lo, hi = _shortest_seed(srt, k)
183
+ else:
184
+ lo, hi = _shortest_contained_in(srt, k, plo, phi)
185
+ out[:, j, 0] = lo
186
+ out[:, j, 1] = hi
187
+ plo, phi = lo, hi
188
+ return out
189
+
190
+
191
+ def _tip_floor_index(sample_count: int) -> int:
192
+ """The canonical-grid index whose mass is nearest ``config['tip_mass']``."""
193
+ tip_mass = float(config.get("tip_mass"))
194
+ return int(np.argmin(np.abs(_CANONICAL_FLOORS - tip_mass)))
195
+
196
+
197
+ def hdi_tower(
198
+ frame: AnyFrame,
199
+ masses: Sequence[float] = (0.5, 0.9, 0.99),
200
+ ) -> NDArray[np.float32]:
201
+ """Per-row constrained-nested HDIs at the requested ``masses`` → ``(N, …, M, 2)``.
202
+
203
+ Each requested mass is pinned to the nearest fixed canonical floor; the interval is
204
+ read out of the full canonical tower (built once per block, outside-in). Nested by
205
+ construction, robust to minority duplicates, and reproducible (a mass's interval is
206
+ independent of the other requested masses). Quiet rows (``max <= zero_cutoff``)
207
+ collapse to ``(0, 0)``. Aligned to ``frame.index``. Tunables come from ``config``.
208
+ """
209
+ values = frame.values
210
+ lead = values.shape[:-1]
211
+ s = values.shape[-1]
212
+ ks = _ks(s)
213
+ pin = _pin(masses)
214
+ block_rows = int(config.get("row_block"))
215
+ flat = np.ascontiguousarray(values).reshape(-1, s)
216
+
217
+ def _block(block: NDArray[np.float32]) -> NDArray[np.float32]:
218
+ srt = np.sort(block, axis=-1)
219
+ sel = _dense_tower(srt, ks)[:, pin, :]
220
+ sel[_zero_mask(block)] = 0.0
221
+ return sel
222
+
223
+ out = block_apply(flat, block_rows, _block)
224
+ return np.asarray(out, dtype=np.float32).reshape(*lead, pin.shape[0], 2)
@@ -0,0 +1,59 @@
1
+ """The tower-tip point estimate (ADR-019) — a `(N, …, 1)` frame.
2
+
3
+ `tower_point` is the "most likely single value" we report to a consumer: the median of
4
+ the draws inside the **`tip_mass` floor** of the nested tower (config-driven, default
5
+ 0.5 — the "shorth"), with the same raw-count zero short-circuit as the tower
6
+ (`max(row) <= zero_cutoff` → 0).
7
+
8
+ It is a *new* estimator, deliberately distinct from the frozen `map_estimate` (ADR-018):
9
+ `map_estimate` is a binned histogram mode with a zero-*mass*-fraction rule and a
10
+ lowest-index tie-break that is directionally biased on right-skewed, zero-inflated,
11
+ low-sample posteriors (register C-32). The tower tip is unbinned — it reads the median
12
+ of a mass-aware floor — so it carries no histogram tie-break **and** is robust to
13
+ minority duplicated draws (register C-44; a lonely outlier cannot define a 50%-mass
14
+ floor). On a genuinely multi-peaked row the tip is, like any point, ambiguous; pair it
15
+ with `bimodality` to detect that case rather than collapse it silently.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import numpy as np
21
+ from numpy.typing import NDArray
22
+
23
+ from views_frames_summarize import config
24
+ from views_frames_summarize._common import AnyFrame, block_apply, rebuild
25
+ from views_frames_summarize.tower import (
26
+ _dense_tower,
27
+ _ks,
28
+ _median_in,
29
+ _tip_floor_index,
30
+ _zero_mask,
31
+ )
32
+
33
+
34
+ def tower_point(frame: AnyFrame) -> AnyFrame:
35
+ """Per-row tower-tip point estimate over the sample axis → a `(N, …, 1)` frame.
36
+
37
+ The median of the ``tip_mass`` floor's samples; ``0.0`` for quiet rows (every draw
38
+ ``<= zero_cutoff``). Returns a frame of the same type as ``frame``. Tunables (the
39
+ ``tip_mass``, the grid, the zero cutoff, the row-block) come from ``config``.
40
+ """
41
+ values = frame.values
42
+ lead = values.shape[:-1]
43
+ s = values.shape[-1]
44
+ ks = _ks(s)
45
+ t = _tip_floor_index(s)
46
+ block_rows = int(config.get("row_block"))
47
+ flat = np.ascontiguousarray(values).reshape(-1, s)
48
+
49
+ def _block(block: NDArray[np.float32]) -> NDArray[np.float32]:
50
+ srt = np.sort(block, axis=-1)
51
+ tower = _dense_tower(srt, ks)
52
+ tip = _median_in(srt, tower[:, t, 0], tower[:, t, 1])
53
+ return np.asarray(
54
+ np.where(_zero_mask(block), np.float32(0.0), tip), dtype=np.float32
55
+ )
56
+
57
+ out = block_apply(flat, block_rows, _block)
58
+ reduced = np.asarray(out, dtype=np.float32).reshape(lead)[..., np.newaxis]
59
+ return rebuild(frame, reduced)
@@ -1,192 +0,0 @@
1
- """The constrained-nested HDI tower (ADR-019) — interval estimates over the sample axis.
2
-
3
- `hdi_tower` returns, for each requested mass, the highest-density interval read out
4
- of a **fixed canonical tower**: a dense set of shortest intervals built inside-out so
5
- each floor is the shortest interval that *contains* the next-narrower one. Nesting is
6
- therefore guaranteed **by construction** (no post-hoc "move to nest" patch; register
7
- C-33), and because the tower is always built on the same fixed `_CANONICAL_FLOORS`
8
- grid — with requested masses *pinned* to the nearest floor, never inserted — "the 50%
9
- HDI" is identical regardless of which other masses a caller asks for (reproducibility).
10
-
11
- The construction is vectorized over the sample axis and runs in row-blocks (register
12
- C-22/C-25): one sort per block, never a whole-grid sorted copy. A raw-count zero
13
- short-circuit (`max(row) <= 1.0` → the whole summary collapses to 0) kills the quiet
14
- cells — the overwhelming majority — cheaply.
15
-
16
- Returns numpy arrays aligned to the frame's index (the interval convention, ADR-017);
17
- the caller holds the index. The private engine here (`_dense_tower`, `_tip`, `_pin`,
18
- `_zero_mask`, `_CANONICAL_FLOORS`) is shared by `tower_point`, `bimodality`, and the
19
- single-pass `summarize_tower` bundle.
20
- """
21
-
22
- from __future__ import annotations
23
-
24
- from collections.abc import Sequence
25
- from typing import Final
26
-
27
- import numpy as np
28
- from numpy.typing import NDArray
29
-
30
- from views_frames_summarize._common import ROW_BLOCK, AnyFrame, block_apply
31
-
32
- # The fixed canonical mass grid: a 5% body plus a fixed fine high-mass tail so a
33
- # requested 0.99 pins to 0.99 (not 0.95). Built from rounded literals — NOT
34
- # ``np.arange`` (whose float accumulation drifts ~1 ulp across numpy versions and
35
- # would make the grid, and therefore every pinned interval, non-reproducible).
36
- _CANONICAL_FLOORS: Final[NDArray[np.float64]] = np.array(
37
- [round(0.05 * i, 2) for i in range(1, 19)] # 0.05 … 0.90
38
- + [0.92, 0.94, 0.95, 0.96, 0.97, 0.98, 0.99], # fine high-mass tail (incl. 0.95)
39
- dtype=np.float64,
40
- )
41
-
42
- # Raw-count zero rule: a row whose every draw is <= this collapses to 0 (the quiet
43
- # cell — no meaningful positive mass). Deliberately a *count* rule, distinct from
44
- # ``map_estimate``'s zero-*mass*-fraction rule, so the two estimators stay independent.
45
- _ZERO_CUTOFF: Final = 1.0
46
-
47
-
48
- def _ks(sample_count: int) -> NDArray[np.intp]:
49
- """The per-floor window size ``k = floor(mass * S)`` for every canonical floor."""
50
- return np.asarray(np.floor(_CANONICAL_FLOORS * sample_count), dtype=np.intp)
51
-
52
-
53
- def _pin(masses: Sequence[float]) -> NDArray[np.intp]:
54
- """Index of the nearest canonical floor for each requested mass.
55
-
56
- Deterministic: ``argmin`` of the distance, breaking ties on the lowest index. A
57
- mass at the midpoint between two floors (e.g. ``0.075`` between ``0.05`` and
58
- ``0.10``) therefore pins **down** to the lower floor — unambiguous and
59
- reproducible. Fails loud (ADR-008) on a mass outside ``(0, 1)`` rather than silently
60
- pinning a nonsense value to the nearest floor and returning a plausible interval.
61
- """
62
- m = np.asarray(masses, dtype=np.float64)
63
- if m.size == 0 or not np.all((m > 0.0) & (m < 1.0)):
64
- raise ValueError(
65
- f"masses must each be in the open interval (0, 1); got {masses!r}"
66
- )
67
- dist = np.abs(_CANONICAL_FLOORS[None, :] - m[:, None])
68
- return np.asarray(np.argmin(dist, axis=1), dtype=np.intp)
69
-
70
-
71
- def _zero_mask(block: NDArray[np.float32]) -> NDArray[np.bool_]:
72
- """Rows whose maximum draw is at/below the zero cutoff — the quiet cells."""
73
- return np.asarray(block.max(axis=-1) <= _ZERO_CUTOFF, dtype=np.bool_)
74
-
75
-
76
- def _shortest(
77
- srt: NDArray[np.float32], k: int
78
- ) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
79
- """Shortest interval holding ``k + 1`` samples, per row of a sorted block.
80
-
81
- For ``k <= 0`` the floor cannot hold two samples (``S`` below the grid's 1/S
82
- resolution); it degenerates to the per-row median point — the tower-tip seed.
83
- """
84
- n = srt.shape[-1]
85
- if k <= 0:
86
- v = srt[:, n // 2]
87
- return v.copy(), v.copy()
88
- widths = srt[:, k:] - srt[:, : n - k]
89
- i = np.argmin(widths, axis=-1)
90
- lo = np.take_along_axis(srt, i[:, None], axis=-1)[:, 0]
91
- hi = np.take_along_axis(srt, (i + k)[:, None], axis=-1)[:, 0]
92
- return lo, hi
93
-
94
-
95
- def _shortest_containing(
96
- srt: NDArray[np.float32],
97
- k: int,
98
- plo: NDArray[np.float32],
99
- phi: NDArray[np.float32],
100
- ) -> tuple[NDArray[np.float32], NDArray[np.float32]]:
101
- """Shortest ``k+1``-sample interval that contains the inner floor ``[plo, phi]``.
102
-
103
- This is what makes the tower nested by construction. ``k <= 0`` inherits the inner
104
- floor; ``k >= n-1`` is the whole row.
105
- """
106
- n = srt.shape[-1]
107
- if k <= 0:
108
- return plo.copy(), phi.copy()
109
- if k >= n - 1:
110
- return srt[:, 0].copy(), srt[:, -1].copy()
111
- starts = srt[:, : n - k]
112
- ends = srt[:, k:]
113
- ok = (starts <= plo[:, None]) & (ends >= phi[:, None])
114
- widths = np.where(ok, ends - starts, np.inf)
115
- i = np.argmin(widths, axis=-1)
116
- lo = np.take_along_axis(starts, i[:, None], axis=-1)[:, 0]
117
- hi = np.take_along_axis(ends, i[:, None], axis=-1)[:, 0]
118
- # Defensive: the construction guarantees an inner floor is itself a sample window,
119
- # so a wider containing window always exists — but if that invariant is ever
120
- # broken, expand minimally rather than emit a non-nested floor.
121
- bad = ~np.isfinite(widths.min(axis=-1))
122
- if bad.any(): # pragma: no cover - unreachable while inner ⊂ outer holds
123
- lo = np.where(bad, np.minimum(srt[:, 0], plo), lo)
124
- hi = np.where(bad, np.maximum(srt[:, -1], phi), hi)
125
- return lo, hi
126
-
127
-
128
- def _dense_tower(srt: NDArray[np.float32], ks: NDArray[np.intp]) -> NDArray[np.float32]:
129
- """Build the full constrained-nested tower over a sorted block → ``(rows, F, 2)``.
130
-
131
- F is the number of canonical floors; each is nested in the next-wider one.
132
- """
133
- rows = srt.shape[0]
134
- out = np.empty((rows, ks.shape[0], 2), dtype=np.float32)
135
- plo: NDArray[np.float32] | None = None
136
- phi: NDArray[np.float32] | None = None
137
- for j, k in enumerate(ks):
138
- if plo is None or phi is None:
139
- lo, hi = _shortest(srt, int(k))
140
- else:
141
- lo, hi = _shortest_containing(srt, int(k), plo, phi)
142
- out[:, j, 0] = lo
143
- out[:, j, 1] = hi
144
- plo, phi = lo, hi
145
- return out
146
-
147
-
148
- def _tip(srt: NDArray[np.float32], k0: int) -> NDArray[np.float32]:
149
- """The tower tip: the median of the narrowest floor's ``k0 + 1`` samples, per row.
150
-
151
- For ``k0 <= 0`` the floor is the median point itself.
152
- """
153
- n = srt.shape[-1]
154
- if k0 <= 0:
155
- return np.array(srt[:, n // 2], dtype=np.float32)
156
- widths = srt[:, k0:] - srt[:, : n - k0]
157
- i = np.argmin(widths, axis=-1)
158
- lo_mid = i + (k0 // 2)
159
- hi_mid = i + ((k0 + 1) // 2)
160
- a = np.take_along_axis(srt, lo_mid[:, None], axis=-1)[:, 0]
161
- b = np.take_along_axis(srt, hi_mid[:, None], axis=-1)[:, 0]
162
- return np.asarray((a + b) * 0.5, dtype=np.float32)
163
-
164
-
165
- def hdi_tower(
166
- frame: AnyFrame,
167
- masses: Sequence[float] = (0.5, 0.9, 0.99),
168
- *,
169
- block_rows: int = ROW_BLOCK,
170
- ) -> NDArray[np.float32]:
171
- """Per-row constrained-nested HDIs at the requested ``masses`` → ``(N, …, M, 2)``.
172
-
173
- Each requested mass is pinned to the nearest fixed canonical floor; the interval is
174
- read out of the full canonical tower (built once per block). Nested by construction
175
- and reproducible (a mass's interval is independent of the other requested masses).
176
- Quiet rows (``max <= 1``) collapse to ``(0, 0)``. Aligned to ``frame.index``.
177
- """
178
- values = frame.values
179
- lead = values.shape[:-1]
180
- s = values.shape[-1]
181
- ks = _ks(s)
182
- pin = _pin(masses)
183
- flat = np.ascontiguousarray(values).reshape(-1, s)
184
-
185
- def _block(block: NDArray[np.float32]) -> NDArray[np.float32]:
186
- srt = np.sort(block, axis=-1)
187
- sel = _dense_tower(srt, ks)[:, pin, :]
188
- sel[_zero_mask(block)] = 0.0
189
- return sel
190
-
191
- out = block_apply(flat, block_rows, _block)
192
- return np.asarray(out, dtype=np.float32).reshape(*lead, pin.shape[0], 2)
@@ -1,46 +0,0 @@
1
- """The tower-tip point estimate (ADR-019) — a `(N, …, 1)` frame.
2
-
3
- `tower_point` is the "most likely single value" we report to a consumer: the median of
4
- the draws inside the **narrowest canonical floor** of the constrained-nested tower (the
5
- tip), with the same raw-count zero short-circuit as the tower (`max(row) <= 1` → 0).
6
-
7
- It is a *new* estimator, deliberately distinct from the frozen `map_estimate` (ADR-018):
8
- `map_estimate` is a binned histogram mode with a zero-*mass*-fraction rule and a
9
- lowest-index tie-break that is directionally biased on right-skewed, zero-inflated,
10
- low-sample posteriors (register C-32). The tower tip is unbinned — it reads the median
11
- of an actual shortest-interval window — so it carries no histogram tie-break. On a
12
- genuinely multi-peaked row the tip is, like any single point, ambiguous; pair it with
13
- `bimodality` to detect that case rather than collapse it silently.
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import numpy as np
19
- from numpy.typing import NDArray
20
-
21
- from views_frames_summarize._common import ROW_BLOCK, AnyFrame, block_apply, rebuild
22
- from views_frames_summarize.tower import _CANONICAL_FLOORS, _tip, _zero_mask
23
-
24
-
25
- def tower_point(frame: AnyFrame, *, block_rows: int = ROW_BLOCK) -> AnyFrame:
26
- """Per-row tower-tip point estimate over the sample axis → a `(N, …, 1)` frame.
27
-
28
- The median of the narrowest canonical floor's samples; ``0.0`` for quiet rows
29
- (every draw ``<= 1``). Returns a frame of the same type as ``frame``.
30
- """
31
- values = frame.values
32
- lead = values.shape[:-1]
33
- s = values.shape[-1]
34
- k0 = int(np.floor(_CANONICAL_FLOORS[0] * s))
35
- flat = np.ascontiguousarray(values).reshape(-1, s)
36
-
37
- def _block(block: NDArray[np.float32]) -> NDArray[np.float32]:
38
- srt = np.sort(block, axis=-1)
39
- tip = _tip(srt, k0)
40
- return np.asarray(
41
- np.where(_zero_mask(block), np.float32(0.0), tip), dtype=np.float32
42
- )
43
-
44
- out = block_apply(flat, block_rows, _block)
45
- reduced = np.asarray(out, dtype=np.float32).reshape(lead)[..., np.newaxis]
46
- return rebuild(frame, reduced)
File without changes
File without changes