views-frames 2.0.0__tar.gz → 3.0.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 (45) hide show
  1. {views_frames-2.0.0 → views_frames-3.0.0}/PKG-INFO +20 -9
  2. {views_frames-2.0.0 → views_frames-3.0.0}/README.md +19 -8
  3. {views_frames-2.0.0 → views_frames-3.0.0}/pyproject.toml +1 -1
  4. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames/conformance/__init__.py +8 -7
  5. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_reconcile/__init__.py +7 -2
  6. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_reconcile/conformance.py +37 -14
  7. views_frames-3.0.0/src/views_frames_reconcile/donor_fill.py +133 -0
  8. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_reconcile/module.py +33 -11
  9. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_reconcile/result.py +15 -5
  10. {views_frames-2.0.0 → views_frames-3.0.0}/.gitignore +0 -0
  11. {views_frames-2.0.0 → views_frames-3.0.0}/LICENSE +0 -0
  12. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames/__init__.py +0 -0
  13. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames/_typing.py +0 -0
  14. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames/_validation.py +0 -0
  15. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames/feature_frame.py +0 -0
  16. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames/index.py +0 -0
  17. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames/io/__init__.py +0 -0
  18. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames/io/arrow.py +0 -0
  19. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames/io/npz.py +0 -0
  20. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames/metadata.py +0 -0
  21. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames/prediction_frame.py +0 -0
  22. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames/protocols.py +0 -0
  23. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames/py.typed +0 -0
  24. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames/spatial_level.py +0 -0
  25. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames/target_frame.py +0 -0
  26. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_reconcile/frames.py +0 -0
  27. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_reconcile/grouping.py +0 -0
  28. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_reconcile/proportional.py +0 -0
  29. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_reconcile/py.typed +0 -0
  30. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_reconcile/validation.py +0 -0
  31. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_summarize/__init__.py +0 -0
  32. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_summarize/_common.py +0 -0
  33. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_summarize/aggregate.py +0 -0
  34. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_summarize/bimodality.py +0 -0
  35. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_summarize/collapse.py +0 -0
  36. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_summarize/config.py +0 -0
  37. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_summarize/conformance.py +0 -0
  38. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_summarize/exceedance.py +0 -0
  39. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_summarize/expected_shortfall.py +0 -0
  40. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_summarize/interval.py +0 -0
  41. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_summarize/point.py +0 -0
  42. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_summarize/py.typed +0 -0
  43. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_summarize/summarize_tower.py +0 -0
  44. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_summarize/tower.py +0 -0
  45. {views_frames-2.0.0 → views_frames-3.0.0}/src/views_frames_summarize/tower_point.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: views-frames
3
- Version: 2.0.0
3
+ Version: 3.0.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
@@ -36,11 +36,11 @@ Description-Content-Type: text/markdown
36
36
  > containers (`FeatureFrame`, `PredictionFrame`, and their anticipated siblings)
37
37
  > that every other repo depends on and that depends on nothing internal.
38
38
  >
39
- > **Status:** **v2.0.0 — the version in this tree.** For what is actually published, see
39
+ > **Status:** **v3.0.0 — the version in this tree.** For what is actually published, see
40
40
  > [PyPI](https://pypi.org/project/views-frames/): between a release commit and its publish
41
41
  > job this number is deliberately ahead, so this banner tracks the source and never claims
42
- > a publication it cannot verify. API frozen at v1.0.0 (ADR-018) and broken once in 2.0.0
43
- > (ADR-028; the
42
+ > a publication it cannot verify. API frozen at v1.0.0 (ADR-018) and broken twice since, in 2.0.0
43
+ > (ADR-028) and 3.0.0 (ADR-030; the
44
44
  > v1.1 surface is
45
45
  > purely additive — the coherent posterior summary, ADR-019; v1.2.0 rebuilt the tower
46
46
  > `outside-in`, C-44; v1.3.0 makes the tower summary distribution-agnostic — no magnitude
@@ -57,7 +57,7 @@ Description-Content-Type: text/markdown
57
57
  > `research/figures/` tower-figure toolkit; v1.10.0 adds the **dense-grid fill**
58
58
  > primitive — `reindex_fill(other, *, fill_value)` on all three frames +
59
59
  > `SpatioTemporalIndex.cartesian` + the published `assert_reindex_fill_law`, ADR-026,
60
- > unblocking pandas-free FAO ingestion; v1.10.1 makes `io.arrow.load` fail loudly on a parquet whose row order breaks the wire contract, #199; v1.10.2 changes no behaviour — it arms the documentation check in CI, turns eleven falsification tests from README-wording checks into behaviour checks, and records ADR-027 declining the construction shortcut; v1.11.0 corrects the published MAP-containment law, which was wrong on tied draws and so failed ~6% of rows on integer count posteriors — register C-88 — and makes the two IO codecs agree on a non-JSON metadata value, C-90; the rest of that release makes the governance documents match the code and adds the CI checks that keep them matching; **v2.0.0 is the first MAJOR** — a falsification audit found that a frame's `index` was never type-checked, so a frame (or any object with an `n_rows`) was accepted silently and the published `assert_summarizer_contract` then certified the result, and it now raises `TypeError`; riding it, `frame.values` becomes read-only (C-66) and `map_estimate` rejects non-finite draws (C-57), and `CONFORMANCE_FLOOR` moves to `2.0.0` — a correct consumer changes its version constraint and re-locks, nothing else, ADR-028). This
60
+ > unblocking pandas-free FAO ingestion; v1.10.1 makes `io.arrow.load` fail loudly on a parquet whose row order breaks the wire contract, #199; v1.10.2 changes no behaviour — it arms the documentation check in CI, turns eleven falsification tests from README-wording checks into behaviour checks, and records ADR-027 declining the construction shortcut; v1.11.0 corrects the published MAP-containment law, which was wrong on tied draws and so failed ~6% of rows on integer count posteriors — register C-88 — and makes the two IO codecs agree on a non-JSON metadata value, C-90; the rest of that release makes the governance documents match the code and adds the CI checks that keep them matching; **v2.0.0 is the first MAJOR** — a falsification audit found that a frame's `index` was never type-checked, so a frame (or any object with an `n_rows`) was accepted silently and the published `assert_summarizer_contract` then certified the result, and it now raises `TypeError`; riding it, `frame.values` becomes read-only (C-66) and `map_estimate` rejects non-finite draws (C-57), and `CONFORMANCE_FLOOR` moves to `2.0.0` — a correct consumer changes its version constraint and re-locks, nothing else, ADR-028; **v3.0.0 is the second MAJOR** — reconciliation silently lost a country's fatalities whenever its grid was all zeros in a draw, 2.9–7.3% of the fatality mass on the first production run (#283, register C-99); such a draw now borrows the pattern of a random donor draw from the same month and country, a group with no donor raises, every other draw is bit-identical, and `CONFORMANCE_FLOOR` moves to `3.0.0`, ADR-030). This
61
61
  > README is the design
62
62
  > bible; the contract it specifies is realised in `src/views_frames/` (index, frames,
63
63
  > io, conformance suite) plus the `src/views_frames_summarize/` sibling package
@@ -472,12 +472,13 @@ views-frames/
472
472
  │ ├── __init__.py # depends on views_frames + numpy only; never the reverse
473
473
  │ ├── proportional.py # reconcile_proportional — per-draw top-down scaling
474
474
  │ ├── grouping.py # reconcile_pgm_to_cm — group by (time, country)
475
+ │ ├── donor_fill.py # empty grid draw → a donor draw's pattern (ADR-030)
475
476
  │ ├── frames.py # prediction_frame_from_arrays adapter
476
477
  │ ├── validation.py # fail-loud input guards
477
478
  │ ├── module.py # ReconciliationModule (holds the injected mapping)
478
479
  │ ├── result.py # ReconciliationResult — the frame plus HOW it was made
479
480
  │ └── conformance.py # assert_reconcile_contract
480
- ├── scripts/ # standalone dev tools, none wired into CI
481
+ ├── scripts/ # dev tools; check_arch_tree + check_doc_refs run in CI
481
482
  ├── examples/ # runnable quickstarts (run by CI)
482
483
  └── tests/ # flat: test_*.py + fixtures/
483
484
  ```
@@ -524,12 +525,22 @@ from views_frames_reconcile import ReconciliationModule
524
525
 
525
526
  # mapping is INJECTED by the caller (sourced from the producer; never fetched here):
526
527
  # map_keys: (M, 2) int (time, priogrid_gid) map_vals: (M,) int country_id
527
- reconciler = ReconciliationModule(map_keys, map_vals)
528
+ reconciler = ReconciliationModule(map_keys, map_vals, seed=0)
528
529
  pgm_reconciled = reconciler.reconcile(cm_frame, pgm_frame) # new pgm PredictionFrame
529
- # each (time, country) group's cells now sum, per draw, to the country total
530
- # (all-zero grid draws stay zero); zeros preserved; non-negative.
530
+ # each (time, country) group's cells now sum, in EVERY draw, to the country total;
531
+ # zeros preserved in draws that had grid mass; non-negative.
531
532
  ```
532
533
 
534
+ **The country forecast is the authority — in every draw (3.0.0, ADR-030).** Scaling
535
+ cannot place a positive country value on a draw whose grid cells are all zero. Until
536
+ 3.0.0 that draw stayed zero and the country's fatalities were silently missing from the
537
+ grid: 2.9–7.3% of the fatality mass on the first production run (#283). Now such a draw
538
+ borrows the pattern of a **donor** — a draw of the same `(time, country)` that has grid
539
+ mass, chosen at random from `default_rng([seed, time, country])` — and is scaled like any
540
+ other. The country decides how many; the grid model's own draws decide where. A group in
541
+ which **no** draw has grid mass raises `ValueError` instead of returning a grid short of
542
+ the country. `reconcile_result(...).filled_draws` reports how many draws were filled.
543
+
533
544
  > **Future DRY pass (deferred).** `grouping.py` labels grid rows by country with the
534
545
  > leaf's `cross_level_align_arrays` and overlaps `cross_level_align` (index.py). Folding
535
546
  > the two is a separate later story — the v1.7.0 relocation is a faithful **WET** move,
@@ -4,11 +4,11 @@
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:** **v2.0.0 — the version in this tree.** For what is actually published, see
7
+ > **Status:** **v3.0.0 — the version in this tree.** For what is actually published, see
8
8
  > [PyPI](https://pypi.org/project/views-frames/): between a release commit and its publish
9
9
  > job this number is deliberately ahead, so this banner tracks the source and never claims
10
- > a publication it cannot verify. API frozen at v1.0.0 (ADR-018) and broken once in 2.0.0
11
- > (ADR-028; the
10
+ > a publication it cannot verify. API frozen at v1.0.0 (ADR-018) and broken twice since, in 2.0.0
11
+ > (ADR-028) and 3.0.0 (ADR-030; the
12
12
  > v1.1 surface is
13
13
  > purely additive — the coherent posterior summary, ADR-019; v1.2.0 rebuilt the tower
14
14
  > `outside-in`, C-44; v1.3.0 makes the tower summary distribution-agnostic — no magnitude
@@ -25,7 +25,7 @@
25
25
  > `research/figures/` tower-figure toolkit; v1.10.0 adds the **dense-grid fill**
26
26
  > primitive — `reindex_fill(other, *, fill_value)` on all three frames +
27
27
  > `SpatioTemporalIndex.cartesian` + the published `assert_reindex_fill_law`, ADR-026,
28
- > unblocking pandas-free FAO ingestion; v1.10.1 makes `io.arrow.load` fail loudly on a parquet whose row order breaks the wire contract, #199; v1.10.2 changes no behaviour — it arms the documentation check in CI, turns eleven falsification tests from README-wording checks into behaviour checks, and records ADR-027 declining the construction shortcut; v1.11.0 corrects the published MAP-containment law, which was wrong on tied draws and so failed ~6% of rows on integer count posteriors — register C-88 — and makes the two IO codecs agree on a non-JSON metadata value, C-90; the rest of that release makes the governance documents match the code and adds the CI checks that keep them matching; **v2.0.0 is the first MAJOR** — a falsification audit found that a frame's `index` was never type-checked, so a frame (or any object with an `n_rows`) was accepted silently and the published `assert_summarizer_contract` then certified the result, and it now raises `TypeError`; riding it, `frame.values` becomes read-only (C-66) and `map_estimate` rejects non-finite draws (C-57), and `CONFORMANCE_FLOOR` moves to `2.0.0` — a correct consumer changes its version constraint and re-locks, nothing else, ADR-028). This
28
+ > unblocking pandas-free FAO ingestion; v1.10.1 makes `io.arrow.load` fail loudly on a parquet whose row order breaks the wire contract, #199; v1.10.2 changes no behaviour — it arms the documentation check in CI, turns eleven falsification tests from README-wording checks into behaviour checks, and records ADR-027 declining the construction shortcut; v1.11.0 corrects the published MAP-containment law, which was wrong on tied draws and so failed ~6% of rows on integer count posteriors — register C-88 — and makes the two IO codecs agree on a non-JSON metadata value, C-90; the rest of that release makes the governance documents match the code and adds the CI checks that keep them matching; **v2.0.0 is the first MAJOR** — a falsification audit found that a frame's `index` was never type-checked, so a frame (or any object with an `n_rows`) was accepted silently and the published `assert_summarizer_contract` then certified the result, and it now raises `TypeError`; riding it, `frame.values` becomes read-only (C-66) and `map_estimate` rejects non-finite draws (C-57), and `CONFORMANCE_FLOOR` moves to `2.0.0` — a correct consumer changes its version constraint and re-locks, nothing else, ADR-028; **v3.0.0 is the second MAJOR** — reconciliation silently lost a country's fatalities whenever its grid was all zeros in a draw, 2.9–7.3% of the fatality mass on the first production run (#283, register C-99); such a draw now borrows the pattern of a random donor draw from the same month and country, a group with no donor raises, every other draw is bit-identical, and `CONFORMANCE_FLOOR` moves to `3.0.0`, ADR-030). This
29
29
  > README is the design
30
30
  > bible; the contract it specifies is realised in `src/views_frames/` (index, frames,
31
31
  > io, conformance suite) plus the `src/views_frames_summarize/` sibling package
@@ -440,12 +440,13 @@ views-frames/
440
440
  │ ├── __init__.py # depends on views_frames + numpy only; never the reverse
441
441
  │ ├── proportional.py # reconcile_proportional — per-draw top-down scaling
442
442
  │ ├── grouping.py # reconcile_pgm_to_cm — group by (time, country)
443
+ │ ├── donor_fill.py # empty grid draw → a donor draw's pattern (ADR-030)
443
444
  │ ├── frames.py # prediction_frame_from_arrays adapter
444
445
  │ ├── validation.py # fail-loud input guards
445
446
  │ ├── module.py # ReconciliationModule (holds the injected mapping)
446
447
  │ ├── result.py # ReconciliationResult — the frame plus HOW it was made
447
448
  │ └── conformance.py # assert_reconcile_contract
448
- ├── scripts/ # standalone dev tools, none wired into CI
449
+ ├── scripts/ # dev tools; check_arch_tree + check_doc_refs run in CI
449
450
  ├── examples/ # runnable quickstarts (run by CI)
450
451
  └── tests/ # flat: test_*.py + fixtures/
451
452
  ```
@@ -492,12 +493,22 @@ from views_frames_reconcile import ReconciliationModule
492
493
 
493
494
  # mapping is INJECTED by the caller (sourced from the producer; never fetched here):
494
495
  # map_keys: (M, 2) int (time, priogrid_gid) map_vals: (M,) int country_id
495
- reconciler = ReconciliationModule(map_keys, map_vals)
496
+ reconciler = ReconciliationModule(map_keys, map_vals, seed=0)
496
497
  pgm_reconciled = reconciler.reconcile(cm_frame, pgm_frame) # new pgm PredictionFrame
497
- # each (time, country) group's cells now sum, per draw, to the country total
498
- # (all-zero grid draws stay zero); zeros preserved; non-negative.
498
+ # each (time, country) group's cells now sum, in EVERY draw, to the country total;
499
+ # zeros preserved in draws that had grid mass; non-negative.
499
500
  ```
500
501
 
502
+ **The country forecast is the authority — in every draw (3.0.0, ADR-030).** Scaling
503
+ cannot place a positive country value on a draw whose grid cells are all zero. Until
504
+ 3.0.0 that draw stayed zero and the country's fatalities were silently missing from the
505
+ grid: 2.9–7.3% of the fatality mass on the first production run (#283). Now such a draw
506
+ borrows the pattern of a **donor** — a draw of the same `(time, country)` that has grid
507
+ mass, chosen at random from `default_rng([seed, time, country])` — and is scaled like any
508
+ other. The country decides how many; the grid model's own draws decide where. A group in
509
+ which **no** draw has grid mass raises `ValueError` instead of returning a grid short of
510
+ the country. `reconcile_result(...).filled_draws` reports how many draws were filled.
511
+
501
512
  > **Future DRY pass (deferred).** `grouping.py` labels grid rows by country with the
502
513
  > leaf's `cross_level_align_arrays` and overlaps `cross_level_align` (index.py). Folding
503
514
  > the two is a separate later story — the v1.7.0 relocation is a faithful **WET** move,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "views-frames"
7
- version = "2.0.0"
7
+ version = "3.0.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" },
@@ -19,8 +19,8 @@ the envelope has one written authority rather than re-asserted copies that drift
19
19
  assert_frame_envelope(my_metric_frame())
20
20
 
21
21
  The floor is governed in ``GOVERNANCE.md``; ``CONFORMANCE_FLOOR`` records the version
22
- this suite belongs to. It moved to ``2.0.0`` with the 2.0.0 MAJOR (ADR-028) — the first
23
- move since the v1.0.0 freeze.
22
+ this suite belongs to. It moved to ``2.0.0`` with the 2.0.0 MAJOR (ADR-028), the first
23
+ move since the v1.0.0 freeze, and to ``3.0.0`` with the 3.0.0 MAJOR (ADR-030).
24
24
  """
25
25
 
26
26
  from __future__ import annotations
@@ -30,11 +30,12 @@ from typing import Any
30
30
 
31
31
  import numpy as np
32
32
 
33
- # Bumped to 2.0.0 on 2026-08-18 — the FIRST move since the freeze. `assert_summarizer_
34
- # contract` now rejects a frame whose index is not an index, which narrows what the
35
- # published suite accepts, and GOVERNANCE bumps the floor on any breaking change to any
36
- # published entry point. A consumer reading this learns which contract its CI asserts.
37
- CONFORMANCE_FLOOR = "2.0.0"
33
+ # Bumped to 3.0.0 for the 3.0.0 MAJOR (ADR-030): `assert_reconcile_contract` now
34
+ # requires every draw to sum to its country, where it used to require draws with an
35
+ # empty grid to stay zero. GOVERNANCE bumps the floor on any breaking change to any
36
+ # published entry point (the 2.0.0 move, ADR-028, was the first since the freeze). A
37
+ # consumer reading this learns which contract its CI asserts.
38
+ CONFORMANCE_FLOOR = "3.0.0"
38
39
 
39
40
  __all__ = [
40
41
  "CONFORMANCE_FLOOR",
@@ -1,14 +1,18 @@
1
1
  """Forecast reconciliation (pgm forecasts reconciled to cm totals).
2
2
 
3
3
  Slice 1 ports the top-down proportional method from views-reporting as a pure
4
- numpy function. New methods (e.g. principled probabilistic reconciliation, C-37)
5
- should be added as sibling modules, not by modifying ``proportional``.
4
+ numpy function. Since 3.0.0 a draw whose grid is empty but whose country is
5
+ positive first borrows a donor draw's pattern (``donor_fill``, ADR-030), so every
6
+ draw sums to its country. New methods (e.g. principled probabilistic
7
+ reconciliation, C-37) should be added as sibling modules, not by modifying
8
+ ``proportional``.
6
9
  """
7
10
 
8
11
  from views_frames_reconcile.module import ReconciliationModule
9
12
  from views_frames_reconcile.proportional import reconcile_proportional
10
13
  from views_frames_reconcile.result import (
11
14
  ALIGNED_DRAWS,
15
+ METHOD_DONOR_FILL,
12
16
  METHOD_PROPORTIONAL,
13
17
  POINT_BROADCAST,
14
18
  ReconciliationResult,
@@ -16,6 +20,7 @@ from views_frames_reconcile.result import (
16
20
 
17
21
  __all__ = [
18
22
  "ALIGNED_DRAWS",
23
+ "METHOD_DONOR_FILL",
19
24
  "METHOD_PROPORTIONAL",
20
25
  "POINT_BROADCAST",
21
26
  "ReconciliationModule",
@@ -1,10 +1,15 @@
1
1
  """Conformance checks for the reconcile package (ADR-023).
2
2
 
3
3
  A consumer can re-run these against its own frame factories to confirm the reconciler
4
- behaves: grid (``pgm``) predictions sum, per draw, to their country (``cm``) totals
5
- (except all-zero grid draws, which stay zero — the documented edge); zeros preserved;
6
- values stay non-negative; the output is a same-shape ``pgm`` ``PredictionFrame`` at PGM
7
- level; and the cm/pgm mapping is **injected, never fetched** (ADR-014/ADR-023).
4
+ behaves: grid (``pgm``) predictions sum, in **every** draw, to their country (``cm``)
5
+ totals (ADR-030: a draw whose grid was empty is filled from a donor draw of its group);
6
+ zero cells stay zero in draws that had grid mass, and a filled draw copies a donor's
7
+ pattern; values stay non-negative; the output is a same-shape ``pgm``
8
+ ``PredictionFrame`` at PGM level; and the cm/pgm mapping is **injected, never fetched**
9
+ (ADR-014/ADR-023).
10
+
11
+ Until 3.0.0 the sum law exempted draws whose grid was all zeros and required them to
12
+ stay zero. That exemption is where the reconciler lost country mass (#283); it is gone.
8
13
 
9
14
  Mirrors ``views_frames_summarize/conformance.py:assert_summarizer_contract``.
10
15
  """
@@ -65,11 +70,7 @@ def assert_reconcile_contract(
65
70
  # 2. Non-negativity.
66
71
  assert bool((out.values >= 0).all()), "reconciled forecasts must be non-negative"
67
72
 
68
- # 3. Zero-preservation: an input zero cell stays zero.
69
- assert bool((out.values[pgm_frame.values == 0] == 0).all()), "zeros preserved"
70
-
71
- # 4. Sum-to-country per draw: each (time, country) group's cells sum, per draw, to
72
- # its country total — except all-zero input draws, which stay zero (the edge).
73
+ # 3 + 4, per (time, country) group.
73
74
  cm_units = pgm_frame.index.cross_level_align_arrays(mk, mv, SpatialLevel.CM).unit
74
75
  pg_time = pgm_frame.index.time
75
76
  cm_pos = {
@@ -80,12 +81,34 @@ def assert_reconcile_contract(
80
81
  }
81
82
  for t, c in np.unique(np.stack([pg_time, cm_units], axis=1), axis=0):
82
83
  rows = (pg_time == t) & (cm_units == c)
83
- in_sum = pgm_frame.values[rows].sum(axis=0) # (S,)
84
- out_sum = out.values[rows].sum(axis=0) # (S,)
84
+ grid_in = np.asarray(pgm_frame.values[rows], dtype=np.float64) # (n, S)
85
+ grid_out = np.asarray(out.values[rows], dtype=np.float64)
86
+ out_sum = grid_out.sum(axis=0) # (S,)
85
87
  # cm may be a point (sample_count == 1, broadcast inside reconcile) or aligned
86
88
  # draws — broadcast its per-(time, country) total to the draw axis either way.
87
89
  cm_total = cm_frame.values[cm_pos[(int(t), int(c))]]
88
90
  total = np.broadcast_to(cm_total, out_sum.shape)
89
- active = in_sum != 0
90
- np.testing.assert_allclose(out_sum[active], total[active], rtol=1e-4, atol=1e-3)
91
- assert bool((out_sum[~active] == 0).all()), "all-zero draws stay zero"
91
+
92
+ # 4. Sum-to-country in every draw: a positive country value is placed in full,
93
+ # a zero one empties the grid. No draw is exempt (ADR-030).
94
+ np.testing.assert_allclose(out_sum, total, rtol=1e-4, atol=1e-3)
95
+
96
+ # 3a. Zero-preservation in draws that had grid mass: their zero cells stay zero.
97
+ positive_in = np.where(grid_in > 0, grid_in, 0.0)
98
+ had_mass = positive_in.sum(axis=0) > 0
99
+ stays_zero = positive_in[:, had_mass] == 0
100
+ assert bool((grid_out[:, had_mass][stays_zero] == 0).all()), "zeros preserved"
101
+
102
+ # 3b. A filled draw (input grid empty, mass placed) is a scaled copy of a draw
103
+ # of the same group that had grid mass: same zero cells, same shares.
104
+ filled = np.flatnonzero(~had_mass & (out_sum > 0))
105
+ if filled.size:
106
+ donors = positive_in[:, had_mass]
107
+ assert donors.shape[1] > 0, "a draw was filled although no draw had mass"
108
+ donor_shares = donors / donors.sum(axis=0)
109
+ for s in filled:
110
+ share = grid_out[:, s] / out_sum[s]
111
+ close = np.isclose(donor_shares, share[:, None], rtol=1e-4, atol=1e-6)
112
+ assert bool(close.all(axis=0).any()), (
113
+ "a filled draw must copy the pattern of a donor draw of its group"
114
+ )
@@ -0,0 +1,133 @@
1
+ """Donor fill: give an empty grid draw a pattern before it is scaled (ADR-030).
2
+
3
+ Scaling is multiplication, so ``reconcile_proportional`` cannot place a positive
4
+ country value on a draw whose grid cells are all zero. Before 3.0.0 that draw stayed
5
+ zero and the country's mass was lost (2.9–7.3% on the first production run, #283).
6
+
7
+ This pass runs **before** the unchanged scaling. Within each ``(time, country)`` group,
8
+ a draw that **needs filling** (country value > 0, no grid cell > 0) receives a copy of
9
+ the grid values of a **donor**: a draw of the same group that has grid mass, chosen
10
+ uniformly at random. The scaling then rescales it to the draw's own country value, so
11
+ the filled draw has exactly the donor's zero cells and proportions.
12
+
13
+ The random choice is reproducible: each group draws from
14
+ ``numpy.random.default_rng([seed, time, country])``, so the result does not depend on
15
+ how a caller chunks its calls or on group order.
16
+
17
+ ``proportional`` and ``grouping`` are not touched (CIC §11). The group-by-sort below
18
+ repeats ``grouping``'s on purpose (ADR-030 §Decision 6).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+ from numpy.typing import NDArray
27
+
28
+ from views_frames import PredictionFrame, SpatialLevel
29
+
30
+
31
+ def fill_empty_draws(
32
+ grid: NDArray[np.floating[Any]],
33
+ country: NDArray[np.floating[Any]],
34
+ rng: np.random.Generator,
35
+ ) -> tuple[NDArray[np.floating[Any]], int]:
36
+ """Fill the draws of one group whose grid is empty but whose country is positive.
37
+
38
+ Args:
39
+ grid: ``(S, n_cells)`` grid values of one ``(time, country)`` group.
40
+ country: ``(S,)`` the country value of each draw.
41
+ rng: the group's generator.
42
+
43
+ Returns:
44
+ ``(grid, n_filled)``. ``grid`` is the input object itself when nothing needed
45
+ filling, otherwise a new array; the input is never modified.
46
+
47
+ Raises:
48
+ ValueError: a draw needs filling but no draw of the group has grid mass, so
49
+ there is no pattern to borrow (ADR-030 §Decision 5).
50
+ """
51
+ has_mass = (grid > 0).any(axis=1) # (S,)
52
+ needs_fill = (country > 0) & ~has_mass
53
+ n_filled = int(needs_fill.sum())
54
+ if n_filled == 0:
55
+ return grid, 0
56
+ donors = np.flatnonzero(has_mass)
57
+ if donors.size == 0:
58
+ raise ValueError(
59
+ f"cannot place the country value of {n_filled} of {grid.shape[0]} "
60
+ "draw(s): the grid is zero in every cell of every draw, so there is no "
61
+ "donor pattern to borrow. The country forecast has fatalities where the "
62
+ "grid forecast has none at all (ADR-030)."
63
+ )
64
+ filled = np.array(grid, copy=True)
65
+ filled[needs_fill] = grid[rng.choice(donors, size=n_filled)]
66
+ return filled, n_filled
67
+
68
+
69
+ def fill_empty_grid_draws(
70
+ pgm_frame: PredictionFrame,
71
+ cm_frame: PredictionFrame,
72
+ map_keys: NDArray[np.integer[Any]],
73
+ map_vals: NDArray[np.integer[Any]],
74
+ seed: int,
75
+ ) -> tuple[PredictionFrame, int]:
76
+ """Apply :func:`fill_empty_draws` to every ``(time, country)`` group of a pgm frame.
77
+
78
+ ``cm_frame`` must already carry the grid's draw count (a point country is broadcast
79
+ before this is called), and every grid group must have a country row
80
+ (``validate_reconciliation_inputs`` guarantees both).
81
+
82
+ Returns:
83
+ ``(frame, n_filled)``: ``pgm_frame`` itself when nothing needed filling,
84
+ otherwise a new frame with the same index and metadata; and the number of draws
85
+ filled.
86
+
87
+ Raises:
88
+ ValueError: a ``(time, country)`` group needs filling and has no donor. Raised
89
+ before any output is returned; the message names the time and country.
90
+ """
91
+ cm_units = pgm_frame.index.cross_level_align_arrays(
92
+ map_keys, map_vals, SpatialLevel.CM
93
+ ).unit
94
+ pg_time = pgm_frame.index.time
95
+ cm_vals = cm_frame.values
96
+ cm_pos = {
97
+ (int(t), int(u)): j
98
+ for j, (t, u) in enumerate(
99
+ zip(cm_frame.index.time, cm_frame.index.unit, strict=True)
100
+ )
101
+ }
102
+
103
+ pg_vals = pgm_frame.values
104
+ groups, inverse, counts = np.unique(
105
+ np.stack([pg_time, cm_units], axis=1),
106
+ axis=0,
107
+ return_inverse=True,
108
+ return_counts=True,
109
+ )
110
+ order = np.argsort(np.asarray(inverse).reshape(-1), kind="stable")
111
+ bounds = np.concatenate(([0], np.cumsum(counts)))
112
+
113
+ out: NDArray[Any] | None = None
114
+ n_filled = 0
115
+ for gi in range(groups.shape[0]):
116
+ t, c = int(groups[gi, 0]), int(groups[gi, 1])
117
+ rows = order[bounds[gi] : bounds[gi + 1]]
118
+ grid = pg_vals[rows].T # (S, n_cells)
119
+ try:
120
+ filled, n = fill_empty_draws(
121
+ grid, cm_vals[cm_pos[(t, c)]], np.random.default_rng([seed, t, c])
122
+ )
123
+ except ValueError as err:
124
+ raise ValueError(f"time {t}, country {c}: {err}") from err
125
+ if n:
126
+ if out is None:
127
+ out = np.array(pg_vals, copy=True)
128
+ out[rows] = filled.T
129
+ n_filled += n
130
+
131
+ if out is None:
132
+ return pgm_frame, 0
133
+ return PredictionFrame(out, pgm_frame.index, pgm_frame.metadata), n_filled
@@ -2,12 +2,14 @@
2
2
 
3
3
  `ReconciliationModule` holds the injected `(time, priogrid_gid) -> country_id`
4
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).
5
+ applies it: `reconcile(cm_frame, pgm_frame)` validates the inputs, gives every draw
6
+ whose grid is empty but whose country is positive a donor draw's pattern (ADR-030),
7
+ and scales the grid forecasts to the country totals, returning a **new** pgm frame
8
+ (de-mutated, C-184).
8
9
 
9
10
  **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
+ grouping is `grouping`, the empty-draw fill is `donor_fill`, the guards are
12
+ `validation`, the I/O is `frames`. No
11
13
  torch, no pandas, no viewser, no wandb: the original's `ProcessPoolExecutor` and
12
14
  WandB alerting are dropped (numpy is fast; there is no GPU). If scale ever needs
13
15
  parallelism, add it behind this same interface (OCP). Multi-target inputs are
@@ -22,10 +24,11 @@ import numpy as np
22
24
  from numpy.typing import NDArray
23
25
 
24
26
  from views_frames import PredictionFrame
27
+ from views_frames_reconcile.donor_fill import fill_empty_grid_draws
25
28
  from views_frames_reconcile.grouping import reconcile_pgm_to_cm
26
29
  from views_frames_reconcile.result import (
27
30
  ALIGNED_DRAWS,
28
- METHOD_PROPORTIONAL,
31
+ METHOD_DONOR_FILL,
29
32
  POINT_BROADCAST,
30
33
  ReconciliationResult,
31
34
  )
@@ -54,16 +57,22 @@ class ReconciliationModule:
54
57
  self,
55
58
  map_keys: NDArray[np.integer[Any]] | object,
56
59
  map_vals: NDArray[np.integer[Any]] | object,
60
+ *,
61
+ seed: int = 0,
57
62
  ) -> None:
58
63
  """Inject the `(time, priogrid_gid) -> country_id` mapping.
59
64
 
60
65
  Args:
61
66
  map_keys: ``(M, 2)`` int array of ``(time, priogrid_gid)`` pairs.
62
67
  map_vals: ``(M,)`` int ``country_id`` for each key.
68
+ seed: non-negative int; seeds the donor choice for draws whose grid is empty
69
+ (ADR-030). Group ``(time, country)`` draws from
70
+ ``default_rng([seed, time, country])``, so the same inputs and seed give
71
+ the same output however the calls are chunked.
63
72
 
64
73
  Raises:
65
- ValueError: ``map_keys`` is not ``(M, 2)`` or ``map_vals`` is not
66
- length ``M``.
74
+ ValueError: ``map_keys`` is not ``(M, 2)``, ``map_vals`` is not length
75
+ ``M``, or ``seed`` is not a non-negative int.
67
76
  """
68
77
  keys = np.asarray(map_keys)
69
78
  vals = np.asarray(map_vals)
@@ -71,8 +80,12 @@ class ReconciliationModule:
71
80
  raise ValueError("map_keys must be an (M, 2) array of (time, priogrid_gid)")
72
81
  if vals.shape != (keys.shape[0],):
73
82
  raise ValueError("map_vals must be a length-M array aligned to map_keys")
83
+ is_int = isinstance(seed, (int, np.integer)) and not isinstance(seed, bool)
84
+ if not is_int or seed < 0:
85
+ raise ValueError(f"seed must be a non-negative int, got {seed!r}")
74
86
  self._map_keys = keys
75
87
  self._map_vals = vals
88
+ self._seed = int(seed)
76
89
 
77
90
  def reconcile_result(
78
91
  self, cm_frame: PredictionFrame, pgm_frame: PredictionFrame
@@ -90,7 +103,9 @@ class ReconciliationModule:
90
103
 
91
104
  Raises:
92
105
  ValueError: the inputs fail validation (level / sample-count — cm must be 1
93
- or pgm's ``S`` / time coverage / missing country forecast).
106
+ or pgm's ``S`` / time coverage / missing country forecast), or a
107
+ ``(time, country)`` has a positive country value in a draw whose grid is
108
+ empty and no draw of that group has grid mass to borrow (ADR-030).
94
109
  """
95
110
  validate_reconciliation_inputs(
96
111
  cm_frame, pgm_frame, self._map_keys, self._map_vals
@@ -101,8 +116,13 @@ class ReconciliationModule:
101
116
  else:
102
117
  cm = cm_frame
103
118
  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)
119
+ filled, filled_draws = fill_empty_grid_draws(
120
+ pgm_frame, cm, self._map_keys, self._map_vals, self._seed
121
+ )
122
+ frame = reconcile_pgm_to_cm(filled, cm, self._map_keys, self._map_vals)
123
+ return ReconciliationResult(
124
+ frame=frame, mode=mode, method=METHOD_DONOR_FILL, filled_draws=filled_draws
125
+ )
106
126
 
107
127
  def reconcile(
108
128
  self, cm_frame: PredictionFrame, pgm_frame: PredictionFrame
@@ -115,6 +135,8 @@ class ReconciliationModule:
115
135
 
116
136
  Raises:
117
137
  ValueError: the inputs fail validation (level / sample-count — cm must be 1
118
- or pgm's ``S`` / time coverage / missing country forecast).
138
+ or pgm's ``S`` / time coverage / missing country forecast), or a
139
+ ``(time, country)`` has a positive country value in a draw whose grid is
140
+ empty and no draw of that group has grid mass to borrow (ADR-030).
119
141
  """
120
142
  return self.reconcile_result(cm_frame, pgm_frame).frame
@@ -31,12 +31,19 @@ from views_frames import PredictionFrame
31
31
  POINT_BROADCAST = "point-broadcast"
32
32
  #: Mode — the call **scaled aligned draws**: the country already carried the grid's draw
33
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).
34
+ #: approximation (the principled joint upgrade is designed and deferred, ADR-024 /
35
+ #: register C-62). The mode describes *what this call did*: a point against a
36
+ #: single-draw grid, or a point the caller pre-tiled to ``S``, also reads as
37
+ #: aligned-draws (nothing was broadcast).
37
38
  ALIGNED_DRAWS = "aligned-draws"
38
- #: The reconciliation algorithm — top-down proportional, per draw.
39
+ #: The 2.x reconciliation algorithm — top-down proportional, per draw. No longer what
40
+ #: ``ReconciliationModule`` reports (see :data:`METHOD_DONOR_FILL`); kept because it is
41
+ #: frozen public surface (ADR-018).
39
42
  METHOD_PROPORTIONAL = "proportional"
43
+ #: The reconciliation algorithm since 3.0.0 — top-down proportional, per draw, after a
44
+ #: draw whose grid is empty but whose country is positive borrows a donor draw's pattern
45
+ #: (ADR-030).
46
+ METHOD_DONOR_FILL = "proportional-donor-fill"
40
47
 
41
48
 
42
49
  @dataclass(frozen=True)
@@ -47,9 +54,12 @@ class ReconciliationResult:
47
54
  frame: the reconciled pgm ``PredictionFrame`` (identical to what ``reconcile``
48
55
  returns).
49
56
  mode: :data:`POINT_BROADCAST` or :data:`ALIGNED_DRAWS`.
50
- method: the reconciliation method, e.g. :data:`METHOD_PROPORTIONAL`.
57
+ method: the reconciliation method, e.g. :data:`METHOD_DONOR_FILL`.
58
+ filled_draws: how many ``(time, country, draw)`` draws had an empty grid and a
59
+ positive country value, and were given a donor draw's pattern (ADR-030).
51
60
  """
52
61
 
53
62
  frame: PredictionFrame
54
63
  mode: str
55
64
  method: str
65
+ filled_draws: int = 0
File without changes
File without changes