views-frames 1.7.0__tar.gz → 1.8.1__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 (45) hide show
  1. {views_frames-1.7.0 → views_frames-1.8.1}/PKG-INFO +20 -4
  2. {views_frames-1.7.0 → views_frames-1.8.1}/README.md +13 -3
  3. {views_frames-1.7.0 → views_frames-1.8.1}/pyproject.toml +27 -8
  4. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames/conformance/__init__.py +18 -0
  5. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames/index.py +5 -0
  6. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_reconcile/__init__.py +14 -1
  7. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_reconcile/conformance.py +18 -1
  8. views_frames-1.8.1/src/views_frames_reconcile/module.py +120 -0
  9. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_reconcile/proportional.py +34 -8
  10. views_frames-1.8.1/src/views_frames_reconcile/result.py +55 -0
  11. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_reconcile/validation.py +6 -4
  12. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_summarize/conformance.py +14 -0
  13. views_frames-1.7.0/src/views_frames_reconcile/module.py +0 -68
  14. {views_frames-1.7.0 → views_frames-1.8.1}/.gitignore +0 -0
  15. {views_frames-1.7.0 → views_frames-1.8.1}/LICENSE +0 -0
  16. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames/__init__.py +0 -0
  17. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames/_typing.py +0 -0
  18. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames/_validation.py +0 -0
  19. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames/feature_frame.py +0 -0
  20. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames/io/__init__.py +0 -0
  21. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames/io/arrow.py +0 -0
  22. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames/io/npz.py +0 -0
  23. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames/metadata.py +0 -0
  24. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames/prediction_frame.py +0 -0
  25. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames/protocols.py +0 -0
  26. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames/py.typed +0 -0
  27. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames/spatial_level.py +0 -0
  28. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames/target_frame.py +0 -0
  29. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_reconcile/frames.py +0 -0
  30. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_reconcile/grouping.py +0 -0
  31. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_reconcile/py.typed +0 -0
  32. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_summarize/__init__.py +0 -0
  33. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_summarize/_common.py +0 -0
  34. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_summarize/aggregate.py +0 -0
  35. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_summarize/bimodality.py +0 -0
  36. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_summarize/collapse.py +0 -0
  37. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_summarize/config.py +0 -0
  38. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_summarize/exceedance.py +0 -0
  39. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_summarize/expected_shortfall.py +0 -0
  40. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_summarize/interval.py +0 -0
  41. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_summarize/point.py +0 -0
  42. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_summarize/py.typed +0 -0
  43. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_summarize/summarize_tower.py +0 -0
  44. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_summarize/tower.py +0 -0
  45. {views_frames-1.7.0 → views_frames-1.8.1}/src/views_frames_summarize/tower_point.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: views-frames
3
- Version: 1.7.0
3
+ Version: 1.8.1
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
@@ -22,6 +22,12 @@ Requires-Python: >=3.10
22
22
  Requires-Dist: numpy<3,>=1.26
23
23
  Provides-Extra: arrow
24
24
  Requires-Dist: pyarrow<20,>=14; extra == 'arrow'
25
+ Provides-Extra: docs
26
+ Requires-Dist: jupyterlab<5,>=4; extra == 'docs'
27
+ Requires-Dist: matplotlib<4,>=3.8; extra == 'docs'
28
+ Requires-Dist: nbmake<2,>=1.5; extra == 'docs'
29
+ Requires-Dist: pyarrow<20,>=14; extra == 'docs'
30
+ Requires-Dist: scipy<2,>=1.11; extra == 'docs'
25
31
  Description-Content-Type: text/markdown
26
32
 
27
33
  # views-frames
@@ -30,14 +36,18 @@ Description-Content-Type: text/markdown
30
36
  > containers (`FeatureFrame`, `PredictionFrame`, and their anticipated siblings)
31
37
  > that every other repo depends on and that depends on nothing internal.
32
38
  >
33
- > **Status:** **v1.7.0 — frozen API** (frozen since v1.0.0, ADR-018; the v1.1 surface is
39
+ > **Status:** **v1.8.0 — frozen API, published to PyPI** (frozen since v1.0.0, ADR-018; the
40
+ > v1.1 surface is
34
41
  > purely additive — the coherent posterior summary, ADR-019; v1.2.0 rebuilt the tower
35
42
  > `outside-in`, C-44; v1.3.0 makes the tower summary distribution-agnostic — no magnitude
36
43
  > zeroing by default, register C-45; v1.4.0 adds generic provenance to `FrameMetadata`
37
44
  > (`run_id`/`data_version`) and publishes the shared `assert_frame_envelope` checker,
38
45
  > ADR-020; v1.5.0 adds the threshold **exceedance** estimator `P(Y > c)`, ADR-021; v1.6.0
39
46
  > adds the worst-case **expected_shortfall** estimator, ADR-022; v1.7.0 adds a third
40
- > sibling package **`views_frames_reconcile`** — forecast reconciliation, ADR-023). This
47
+ > sibling package **`views_frames_reconcile`** — forecast reconciliation, ADR-023; v1.8.0
48
+ > adds the native point-country broadcast + the self-describing reconciliation mode,
49
+ > the three showcase notebooks, and the frames-family hardening pass — Reconcile.md CIC,
50
+ > ADR-025 immutability-by-convention, the adversarial red-test batch). This
41
51
  > README is the design
42
52
  > bible; the contract it specifies is realised in `src/views_frames/` (index, frames,
43
53
  > io, conformance suite) plus the `src/views_frames_summarize/` sibling package
@@ -249,7 +259,13 @@ makes it safe to depend on from everywhere (SDP).
249
259
  3. **Immutable value objects.** A frame is validated at construction and then
250
260
  treated as read-only. Operations (`collapse`, `select`, `with_metadata`)
251
261
  **return new frames**; they never mutate in place. (Directly forbids the
252
- C-184 cross-repo-mutation anti-pattern.) **Copy-vs-view:** structural and
262
+ C-184 cross-repo-mutation anti-pattern.) **Enforced for the index, by
263
+ convention for the value buffer:** the identifier arrays (`time`/`unit`) are
264
+ write-protected (`setflags(write=False)`); the `values` buffer is left
265
+ **writeable on purpose** — so structural ops can share it zero-copy — and is
266
+ immutable *by convention*: mutating `.values` in place is unsupported and may
267
+ silently corrupt buffer-sharing frames (ADR-025 / register C-63; enforcing it
268
+ is a deferred MAJOR-rider). **Copy-vs-view:** structural and
253
269
  metadata-only operations (`with_metadata`, contiguous `select`) return frames
254
270
  that **share** the underlying `values` buffer (numpy view / zero-copy), and a
255
271
  `mmap`-backed frame stays `mmap`-backed — a new frame must never copy a
@@ -4,14 +4,18 @@
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.7.0 — frozen API** (frozen since v1.0.0, ADR-018; the v1.1 surface is
7
+ > **Status:** **v1.8.0 — frozen API, published to PyPI** (frozen since v1.0.0, ADR-018; the
8
+ > v1.1 surface is
8
9
  > purely additive — the coherent posterior summary, ADR-019; v1.2.0 rebuilt the tower
9
10
  > `outside-in`, C-44; v1.3.0 makes the tower summary distribution-agnostic — no magnitude
10
11
  > zeroing by default, register C-45; v1.4.0 adds generic provenance to `FrameMetadata`
11
12
  > (`run_id`/`data_version`) and publishes the shared `assert_frame_envelope` checker,
12
13
  > ADR-020; v1.5.0 adds the threshold **exceedance** estimator `P(Y > c)`, ADR-021; v1.6.0
13
14
  > adds the worst-case **expected_shortfall** estimator, ADR-022; v1.7.0 adds a third
14
- > sibling package **`views_frames_reconcile`** — forecast reconciliation, ADR-023). This
15
+ > sibling package **`views_frames_reconcile`** — forecast reconciliation, ADR-023; v1.8.0
16
+ > adds the native point-country broadcast + the self-describing reconciliation mode,
17
+ > the three showcase notebooks, and the frames-family hardening pass — Reconcile.md CIC,
18
+ > ADR-025 immutability-by-convention, the adversarial red-test batch). This
15
19
  > README is the design
16
20
  > bible; the contract it specifies is realised in `src/views_frames/` (index, frames,
17
21
  > io, conformance suite) plus the `src/views_frames_summarize/` sibling package
@@ -223,7 +227,13 @@ makes it safe to depend on from everywhere (SDP).
223
227
  3. **Immutable value objects.** A frame is validated at construction and then
224
228
  treated as read-only. Operations (`collapse`, `select`, `with_metadata`)
225
229
  **return new frames**; they never mutate in place. (Directly forbids the
226
- C-184 cross-repo-mutation anti-pattern.) **Copy-vs-view:** structural and
230
+ C-184 cross-repo-mutation anti-pattern.) **Enforced for the index, by
231
+ convention for the value buffer:** the identifier arrays (`time`/`unit`) are
232
+ write-protected (`setflags(write=False)`); the `values` buffer is left
233
+ **writeable on purpose** — so structural ops can share it zero-copy — and is
234
+ immutable *by convention*: mutating `.values` in place is unsupported and may
235
+ silently corrupt buffer-sharing frames (ADR-025 / register C-63; enforcing it
236
+ is a deferred MAJOR-rider). **Copy-vs-view:** structural and
227
237
  metadata-only operations (`with_metadata`, contiguous `select`) return frames
228
238
  that **share** the underlying `values` buffer (numpy view / zero-copy), and a
229
239
  `mmap`-backed frame stays `mmap`-backed — a new frame must never copy a
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "views-frames"
7
- version = "1.7.0"
7
+ version = "1.8.1"
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" },
@@ -39,6 +39,22 @@ Changelog = "https://github.com/views-platform/views-frames/blob/main/CHANGELOG.
39
39
  # Optional serialization extra — pyarrow is allowed ONLY behind io/ (README §3.1).
40
40
  [project.optional-dependencies]
41
41
  arrow = ["pyarrow>=14,<20"]
42
+ # Optional docs/notebooks extra — plotting + jupyter for the showcase notebooks
43
+ # (notebooks/, an un-gated dev artifact outside src/). NEVER runtime dependencies;
44
+ # self-references [arrow] so `uv sync --extra docs` also enables the parquet round-trip
45
+ # demo in notebooks/01_frames.ipynb. matplotlib/scipy are notebook-only (the core is
46
+ # numpy-only); these are imported by notebooks, never by the package.
47
+ docs = [
48
+ "views-frames[arrow]",
49
+ "matplotlib>=3.8,<4",
50
+ "scipy>=1.11,<2",
51
+ "jupyterlab>=4,<5",
52
+ # nbmake drives the non-blocking notebook-execution CI check (issue #151): it just
53
+ # runs the notebooks end-to-end to catch frozen-API drift. A test tool, but it rides
54
+ # in [docs] so the single `uv sync --extra docs` provisions everything the notebooks
55
+ # job needs (kernel + plotting + the checker).
56
+ "nbmake>=1.5,<2",
57
+ ]
42
58
 
43
59
  [dependency-groups]
44
60
  dev = [
@@ -76,9 +92,10 @@ show_missing = true
76
92
  [tool.ruff]
77
93
  line-length = 88
78
94
  target-version = "py310"
79
- # research/ is the volatile estimator lab (outside the frozen, gated src/); it is
80
- # not held to the leaf's lint/format bar.
81
- extend-exclude = ["research"]
95
+ # research/ is the volatile estimator lab and notebooks/ holds the un-gated showcase
96
+ # notebooks + their helper (notebooks/_synthetic.py) — both sit outside the frozen,
97
+ # gated src/ and are not held to the leaf's lint/format bar.
98
+ extend-exclude = ["research", "notebooks"]
82
99
 
83
100
  [tool.ruff.lint]
84
101
  select = ["E", "F", "I", "N", "UP", "B", "A", "C4", "SIM"]
@@ -87,11 +104,13 @@ select = ["E", "F", "I", "N", "UP", "B", "A", "C4", "SIM"]
87
104
  # The falsification stubs assert against the README via long, literal regexes
88
105
  # whose lines cannot wrap without changing their semantics.
89
106
  "tests/test_falsification_*.py" = ["E501"]
90
- # The reconciliation parity tests + their oracle-regeneration scripts are
91
- # faithfully relocated from views-postprocessing (Epic 11 / ADR-023); their dense
92
- # numpy/dict-comprehension assertions are kept verbatim rather than re-wrapped.
107
+ # The reconciliation parity tests are faithfully relocated from views-postprocessing
108
+ # (Epic 11 / ADR-023); their dense numpy/dict-comprehension assertions are kept verbatim
109
+ # rather than re-wrapped.
93
110
  "tests/test_reconciliation_*.py" = ["E501"]
94
- "scripts/gen_reconciliation_*.py" = ["E501"]
111
+ # The dev/ops scripts (oracle regeneration; the reconciliation parity verifier) are not
112
+ # CI-run and carry dense numpy one-liners + report f-strings; not held to the wrap bar.
113
+ "scripts/*.py" = ["E501"]
95
114
 
96
115
  [tool.mypy]
97
116
  python_version = "3.10"
@@ -40,6 +40,20 @@ __all__ = [
40
40
  ]
41
41
 
42
42
 
43
+ def _require_assertions() -> None:
44
+ """Fail loud if assertions are stripped (``python -O``/``-OO``).
45
+
46
+ The suite is built from ``assert`` statements; under optimized bytecode every
47
+ check would silently pass regardless of conformance — a verification tool that
48
+ reports green while dead (falsify audit 2026-07, F3). Refuse to run instead.
49
+ """
50
+ if not __debug__: # pragma: no cover — pytest always runs with assertions on
51
+ raise RuntimeError(
52
+ "the views-frames conformance suite requires assertions; run without "
53
+ "python -O/-OO (PYTHONOPTIMIZE), otherwise every check silently passes"
54
+ )
55
+
56
+
43
57
  def assert_frame_envelope(frame: Any) -> None:
44
58
  """Assert ``frame`` satisfies the shared **frame envelope**.
45
59
 
@@ -56,6 +70,7 @@ def assert_frame_envelope(frame: Any) -> None:
56
70
  Raises:
57
71
  AssertionError: any part of the envelope is violated.
58
72
  """
73
+ _require_assertions()
59
74
  values = frame.values
60
75
  assert isinstance(values, np.ndarray), "values must be a numpy array"
61
76
  assert values.dtype == np.float32, f"values must be float32, got {values.dtype}"
@@ -77,6 +92,7 @@ def assert_frame_contract(frame: Any) -> None:
77
92
  Raises:
78
93
  AssertionError: the envelope or the spatiotemporal identifier rule is violated.
79
94
  """
95
+ _require_assertions()
80
96
  assert_frame_envelope(frame)
81
97
 
82
98
  ids = frame.identifiers
@@ -110,6 +126,7 @@ def assert_index_alignment_laws(index_a: Any, index_b: Any) -> None:
110
126
  Raises:
111
127
  AssertionError: a law is violated.
112
128
  """
129
+ _require_assertions()
113
130
  assert index_a.intersect(index_b) == index_b.intersect(index_a), (
114
131
  "intersect must be commutative"
115
132
  )
@@ -140,6 +157,7 @@ def assert_cross_level_alignment_law(
140
157
  AssertionError: the remap disagrees with the mapping, drops time, or
141
158
  produces the wrong level.
142
159
  """
160
+ _require_assertions()
143
161
  aligned = index.cross_level_align(mapping, target_level)
144
162
  assert aligned.level is target_level, "cross_level_align must carry target_level"
145
163
  assert np.array_equal(aligned.time, index.time), "cross_level_align must keep time"
@@ -132,6 +132,11 @@ class SpatioTemporalIndex:
132
132
  The pure-numpy analogue of ``pd.Index.get_indexer``: a same-level join.
133
133
  """
134
134
  self._require_same_level(other)
135
+ if self.n_rows == 0:
136
+ # An empty index contains nothing: every row of `other` is absent. The
137
+ # general path below would clip positions to -1 and crash with an
138
+ # obscure IndexError on the empty array (falsify audit 2026-07, F4).
139
+ return np.full(other.n_rows, -1, dtype=np.intp)
135
140
  self_rows = self._row_view(self._keys())
136
141
  other_rows = self._row_view(other._keys())
137
142
  order = np.argsort(self_rows, kind="stable")
@@ -7,5 +7,18 @@ should be added as sibling modules, not by modifying ``proportional``.
7
7
 
8
8
  from views_frames_reconcile.module import ReconciliationModule
9
9
  from views_frames_reconcile.proportional import reconcile_proportional
10
+ from views_frames_reconcile.result import (
11
+ ALIGNED_DRAWS,
12
+ METHOD_PROPORTIONAL,
13
+ POINT_BROADCAST,
14
+ ReconciliationResult,
15
+ )
10
16
 
11
- __all__ = ["ReconciliationModule", "reconcile_proportional"]
17
+ __all__ = [
18
+ "ALIGNED_DRAWS",
19
+ "METHOD_PROPORTIONAL",
20
+ "POINT_BROADCAST",
21
+ "ReconciliationModule",
22
+ "ReconciliationResult",
23
+ "reconcile_proportional",
24
+ ]
@@ -20,6 +20,19 @@ from views_frames import PredictionFrame, SpatialLevel
20
20
  from views_frames_reconcile.module import ReconciliationModule
21
21
 
22
22
 
23
+ def _require_assertions() -> None:
24
+ """Fail loud if assertions are stripped (``python -O``/``-OO``).
25
+
26
+ Mirrors ``views_frames.conformance._require_assertions`` (falsify audit 2026-07,
27
+ F3): under optimized bytecode every ``assert`` silently passes — refuse to run.
28
+ """
29
+ if not __debug__: # pragma: no cover — pytest always runs with assertions on
30
+ raise RuntimeError(
31
+ "the reconcile conformance suite requires assertions; run without "
32
+ "python -O/-OO (PYTHONOPTIMIZE), otherwise every check silently passes"
33
+ )
34
+
35
+
23
36
  def assert_reconcile_contract(
24
37
  cm_frame: PredictionFrame,
25
38
  pgm_frame: PredictionFrame,
@@ -35,6 +48,7 @@ def assert_reconcile_contract(
35
48
  Raises:
36
49
  AssertionError: a contract law is violated.
37
50
  """
51
+ _require_assertions()
38
52
  mk = np.asarray(map_keys)
39
53
  mv = np.asarray(map_vals)
40
54
  out = ReconciliationModule(mk, mv).reconcile(cm_frame, pgm_frame)
@@ -66,7 +80,10 @@ def assert_reconcile_contract(
66
80
  rows = (pg_time == t) & (cm_units == c)
67
81
  in_sum = pgm_frame.values[rows].sum(axis=0) # (S,)
68
82
  out_sum = out.values[rows].sum(axis=0) # (S,)
69
- total = cm_frame.values[cm_pos[(int(t), int(c))]] # (S,)
83
+ # cm may be a point (sample_count == 1, broadcast inside reconcile) or aligned
84
+ # draws — broadcast its per-(time, country) total to the draw axis either way.
85
+ cm_total = cm_frame.values[cm_pos[(int(t), int(c))]]
86
+ total = np.broadcast_to(cm_total, out_sum.shape)
70
87
  active = in_sum != 0
71
88
  np.testing.assert_allclose(
72
89
  out_sum[active], total[active], rtol=1e-4, atol=1e-3
@@ -0,0 +1,120 @@
1
+ """Frames-native reconciliation orchestration (epic #31, story #36).
2
+
3
+ `ReconciliationModule` holds the injected `(time, priogrid_gid) -> country_id`
4
+ mapping (geography is injected, never embedded — views-frames ADR-014) and
5
+ applies it: `reconcile(cm_frame, pgm_frame)` validates the inputs and scales the
6
+ grid forecasts to the country totals, returning a **new** pgm frame (de-mutated,
7
+ C-184).
8
+
9
+ **SRP:** orchestration only — the scaling math is the leaf (`proportional`), the
10
+ grouping is `grouping`, the guards are `validation`, the I/O is `frames`. No
11
+ torch, no pandas, no viewser, no wandb: the original's `ProcessPoolExecutor` and
12
+ WandB alerting are dropped (numpy is fast; there is no GPU). If scale ever needs
13
+ parallelism, add it behind this same interface (OCP). Multi-target inputs are
14
+ reconciled by calling `reconcile` once per target.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ import numpy as np
22
+ from numpy.typing import NDArray
23
+
24
+ from views_frames import PredictionFrame
25
+ from views_frames_reconcile.grouping import reconcile_pgm_to_cm
26
+ from views_frames_reconcile.result import (
27
+ ALIGNED_DRAWS,
28
+ METHOD_PROPORTIONAL,
29
+ POINT_BROADCAST,
30
+ ReconciliationResult,
31
+ )
32
+ from views_frames_reconcile.validation import validate_reconciliation_inputs
33
+
34
+
35
+ def _broadcast_point_country(
36
+ cm_frame: PredictionFrame, n_samples: int
37
+ ) -> PredictionFrame:
38
+ """Tile a point country forecast (``sample_count == 1``) across ``n_samples`` draws.
39
+
40
+ Bit-identical to the WET broadcast pipeline-core's ``align_country_to_grid`` does
41
+ (views-frames#143): the single column is repeated, so every draw is rescaled to the
42
+ same total. It lives in the orchestrator so the leaf ``proportional`` and the
43
+ parity-frozen ``grouping`` hot loop stay **untouched** — the equal-count path is
44
+ byte-for-byte identical and only ``sample_count == 1`` takes this new path.
45
+ """
46
+ tiled = np.tile(np.asarray(cm_frame.values, dtype=np.float32), (1, n_samples))
47
+ return PredictionFrame(tiled, cm_frame.index, cm_frame.metadata)
48
+
49
+
50
+ class ReconciliationModule:
51
+ """Reconcile pgm forecasts to cm country totals (one target per call)."""
52
+
53
+ def __init__(
54
+ self,
55
+ map_keys: NDArray[np.integer[Any]] | object,
56
+ map_vals: NDArray[np.integer[Any]] | object,
57
+ ) -> None:
58
+ """Inject the `(time, priogrid_gid) -> country_id` mapping.
59
+
60
+ Args:
61
+ map_keys: ``(M, 2)`` int array of ``(time, priogrid_gid)`` pairs.
62
+ map_vals: ``(M,)`` int ``country_id`` for each key.
63
+
64
+ Raises:
65
+ ValueError: ``map_keys`` is not ``(M, 2)`` or ``map_vals`` is not
66
+ length ``M``.
67
+ """
68
+ keys = np.asarray(map_keys)
69
+ vals = np.asarray(map_vals)
70
+ if keys.ndim != 2 or keys.shape[1] != 2:
71
+ raise ValueError("map_keys must be an (M, 2) array of (time, priogrid_gid)")
72
+ if vals.shape != (keys.shape[0],):
73
+ raise ValueError("map_vals must be a length-M array aligned to map_keys")
74
+ self._map_keys = keys
75
+ self._map_vals = vals
76
+
77
+ def reconcile_result(
78
+ self, cm_frame: PredictionFrame, pgm_frame: PredictionFrame
79
+ ) -> ReconciliationResult:
80
+ """Reconcile, also reporting the mode — a ``ReconciliationResult``.
81
+
82
+ A **point** country (``cm.sample_count == 1``) is broadcast across the grid's
83
+ draws before reconciling (mode ``point-broadcast``); an **aligned** country
84
+ (``cm.sample_count == S``) scales draw-for-draw (mode ``aligned-draws``, the
85
+ documented per-draw approximation). Any other count fails loud.
86
+
87
+ The mode is **returned**, never stamped on the leaf's generic ``FrameMetadata``
88
+ (ADR-020 / register C-47 — the leaf carries no reconciliation vocabulary; see
89
+ ``result.py``).
90
+
91
+ Raises:
92
+ ValueError: the inputs fail validation (level / sample-count — cm must be 1
93
+ or pgm's ``S`` / time coverage / missing country forecast).
94
+ """
95
+ validate_reconciliation_inputs(
96
+ cm_frame, pgm_frame, self._map_keys, self._map_vals
97
+ )
98
+ if cm_frame.sample_count != pgm_frame.sample_count:
99
+ cm = _broadcast_point_country(cm_frame, pgm_frame.sample_count)
100
+ mode = POINT_BROADCAST
101
+ else:
102
+ cm = cm_frame
103
+ mode = ALIGNED_DRAWS
104
+ frame = reconcile_pgm_to_cm(pgm_frame, cm, self._map_keys, self._map_vals)
105
+ return ReconciliationResult(frame=frame, mode=mode, method=METHOD_PROPORTIONAL)
106
+
107
+ def reconcile(
108
+ self, cm_frame: PredictionFrame, pgm_frame: PredictionFrame
109
+ ) -> PredictionFrame:
110
+ """Validate the inputs, then return a new pgm frame reconciled to cm totals.
111
+
112
+ Point/aligned handling and fail-loud are as :meth:`reconcile_result`; this
113
+ returns only the reconciled frame. Call :meth:`reconcile_result` when you also
114
+ need the reconciliation *mode* (point-broadcast vs aligned-draws).
115
+
116
+ Raises:
117
+ ValueError: the inputs fail validation (level / sample-count — cm must be 1
118
+ or pgm's ``S`` / time coverage / missing country forecast).
119
+ """
120
+ return self.reconcile_result(cm_frame, pgm_frame).frame
@@ -9,10 +9,18 @@ stay zero; country totals are authoritative; the result is non-negative.
9
9
  This is a *faithful, numpy-only* port of views-reporting's
10
10
  ``ForecastReconciler.reconcile_forecast`` (torch), migrated here because the
11
11
  algorithm belongs in post-processing, not reporting (views-reporting issue #72).
12
+ One deliberate deviation (falsify audit 2026-07): the original's ``+ 1e-8``
13
+ denominator epsilon is replaced by an explicit all-zero-draw guard — bit-identical
14
+ on all realistic data (the epsilon was a float32 no-op for draw sums ≳ 0.1) but
15
+ exactly conserving for tiny nonzero sums, where the epsilon silently deflated the
16
+ scale factor. Negative country totals now fail loud instead of silently clamping.
12
17
  It is intentionally the **same** method — a pragmatic per-draw approximation, not
13
- principled joint probabilistic reconciliation. The upgrade to the latter is
14
- tracked as **C-37** and is deliberately deferred until this port's parity with
15
- the original is proven and the move is wired.
18
+ principled joint probabilistic reconciliation. The upgrade to the latter is designed
19
+ in **ADR-024** (register **C-62**; the cross-repo lineage is views-postprocessing
20
+ C-37) and is deliberately **deferred**: per-draw pairing of independently-trained
21
+ grid and country draws has no shared draw identity, so the principled method waits on
22
+ a defined draw-identity/coupling contract and a consumer that needs calibrated joint
23
+ tails.
16
24
 
17
25
  No torch, no pandas — numpy only.
18
26
  """
@@ -24,8 +32,6 @@ from typing import Any
24
32
  import numpy as np
25
33
  from numpy.typing import NDArray
26
34
 
27
- _EPS = np.float32(1e-8)
28
-
29
35
 
30
36
  def reconcile_proportional(
31
37
  grid: NDArray[np.floating[Any]] | object,
@@ -46,7 +52,8 @@ def reconcile_proportional(
46
52
  are clamped to be non-negative.
47
53
 
48
54
  Raises:
49
- ValueError: the grid and country sample counts disagree.
55
+ ValueError: the grid and country sample counts disagree, or any country
56
+ total is negative (cannot be conserved under the non-negativity clamp).
50
57
  """
51
58
  grid_arr = np.asarray(grid, dtype=np.float32)
52
59
  is_point = grid_arr.ndim == 1
@@ -62,13 +69,32 @@ def reconcile_proportional(
62
69
  f"Mismatch in sample count: grid has {grid_arr.shape[0]}, "
63
70
  f"country has {country_arr.shape[0]}"
64
71
  )
72
+ if bool((country_arr < 0).any()):
73
+ # A negative total cannot be conserved under the non-negativity clamp — the
74
+ # output would silently sum to 0, not the total (falsify audit 2026-07, F8).
75
+ raise ValueError(
76
+ "country totals must be non-negative; got a negative total "
77
+ f"(min={float(country_arr.min())}). A negative forecast is an upstream "
78
+ "bug; reconciliation cannot conserve it under the non-negativity clamp."
79
+ )
65
80
 
66
81
  # Preserve zeros: only strictly-positive cells carry probability mass.
67
82
  nonzero = np.where(grid_arr > 0, grid_arr, np.float32(0.0))
68
83
 
69
- # Per-draw proportional scaling to the (authoritative) country total.
84
+ # Per-draw proportional scaling to the (authoritative) country total. Guard the
85
+ # all-zero draws explicitly (they carry no mass and stay zero — the documented
86
+ # edge) instead of an additive epsilon in the denominator: `+ 1e-8` was a no-op
87
+ # in float32 for sums ≳ 0.1 (machine eps exceeds it) but silently deflated the
88
+ # scale factor for tiny nonzero sums — at a draw sum of 1e-8 a country total of
89
+ # 100 reconciled to 50 with no error signal (falsify audit 2026-07, F1). The
90
+ # exact division below is bit-identical to the epsilon form on all realistic
91
+ # data (torch-oracle parity preserved) and conserves exactly for any nonzero sum.
70
92
  sum_nonzero = nonzero.sum(axis=1, keepdims=True) # (S, 1)
71
- scaling = country_arr.reshape(-1, 1) / (sum_nonzero + _EPS) # (S, 1)
93
+ has_mass = sum_nonzero > 0
94
+ safe_sum = np.where(has_mass, sum_nonzero, np.float32(1.0))
95
+ scaling = np.where(
96
+ has_mass, country_arr.reshape(-1, 1) / safe_sum, np.float32(0.0)
97
+ ) # (S, 1)
72
98
  adjusted = np.clip(nonzero * scaling, 0.0, None).astype(np.float32)
73
99
 
74
100
  return np.asarray(adjusted[0] if is_point else adjusted, dtype=np.float32)
@@ -0,0 +1,55 @@
1
+ """The reconciliation result — the reconciled frame plus *how* it was produced (#144).
2
+
3
+ The reconciliation **mode** (`point-broadcast` vs `aligned-draws`) and **method** are
4
+ reported here, on a reconcile-package result object — deliberately **not** stamped onto
5
+ the leaf's generic ``FrameMetadata``. That header is governed *generic-only* (ADR-020 /
6
+ register C-47): the numpy leaf carries provenance meaningful for *any* frame (`run_id`,
7
+ `data_version`) and must never carry domain/operation vocabulary it cannot know — as
8
+ evaluation provenance lives in views-evaluation's ``MetricFrame``, not the leaf.
9
+ Reconciliation is a sibling operation, so its provenance is reported by the sibling.
10
+
11
+ A caller that needs the mode reads it off :class:`ReconciliationResult`
12
+ (``ReconciliationModule.reconcile_result``); a caller that only needs the frame uses
13
+ ``ReconciliationModule.reconcile``, which returns the frame directly.
14
+
15
+ The mode reflects **what this reconcile call did** (broadcast a point, or scaled aligned
16
+ draws) — not upstream provenance: a caller that pre-tiles a point country to ``S`` draws
17
+ before calling reconciles in ``aligned-draws`` mode. The string values match
18
+ pipeline-core's ``reconcile_frames`` constants verbatim, so the *vocabulary* is shared
19
+ across the repos that produce and consume the mode.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+
26
+ from views_frames import PredictionFrame
27
+
28
+ #: Mode — the call **broadcast a point**: the country arrived as a point
29
+ #: (``cm.sample_count == 1``) against a multi-draw grid and was tiled across the grid's
30
+ #: ``S`` draws (every draw rescaled to the same total).
31
+ POINT_BROADCAST = "point-broadcast"
32
+ #: Mode — the call **scaled aligned draws**: the country already carried the grid's draw
33
+ #: count (``cm.sample_count == S``) and was scaled draw-for-draw — the per-draw
34
+ #: approximation (the principled joint upgrade is a separate design, #145). The mode
35
+ #: describes *what this call did*: a point against a single-draw grid, or a point the
36
+ #: caller pre-tiled to ``S``, also reads as aligned-draws (nothing was broadcast).
37
+ ALIGNED_DRAWS = "aligned-draws"
38
+ #: The reconciliation algorithm — top-down proportional, per draw.
39
+ METHOD_PROPORTIONAL = "proportional"
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ReconciliationResult:
44
+ """A reconciled grid frame plus how it was produced.
45
+
46
+ Attributes:
47
+ frame: the reconciled pgm ``PredictionFrame`` (identical to what ``reconcile``
48
+ returns).
49
+ mode: :data:`POINT_BROADCAST` or :data:`ALIGNED_DRAWS`.
50
+ method: the reconciliation method, e.g. :data:`METHOD_PROPORTIONAL`.
51
+ """
52
+
53
+ frame: PredictionFrame
54
+ mode: str
55
+ method: str
@@ -6,7 +6,8 @@ buried in the orchestrator. The original's intents map as:
6
6
 
7
7
  - dataset type checks -> spatial-level guard (cm@CM, pgm@PGM)
8
8
  - same time steps + exact overlap -> identical set of time values
9
- - (per-draw scaling needs it) -> equal `sample_count`
9
+ - (per-draw scaling needs it) -> equal `sample_count`, or a point cm
10
+ (`sample_count == 1`) broadcast across the draws
10
11
  - valid countries -> every (time, country) the grid maps to has
11
12
  a country forecast in the cm frame
12
13
 
@@ -44,10 +45,11 @@ def validate_reconciliation_inputs(
44
45
  raise ValueError(
45
46
  f"grid frame must be at SpatialLevel.PGM, got {pgm_frame.index.level}"
46
47
  )
47
- if cm_frame.sample_count != pgm_frame.sample_count:
48
+ if cm_frame.sample_count not in (1, pgm_frame.sample_count):
48
49
  raise ValueError(
49
- f"sample-count mismatch: cm has {cm_frame.sample_count}, "
50
- f"pgm has {pgm_frame.sample_count}"
50
+ f"sample-count mismatch: cm has {cm_frame.sample_count}, pgm has "
51
+ f"{pgm_frame.sample_count}; cm must be 1 (point, broadcast across draws) "
52
+ f"or {pgm_frame.sample_count} (aligned draws)"
51
53
  )
52
54
 
53
55
  cm_times = {int(t) for t in np.unique(cm_frame.index.time)}
@@ -22,12 +22,26 @@ from views_frames_summarize.tower import hdi_tower
22
22
  from views_frames_summarize.tower_point import tower_point
23
23
 
24
24
 
25
+ def _require_assertions() -> None:
26
+ """Fail loud if assertions are stripped (``python -O``/``-OO``).
27
+
28
+ Mirrors ``views_frames.conformance._require_assertions`` (falsify audit 2026-07,
29
+ F3): under optimized bytecode every ``assert`` silently passes — refuse to run.
30
+ """
31
+ if not __debug__: # pragma: no cover — pytest always runs with assertions on
32
+ raise RuntimeError(
33
+ "the summarize conformance suite requires assertions; run without "
34
+ "python -O/-OO (PYTHONOPTIMIZE), otherwise every check silently passes"
35
+ )
36
+
37
+
25
38
  def assert_summarizer_contract(frame: AnyFrame) -> None:
26
39
  """Assert the summarizers behave on ``frame``.
27
40
 
28
41
  Raises:
29
42
  AssertionError: a summarizer violates its output contract.
30
43
  """
44
+ _require_assertions()
31
45
  n = frame.n_rows
32
46
 
33
47
  point = collapse(frame, np.mean)
@@ -1,68 +0,0 @@
1
- """Frames-native reconciliation orchestration (epic #31, story #36).
2
-
3
- `ReconciliationModule` holds the injected `(time, priogrid_gid) -> country_id`
4
- mapping (geography is injected, never embedded — views-frames ADR-014) and
5
- applies it: `reconcile(cm_frame, pgm_frame)` validates the inputs and scales the
6
- grid forecasts to the country totals, returning a **new** pgm frame (de-mutated,
7
- C-184).
8
-
9
- **SRP:** orchestration only — the scaling math is the leaf (`proportional`), the
10
- grouping is `grouping`, the guards are `validation`, the I/O is `frames`. No
11
- torch, no pandas, no viewser, no wandb: the original's `ProcessPoolExecutor` and
12
- WandB alerting are dropped (numpy is fast; there is no GPU). If scale ever needs
13
- parallelism, add it behind this same interface (OCP). Multi-target inputs are
14
- reconciled by calling `reconcile` once per target.
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- from typing import Any
20
-
21
- import numpy as np
22
- from numpy.typing import NDArray
23
-
24
- from views_frames import PredictionFrame
25
- from views_frames_reconcile.grouping import reconcile_pgm_to_cm
26
- from views_frames_reconcile.validation import validate_reconciliation_inputs
27
-
28
-
29
- class ReconciliationModule:
30
- """Reconcile pgm forecasts to cm country totals (one target per call)."""
31
-
32
- def __init__(
33
- self,
34
- map_keys: NDArray[np.integer[Any]] | object,
35
- map_vals: NDArray[np.integer[Any]] | object,
36
- ) -> None:
37
- """Inject the `(time, priogrid_gid) -> country_id` mapping.
38
-
39
- Args:
40
- map_keys: ``(M, 2)`` int array of ``(time, priogrid_gid)`` pairs.
41
- map_vals: ``(M,)`` int ``country_id`` for each key.
42
-
43
- Raises:
44
- ValueError: ``map_keys`` is not ``(M, 2)`` or ``map_vals`` is not
45
- length ``M``.
46
- """
47
- keys = np.asarray(map_keys)
48
- vals = np.asarray(map_vals)
49
- if keys.ndim != 2 or keys.shape[1] != 2:
50
- raise ValueError("map_keys must be an (M, 2) array of (time, priogrid_gid)")
51
- if vals.shape != (keys.shape[0],):
52
- raise ValueError("map_vals must be a length-M array aligned to map_keys")
53
- self._map_keys = keys
54
- self._map_vals = vals
55
-
56
- def reconcile(
57
- self, cm_frame: PredictionFrame, pgm_frame: PredictionFrame
58
- ) -> PredictionFrame:
59
- """Validate the inputs, then return a new pgm frame reconciled to cm totals.
60
-
61
- Raises:
62
- ValueError: the inputs fail validation (level / sample-count / time
63
- coverage / missing country forecast).
64
- """
65
- validate_reconciliation_inputs(
66
- cm_frame, pgm_frame, self._map_keys, self._map_vals
67
- )
68
- return reconcile_pgm_to_cm(pgm_frame, cm_frame, self._map_keys, self._map_vals)
File without changes
File without changes