views-frames 1.10.2__tar.gz → 2.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.10.2 → views_frames-2.0.0}/.gitignore +3 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/PKG-INFO +45 -22
- {views_frames-1.10.2 → views_frames-2.0.0}/README.md +43 -20
- {views_frames-1.10.2 → views_frames-2.0.0}/pyproject.toml +48 -1
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames/_validation.py +5 -13
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames/conformance/__init__.py +7 -2
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames/feature_frame.py +11 -1
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames/index.py +15 -1
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames/io/npz.py +7 -1
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames/metadata.py +6 -1
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames/prediction_frame.py +11 -1
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames/target_frame.py +11 -1
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_reconcile/conformance.py +3 -3
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_reconcile/grouping.py +1 -1
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_reconcile/validation.py +1 -2
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_summarize/config.py +2 -1
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_summarize/conformance.py +90 -25
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_summarize/point.py +8 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_summarize/tower_point.py +1 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/LICENSE +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames/__init__.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames/_typing.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames/io/__init__.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames/io/arrow.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames/protocols.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames/py.typed +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames/spatial_level.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_reconcile/__init__.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_reconcile/frames.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_reconcile/module.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_reconcile/proportional.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_reconcile/py.typed +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_reconcile/result.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_summarize/__init__.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_summarize/_common.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_summarize/aggregate.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_summarize/bimodality.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_summarize/collapse.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_summarize/exceedance.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_summarize/expected_shortfall.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_summarize/interval.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_summarize/py.typed +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_summarize/summarize_tower.py +0 -0
- {views_frames-1.10.2 → views_frames-2.0.0}/src/views_frames_summarize/tower.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: views-frames
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.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:** **v2.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 once in 2.0.0
|
|
43
|
+
> (ADR-028; 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). 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). 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
|
|
@@ -433,24 +438,36 @@ views-frames/
|
|
|
433
438
|
├── LICENSE
|
|
434
439
|
├── src/views_frames/ # the pure data contract (numpy only, depends on nothing)
|
|
435
440
|
│ ├── __init__.py # EXPLICIT re-exports only (no `import *`)
|
|
436
|
-
│ ├──
|
|
441
|
+
│ ├── _typing.py # IntArray / Float32Array aliases (private)
|
|
442
|
+
│ ├── metadata.py # FrameMetadata — the typed provenance header
|
|
437
443
|
│ ├── spatial_level.py # SpatialLevel enum (cm/pgm) — relocated here
|
|
438
|
-
│ ├── protocols.py # Frame / SpatioTemporalIndexed / Sampled / Persistable
|
|
439
444
|
│ ├── _validation.py # shared construction-time invariants (private helper)
|
|
445
|
+
│ ├── index.py # SpatioTemporalIndex value object + alignment
|
|
446
|
+
│ ├── protocols.py # Frame / SpatioTemporalIndexed / Sampled / Persistable
|
|
440
447
|
│ ├── feature_frame.py # FeatureFrame ── one concept per file
|
|
441
448
|
│ ├── prediction_frame.py # PredictionFrame
|
|
442
449
|
│ ├── target_frame.py # TargetFrame
|
|
443
450
|
│ ├── conformance/ # the published contract suite consumers re-run (§9)
|
|
444
|
-
│ └──
|
|
451
|
+
│ │ └── __init__.py
|
|
452
|
+
│ └── io/ # serialization adapters — raw arrays in, files out
|
|
445
453
|
│ ├── __init__.py
|
|
446
454
|
│ ├── npz.py # native save()/load() (.npy + .npz)
|
|
447
455
|
│ └── arrow.py # flat columnar (.parquet) — the scalable disk format
|
|
448
456
|
├── src/views_frames_summarize/ # sample-axis summarization OVER frames (ADR-017)
|
|
449
457
|
│ ├── __init__.py # depends on views_frames + numpy only; never the reverse
|
|
458
|
+
│ ├── _common.py # block_apply / rebuild — the package's shared spine
|
|
459
|
+
│ ├── config.py # tower-family tunables; fail-loud, no defaults
|
|
450
460
|
│ ├── collapse.py # collapse(frame, reducer) — generic point fold
|
|
451
461
|
│ ├── point.py # map_estimate (histogram MAP)
|
|
452
462
|
│ ├── interval.py # hdi, quantiles → arrays aligned to the frame index
|
|
453
|
-
│
|
|
463
|
+
│ ├── tower.py # the constrained-nested HDI tower (ADR-019)
|
|
464
|
+
│ ├── tower_point.py # the tower-tip point estimate
|
|
465
|
+
│ ├── bimodality.py # per-row multimodality flag
|
|
466
|
+
│ ├── summarize_tower.py # single-pass coherent summary → TowerSummary
|
|
467
|
+
│ ├── exceedance.py # threshold exceedance probabilities (ADR-021)
|
|
468
|
+
│ ├── expected_shortfall.py # worst-case tail mean (ADR-022)
|
|
469
|
+
│ ├── aggregate.py # conservation-correct cross-level aggregation
|
|
470
|
+
│ └── conformance.py # assert_summarizer_contract
|
|
454
471
|
├── src/views_frames_reconcile/ # forecast reconciliation OVER frames (ADR-023)
|
|
455
472
|
│ ├── __init__.py # depends on views_frames + numpy only; never the reverse
|
|
456
473
|
│ ├── proportional.py # reconcile_proportional — per-draw top-down scaling
|
|
@@ -458,19 +475,24 @@ views-frames/
|
|
|
458
475
|
│ ├── frames.py # prediction_frame_from_arrays adapter
|
|
459
476
|
│ ├── validation.py # fail-loud input guards
|
|
460
477
|
│ ├── module.py # ReconciliationModule (holds the injected mapping)
|
|
478
|
+
│ ├── result.py # ReconciliationResult — the frame plus HOW it was made
|
|
461
479
|
│ └── conformance.py # assert_reconcile_contract
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
480
|
+
├── scripts/ # standalone dev tools, none wired into CI
|
|
481
|
+
├── examples/ # runnable quickstarts (run by CI)
|
|
482
|
+
└── tests/ # flat: test_*.py + fixtures/
|
|
465
483
|
```
|
|
466
484
|
|
|
467
485
|
Layout rules (these *are* the screaming-architecture requirements):
|
|
468
486
|
|
|
469
487
|
- **One main class/concept per file.** Multiple classes in a file is the
|
|
470
488
|
exception, allowed only for genuinely inseparable units.
|
|
471
|
-
- **Serialization
|
|
472
|
-
|
|
473
|
-
|
|
489
|
+
- **Serialization logic does not live in a frame file.** The codecs under `io/`
|
|
490
|
+
take **raw arrays** — `npz.save` is handed `values`, `time`, `unit`, `level`
|
|
491
|
+
and `metadata` separately — and change for *their own* reasons (a new store
|
|
492
|
+
format), not when a frame's schema changes (SRP + CCP). They never import a
|
|
493
|
+
frame; a frame's `save`/`load` are thin delegations *down* into them, because
|
|
494
|
+
`Persistable` places those methods on the frame (ADR-002, amended 2026-08-17,
|
|
495
|
+
register C-82). `PredictionFrameConverter`
|
|
474
496
|
(PF↔list-in-cell DataFrame, a pipeline-core boundary format) **stays in
|
|
475
497
|
pipeline-core**; it is an adapter, not a frame concern.
|
|
476
498
|
- **No dumping grounds.** A file accumulating loose helpers/types/constants/
|
|
@@ -559,7 +581,8 @@ Because everyone depends on this, breakage is expensive — version it as a
|
|
|
559
581
|
|
|
560
582
|
## 9. Testing strategy (closes the cross-repo contract-test gap, C-30)
|
|
561
583
|
|
|
562
|
-
- **Conformance suite (`
|
|
584
|
+
- **Conformance suite (`views_frames.conformance`, shipped in the wheel at
|
|
585
|
+
`src/views_frames/conformance/`):** a *published*, importable set of
|
|
563
586
|
contract tests asserting the invariants of each Protocol (round-trip
|
|
564
587
|
save/load, identifier completeness, collapse semantics, alignment laws). Every
|
|
565
588
|
consumer repo runs it in CI against its own adapters. This is the missing
|
|
@@ -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:** **v2.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 once in 2.0.0
|
|
11
|
+
> (ADR-028; 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). 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). 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
|
|
@@ -401,24 +406,36 @@ views-frames/
|
|
|
401
406
|
├── LICENSE
|
|
402
407
|
├── src/views_frames/ # the pure data contract (numpy only, depends on nothing)
|
|
403
408
|
│ ├── __init__.py # EXPLICIT re-exports only (no `import *`)
|
|
404
|
-
│ ├──
|
|
409
|
+
│ ├── _typing.py # IntArray / Float32Array aliases (private)
|
|
410
|
+
│ ├── metadata.py # FrameMetadata — the typed provenance header
|
|
405
411
|
│ ├── spatial_level.py # SpatialLevel enum (cm/pgm) — relocated here
|
|
406
|
-
│ ├── protocols.py # Frame / SpatioTemporalIndexed / Sampled / Persistable
|
|
407
412
|
│ ├── _validation.py # shared construction-time invariants (private helper)
|
|
413
|
+
│ ├── index.py # SpatioTemporalIndex value object + alignment
|
|
414
|
+
│ ├── protocols.py # Frame / SpatioTemporalIndexed / Sampled / Persistable
|
|
408
415
|
│ ├── feature_frame.py # FeatureFrame ── one concept per file
|
|
409
416
|
│ ├── prediction_frame.py # PredictionFrame
|
|
410
417
|
│ ├── target_frame.py # TargetFrame
|
|
411
418
|
│ ├── conformance/ # the published contract suite consumers re-run (§9)
|
|
412
|
-
│ └──
|
|
419
|
+
│ │ └── __init__.py
|
|
420
|
+
│ └── io/ # serialization adapters — raw arrays in, files out
|
|
413
421
|
│ ├── __init__.py
|
|
414
422
|
│ ├── npz.py # native save()/load() (.npy + .npz)
|
|
415
423
|
│ └── arrow.py # flat columnar (.parquet) — the scalable disk format
|
|
416
424
|
├── src/views_frames_summarize/ # sample-axis summarization OVER frames (ADR-017)
|
|
417
425
|
│ ├── __init__.py # depends on views_frames + numpy only; never the reverse
|
|
426
|
+
│ ├── _common.py # block_apply / rebuild — the package's shared spine
|
|
427
|
+
│ ├── config.py # tower-family tunables; fail-loud, no defaults
|
|
418
428
|
│ ├── collapse.py # collapse(frame, reducer) — generic point fold
|
|
419
429
|
│ ├── point.py # map_estimate (histogram MAP)
|
|
420
430
|
│ ├── interval.py # hdi, quantiles → arrays aligned to the frame index
|
|
421
|
-
│
|
|
431
|
+
│ ├── tower.py # the constrained-nested HDI tower (ADR-019)
|
|
432
|
+
│ ├── tower_point.py # the tower-tip point estimate
|
|
433
|
+
│ ├── bimodality.py # per-row multimodality flag
|
|
434
|
+
│ ├── summarize_tower.py # single-pass coherent summary → TowerSummary
|
|
435
|
+
│ ├── exceedance.py # threshold exceedance probabilities (ADR-021)
|
|
436
|
+
│ ├── expected_shortfall.py # worst-case tail mean (ADR-022)
|
|
437
|
+
│ ├── aggregate.py # conservation-correct cross-level aggregation
|
|
438
|
+
│ └── conformance.py # assert_summarizer_contract
|
|
422
439
|
├── src/views_frames_reconcile/ # forecast reconciliation OVER frames (ADR-023)
|
|
423
440
|
│ ├── __init__.py # depends on views_frames + numpy only; never the reverse
|
|
424
441
|
│ ├── proportional.py # reconcile_proportional — per-draw top-down scaling
|
|
@@ -426,19 +443,24 @@ views-frames/
|
|
|
426
443
|
│ ├── frames.py # prediction_frame_from_arrays adapter
|
|
427
444
|
│ ├── validation.py # fail-loud input guards
|
|
428
445
|
│ ├── module.py # ReconciliationModule (holds the injected mapping)
|
|
446
|
+
│ ├── result.py # ReconciliationResult — the frame plus HOW it was made
|
|
429
447
|
│ └── conformance.py # assert_reconcile_contract
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
448
|
+
├── scripts/ # standalone dev tools, none wired into CI
|
|
449
|
+
├── examples/ # runnable quickstarts (run by CI)
|
|
450
|
+
└── tests/ # flat: test_*.py + fixtures/
|
|
433
451
|
```
|
|
434
452
|
|
|
435
453
|
Layout rules (these *are* the screaming-architecture requirements):
|
|
436
454
|
|
|
437
455
|
- **One main class/concept per file.** Multiple classes in a file is the
|
|
438
456
|
exception, allowed only for genuinely inseparable units.
|
|
439
|
-
- **Serialization
|
|
440
|
-
|
|
441
|
-
|
|
457
|
+
- **Serialization logic does not live in a frame file.** The codecs under `io/`
|
|
458
|
+
take **raw arrays** — `npz.save` is handed `values`, `time`, `unit`, `level`
|
|
459
|
+
and `metadata` separately — and change for *their own* reasons (a new store
|
|
460
|
+
format), not when a frame's schema changes (SRP + CCP). They never import a
|
|
461
|
+
frame; a frame's `save`/`load` are thin delegations *down* into them, because
|
|
462
|
+
`Persistable` places those methods on the frame (ADR-002, amended 2026-08-17,
|
|
463
|
+
register C-82). `PredictionFrameConverter`
|
|
442
464
|
(PF↔list-in-cell DataFrame, a pipeline-core boundary format) **stays in
|
|
443
465
|
pipeline-core**; it is an adapter, not a frame concern.
|
|
444
466
|
- **No dumping grounds.** A file accumulating loose helpers/types/constants/
|
|
@@ -527,7 +549,8 @@ Because everyone depends on this, breakage is expensive — version it as a
|
|
|
527
549
|
|
|
528
550
|
## 9. Testing strategy (closes the cross-repo contract-test gap, C-30)
|
|
529
551
|
|
|
530
|
-
- **Conformance suite (`
|
|
552
|
+
- **Conformance suite (`views_frames.conformance`, shipped in the wheel at
|
|
553
|
+
`src/views_frames/conformance/`):** a *published*, importable set of
|
|
531
554
|
contract tests asserting the invariants of each Protocol (round-trip
|
|
532
555
|
save/load, identifier completeness, collapse semantics, alignment laws). Every
|
|
533
556
|
consumer repo runs it in CI against its own adapters. This is the missing
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "views-frames"
|
|
7
|
-
version = "
|
|
7
|
+
version = "2.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" },
|
|
@@ -65,6 +65,53 @@ dev = [
|
|
|
65
65
|
# pyarrow is the consumer-facing [arrow] extra; installed in dev so CI
|
|
66
66
|
# exercises io/arrow. It must never be imported outside src/views_frames/io/.
|
|
67
67
|
"pyarrow>=14,<20",
|
|
68
|
+
# Enforces [tool.importlinter] below. All three packages are cycle-free today;
|
|
69
|
+
# this keeps them that way rather than discovering otherwise in six months.
|
|
70
|
+
"import-linter>=2.0",
|
|
71
|
+
# tomllib is stdlib from 3.11; the 3.10 floor needs the backport, or
|
|
72
|
+
# tests/test_packaging.py collects nothing on the leg the floor job exists for
|
|
73
|
+
# (register C-80).
|
|
74
|
+
"tomli>=2; python_version<'3.11'",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
# Both contracts record the dependency direction the code already has — they are not a
|
|
78
|
+
# new architecture. `|` between siblings means *independent*: they may not import each
|
|
79
|
+
# other, which is what actually prevents a cycle. (`:` would permit it.)
|
|
80
|
+
#
|
|
81
|
+
# Contract 1 duplicates tests/test_import_enforcement.py, which is stricter (it also bans
|
|
82
|
+
# foreign views_* packages, pandas et al., and pyarrow outside io/). The duplication is
|
|
83
|
+
# deliberate: the same contract is landing across the platform's cycle-free repos, so it
|
|
84
|
+
# is written the same way here. Do not delete the test believing this replaces it.
|
|
85
|
+
[tool.importlinter]
|
|
86
|
+
root_packages = ["views_frames", "views_frames_summarize", "views_frames_reconcile"]
|
|
87
|
+
|
|
88
|
+
[[tool.importlinter.contracts]]
|
|
89
|
+
name = "The core does not depend on its consumers"
|
|
90
|
+
type = "layers"
|
|
91
|
+
layers = [
|
|
92
|
+
"views_frames_summarize | views_frames_reconcile",
|
|
93
|
+
"views_frames",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
# `conformance` is in the top layer because that is its role — it validates frames from
|
|
97
|
+
# outside. It imports nothing internal today (it duck-types against `Any`), so it would
|
|
98
|
+
# satisfy any layer; putting it at the bottom would break this contract the first time
|
|
99
|
+
# someone added a frame import for a type hint.
|
|
100
|
+
#
|
|
101
|
+
# NOTE: `io` sits *below* the frames because that is what the code does — `io/npz` and
|
|
102
|
+
# `io/arrow` are flat array codecs importing only `_typing`, and the frames call them.
|
|
103
|
+
# `Persistable` puts `save`/`load` on the frame, so the frame is what reaches the
|
|
104
|
+
# serializer. ADR-002 described the inverse until its 2026-08-17 amendment (register
|
|
105
|
+
# C-82); it now matches this contract.
|
|
106
|
+
[[tool.importlinter.contracts]]
|
|
107
|
+
name = "The leaf's internal layering runs one way"
|
|
108
|
+
type = "layers"
|
|
109
|
+
layers = [
|
|
110
|
+
"views_frames.feature_frame | views_frames.prediction_frame | views_frames.target_frame | views_frames.conformance",
|
|
111
|
+
"views_frames.protocols",
|
|
112
|
+
"views_frames.index",
|
|
113
|
+
"views_frames._validation | views_frames.io",
|
|
114
|
+
"views_frames._typing | views_frames.metadata | views_frames.spatial_level",
|
|
68
115
|
]
|
|
69
116
|
|
|
70
117
|
[tool.hatch.build.targets.wheel]
|
|
@@ -22,9 +22,7 @@ from views_frames._typing import IntArray
|
|
|
22
22
|
REQUIRED_IDENTIFIERS = ("time", "unit")
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def validate_identifiers(
|
|
26
|
-
identifiers: dict[str, IntArray], n_rows: int
|
|
27
|
-
) -> None:
|
|
25
|
+
def validate_identifiers(identifiers: dict[str, IntArray], n_rows: int) -> None:
|
|
28
26
|
"""Assert identifiers are integer 1-D arrays of length ``n_rows``, complete.
|
|
29
27
|
|
|
30
28
|
Args:
|
|
@@ -42,8 +40,7 @@ def validate_identifiers(
|
|
|
42
40
|
for key, arr in identifiers.items():
|
|
43
41
|
if not isinstance(arr, np.ndarray):
|
|
44
42
|
raise TypeError(
|
|
45
|
-
f"Identifier '{key}' must be a numpy array, "
|
|
46
|
-
f"got {type(arr).__name__}"
|
|
43
|
+
f"Identifier '{key}' must be a numpy array, got {type(arr).__name__}"
|
|
47
44
|
)
|
|
48
45
|
if not np.issubdtype(arr.dtype, np.integer):
|
|
49
46
|
raise TypeError(
|
|
@@ -51,13 +48,10 @@ def validate_identifiers(
|
|
|
51
48
|
f"(integers cannot be NaN); got {arr.dtype}"
|
|
52
49
|
)
|
|
53
50
|
if arr.ndim != 1:
|
|
54
|
-
raise ValueError(
|
|
55
|
-
f"Identifier '{key}' must be 1-D, got ndim={arr.ndim}"
|
|
56
|
-
)
|
|
51
|
+
raise ValueError(f"Identifier '{key}' must be 1-D, got ndim={arr.ndim}")
|
|
57
52
|
if arr.shape[0] != n_rows:
|
|
58
53
|
raise ValueError(
|
|
59
|
-
f"Identifier '{key}' has length {arr.shape[0]} "
|
|
60
|
-
f"but expected {n_rows}"
|
|
54
|
+
f"Identifier '{key}' has length {arr.shape[0]} but expected {n_rows}"
|
|
61
55
|
)
|
|
62
56
|
|
|
63
57
|
|
|
@@ -98,9 +92,7 @@ def validate_values(values: NDArray[np.float32]) -> None:
|
|
|
98
92
|
an explicit trailing sample axis (``ndim < 2``).
|
|
99
93
|
"""
|
|
100
94
|
if not isinstance(values, np.ndarray):
|
|
101
|
-
raise TypeError(
|
|
102
|
-
f"values must be a numpy array, got {type(values).__name__}"
|
|
103
|
-
)
|
|
95
|
+
raise TypeError(f"values must be a numpy array, got {type(values).__name__}")
|
|
104
96
|
if values.dtype == np.dtype(object):
|
|
105
97
|
raise ValueError(
|
|
106
98
|
"values must not be object dtype — list-in-cell is banned (README §7)"
|
|
@@ -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.
|
|
23
24
|
"""
|
|
24
25
|
|
|
25
26
|
from __future__ import annotations
|
|
@@ -29,7 +30,11 @@ from typing import Any
|
|
|
29
30
|
|
|
30
31
|
import numpy as np
|
|
31
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
38
|
|
|
34
39
|
__all__ = [
|
|
35
40
|
"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; "
|
|
@@ -36,7 +36,13 @@ def save(
|
|
|
36
36
|
header: dict[str, Any] = {"level": level, "metadata": metadata}
|
|
37
37
|
if feature_names is not None:
|
|
38
38
|
header["feature_names"] = feature_names
|
|
39
|
-
|
|
39
|
+
# No `default=` on purpose (register C-90). It used to be `default=str`, which
|
|
40
|
+
# silently stringified a non-JSON header value — a `datetime` timestamp reloaded as
|
|
41
|
+
# "2026-01-01 00:00:00", where the field is declared `int | None`. `io/arrow` has
|
|
42
|
+
# never had it, so the same frame persisted lossily through one codec and raised
|
|
43
|
+
# through the other: the storage backend decided whether the run failed. ADR-008
|
|
44
|
+
# says fail loud, so both now raise.
|
|
45
|
+
payload = json.dumps(header, sort_keys=True)
|
|
40
46
|
(directory / "header.json").write_text(payload)
|
|
41
47
|
|
|
42
48
|
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
"""`FrameMetadata` — the typed, optional-extensible provenance header (ADR-013).
|
|
2
2
|
|
|
3
|
-
Not a free-form dict: a frozen dataclass with all-optional,
|
|
3
|
+
Not a free-form dict: a frozen dataclass with all-optional, *declared* fields, so
|
|
4
4
|
adding a field is a MINOR change and consumers cannot diverge on key names (the
|
|
5
5
|
store-side cause of reporting's C-48). It is the typed home for run/eval identity.
|
|
6
6
|
|
|
7
|
+
The field **values** are not validated — ADR-013's as-built amendment records that
|
|
8
|
+
"validated at construction" applies to the frames and identifiers, not to the header's
|
|
9
|
+
own fields, and checking them here would be the semantic inference ADR-003 forbids.
|
|
10
|
+
See ``docs/CICs/FrameMetadata.md`` §4.
|
|
11
|
+
|
|
7
12
|
Provenance fields are **generic only** (``run_id``, ``data_version``): meaningful for
|
|
8
13
|
any frame. Evaluation-specific provenance (``scoring_code_version``, full-precision
|
|
9
14
|
``evaluation_timestamp``) stays in views-evaluation's ``MetricFrame`` and must not leak
|
|
@@ -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
|
|
|
@@ -19,6 +19,8 @@ from numpy.typing import NDArray
|
|
|
19
19
|
from views_frames import PredictionFrame, SpatialLevel
|
|
20
20
|
from views_frames_reconcile.module import ReconciliationModule
|
|
21
21
|
|
|
22
|
+
__all__ = ["assert_reconcile_contract"]
|
|
23
|
+
|
|
22
24
|
|
|
23
25
|
def _require_assertions() -> None:
|
|
24
26
|
"""Fail loud if assertions are stripped (``python -O``/``-OO``).
|
|
@@ -85,7 +87,5 @@ def assert_reconcile_contract(
|
|
|
85
87
|
cm_total = cm_frame.values[cm_pos[(int(t), int(c))]]
|
|
86
88
|
total = np.broadcast_to(cm_total, out_sum.shape)
|
|
87
89
|
active = in_sum != 0
|
|
88
|
-
np.testing.assert_allclose(
|
|
89
|
-
out_sum[active], total[active], rtol=1e-4, atol=1e-3
|
|
90
|
-
)
|
|
90
|
+
np.testing.assert_allclose(out_sum[active], total[active], rtol=1e-4, atol=1e-3)
|
|
91
91
|
assert bool((out_sum[~active] == 0).all()), "all-zero draws stay zero"
|
|
@@ -83,7 +83,7 @@ def reconcile_pgm_to_cm(
|
|
|
83
83
|
f"grid group (time={t}, country={c}) has no country forecast "
|
|
84
84
|
"in cm_frame"
|
|
85
85
|
)
|
|
86
|
-
rows = order[bounds[gi]:bounds[gi + 1]]
|
|
86
|
+
rows = order[bounds[gi] : bounds[gi + 1]]
|
|
87
87
|
country_total = cm_vals[cm_pos[(t, c)]] # (S,)
|
|
88
88
|
# leaf convention: grid is (samples, cells); our frame slice is (cells, samples)
|
|
89
89
|
scaled = reconcile_proportional(pg_vals[rows].T, country_total) # (S, n_cells)
|
|
@@ -66,8 +66,7 @@ def validate_reconciliation_inputs(
|
|
|
66
66
|
np.asarray(map_keys), np.asarray(map_vals), SpatialLevel.CM
|
|
67
67
|
).unit
|
|
68
68
|
needed = {
|
|
69
|
-
(int(t), int(c))
|
|
70
|
-
for t, c in zip(pgm_frame.index.time, cm_units, strict=True)
|
|
69
|
+
(int(t), int(c)) for t, c in zip(pgm_frame.index.time, cm_units, strict=True)
|
|
71
70
|
}
|
|
72
71
|
have = {
|
|
73
72
|
(int(t), int(c))
|
|
@@ -35,7 +35,8 @@ TOWER_CONFIG: Final[dict[str, Any]] = {
|
|
|
35
35
|
# passes the real-cell C-44 gate with margin — the tip floor's median is safe as
|
|
36
36
|
# long as a duplicate stack is under half the floor (k > 2·duplicates; at S=32,
|
|
37
37
|
# k=8 tolerates the observed 2-3-zero stacks). Containment law: every HDI of mass
|
|
38
|
-
# > tip_mass/2 provably contains the tip (asserted in conformance.py
|
|
38
|
+
# > tip_mass/2 provably contains the tip (asserted in conformance.py, which counts a
|
|
39
|
+
# floor's occupancy rather than computing it from the mass — register C-88).
|
|
39
40
|
"tip_mass": 0.25,
|
|
40
41
|
# Optional magnitude zero rule, OFF by default (``None``). When set to a float, a
|
|
41
42
|
# row whose every draw is <= it collapses to 0 — a *count* opt-in (sub-1 ⇒ 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
|
|
@@ -18,9 +19,11 @@ from views_frames_summarize.expected_shortfall import expected_shortfall
|
|
|
18
19
|
from views_frames_summarize.interval import hdi, quantiles
|
|
19
20
|
from views_frames_summarize.point import map_estimate
|
|
20
21
|
from views_frames_summarize.summarize_tower import summarize_tower
|
|
21
|
-
from views_frames_summarize.tower import hdi_tower
|
|
22
|
+
from views_frames_summarize.tower import _in_range_span, hdi_tower
|
|
22
23
|
from views_frames_summarize.tower_point import tower_point
|
|
23
24
|
|
|
25
|
+
__all__ = ["assert_summarizer_contract"]
|
|
26
|
+
|
|
24
27
|
|
|
25
28
|
def _require_assertions() -> None:
|
|
26
29
|
"""Fail loud if assertions are stripped (``python -O``/``-OO``).
|
|
@@ -42,6 +45,23 @@ def assert_summarizer_contract(frame: AnyFrame) -> None:
|
|
|
42
45
|
AssertionError: a summarizer violates its output contract.
|
|
43
46
|
"""
|
|
44
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
|
+
)
|
|
45
65
|
n = frame.n_rows
|
|
46
66
|
|
|
47
67
|
point = collapse(frame, np.mean)
|
|
@@ -126,31 +146,76 @@ def _assert_tower_contract(frame: AnyFrame, n: int) -> None:
|
|
|
126
146
|
assert (tip.values[..., 0] >= tlo - 1e-6).all(), "tip below the tip_mass floor"
|
|
127
147
|
assert (tip.values[..., 0] <= thi + 1e-6).all(), "tip above the tip_mass floor"
|
|
128
148
|
|
|
129
|
-
# MAP-containment law (ADR-019 amendment 2026-07-24
|
|
130
|
-
# contain the tip by nesting. Below tip_mass, a nested
|
|
131
|
-
# whenever it holds MORE THAN HALF the tip floor's draws:
|
|
132
|
-
# longer than half the parent cannot trim away the parent's
|
|
133
|
-
# the tip is their median/average.
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
# (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
# MAP-containment law (ADR-019 amendment 2026-07-24; corrected 2026-08-18, C-88).
|
|
150
|
+
# Wider-than-tip_mass floors contain the tip by nesting. Below tip_mass, a nested
|
|
151
|
+
# floor still contains it whenever it holds MORE THAN HALF the tip floor's draws:
|
|
152
|
+
# a contiguous sub-window longer than half the parent cannot trim away the parent's
|
|
153
|
+
# middle draw(s), and the tip is their median/average.
|
|
154
|
+
#
|
|
155
|
+
# The count must be the draws whose VALUE lies in the floor — what `_in_range_span`
|
|
156
|
+
# returns and what `tower_point` takes the median of. The law originally used
|
|
157
|
+
# `floor(m·S)+1`, the *index span* `_ks` builds a floor from. Those agree only when
|
|
158
|
+
# draws are distinct: duplicated endpoint values put more draws inside the same
|
|
159
|
+
# bounds, so the tip floor held more than the formula said and narrower floors were
|
|
160
|
+
# certified that hold less than half of it. On zero-inflated integer counts — this
|
|
161
|
+
# platform's primary shape — that failed ~6% of rows, in consumers' own CI.
|
|
162
|
+
#
|
|
163
|
+
# Rows differ, so a floor may qualify in one row and not another; it is asserted
|
|
164
|
+
# only where it qualifies. Floors qualifying nowhere carry NO guarantee and sit
|
|
165
|
+
# below platform sample resolution (see tower_point.py and the tip_mass study).
|
|
166
|
+
#
|
|
167
|
+
# Only floors at or below tip_mass are candidates. A wider floor contains the tip by
|
|
168
|
+
# nesting from the tip_mass floor, which the assertion above already covers.
|
|
169
|
+
candidates = tuple(
|
|
170
|
+
float(m) for m in config.canonical_floors() if float(m) <= tip_mass
|
|
144
171
|
)
|
|
145
|
-
law_tower = hdi_tower(frame, masses=
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
172
|
+
law_tower = hdi_tower(frame, masses=candidates)
|
|
173
|
+
|
|
174
|
+
# Nesting across the candidate grid, asserted UNCONDITIONALLY. Qualification is
|
|
175
|
+
# derived from `law_tower` — the output under test — so without this a broken tower
|
|
176
|
+
# could disarm the law with its own defect: degenerate narrow floors give a small
|
|
177
|
+
# in-range count, fail `2·n_floor > n_tip`, and skip themselves. Nesting needs no
|
|
178
|
+
# qualification (it is true by construction), so it keeps the teeth.
|
|
179
|
+
lo_grid, hi_grid = law_tower[..., 0], law_tower[..., 1]
|
|
180
|
+
assert (np.diff(lo_grid, axis=-1) <= 1e-6).all(), (
|
|
181
|
+
"sub-tip_mass floors must nest: lowers non-increasing"
|
|
182
|
+
)
|
|
183
|
+
assert (np.diff(hi_grid, axis=-1) >= -1e-6).all(), (
|
|
184
|
+
"sub-tip_mass floors must nest: uppers non-decreasing"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Counted block-wise, in the same row blocks `hdi_tower` uses. The published suite
|
|
188
|
+
# must stay inside the memory discipline of the code it certifies (C-22/C-25/C-71):
|
|
189
|
+
# sorting the whole grid at once would allocate a full copy of a 1M×1000 frame.
|
|
190
|
+
#
|
|
191
|
+
# A `zero_cutoff` row (C-45) collapses to (0, 0) in both tower and tip, so its
|
|
192
|
+
# in-range counts are 0, nothing qualifies, and the row is skipped — correctly:
|
|
193
|
+
# containment of tip 0 in floor (0, 0) is trivially true there.
|
|
194
|
+
s_count = int(frame.values.shape[-1])
|
|
195
|
+
flat = np.ascontiguousarray(frame.values).reshape(-1, s_count)
|
|
196
|
+
flat_lo = lo_grid.reshape(-1, len(candidates))
|
|
197
|
+
flat_hi = hi_grid.reshape(-1, len(candidates))
|
|
198
|
+
flat_tip = np.ravel(tip.values[..., 0])
|
|
199
|
+
tip_lo, tip_hi = np.ravel(tlo), np.ravel(thi)
|
|
200
|
+
block_rows = int(config.get("row_block"))
|
|
201
|
+
|
|
202
|
+
for start in range(0, flat.shape[0], block_rows):
|
|
203
|
+
stop = min(start + block_rows, flat.shape[0])
|
|
204
|
+
srt = np.sort(flat[start:stop], axis=-1)
|
|
205
|
+
_, n_tip = _in_range_span(srt, tip_lo[start:stop], tip_hi[start:stop])
|
|
206
|
+
for j, m in enumerate(candidates):
|
|
207
|
+
glo, ghi = flat_lo[start:stop, j], flat_hi[start:stop, j]
|
|
208
|
+
_, n_floor = _in_range_span(srt, glo, ghi)
|
|
209
|
+
qualifies = 2 * n_floor > n_tip
|
|
210
|
+
if not qualifies.any():
|
|
211
|
+
continue
|
|
212
|
+
tips = flat_tip[start:stop]
|
|
213
|
+
assert (tips[qualifies] >= glo[qualifies] - 1e-6).all(), (
|
|
214
|
+
f"MAP-containment violated: tip below the {m:.2f} floor"
|
|
215
|
+
)
|
|
216
|
+
assert (tips[qualifies] <= ghi[qualifies] + 1e-6).all(), (
|
|
217
|
+
f"MAP-containment violated: tip above the {m:.2f} floor"
|
|
218
|
+
)
|
|
154
219
|
|
|
155
220
|
# Reproducibility law: the 50% HDI is independent of the other requested masses.
|
|
156
221
|
just_50 = hdi_tower(frame, masses=(0.5,))
|
|
@@ -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 —
|
|
@@ -7,6 +7,7 @@ amendment 2026-07-24, evidence in `research/map_hdi/tip_mass_study.py`). Zero-in
|
|
|
7
7
|
is handled by that floor's density (a zero-majority row reads 0); the optional,
|
|
8
8
|
off-by-default magnitude cutoff applies too if set (C-45). Containment law: every HDI
|
|
9
9
|
whose floor holds more than half the tip floor's draws provably contains the tip
|
|
10
|
+
(occupancy counted from the draws in range, not computed from the mass — register C-88)
|
|
10
11
|
(asymptotically, mass > `tip_mass`/2 — a nested contiguous window longer than half
|
|
11
12
|
the parent cannot trim away the parent's median); narrower floors carry no guarantee
|
|
12
13
|
and are below platform sample resolution. Asserted in ``conformance.py``.
|
|
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
|