views-frames 1.11.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.
- {views_frames-1.11.0 → views_frames-3.0.0}/PKG-INFO +30 -14
- {views_frames-1.11.0 → views_frames-3.0.0}/README.md +29 -13
- {views_frames-1.11.0 → views_frames-3.0.0}/pyproject.toml +1 -1
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames/conformance/__init__.py +8 -2
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames/feature_frame.py +11 -1
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames/index.py +15 -1
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames/prediction_frame.py +11 -1
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames/target_frame.py +11 -1
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_reconcile/__init__.py +7 -2
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_reconcile/conformance.py +37 -14
- views_frames-3.0.0/src/views_frames_reconcile/donor_fill.py +133 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_reconcile/module.py +33 -11
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_reconcile/result.py +15 -5
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_summarize/conformance.py +18 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_summarize/point.py +8 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/.gitignore +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/LICENSE +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames/__init__.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames/_typing.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames/_validation.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames/io/__init__.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames/io/arrow.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames/io/npz.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames/metadata.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames/protocols.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames/py.typed +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames/spatial_level.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_reconcile/frames.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_reconcile/grouping.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_reconcile/proportional.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_reconcile/py.typed +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_reconcile/validation.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_summarize/__init__.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_summarize/_common.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_summarize/aggregate.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_summarize/bimodality.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_summarize/collapse.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_summarize/config.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_summarize/exceedance.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_summarize/expected_shortfall.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_summarize/interval.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_summarize/py.typed +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_summarize/summarize_tower.py +0 -0
- {views_frames-1.11.0 → views_frames-3.0.0}/src/views_frames_summarize/tower.py +0 -0
- {views_frames-1.11.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:
|
|
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,7 +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:** **
|
|
39
|
+
> **Status:** **v3.0.0 — the version in this tree.** For what is actually published, see
|
|
40
|
+
> [PyPI](https://pypi.org/project/views-frames/): between a release commit and its publish
|
|
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 twice since, in 2.0.0
|
|
43
|
+
> (ADR-028) and 3.0.0 (ADR-030; the
|
|
40
44
|
> v1.1 surface is
|
|
41
45
|
> purely additive — the coherent posterior summary, ADR-019; v1.2.0 rebuilt the tower
|
|
42
46
|
> `outside-in`, C-44; v1.3.0 makes the tower summary distribution-agnostic — no magnitude
|
|
@@ -53,7 +57,7 @@ Description-Content-Type: text/markdown
|
|
|
53
57
|
> `research/figures/` tower-figure toolkit; v1.10.0 adds the **dense-grid fill**
|
|
54
58
|
> primitive — `reindex_fill(other, *, fill_value)` on all three frames +
|
|
55
59
|
> `SpatioTemporalIndex.cartesian` + the published `assert_reindex_fill_law`, ADR-026,
|
|
56
|
-
> 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). 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
|
|
57
61
|
> README is the design
|
|
58
62
|
> bible; the contract it specifies is realised in `src/views_frames/` (index, frames,
|
|
59
63
|
> io, conformance suite) plus the `src/views_frames_summarize/` sibling package
|
|
@@ -265,13 +269,14 @@ makes it safe to depend on from everywhere (SDP).
|
|
|
265
269
|
3. **Immutable value objects.** A frame is validated at construction and then
|
|
266
270
|
treated as read-only. Operations (`collapse`, `select`, `with_metadata`)
|
|
267
271
|
**return new frames**; they never mutate in place. (Directly forbids the
|
|
268
|
-
C-184 cross-repo-mutation anti-pattern.) **Enforced for the index,
|
|
269
|
-
|
|
270
|
-
write-protected (`setflags(write=False)`); the `values` buffer
|
|
271
|
-
**writeable on purpose** — so structural ops can share it zero-copy — and
|
|
272
|
-
immutable *by convention
|
|
273
|
-
|
|
274
|
-
|
|
272
|
+
C-184 cross-repo-mutation anti-pattern.) **Enforced for the index, and since 2.0.0
|
|
273
|
+
for the value buffer too:** the identifier arrays (`time`/`unit`) are
|
|
274
|
+
write-protected (`setflags(write=False)`); the `values` buffer was left
|
|
275
|
+
**writeable on purpose** — so structural ops can share it zero-copy — and was
|
|
276
|
+
immutable *by convention* **until 2.0.0**, which enforced it: `values` is now a
|
|
277
|
+
read-only **view**, so an in-place mutation raises instead of silently corrupting
|
|
278
|
+
buffer-sharing frames, while zero-copy, mmap and the caller's own array are all
|
|
279
|
+
preserved (ADR-028 / register C-66; the convention-only era is ADR-025 / C-63). **Copy-vs-view:** structural and
|
|
275
280
|
metadata-only operations (`with_metadata`, contiguous `select`) return frames
|
|
276
281
|
that **share** the underlying `values` buffer (numpy view / zero-copy), and a
|
|
277
282
|
`mmap`-backed frame stays `mmap`-backed — a new frame must never copy a
|
|
@@ -467,12 +472,13 @@ views-frames/
|
|
|
467
472
|
│ ├── __init__.py # depends on views_frames + numpy only; never the reverse
|
|
468
473
|
│ ├── proportional.py # reconcile_proportional — per-draw top-down scaling
|
|
469
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)
|
|
470
476
|
│ ├── frames.py # prediction_frame_from_arrays adapter
|
|
471
477
|
│ ├── validation.py # fail-loud input guards
|
|
472
478
|
│ ├── module.py # ReconciliationModule (holds the injected mapping)
|
|
473
479
|
│ ├── result.py # ReconciliationResult — the frame plus HOW it was made
|
|
474
480
|
│ └── conformance.py # assert_reconcile_contract
|
|
475
|
-
├── scripts/ #
|
|
481
|
+
├── scripts/ # dev tools; check_arch_tree + check_doc_refs run in CI
|
|
476
482
|
├── examples/ # runnable quickstarts (run by CI)
|
|
477
483
|
└── tests/ # flat: test_*.py + fixtures/
|
|
478
484
|
```
|
|
@@ -519,12 +525,22 @@ from views_frames_reconcile import ReconciliationModule
|
|
|
519
525
|
|
|
520
526
|
# mapping is INJECTED by the caller (sourced from the producer; never fetched here):
|
|
521
527
|
# map_keys: (M, 2) int (time, priogrid_gid) map_vals: (M,) int country_id
|
|
522
|
-
reconciler = ReconciliationModule(map_keys, map_vals)
|
|
528
|
+
reconciler = ReconciliationModule(map_keys, map_vals, seed=0)
|
|
523
529
|
pgm_reconciled = reconciler.reconcile(cm_frame, pgm_frame) # new pgm PredictionFrame
|
|
524
|
-
# each (time, country) group's cells now sum,
|
|
525
|
-
#
|
|
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.
|
|
526
532
|
```
|
|
527
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
|
+
|
|
528
544
|
> **Future DRY pass (deferred).** `grouping.py` labels grid rows by country with the
|
|
529
545
|
> leaf's `cross_level_align_arrays` and overlaps `cross_level_align` (index.py). Folding
|
|
530
546
|
> the two is a separate later story — the v1.7.0 relocation is a faithful **WET** move,
|
|
@@ -4,7 +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:** **
|
|
7
|
+
> **Status:** **v3.0.0 — the version in this tree.** For what is actually published, see
|
|
8
|
+
> [PyPI](https://pypi.org/project/views-frames/): between a release commit and its publish
|
|
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 twice since, in 2.0.0
|
|
11
|
+
> (ADR-028) and 3.0.0 (ADR-030; the
|
|
8
12
|
> v1.1 surface is
|
|
9
13
|
> purely additive — the coherent posterior summary, ADR-019; v1.2.0 rebuilt the tower
|
|
10
14
|
> `outside-in`, C-44; v1.3.0 makes the tower summary distribution-agnostic — no magnitude
|
|
@@ -21,7 +25,7 @@
|
|
|
21
25
|
> `research/figures/` tower-figure toolkit; v1.10.0 adds the **dense-grid fill**
|
|
22
26
|
> primitive — `reindex_fill(other, *, fill_value)` on all three frames +
|
|
23
27
|
> `SpatioTemporalIndex.cartesian` + the published `assert_reindex_fill_law`, ADR-026,
|
|
24
|
-
> 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). 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
|
|
25
29
|
> README is the design
|
|
26
30
|
> bible; the contract it specifies is realised in `src/views_frames/` (index, frames,
|
|
27
31
|
> io, conformance suite) plus the `src/views_frames_summarize/` sibling package
|
|
@@ -233,13 +237,14 @@ makes it safe to depend on from everywhere (SDP).
|
|
|
233
237
|
3. **Immutable value objects.** A frame is validated at construction and then
|
|
234
238
|
treated as read-only. Operations (`collapse`, `select`, `with_metadata`)
|
|
235
239
|
**return new frames**; they never mutate in place. (Directly forbids the
|
|
236
|
-
C-184 cross-repo-mutation anti-pattern.) **Enforced for the index,
|
|
237
|
-
|
|
238
|
-
write-protected (`setflags(write=False)`); the `values` buffer
|
|
239
|
-
**writeable on purpose** — so structural ops can share it zero-copy — and
|
|
240
|
-
immutable *by convention
|
|
241
|
-
|
|
242
|
-
|
|
240
|
+
C-184 cross-repo-mutation anti-pattern.) **Enforced for the index, and since 2.0.0
|
|
241
|
+
for the value buffer too:** the identifier arrays (`time`/`unit`) are
|
|
242
|
+
write-protected (`setflags(write=False)`); the `values` buffer was left
|
|
243
|
+
**writeable on purpose** — so structural ops can share it zero-copy — and was
|
|
244
|
+
immutable *by convention* **until 2.0.0**, which enforced it: `values` is now a
|
|
245
|
+
read-only **view**, so an in-place mutation raises instead of silently corrupting
|
|
246
|
+
buffer-sharing frames, while zero-copy, mmap and the caller's own array are all
|
|
247
|
+
preserved (ADR-028 / register C-66; the convention-only era is ADR-025 / C-63). **Copy-vs-view:** structural and
|
|
243
248
|
metadata-only operations (`with_metadata`, contiguous `select`) return frames
|
|
244
249
|
that **share** the underlying `values` buffer (numpy view / zero-copy), and a
|
|
245
250
|
`mmap`-backed frame stays `mmap`-backed — a new frame must never copy a
|
|
@@ -435,12 +440,13 @@ views-frames/
|
|
|
435
440
|
│ ├── __init__.py # depends on views_frames + numpy only; never the reverse
|
|
436
441
|
│ ├── proportional.py # reconcile_proportional — per-draw top-down scaling
|
|
437
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)
|
|
438
444
|
│ ├── frames.py # prediction_frame_from_arrays adapter
|
|
439
445
|
│ ├── validation.py # fail-loud input guards
|
|
440
446
|
│ ├── module.py # ReconciliationModule (holds the injected mapping)
|
|
441
447
|
│ ├── result.py # ReconciliationResult — the frame plus HOW it was made
|
|
442
448
|
│ └── conformance.py # assert_reconcile_contract
|
|
443
|
-
├── scripts/ #
|
|
449
|
+
├── scripts/ # dev tools; check_arch_tree + check_doc_refs run in CI
|
|
444
450
|
├── examples/ # runnable quickstarts (run by CI)
|
|
445
451
|
└── tests/ # flat: test_*.py + fixtures/
|
|
446
452
|
```
|
|
@@ -487,12 +493,22 @@ from views_frames_reconcile import ReconciliationModule
|
|
|
487
493
|
|
|
488
494
|
# mapping is INJECTED by the caller (sourced from the producer; never fetched here):
|
|
489
495
|
# map_keys: (M, 2) int (time, priogrid_gid) map_vals: (M,) int country_id
|
|
490
|
-
reconciler = ReconciliationModule(map_keys, map_vals)
|
|
496
|
+
reconciler = ReconciliationModule(map_keys, map_vals, seed=0)
|
|
491
497
|
pgm_reconciled = reconciler.reconcile(cm_frame, pgm_frame) # new pgm PredictionFrame
|
|
492
|
-
# each (time, country) group's cells now sum,
|
|
493
|
-
#
|
|
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.
|
|
494
500
|
```
|
|
495
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
|
+
|
|
496
512
|
> **Future DRY pass (deferred).** `grouping.py` labels grid rows by country with the
|
|
497
513
|
> leaf's `cross_level_align_arrays` and overlaps `cross_level_align` (index.py). Folding
|
|
498
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 = "
|
|
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,7 +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.
|
|
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).
|
|
23
24
|
"""
|
|
24
25
|
|
|
25
26
|
from __future__ import annotations
|
|
@@ -29,7 +30,12 @@ from typing import Any
|
|
|
29
30
|
|
|
30
31
|
import numpy as np
|
|
31
32
|
|
|
32
|
-
|
|
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"
|
|
33
39
|
|
|
34
40
|
__all__ = [
|
|
35
41
|
"CONFORMANCE_FLOOR",
|
|
@@ -41,6 +41,10 @@ class FeatureFrame:
|
|
|
41
41
|
f"trailing sample axis (ADR-012), got ndim={values.ndim}. "
|
|
42
42
|
"Use FeatureFrame.from_2d to build a frame from a 2-D (N, F) array."
|
|
43
43
|
)
|
|
44
|
+
if not isinstance(index, SpatioTemporalIndex):
|
|
45
|
+
raise TypeError(
|
|
46
|
+
f"index must be a SpatioTemporalIndex, got {type(index).__name__}"
|
|
47
|
+
)
|
|
44
48
|
if values.shape[0] != index.n_rows:
|
|
45
49
|
raise ValueError(
|
|
46
50
|
f"y_features has {values.shape[0]} rows but index has {index.n_rows}"
|
|
@@ -50,7 +54,13 @@ class FeatureFrame:
|
|
|
50
54
|
f"feature_names length ({len(feature_names)}) must match the "
|
|
51
55
|
f"feature axis ({values.shape[1]})"
|
|
52
56
|
)
|
|
53
|
-
|
|
57
|
+
# A read-only *view*, not `values.setflags(write=False)` (register C-66).
|
|
58
|
+
# `coerce_values` returns the caller's own array when it is already float32
|
|
59
|
+
# (the C-07 zero-copy guarantee), so locking it in place would silently make
|
|
60
|
+
# the CALLER's array read-only — action at a distance well outside this
|
|
61
|
+
# contract. The view shares the buffer, so zero-copy and mmap are preserved.
|
|
62
|
+
self._values: NDArray[np.float32] = values.view()
|
|
63
|
+
self._values.setflags(write=False)
|
|
54
64
|
self._index = index
|
|
55
65
|
self._feature_names = list(feature_names)
|
|
56
66
|
self._metadata = metadata if metadata is not None else FrameMetadata()
|
|
@@ -47,7 +47,11 @@ class SpatioTemporalIndex:
|
|
|
47
47
|
is_array = isinstance(time, np.ndarray) and time.ndim >= 1
|
|
48
48
|
n = int(time.shape[0]) if is_array else -1
|
|
49
49
|
validate_identifiers({"time": time, "unit": unit}, n_rows=n)
|
|
50
|
-
#
|
|
50
|
+
# Write-protect the identifier arrays so the value object cannot be mutated
|
|
51
|
+
# in place. Note `ascontiguousarray` returns the *same* array when the input
|
|
52
|
+
# is already contiguous, so this also write-protects the caller's array. That
|
|
53
|
+
# is long-standing index behaviour, deliberately left alone here; the frames
|
|
54
|
+
# take a read-only view instead, for the reason given in their constructors.
|
|
51
55
|
self._time = np.ascontiguousarray(time)
|
|
52
56
|
self._unit = np.ascontiguousarray(unit)
|
|
53
57
|
self._time.setflags(write=False)
|
|
@@ -173,6 +177,16 @@ class SpatioTemporalIndex:
|
|
|
173
177
|
)
|
|
174
178
|
|
|
175
179
|
def _require_same_level(self, other: SpatioTemporalIndex) -> None:
|
|
180
|
+
# Type first. Every same-level binary op (`searchsorted`/`reindex`,
|
|
181
|
+
# `is_superset_of`, `intersect`) routes through here, so this one guard is
|
|
182
|
+
# what stops a non-index argument reaching `other._level` and leaking
|
|
183
|
+
# `AttributeError: '...' object has no attribute '_level'` — a private
|
|
184
|
+
# attribute of a class the caller never named. ADR-008 requires a
|
|
185
|
+
# ValueError/TypeError at every validation guard (falsify audit 2026-08-18).
|
|
186
|
+
if not isinstance(other, SpatioTemporalIndex):
|
|
187
|
+
raise TypeError(
|
|
188
|
+
f"expected a SpatioTemporalIndex, got {type(other).__name__}"
|
|
189
|
+
)
|
|
176
190
|
if self._level != other._level:
|
|
177
191
|
raise ValueError(
|
|
178
192
|
"same-level operation requires equal SpatialLevel; "
|
|
@@ -37,11 +37,21 @@ class PredictionFrame:
|
|
|
37
37
|
raise ValueError(
|
|
38
38
|
f"PredictionFrame y_pred must be 2D (N, S), got ndim={values.ndim}"
|
|
39
39
|
)
|
|
40
|
+
if not isinstance(index, SpatioTemporalIndex):
|
|
41
|
+
raise TypeError(
|
|
42
|
+
f"index must be a SpatioTemporalIndex, got {type(index).__name__}"
|
|
43
|
+
)
|
|
40
44
|
if values.shape[0] != index.n_rows:
|
|
41
45
|
raise ValueError(
|
|
42
46
|
f"y_pred has {values.shape[0]} rows but index has {index.n_rows}"
|
|
43
47
|
)
|
|
44
|
-
|
|
48
|
+
# A read-only *view*, not `values.setflags(write=False)` (register C-66).
|
|
49
|
+
# `coerce_values` returns the caller's own array when it is already float32
|
|
50
|
+
# (the C-07 zero-copy guarantee), so locking it in place would silently make
|
|
51
|
+
# the CALLER's array read-only — action at a distance well outside this
|
|
52
|
+
# contract. The view shares the buffer, so zero-copy and mmap are preserved.
|
|
53
|
+
self._values: NDArray[np.float32] = values.view()
|
|
54
|
+
self._values.setflags(write=False)
|
|
45
55
|
self._index = index
|
|
46
56
|
self._metadata = metadata if metadata is not None else FrameMetadata()
|
|
47
57
|
|
|
@@ -36,11 +36,21 @@ class TargetFrame:
|
|
|
36
36
|
"TargetFrame y_true must have shape (N, 1) with an explicit "
|
|
37
37
|
f"trailing axis (ADR-012), got shape {values.shape}"
|
|
38
38
|
)
|
|
39
|
+
if not isinstance(index, SpatioTemporalIndex):
|
|
40
|
+
raise TypeError(
|
|
41
|
+
f"index must be a SpatioTemporalIndex, got {type(index).__name__}"
|
|
42
|
+
)
|
|
39
43
|
if values.shape[0] != index.n_rows:
|
|
40
44
|
raise ValueError(
|
|
41
45
|
f"y_true has {values.shape[0]} rows but index has {index.n_rows}"
|
|
42
46
|
)
|
|
43
|
-
|
|
47
|
+
# A read-only *view*, not `values.setflags(write=False)` (register C-66).
|
|
48
|
+
# `coerce_values` returns the caller's own array when it is already float32
|
|
49
|
+
# (the C-07 zero-copy guarantee), so locking it in place would silently make
|
|
50
|
+
# the CALLER's array read-only — action at a distance well outside this
|
|
51
|
+
# contract. The view shares the buffer, so zero-copy and mmap are preserved.
|
|
52
|
+
self._values: NDArray[np.float32] = values.view()
|
|
53
|
+
self._values.setflags(write=False)
|
|
44
54
|
self._index = index
|
|
45
55
|
self._metadata = metadata if metadata is not None else FrameMetadata()
|
|
46
56
|
|
|
@@ -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.
|
|
5
|
-
|
|
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,
|
|
5
|
-
(
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
6
|
-
grid
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
66
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
35
|
-
#: describes *what this call did*: a point against a
|
|
36
|
-
#: caller pre-tiled to ``S``, also reads as
|
|
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:`
|
|
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
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import numpy as np
|
|
11
11
|
|
|
12
|
+
from views_frames import SpatioTemporalIndex
|
|
12
13
|
from views_frames_summarize import config
|
|
13
14
|
from views_frames_summarize._common import AnyFrame
|
|
14
15
|
from views_frames_summarize.bimodality import bimodality
|
|
@@ -44,6 +45,23 @@ def assert_summarizer_contract(frame: AnyFrame) -> None:
|
|
|
44
45
|
AssertionError: a summarizer violates its output contract.
|
|
45
46
|
"""
|
|
46
47
|
_require_assertions()
|
|
48
|
+
|
|
49
|
+
# The frame must be structurally sound before any law about it means anything.
|
|
50
|
+
# Every summarizer below reads `frame.values` and `frame.n_rows` and none reads
|
|
51
|
+
# `frame.index`, so before 2.0.0 this suite returned cleanly for a frame whose
|
|
52
|
+
# index was not an index at all — a published checker issuing a false pass, which
|
|
53
|
+
# `docs/CICs/Conformance.md` names as the failure mode this module must never
|
|
54
|
+
# have (falsify audit 2026-08-18).
|
|
55
|
+
#
|
|
56
|
+
# Construction now rejects such a frame, so this is unreachable through the
|
|
57
|
+
# ordinary constructors. It is kept because consumers run this suite against
|
|
58
|
+
# *their own* frame factories under ADR-016, and `with_metadata` already builds
|
|
59
|
+
# frames through `Frame.__new__` rather than `__init__` — so a bypass path exists
|
|
60
|
+
# today and more may be added.
|
|
61
|
+
assert isinstance(frame.index, SpatioTemporalIndex), (
|
|
62
|
+
"frame.index must be a SpatioTemporalIndex, got "
|
|
63
|
+
f"{type(frame.index).__name__} — the frame misreports itself"
|
|
64
|
+
)
|
|
47
65
|
n = frame.n_rows
|
|
48
66
|
|
|
49
67
|
point = collapse(frame, np.mean)
|
|
@@ -44,6 +44,14 @@ def map_estimate(
|
|
|
44
44
|
on such distributions; a robust mode estimator is tracked separately (#89).
|
|
45
45
|
"""
|
|
46
46
|
values = frame.values
|
|
47
|
+
if not bool(np.isfinite(values).all()):
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"map_estimate is undefined on non-finite draws (NaN or ±inf); strip or "
|
|
50
|
+
"impute upstream. Until 2.0.0 an inf draw reached the histogram and "
|
|
51
|
+
"crashed with an obscure IndexError from an integer overflow in the bin "
|
|
52
|
+
"index — loud, but it named neither the cause nor the caller. This is the "
|
|
53
|
+
"same guard exceedance and expected_shortfall carry (register C-57/C-50)"
|
|
54
|
+
)
|
|
47
55
|
lead = values.shape[:-1]
|
|
48
56
|
s = values.shape[-1]
|
|
49
57
|
# Bin in the input dtype, exactly as the v0.2.0 per-row np.histogram did —
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|