views-frames 1.10.2__tar.gz → 1.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {views_frames-1.10.2 → views_frames-1.11.0}/.gitignore +3 -0
  2. {views_frames-1.10.2 → views_frames-1.11.0}/PKG-INFO +33 -15
  3. {views_frames-1.10.2 → views_frames-1.11.0}/README.md +31 -13
  4. {views_frames-1.10.2 → views_frames-1.11.0}/pyproject.toml +48 -1
  5. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames/_validation.py +5 -13
  6. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames/io/npz.py +7 -1
  7. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames/metadata.py +6 -1
  8. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_reconcile/conformance.py +3 -3
  9. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_reconcile/grouping.py +1 -1
  10. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_reconcile/validation.py +1 -2
  11. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_summarize/config.py +2 -1
  12. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_summarize/conformance.py +72 -25
  13. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_summarize/tower_point.py +1 -0
  14. {views_frames-1.10.2 → views_frames-1.11.0}/LICENSE +0 -0
  15. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames/__init__.py +0 -0
  16. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames/_typing.py +0 -0
  17. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames/conformance/__init__.py +0 -0
  18. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames/feature_frame.py +0 -0
  19. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames/index.py +0 -0
  20. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames/io/__init__.py +0 -0
  21. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames/io/arrow.py +0 -0
  22. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames/prediction_frame.py +0 -0
  23. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames/protocols.py +0 -0
  24. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames/py.typed +0 -0
  25. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames/spatial_level.py +0 -0
  26. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames/target_frame.py +0 -0
  27. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_reconcile/__init__.py +0 -0
  28. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_reconcile/frames.py +0 -0
  29. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_reconcile/module.py +0 -0
  30. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_reconcile/proportional.py +0 -0
  31. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_reconcile/py.typed +0 -0
  32. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_reconcile/result.py +0 -0
  33. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_summarize/__init__.py +0 -0
  34. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_summarize/_common.py +0 -0
  35. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_summarize/aggregate.py +0 -0
  36. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_summarize/bimodality.py +0 -0
  37. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_summarize/collapse.py +0 -0
  38. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_summarize/exceedance.py +0 -0
  39. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_summarize/expected_shortfall.py +0 -0
  40. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_summarize/interval.py +0 -0
  41. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_summarize/point.py +0 -0
  42. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_summarize/py.typed +0 -0
  43. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_summarize/summarize_tower.py +0 -0
  44. {views_frames-1.10.2 → views_frames-1.11.0}/src/views_frames_summarize/tower.py +0 -0
@@ -47,3 +47,6 @@ research/**/audit_plots/
47
47
 
48
48
  # generated figures (research/figures/ generators; deliverable copies travel with the deliverable)
49
49
  reports/plots/
50
+
51
+ # knowledge-graph output (/graphify) — regenerated on demand, ~3.6 MB
52
+ graphify-out/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: views-frames
3
- Version: 1.10.2
3
+ Version: 1.11.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,7 @@ 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:** **v1.10.2 — frozen API, published to PyPI** (frozen since v1.0.0, ADR-018; the
39
+ > **Status:** **v1.11.0 — frozen API, published to PyPI** (frozen since v1.0.0, ADR-018; the
40
40
  > v1.1 surface is
41
41
  > purely additive — the coherent posterior summary, ADR-019; v1.2.0 rebuilt the tower
42
42
  > `outside-in`, C-44; v1.3.0 makes the tower summary distribution-agnostic — no magnitude
@@ -53,7 +53,7 @@ Description-Content-Type: text/markdown
53
53
  > `research/figures/` tower-figure toolkit; v1.10.0 adds the **dense-grid fill**
54
54
  > primitive — `reindex_fill(other, *, fill_value)` on all three frames +
55
55
  > `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
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
57
57
  > README is the design
58
58
  > bible; the contract it specifies is realised in `src/views_frames/` (index, frames,
59
59
  > io, conformance suite) plus the `src/views_frames_summarize/` sibling package
@@ -433,24 +433,36 @@ views-frames/
433
433
  ├── LICENSE
434
434
  ├── src/views_frames/ # the pure data contract (numpy only, depends on nothing)
435
435
  │ ├── __init__.py # EXPLICIT re-exports only (no `import *`)
436
- │ ├── index.py # SpatioTemporalIndex value object + alignment
436
+ │ ├── _typing.py # IntArray / Float32Array aliases (private)
437
+ │ ├── metadata.py # FrameMetadata — the typed provenance header
437
438
  │ ├── spatial_level.py # SpatialLevel enum (cm/pgm) — relocated here
438
- │ ├── protocols.py # Frame / SpatioTemporalIndexed / Sampled / Persistable
439
439
  │ ├── _validation.py # shared construction-time invariants (private helper)
440
+ │ ├── index.py # SpatioTemporalIndex value object + alignment
441
+ │ ├── protocols.py # Frame / SpatioTemporalIndexed / Sampled / Persistable
440
442
  │ ├── feature_frame.py # FeatureFrame ── one concept per file
441
443
  │ ├── prediction_frame.py # PredictionFrame
442
444
  │ ├── target_frame.py # TargetFrame
443
445
  │ ├── conformance/ # the published contract suite consumers re-run (§9)
444
- │ └── io/ # serialization adapters — SEPARATE from frames (SRP)
446
+ │ │ └── __init__.py
447
+ │ └── io/ # serialization adapters — raw arrays in, files out
445
448
  │ ├── __init__.py
446
449
  │ ├── npz.py # native save()/load() (.npy + .npz)
447
450
  │ └── arrow.py # flat columnar (.parquet) — the scalable disk format
448
451
  ├── src/views_frames_summarize/ # sample-axis summarization OVER frames (ADR-017)
449
452
  │ ├── __init__.py # depends on views_frames + numpy only; never the reverse
453
+ │ ├── _common.py # block_apply / rebuild — the package's shared spine
454
+ │ ├── config.py # tower-family tunables; fail-loud, no defaults
450
455
  │ ├── collapse.py # collapse(frame, reducer) — generic point fold
451
456
  │ ├── point.py # map_estimate (histogram MAP)
452
457
  │ ├── interval.py # hdi, quantiles → arrays aligned to the frame index
453
- │ └── aggregate.py # conservation-correct cross-level aggregation
458
+ │ ├── tower.py # the constrained-nested HDI tower (ADR-019)
459
+ │ ├── tower_point.py # the tower-tip point estimate
460
+ │ ├── bimodality.py # per-row multimodality flag
461
+ │ ├── summarize_tower.py # single-pass coherent summary → TowerSummary
462
+ │ ├── exceedance.py # threshold exceedance probabilities (ADR-021)
463
+ │ ├── expected_shortfall.py # worst-case tail mean (ADR-022)
464
+ │ ├── aggregate.py # conservation-correct cross-level aggregation
465
+ │ └── conformance.py # assert_summarizer_contract
454
466
  ├── src/views_frames_reconcile/ # forecast reconciliation OVER frames (ADR-023)
455
467
  │ ├── __init__.py # depends on views_frames + numpy only; never the reverse
456
468
  │ ├── proportional.py # reconcile_proportional — per-draw top-down scaling
@@ -458,19 +470,24 @@ views-frames/
458
470
  │ ├── frames.py # prediction_frame_from_arrays adapter
459
471
  │ ├── validation.py # fail-loud input guards
460
472
  │ ├── module.py # ReconciliationModule (holds the injected mapping)
473
+ │ ├── result.py # ReconciliationResult — the frame plus HOW it was made
461
474
  │ └── conformance.py # assert_reconcile_contract
462
- └── tests/
463
- ├── conformance/ # the published contract suite consumers re-run (see §9)
464
- └── unit/
475
+ ├── scripts/ # standalone dev tools, none wired into CI
476
+ ├── examples/ # runnable quickstarts (run by CI)
477
+ └── tests/ # flat: test_*.py + fixtures/
465
478
  ```
466
479
 
467
480
  Layout rules (these *are* the screaming-architecture requirements):
468
481
 
469
482
  - **One main class/concept per file.** Multiple classes in a file is the
470
483
  exception, allowed only for genuinely inseparable units.
471
- - **Serialization is not the frame's job.** I/O adapters live under `io/`, import
472
- the frame, and change for *their own* reasons (a new store format) — not when
473
- the frame's schema changes (SRP + CCP). `PredictionFrameConverter`
484
+ - **Serialization logic does not live in a frame file.** The codecs under `io/`
485
+ take **raw arrays** — `npz.save` is handed `values`, `time`, `unit`, `level`
486
+ and `metadata` separately — and change for *their own* reasons (a new store
487
+ format), not when a frame's schema changes (SRP + CCP). They never import a
488
+ frame; a frame's `save`/`load` are thin delegations *down* into them, because
489
+ `Persistable` places those methods on the frame (ADR-002, amended 2026-08-17,
490
+ register C-82). `PredictionFrameConverter`
474
491
  (PF↔list-in-cell DataFrame, a pipeline-core boundary format) **stays in
475
492
  pipeline-core**; it is an adapter, not a frame concern.
476
493
  - **No dumping grounds.** A file accumulating loose helpers/types/constants/
@@ -559,7 +576,8 @@ Because everyone depends on this, breakage is expensive — version it as a
559
576
 
560
577
  ## 9. Testing strategy (closes the cross-repo contract-test gap, C-30)
561
578
 
562
- - **Conformance suite (`tests/conformance/`):** a *published*, importable set of
579
+ - **Conformance suite (`views_frames.conformance`, shipped in the wheel at
580
+ `src/views_frames/conformance/`):** a *published*, importable set of
563
581
  contract tests asserting the invariants of each Protocol (round-trip
564
582
  save/load, identifier completeness, collapse semantics, alignment laws). Every
565
583
  consumer repo runs it in CI against its own adapters. This is the missing
@@ -4,7 +4,7 @@
4
4
  > containers (`FeatureFrame`, `PredictionFrame`, and their anticipated siblings)
5
5
  > that every other repo depends on and that depends on nothing internal.
6
6
  >
7
- > **Status:** **v1.10.2 — frozen API, published to PyPI** (frozen since v1.0.0, ADR-018; the
7
+ > **Status:** **v1.11.0 — frozen API, published to PyPI** (frozen since v1.0.0, ADR-018; the
8
8
  > v1.1 surface is
9
9
  > purely additive — the coherent posterior summary, ADR-019; v1.2.0 rebuilt the tower
10
10
  > `outside-in`, C-44; v1.3.0 makes the tower summary distribution-agnostic — no magnitude
@@ -21,7 +21,7 @@
21
21
  > `research/figures/` tower-figure toolkit; v1.10.0 adds the **dense-grid fill**
22
22
  > primitive — `reindex_fill(other, *, fill_value)` on all three frames +
23
23
  > `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
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
25
25
  > README is the design
26
26
  > bible; the contract it specifies is realised in `src/views_frames/` (index, frames,
27
27
  > io, conformance suite) plus the `src/views_frames_summarize/` sibling package
@@ -401,24 +401,36 @@ views-frames/
401
401
  ├── LICENSE
402
402
  ├── src/views_frames/ # the pure data contract (numpy only, depends on nothing)
403
403
  │ ├── __init__.py # EXPLICIT re-exports only (no `import *`)
404
- │ ├── index.py # SpatioTemporalIndex value object + alignment
404
+ │ ├── _typing.py # IntArray / Float32Array aliases (private)
405
+ │ ├── metadata.py # FrameMetadata — the typed provenance header
405
406
  │ ├── spatial_level.py # SpatialLevel enum (cm/pgm) — relocated here
406
- │ ├── protocols.py # Frame / SpatioTemporalIndexed / Sampled / Persistable
407
407
  │ ├── _validation.py # shared construction-time invariants (private helper)
408
+ │ ├── index.py # SpatioTemporalIndex value object + alignment
409
+ │ ├── protocols.py # Frame / SpatioTemporalIndexed / Sampled / Persistable
408
410
  │ ├── feature_frame.py # FeatureFrame ── one concept per file
409
411
  │ ├── prediction_frame.py # PredictionFrame
410
412
  │ ├── target_frame.py # TargetFrame
411
413
  │ ├── conformance/ # the published contract suite consumers re-run (§9)
412
- │ └── io/ # serialization adapters — SEPARATE from frames (SRP)
414
+ │ │ └── __init__.py
415
+ │ └── io/ # serialization adapters — raw arrays in, files out
413
416
  │ ├── __init__.py
414
417
  │ ├── npz.py # native save()/load() (.npy + .npz)
415
418
  │ └── arrow.py # flat columnar (.parquet) — the scalable disk format
416
419
  ├── src/views_frames_summarize/ # sample-axis summarization OVER frames (ADR-017)
417
420
  │ ├── __init__.py # depends on views_frames + numpy only; never the reverse
421
+ │ ├── _common.py # block_apply / rebuild — the package's shared spine
422
+ │ ├── config.py # tower-family tunables; fail-loud, no defaults
418
423
  │ ├── collapse.py # collapse(frame, reducer) — generic point fold
419
424
  │ ├── point.py # map_estimate (histogram MAP)
420
425
  │ ├── interval.py # hdi, quantiles → arrays aligned to the frame index
421
- │ └── aggregate.py # conservation-correct cross-level aggregation
426
+ │ ├── tower.py # the constrained-nested HDI tower (ADR-019)
427
+ │ ├── tower_point.py # the tower-tip point estimate
428
+ │ ├── bimodality.py # per-row multimodality flag
429
+ │ ├── summarize_tower.py # single-pass coherent summary → TowerSummary
430
+ │ ├── exceedance.py # threshold exceedance probabilities (ADR-021)
431
+ │ ├── expected_shortfall.py # worst-case tail mean (ADR-022)
432
+ │ ├── aggregate.py # conservation-correct cross-level aggregation
433
+ │ └── conformance.py # assert_summarizer_contract
422
434
  ├── src/views_frames_reconcile/ # forecast reconciliation OVER frames (ADR-023)
423
435
  │ ├── __init__.py # depends on views_frames + numpy only; never the reverse
424
436
  │ ├── proportional.py # reconcile_proportional — per-draw top-down scaling
@@ -426,19 +438,24 @@ views-frames/
426
438
  │ ├── frames.py # prediction_frame_from_arrays adapter
427
439
  │ ├── validation.py # fail-loud input guards
428
440
  │ ├── module.py # ReconciliationModule (holds the injected mapping)
441
+ │ ├── result.py # ReconciliationResult — the frame plus HOW it was made
429
442
  │ └── conformance.py # assert_reconcile_contract
430
- └── tests/
431
- ├── conformance/ # the published contract suite consumers re-run (see §9)
432
- └── unit/
443
+ ├── scripts/ # standalone dev tools, none wired into CI
444
+ ├── examples/ # runnable quickstarts (run by CI)
445
+ └── tests/ # flat: test_*.py + fixtures/
433
446
  ```
434
447
 
435
448
  Layout rules (these *are* the screaming-architecture requirements):
436
449
 
437
450
  - **One main class/concept per file.** Multiple classes in a file is the
438
451
  exception, allowed only for genuinely inseparable units.
439
- - **Serialization is not the frame's job.** I/O adapters live under `io/`, import
440
- the frame, and change for *their own* reasons (a new store format) — not when
441
- the frame's schema changes (SRP + CCP). `PredictionFrameConverter`
452
+ - **Serialization logic does not live in a frame file.** The codecs under `io/`
453
+ take **raw arrays** — `npz.save` is handed `values`, `time`, `unit`, `level`
454
+ and `metadata` separately — and change for *their own* reasons (a new store
455
+ format), not when a frame's schema changes (SRP + CCP). They never import a
456
+ frame; a frame's `save`/`load` are thin delegations *down* into them, because
457
+ `Persistable` places those methods on the frame (ADR-002, amended 2026-08-17,
458
+ register C-82). `PredictionFrameConverter`
442
459
  (PF↔list-in-cell DataFrame, a pipeline-core boundary format) **stays in
443
460
  pipeline-core**; it is an adapter, not a frame concern.
444
461
  - **No dumping grounds.** A file accumulating loose helpers/types/constants/
@@ -527,7 +544,8 @@ Because everyone depends on this, breakage is expensive — version it as a
527
544
 
528
545
  ## 9. Testing strategy (closes the cross-repo contract-test gap, C-30)
529
546
 
530
- - **Conformance suite (`tests/conformance/`):** a *published*, importable set of
547
+ - **Conformance suite (`views_frames.conformance`, shipped in the wheel at
548
+ `src/views_frames/conformance/`):** a *published*, importable set of
531
549
  contract tests asserting the invariants of each Protocol (round-trip
532
550
  save/load, identifier completeness, collapse semantics, alignment laws). Every
533
551
  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 = "1.10.2"
7
+ version = "1.11.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)"
@@ -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
- payload = json.dumps(header, sort_keys=True, default=str)
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, validated fields, so
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
@@ -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).
@@ -18,9 +18,11 @@ from views_frames_summarize.expected_shortfall import expected_shortfall
18
18
  from views_frames_summarize.interval import hdi, quantiles
19
19
  from views_frames_summarize.point import map_estimate
20
20
  from views_frames_summarize.summarize_tower import summarize_tower
21
- from views_frames_summarize.tower import hdi_tower
21
+ from views_frames_summarize.tower import _in_range_span, hdi_tower
22
22
  from views_frames_summarize.tower_point import tower_point
23
23
 
24
+ __all__ = ["assert_summarizer_contract"]
25
+
24
26
 
25
27
  def _require_assertions() -> None:
26
28
  """Fail loud if assertions are stripped (``python -O``/``-OO``).
@@ -126,31 +128,76 @@ def _assert_tower_contract(frame: AnyFrame, n: int) -> None:
126
128
  assert (tip.values[..., 0] >= tlo - 1e-6).all(), "tip below the tip_mass floor"
127
129
  assert (tip.values[..., 0] <= thi + 1e-6).all(), "tip above the tip_mass floor"
128
130
 
129
- # MAP-containment law (ADR-019 amendment 2026-07-24). Wider-than-tip_mass floors
130
- # contain the tip by nesting. Below tip_mass, a nested floor still contains it
131
- # whenever it holds MORE THAN HALF the tip floor's draws: a contiguous sub-window
132
- # longer than half the parent cannot trim away the parent's middle draw(s), and
133
- # the tip is their median/average. A floor of mass m spans floor(m·S)+1 draws
134
- # (the `_ks` value counts inter-draw steps), so the exact condition is
135
- # 2·(floor(m·S)+1) > floor(tip_mass·S)+1 — asymptotically mass > tip_mass/2.
136
- # Floors below it carry NO guarantee and are below platform sample resolution
137
- # (see tower_point.py / research/map_hdi/tip_mass_study.py).
138
- s_count = int(frame.values.shape[-1])
139
- n_tip = int(np.floor(tip_mass * s_count)) + 1
140
- guaranteed = tuple(
141
- float(m)
142
- for m in config.canonical_floors()
143
- if 2 * (int(np.floor(float(m) * s_count)) + 1) > n_tip
131
+ # MAP-containment law (ADR-019 amendment 2026-07-24; corrected 2026-08-18, C-88).
132
+ # Wider-than-tip_mass floors contain the tip by nesting. Below tip_mass, a nested
133
+ # floor still contains it whenever it holds MORE THAN HALF the tip floor's draws:
134
+ # a contiguous sub-window longer than half the parent cannot trim away the parent's
135
+ # middle draw(s), and the tip is their median/average.
136
+ #
137
+ # The count must be the draws whose VALUE lies in the floor — what `_in_range_span`
138
+ # returns and what `tower_point` takes the median of. The law originally used
139
+ # `floor(m·S)+1`, the *index span* `_ks` builds a floor from. Those agree only when
140
+ # draws are distinct: duplicated endpoint values put more draws inside the same
141
+ # bounds, so the tip floor held more than the formula said and narrower floors were
142
+ # certified that hold less than half of it. On zero-inflated integer counts — this
143
+ # platform's primary shape — that failed ~6% of rows, in consumers' own CI.
144
+ #
145
+ # Rows differ, so a floor may qualify in one row and not another; it is asserted
146
+ # only where it qualifies. Floors qualifying nowhere carry NO guarantee and sit
147
+ # below platform sample resolution (see tower_point.py and the tip_mass study).
148
+ #
149
+ # Only floors at or below tip_mass are candidates. A wider floor contains the tip by
150
+ # nesting from the tip_mass floor, which the assertion above already covers.
151
+ candidates = tuple(
152
+ float(m) for m in config.canonical_floors() if float(m) <= tip_mass
144
153
  )
145
- law_tower = hdi_tower(frame, masses=guaranteed)
146
- for j, m in enumerate(guaranteed):
147
- glo, ghi = law_tower[..., j, 0], law_tower[..., j, 1]
148
- assert (tip.values[..., 0] >= glo - 1e-6).all(), (
149
- f"MAP-containment violated: tip below the {m:.2f} floor"
150
- )
151
- assert (tip.values[..., 0] <= ghi + 1e-6).all(), (
152
- f"MAP-containment violated: tip above the {m:.2f} floor"
153
- )
154
+ law_tower = hdi_tower(frame, masses=candidates)
155
+
156
+ # Nesting across the candidate grid, asserted UNCONDITIONALLY. Qualification is
157
+ # derived from `law_tower` — the output under test — so without this a broken tower
158
+ # could disarm the law with its own defect: degenerate narrow floors give a small
159
+ # in-range count, fail `2·n_floor > n_tip`, and skip themselves. Nesting needs no
160
+ # qualification (it is true by construction), so it keeps the teeth.
161
+ lo_grid, hi_grid = law_tower[..., 0], law_tower[..., 1]
162
+ assert (np.diff(lo_grid, axis=-1) <= 1e-6).all(), (
163
+ "sub-tip_mass floors must nest: lowers non-increasing"
164
+ )
165
+ assert (np.diff(hi_grid, axis=-1) >= -1e-6).all(), (
166
+ "sub-tip_mass floors must nest: uppers non-decreasing"
167
+ )
168
+
169
+ # Counted block-wise, in the same row blocks `hdi_tower` uses. The published suite
170
+ # must stay inside the memory discipline of the code it certifies (C-22/C-25/C-71):
171
+ # sorting the whole grid at once would allocate a full copy of a 1M×1000 frame.
172
+ #
173
+ # A `zero_cutoff` row (C-45) collapses to (0, 0) in both tower and tip, so its
174
+ # in-range counts are 0, nothing qualifies, and the row is skipped — correctly:
175
+ # containment of tip 0 in floor (0, 0) is trivially true there.
176
+ s_count = int(frame.values.shape[-1])
177
+ flat = np.ascontiguousarray(frame.values).reshape(-1, s_count)
178
+ flat_lo = lo_grid.reshape(-1, len(candidates))
179
+ flat_hi = hi_grid.reshape(-1, len(candidates))
180
+ flat_tip = np.ravel(tip.values[..., 0])
181
+ tip_lo, tip_hi = np.ravel(tlo), np.ravel(thi)
182
+ block_rows = int(config.get("row_block"))
183
+
184
+ for start in range(0, flat.shape[0], block_rows):
185
+ stop = min(start + block_rows, flat.shape[0])
186
+ srt = np.sort(flat[start:stop], axis=-1)
187
+ _, n_tip = _in_range_span(srt, tip_lo[start:stop], tip_hi[start:stop])
188
+ for j, m in enumerate(candidates):
189
+ glo, ghi = flat_lo[start:stop, j], flat_hi[start:stop, j]
190
+ _, n_floor = _in_range_span(srt, glo, ghi)
191
+ qualifies = 2 * n_floor > n_tip
192
+ if not qualifies.any():
193
+ continue
194
+ tips = flat_tip[start:stop]
195
+ assert (tips[qualifies] >= glo[qualifies] - 1e-6).all(), (
196
+ f"MAP-containment violated: tip below the {m:.2f} floor"
197
+ )
198
+ assert (tips[qualifies] <= ghi[qualifies] + 1e-6).all(), (
199
+ f"MAP-containment violated: tip above the {m:.2f} floor"
200
+ )
154
201
 
155
202
  # Reproducibility law: the 50% HDI is independent of the other requested masses.
156
203
  just_50 = hdi_tower(frame, masses=(0.5,))
@@ -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