From bf22b5874b26b78fda0f58c7616e5f13ed0450a5 Mon Sep 17 00:00:00 2001 From: skyalphatek Date: Sun, 16 Aug 2026 17:17:30 -0400 Subject: [PATCH 01/14] joss: add paper.md, paper.bib, draft-PDF workflow, README completeness Prepares the JOSS submission on a branch. Not merged to master; nothing is submitted by this commit. - paper.md: 819-word software paper, single author (Kuan-Ta Wu, ORCID 0009-0006-0529-8709). Describes the software only; research results of the three methodology preprints are cited, not restated. - paper.bib: six references, every field taken verbatim from the resolved DOI record (DataCite for the figshare preprints, Crossref for the rest). - README: adds Statement of need, API overview and Community guidelines -- the three items the JOSS review checklist checks in the docs separately from the paper. - .github/workflows/draft-pdf.yml: Open Journals preview build (artifact only, contacts nothing). No package code touched; numerical behavior unchanged. --- .github/workflows/draft-pdf.yml | 34 ++++++++++ README.md | 42 ++++++++++++ paper.bib | 84 +++++++++++++++++++++++ paper.md | 115 ++++++++++++++++++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 .github/workflows/draft-pdf.yml create mode 100644 paper.bib create mode 100644 paper.md diff --git a/.github/workflows/draft-pdf.yml b/.github/workflows/draft-pdf.yml new file mode 100644 index 0000000..ef5cb51 --- /dev/null +++ b/.github/workflows/draft-pdf.yml @@ -0,0 +1,34 @@ +name: Draft PDF + +# Compiles paper.md with the Open Journals (inara) toolchain and uploads the +# rendered paper.pdf as a build artifact. This is the JOSS-documented preview +# workflow: it proves the paper compiles before submission. It does not contact +# JOSS and does not submit anything. + +on: + push: + paths: + - 'paper.md' + - 'paper.bib' + - '.github/workflows/draft-pdf.yml' + workflow_dispatch: + +jobs: + paper: + runs-on: ubuntu-latest + name: Paper Draft + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build draft PDF + uses: openjournals/openjournals-draft-action@master + with: + journal: joss + paper-path: paper.md + + - name: Upload rendered paper + uses: actions/upload-artifact@v4 + with: + name: paper + path: paper.pdf diff --git a/README.md b/README.md index 0bd57e9..368e48d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,24 @@ correlation fitted honestly on prior *completed* periods — and grades the requ completeness per signal, so benign signals release early and susceptible signals are withheld until enough of the cross-section has arrived to suppress the bias. +## Statement of need + +Point-in-time discipline in ML pipelines currently rests on tooling that answers one +question: *was this value readable at time t?* Feature-store as-of joins, bitemporal +and vintage-aware storage, and purged or embargoed cross-validation all enforce +read-time correctness, and they do it well. + +None of them answers a second question: given that every value read was legitimately +readable, was the *set* of entities that had reported by t a selected sample? An +as-of join over an incomplete cross-section is a correct join over a biased sample. +The two failures need different remedies — the first is fixed by timestamp hygiene, +the second only by waiting or by an explicit correction. Researchers building +cross-sectional signals on staggered-arrival panels have had no routine, per-signal +screen for the second. `pit-release-gate` is that screen, plus the release controller +that acts on it: one `fit_trailing` call per signal, so reporting a susceptibility +estimate alongside a released signal costs about as much as reporting a standard +error. + ## Install ```bash @@ -56,6 +74,21 @@ values, and per-entity filing-arrival times, then call `ReleaseController.decide(store, t)` at each evaluation time — it returns `WITHHOLD`, `REWEIGHT_RELEASE`, or `RELEASE` plus the released values. +## API overview + +Five public components, all importable from the top-level `pit_release_gate` package: + +| component | what it does | +|---|---| +| [`AsOfDataStore`](src/pit_release_gate/store.py) | Holds one period's as-filed records for a cross-sectional group: design matrix, signal values, and a filing-arrival time per entity. | +| [`CompletenessMonitor`](src/pit_release_gate/monitor.py) | Reports the arrived fraction at an evaluation time, plus a composition-shift gauge for the arrived subset. | +| [`SusceptibilityGate`](src/pit_release_gate/gate.py) | Estimates ρ̂, the partial correlation between filing latency and the complete-cross-section residual given observables. `fit_trailing` enforces the honest-estimation contract: prior *completed* periods only. | +| [`PropensityReweighter`](src/pit_release_gate/reweight.py) | Inverse-filing-propensity weights. Included to make a negative result executable: reweighting on observables corrects composition, but cannot remove selection on the disturbance. | +| [`ReleaseController`](src/pit_release_gate/controller.py) | Maps \|ρ̂\| to a required completeness `φ_req = min(1, φ_min + κ·\|ρ̂\|)` and returns `WITHHOLD` / `REWEIGHT_RELEASE` / `RELEASE` at each evaluation time. | + +`make_group`, `run_demo` and `demo` ([`simulate.py`](src/pit_release_gate/simulate.py)) +generate and run the known-ground-truth worked example described below. + ## The known-ground-truth demo The package ships a self-contained worked example with a *planted* leakage strength, @@ -110,6 +143,15 @@ See [`CITATION.cff`](CITATION.cff). If you use this software, please cite paper } ``` +## Community guidelines + +- **Report a bug or request a feature:** open an issue at + [github.com/MaxWellApexLab/pit-release-gate/issues](https://github.com/MaxWellApexLab/pit-release-gate/issues). +- **Contribute:** see [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup, + test requirements, and pull-request process. +- **Get help:** open an issue with a minimal reproducing example, or email + maxwellapexlab@proton.me. + ## License MIT — see [LICENSE](LICENSE). diff --git a/paper.bib b/paper.bib new file mode 100644 index 0000000..42bf9bc --- /dev/null +++ b/paper.bib @@ -0,0 +1,84 @@ +% paper.bib — pit-release-gate (JOSS) +% +% PROVENANCE: every field below was taken verbatim from the resolved DOI record +% on 2026-08-16. figshare entries from api.datacite.org; the rest from +% api.crossref.org. Nothing in this file was written from memory. +% 10.6084/m9.figshare.32952482 -> DataCite, resourceTypeGeneral "Preprint" +% 10.6084/m9.figshare.33061955 -> DataCite, resourceTypeGeneral "Preprint" +% 10.6084/m9.figshare.33158615 -> DataCite, resourceTypeGeneral "Preprint" +% 10.1145/2382577.2382579 -> Crossref +% 10.1016/j.patter.2023.100804 -> Crossref +% 10.2307/1912352 -> Crossref +% +% Two deliberate fidelity notes (do NOT "fix" these without re-resolving the DOI): +% * The figshare records register the first creator as "Kuan Ta Wu" (no hyphen) +% and carry no ORCIDs. That is what the DOI resolves to, so that is what is +% recorded here. See the submission-day checklist for the figshare metadata fix. +% * Crossref stores only the first page (153) for Heckman (1979); the record does +% not carry an end page, so none is invented here. + +@misc{wu2026a, + author = {Wu, Kuan Ta and Wu, Kuan-I}, + title = {{Correct-by-Construction Factor Computation: A Verifiably Point-in-Time Engine for Tradeable Signals}}, + year = {2026}, + publisher = {figshare}, + note = {Preprint}, + doi = {10.6084/m9.figshare.32952482}, + url = {https://doi.org/10.6084/m9.figshare.32952482} +} + +@misc{wu2026b, + author = {Wu, Kuan Ta and Wu, Kuan-I}, + title = {{Measuring Incomplete-Cross-Section Leakage: A Matched Placebo, a Susceptibility Screen, and Evidence from Taiwan and US As-Filed Data that the Channel Is Benign and Correctable}}, + year = {2026}, + publisher = {figshare}, + note = {Preprint}, + doi = {10.6084/m9.figshare.33061955}, + url = {https://doi.org/10.6084/m9.figshare.33061955} +} + +@misc{wu2026c, + author = {Wu, Kuan Ta and Wu, Kuan-I}, + title = {{Susceptibility-Graded Release Control: Preventing Incomplete-Cross-Section Leakage in Financial Machine-Learning Pipelines without a Blanket Timeliness Penalty}}, + year = {2026}, + publisher = {figshare}, + note = {Preprint}, + doi = {10.6084/m9.figshare.33158615}, + url = {https://doi.org/10.6084/m9.figshare.33158615} +} + +@article{kaufman2012, + author = {Kaufman, Shachar and Rosset, Saharon and Perlich, Claudia and Stitelman, Ori}, + title = {{Leakage in data mining: Formulation, detection, and avoidance}}, + journal = {ACM Transactions on Knowledge Discovery from Data}, + volume = {6}, + number = {4}, + pages = {1--21}, + year = {2012}, + publisher = {Association for Computing Machinery (ACM)}, + doi = {10.1145/2382577.2382579} +} + +@article{kapoor2023, + author = {Kapoor, Sayash and Narayanan, Arvind}, + title = {{Leakage and the reproducibility crisis in machine-learning-based science}}, + journal = {Patterns}, + volume = {4}, + number = {9}, + pages = {100804}, + year = {2023}, + publisher = {Elsevier BV}, + doi = {10.1016/j.patter.2023.100804} +} + +@article{heckman1979, + author = {Heckman, James J.}, + title = {{Sample Selection Bias as a Specification Error}}, + journal = {Econometrica}, + volume = {47}, + number = {1}, + pages = {153}, + year = {1979}, + publisher = {JSTOR}, + doi = {10.2307/1912352} +} diff --git a/paper.md b/paper.md new file mode 100644 index 0000000..0830b8d --- /dev/null +++ b/paper.md @@ -0,0 +1,115 @@ +--- +title: 'pit-release-gate: completeness-aware release control for staggered-arrival cross-sectional data' +tags: + - Python + - data leakage + - point-in-time data + - machine learning pipelines + - quantitative finance +authors: + - name: Kuan-Ta Wu + orcid: 0009-0006-0529-8709 + affiliation: 1 +affiliations: + - name: Max Well Apex LLC, NY, United States + index: 1 +date: 1 September 2026 +bibliography: paper.bib +--- + +# Summary + +Many empirical cross-sections are assembled from records that arrive on staggered +dates. Companies filing quarterly financial statements are the canonical case: a +fiscal period ends on a single date, but individual filings land over the following +weeks, up to a statutory deadline. Any same-period cross-sectional signal computed +before the last filer has arrived is therefore estimated from an incomplete +cross-section. Incompleteness is harmless when arrival timing is unrelated to what +the signal measures. It is not harmless when filing timing depends on the very +disturbance the signal is meant to capture: the early-arriving subset is then +selected on the estimand itself, and the released signal carries a systematic bias. +The standard defence — wait for the deadline before releasing anything — removes +the bias but charges a timeliness penalty to every signal, including those that +were never at risk. + +`pit-release-gate` replaces that blanket rule with a per-signal measurement. It +estimates each signal's susceptibility to this bias — a disturbance-conditional +partial correlation, fitted only on prior *completed* periods — and converts the +estimate into the cross-sectional completeness that the signal must reach before it +may be released. Benign signals release as soon as a minimum completeness floor is +met; susceptible signals are withheld until enough of the cross-section has arrived +to suppress the bias, up to the deadline. + +# Statement of need + +Point-in-time discipline in machine-learning pipelines currently rests on tooling +that answers one question: *was this value readable at time $t$?* Feature-store +as-of joins, bitemporal and vintage-aware storage, and purged or embargoed +cross-validation all enforce read-time correctness, and they do it well. + +None of them answers a second question: given that every value read was legitimately +readable, was the *set* of entities that had reported by $t$ a selected sample? An +as-of join over an incomplete cross-section is a correct join over a biased sample. +The distinction matters because the two failures need different remedies — the +first is fixed by timestamp hygiene, the second only by waiting or by an explicit +correction. Existing leakage taxonomies [@kaufman2012] and recent surveys of leakage +in machine-learning-based science [@kapoor2023] name this family of problems, and +the mechanism is a selection problem in the classical sense [@heckman1979], but +researchers have had no routine, per-signal screen they can execute inside a +pipeline. + +`pit-release-gate` provides one. The susceptibility measure, the grading rule that +turns it into a release threshold, and their evaluation on as-filed data are +developed in three publicly available preprints [@wu2026a; @wu2026b; @wu2026c]; this +package is the reference implementation of the release controller of the third. It +is aimed at researchers who build cross-sectional signals on staggered-arrival +panels — most immediately in empirical accounting and quantitative finance, but the +same arrival structure appears wherever administrative records backfill after a +reporting period closes. The design goal is that the screen costs one function call +per signal, so that reporting a susceptibility estimate alongside a released signal +becomes as ordinary as reporting a standard error. + +# Functionality + +The public API has five components: + +- `AsOfDataStore` holds the as-filed records for one period and cross-sectional + group: a design matrix, the constructed signal values, and a filing-arrival time + per entity. +- `CompletenessMonitor` reports the arrived fraction at an evaluation time, plus a + composition-shift gauge for the arrived subset. +- `SusceptibilityGate` estimates $\hat{\rho}$, the partial correlation between + filing latency and the complete-cross-section residual, conditional on + observables. Its `fit_trailing` method enforces an honest-estimation contract: + $\hat{\rho}$ is fitted on prior completed periods only, never on the period being + gated, whose cross-section is by definition still incomplete. +- `PropensityReweighter` supplies inverse-filing-propensity weights. It is included + to make a negative result executable: reweighting on observables corrects + composition shift but cannot remove selection on the disturbance, which is why the + controller grades on completeness rather than on reweighting. +- `ReleaseController` maps $|\hat{\rho}|$ to a required completeness, + $\varphi_{\mathrm{req}} = \min(1,\ \varphi_{\min} + \kappa|\hat{\rho}|)$, and + returns at each evaluation time one of `WITHHOLD`, `REWEIGHT_RELEASE` or + `RELEASE`, together with the released values. + +The package ships a self-contained worked example in which the strength of selection +on the disturbance is *planted*, so the correct decision is known exactly and no +licensed data is required. Running `pit-release-gate` compares five release policies +across four signal types and prints the resulting timeliness and bias. The run is +deterministic under a fixed seed and the test suite asserts its headline numbers, so +a reader can confirm in one command that the gate releases benign signals at roughly +a third of the cross-section, while holding a strongly selected signal to the +complete cross-section, where its bias is exactly zero. + +Installation is `pip install pit-release-gate`. The package requires Python 3.10 or +later and depends only on NumPy, pandas and SciPy; it is MIT licensed and tested on +Linux and Windows against Python 3.10 and 3.13. + +# Acknowledgements + +The methodology preprints cited above are co-authored with Kuan-I Wu; the software +described here was designed and written solely by the author. AI-based tools were +used for surface-level language editing and debugging assistance; all original code +and the research design are the author's own. + +# References From dc3133349f02a0aed816596f71ebf66b604ba80e Mon Sep 17 00:00:00 2001 From: skyalphatek Date: Sun, 16 Aug 2026 17:22:57 -0400 Subject: [PATCH 02/14] feat(cli): optional --badge flag printing a README badge snippet pit-release-gate --badge runs the demo unchanged and then appends a markdown badge snippet plus a comment carrying each signal's frozen rho_hat, so a user who pastes the badge has something to point it at. The badge reads 'screened with' -- it records that the screen was RUN and explicitly disclaims any pass/fail reading. No certification language. No numerical behavior changed: badge_snippet() is pure formatting over the dict run_demo() already returns, and test_badge_flag_does_not_change_demo_output asserts the demo output is byte-identical with and without the flag. tests/test_reproduces_paper.py is untouched. 14 passed (11 existing + 3 new). --- src/pit_release_gate/__init__.py | 5 ++- src/pit_release_gate/simulate.py | 53 +++++++++++++++++++++++++++++++- tests/test_cli_and_monitor.py | 40 +++++++++++++++++++++++- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/pit_release_gate/__init__.py b/src/pit_release_gate/__init__.py index f02846e..6937701 100644 --- a/src/pit_release_gate/__init__.py +++ b/src/pit_release_gate/__init__.py @@ -18,12 +18,14 @@ make_group one synthetic staggered-arrival cross-section (known truth) run_demo the full known-ground-truth worked example (returns dict) demo same, console-table form +badge_snippet README badge markdown for a completed screen run """ from .controller import ReleaseController, ReleaseDecision from .gate import SusceptibilityGate from .monitor import CompletenessMonitor from .reweight import PropensityReweighter -from .simulate import DEMO_POLICIES, DEMO_SIGNALS, SEED, demo, main, make_group, run_demo +from .simulate import (DEMO_POLICIES, DEMO_SIGNALS, SEED, badge_snippet, demo, main, + make_group, run_demo) from .store import AsOfDataStore __version__ = "0.1.0" @@ -39,6 +41,7 @@ "run_demo", "demo", "main", + "badge_snippet", "SEED", "DEMO_SIGNALS", "DEMO_POLICIES", diff --git a/src/pit_release_gate/simulate.py b/src/pit_release_gate/simulate.py index 516193f..a9756af 100644 --- a/src/pit_release_gate/simulate.py +++ b/src/pit_release_gate/simulate.py @@ -212,6 +212,51 @@ def demo(n_train=10, n_eval=60): return run_demo(n_train=n_train, n_eval=n_eval, verbose=True) +BADGE_MARKDOWN = ( + '[![screened with pit-release-gate]' + '(https://img.shields.io/badge/screened%20with-pit--release--gate-blue)]' + '(https://github.com/MaxWellApexLab/pit-release-gate)' +) + + +def badge_snippet(results) -> str: + """README badge markdown for a completed screen, with a rho_hat summary. + + Pure formatting over an existing ``run_demo`` result -- it reads the + result dict and returns a string. It performs no estimation and changes + no numerical behavior. + + The badge states that the screen was RUN. It is deliberately not a + pass/fail claim: a susceptible verdict is as worth reporting as a + benign one. + """ + try: + from . import __version__ as version + except ImportError: # pragma: no cover + version = '' + + sigs = results['signals'] + keys = [k for k, *_ in DEMO_SIGNALS if k in sigs] + n_susc = sum(bool(sigs[k]['susceptible']) for k in keys) + rhos = ' | '.join(f"{k} {sigs[k]['rho_trailing']:+.3f}" for k in keys) + + rule = '-' * 72 + return '\n'.join([ + rule, + 'Badge snippet (paste into your README):', + '', + BADGE_MARKDOWN, + '', + f'', + '', + 'The badge states that the screen was RUN, not that anything passed.', + 'Point it at your own screen output to make it worth clicking.', + rule, + ]) + + def main(argv=None): ap = argparse.ArgumentParser( prog='pit-release-gate', @@ -221,8 +266,14 @@ def main(argv=None): help='number of prior completed periods used to fit rho_hat (default 10)') ap.add_argument('--eval', dest='n_eval', type=int, default=60, help='number of fresh evaluation periods (default 60)') + ap.add_argument('--badge', action='store_true', + help='after the demo, print a README badge snippet recording ' + 'that the screen was run (does not change the demo output)') a = ap.parse_args(argv) - run_demo(n_train=a.train, n_eval=a.n_eval, verbose=True) + results = run_demo(n_train=a.train, n_eval=a.n_eval, verbose=True) + if a.badge: + print() + print(badge_snippet(results)) if __name__ == '__main__': diff --git a/tests/test_cli_and_monitor.py b/tests/test_cli_and_monitor.py index e5ac233..fb10769 100644 --- a/tests/test_cli_and_monitor.py +++ b/tests/test_cli_and_monitor.py @@ -6,7 +6,8 @@ import numpy as np -from pit_release_gate import AsOfDataStore, CompletenessMonitor, make_group +from pit_release_gate import (AsOfDataStore, CompletenessMonitor, badge_snippet, + make_group, run_demo) def test_cli_demo_runs_and_prints_verdicts(): @@ -31,6 +32,43 @@ def test_demo_main_in_process(capsys): assert token in text +def test_badge_snippet_reports_the_screen_result(): + r = run_demo(n_train=2, n_eval=4, verbose=False) + text = badge_snippet(r) + + # the markdown a user actually pastes + assert "img.shields.io/badge/screened%20with-pit--release--gate-blue" in text + assert "github.com/MaxWellApexLab/pit-release-gate" in text + + # the summary comment carries every signal's frozen rho_hat + for key in ("clean", "composition", "mild_leak", "strong_leak"): + assert key in text + # demo plants two benign and two susceptible signals + assert "2 benign, 2 susceptible" in text + + +def test_badge_snippet_makes_no_pass_fail_claim(): + text = badge_snippet(run_demo(n_train=2, n_eval=4, verbose=False)).lower() + assert "screened with" in text + assert "not that anything passed" in text + for forbidden in ("certif", "approv", "endors", "official", "trusted"): + assert forbidden not in text, f"badge output must not claim {forbidden!r}" + + +def test_badge_flag_does_not_change_demo_output(capsys): + from pit_release_gate.simulate import main + + main(["--train", "2", "--eval", "4"]) + plain = capsys.readouterr().out + main(["--train", "2", "--eval", "4", "--badge"]) + badged = capsys.readouterr().out + + # the badge is strictly appended: the demo output is byte-identical + assert badged.startswith(plain) + assert "Badge snippet" in badged[len(plain):] + assert "Badge snippet" not in plain + + def test_completeness_monitor_fraction_and_shift(): rng = np.random.default_rng(7) store = make_group(c_a=0.0, c_x=1.0, rng=rng) From 284055f6deb3acf0fe13ded2fd0af5142c7d5f9d Mon Sep 17 00:00:00 2001 From: skyalphatek Date: Sun, 16 Aug 2026 20:50:25 -0400 Subject: [PATCH 03/14] joss: use the verbatim AI-disclosure sentence mandated by the house rules --- paper.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/paper.md b/paper.md index 0830b8d..5c3231a 100644 --- a/paper.md +++ b/paper.md @@ -108,8 +108,8 @@ Linux and Windows against Python 3.10 and 3.13. # Acknowledgements The methodology preprints cited above are co-authored with Kuan-I Wu; the software -described here was designed and written solely by the author. AI-based tools were -used for surface-level language editing and debugging assistance; all original code -and the research design are the author's own. +described here was designed and written solely by the author. The author used +AI-assisted drafting tools in preparing this manuscript; all technical content, +experiments, and claims were designed, executed, and verified by the author. # References From 6118a5c220153d5e5b3df11dfe9c161a9304539a Mon Sep 17 00:00:00 2001 From: skyalphatek Date: Sun, 16 Aug 2026 21:17:19 -0400 Subject: [PATCH 04/14] 0.1.1: JOSS submission materials and documentation completeness Documentation only. No package behavior changed: the only edit under src/ is the __version__ string, tests/test_reproduces_paper.py is untouched, and the demo reproduces the same numbers as 0.1.0 (11 passed). - paper.md / paper.bib for the JOSS submission - README: Statement of need, API overview, Community guidelines - .github/workflows/draft-pdf.yml: Open Journals preview build --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- src/pit_release_gate/__init__.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5be9b7e..bc072ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.1.1 — 2026-08-16 + +Documentation and submission materials only. **No change to package behavior:** +the test suite, including `tests/test_reproduces_paper.py`, is untouched and the +demo reproduces the same numbers as 0.1.0. + +- `paper.md` and `paper.bib`: software paper prepared for submission to the + Journal of Open Source Software. +- README: added *Statement of need*, *API overview*, and *Community guidelines* + sections. +- CI: `draft-pdf.yml` builds a preview PDF of `paper.md` with the Open Journals + toolchain and uploads it as a build artifact. + ## 0.1.0 — 2026-08-15 First public release. diff --git a/pyproject.toml b/pyproject.toml index 11f6233..2a705bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pit-release-gate" -version = "0.1.0" +version = "0.1.1" description = "Completeness-aware release control for staggered-arrival cross-sectional data: a susceptibility-graded gate that blocks incomplete-cross-section leakage without a blanket timeliness penalty" readme = "README.md" requires-python = ">=3.10" diff --git a/src/pit_release_gate/__init__.py b/src/pit_release_gate/__init__.py index f02846e..d9e5f0f 100644 --- a/src/pit_release_gate/__init__.py +++ b/src/pit_release_gate/__init__.py @@ -26,7 +26,7 @@ from .simulate import DEMO_POLICIES, DEMO_SIGNALS, SEED, demo, main, make_group, run_demo from .store import AsOfDataStore -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = [ "AsOfDataStore", From 8c63aaa4922df32fca446113203d894f6e96034f Mon Sep 17 00:00:00 2001 From: skyalphatek Date: Sun, 16 Aug 2026 21:22:40 -0400 Subject: [PATCH 05/14] docs: add Badge section linking the hygiene pledge and the audit registry --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 368e48d..83e66bf 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,30 @@ The method and its evaluation are developed in three public papers: This package is the reference implementation of paper 3's release controller; its demo reproduces the paper's controlled experiment. +## Badge + +If you have run the susceptibility screen on your own data — whatever the result — you +are welcome to say so: + +```markdown +[![screened with pit-release-gate](https://img.shields.io/badge/screened%20with-pit--release--gate-blue)](https://github.com/MaxWellApexLab/pit-release-gate) +``` + +The badge reads **screened with**, not *passed* — it states that the screen was run, the +same way a formatter badge states that the formatter was run. A benign result and a +susceptible result are equally worth badging; the second one arguably more, because it +means the screen found something and your pipeline now waits for it. + +**Make it point at something.** A badge is worth reading only if there is evidence behind +it. Commit your screen output — which signals came out benign, which came out susceptible, +and the required completeness each was assigned — and link the badge at that file rather +than at this repo. A worked example is the OSAP screen in the +[PIT audit registry](https://github.com/MaxWellApexLab/pit-audit-registry/blob/main/audits/2026-08_osap/report.md). + +**Related:** the [PIT Hygiene pledge](https://github.com/MaxWellApexLab/pit-hygiene) is a +broader, tool-neutral statement about how a staggered-arrival pipeline is built; this badge +is the narrower statement that this particular screen was run. + ## Cite this See [`CITATION.cff`](CITATION.cff). If you use this software, please cite paper 3: From 4a600d570ebfa436bd03471f5c7131db1bb1a304 Mon Sep 17 00:00:00 2001 From: Max Well Apex LLC Date: Sun, 16 Aug 2026 21:26:51 -0400 Subject: [PATCH 06/14] docs: mount the PIT Hygiene pledge badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 83e66bf..570d3f8 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![PyPI](https://img.shields.io/pypi/v/pit-release-gate)](https://pypi.org/project/pit-release-gate/) [![Python](https://img.shields.io/pypi/pyversions/pit-release-gate)](https://pypi.org/project/pit-release-gate/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![PIT Hygiene](https://img.shields.io/badge/PIT%20Hygiene-pledged-2ea44f)](https://github.com/MaxWellApexLab/pit-hygiene) Completeness-aware release control for staggered-arrival cross-sectional data. From 7cddca59e9157639c0f42728a854fd66b9b3188e Mon Sep 17 00:00:00 2001 From: Max Well Apex LLC Date: Sun, 16 Aug 2026 21:34:32 -0400 Subject: [PATCH 07/14] docs: live downloads badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 570d3f8..66e83c6 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![CI](https://github.com/MaxWellApexLab/pit-release-gate/actions/workflows/ci.yml/badge.svg)](https://github.com/MaxWellApexLab/pit-release-gate/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/MaxWellApexLab/pit-release-gate/branch/master/graph/badge.svg)](https://codecov.io/gh/MaxWellApexLab/pit-release-gate) [![PyPI](https://img.shields.io/pypi/v/pit-release-gate)](https://pypi.org/project/pit-release-gate/) +[![Downloads](https://img.shields.io/pypi/dm/pit-release-gate)](https://pypi.org/project/pit-release-gate/) [![Python](https://img.shields.io/pypi/pyversions/pit-release-gate)](https://pypi.org/project/pit-release-gate/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![PIT Hygiene](https://img.shields.io/badge/PIT%20Hygiene-pledged-2ea44f)](https://github.com/MaxWellApexLab/pit-hygiene) From fe48737dddeac4d271d9361180a1fdd7acb5bcc7 Mon Sep 17 00:00:00 2001 From: skyalphatek Date: Sun, 16 Aug 2026 23:15:05 -0400 Subject: [PATCH 08/14] feat(cli): --export writes a pit-screen-results v1.0 record Adds an offline export of the screen result: per-signal summary statistics (periods screened and flagged, mean and max rho_hat, assigned completeness, verdict) plus the five settings that produced those verdicts. The format is documented as a standalone versioned interchange spec in docs/results-schema.md and is free for other tools to adopt. No change to package behavior: tests/test_reproduces_paper.py is untouched and the demo reproduces the same numbers as 0.1.1. controller.MIN_ENTITIES names an existing literal; the value is unchanged. No telemetry, enforced structurally rather than by policy: no module in the package imports a transport, and a test walks every module and fails if one ever does. The submission half of this work is parked on the submit-cli branch until a receiving endpoint exists. --- CHANGELOG.md | 20 ++ README.md | 43 ++++- docs/results-schema.md | 214 +++++++++++++++++++++ src/pit_release_gate/__init__.py | 36 +++- src/pit_release_gate/controller.py | 13 +- src/pit_release_gate/results.py | 256 +++++++++++++++++++++++++ src/pit_release_gate/simulate.py | 74 +++++++- tests/test_export.py | 287 +++++++++++++++++++++++++++++ 8 files changed, 929 insertions(+), 14 deletions(-) create mode 100644 docs/results-schema.md create mode 100644 src/pit_release_gate/results.py create mode 100644 tests/test_export.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bc072ad..bea616b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## Unreleased + +Screen-result export. **No change to package behavior:** +`tests/test_reproduces_paper.py` is untouched and the demo reproduces the same +numbers as 0.1.1. + +- `--export PATH` writes a `pit-screen-results` v1.0 record — per-signal summary + statistics and the screen settings that produced the verdicts. Fully offline + (local file, no network call) and free of clocks, so the same screen exports + byte-identical bytes. +- `docs/results-schema.md`: the schema as a standalone versioned interchange + spec, free for other tools to adopt. +- New module `pit_release_gate.results`: `summarize_signal`, `screen_config`, + `build_results`, `validate_results`, `write_results`. Totals are derived, so + they cannot disagree with the per-signal rows. +- Still no telemetry of any kind, now enforced structurally rather than by + policy: no module in the package imports a transport, and a test asserts that + over every module — no background thread, no `atexit` hook, nothing to opt + out of. + ## 0.1.1 — 2026-08-16 Documentation and submission materials only. **No change to package behavior:** diff --git a/README.md b/README.md index 66e83c6..565e9ce 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,44 @@ A sensitivity sweep of the policy slope κ shows the timeliness–bias dial: κ = 2.0 → 100% (bias exactly 0). The demo is deterministic (fixed seed), and `tests/test_reproduces_paper.py` asserts these numbers. +## Export your screen result + +```bash +pit-release-gate --export results.json +``` + +writes a `pit-screen-results` v1.0 record: per screened signal, how many periods +were screened, how many the measure flagged, mean and max ρ̂, the required +completeness that was assigned, and the verdict (`benign` / `susceptible`), plus +the five settings that produced those verdicts. It is the file a *screened with* +badge should point at. The format is a standalone versioned interchange spec — +[`docs/results-schema.md`](docs/results-schema.md) — that any other tool is free +to emit or consume. + +**`--export` is fully offline**: it writes a local file and makes no network +call. Records carry summary statistics only — never input rows, file paths, +usernames, hostnames, or any environment detail beyond the tool version — and no +clock is read while building one, so the same screen always exports byte-identical +bytes. + +Where the record goes afterwards is entirely your business — commit it next to +your badge, publish it, or keep it. This tool does not send it anywhere. + +**No telemetry — structurally, not merely by default.** No module in this +package imports a transport at all, and a test walks every module in the package +and fails the suite if one ever does. There is no background thread, no `atexit` +hook, no anonymous usage counter, and nothing to opt out of. + +Programmatic use, for screens on your own data: + +```python +from pit_release_gate import build_results, screen_config, summarize_signal, validate_results + +sig = summarize_signal("accruals", rhos=[...], phi_reqs=[...], rho_threshold=0.10) +record = build_results([sig], screen_config(0.10, 0.35, 1.0, trailing_k=8, min_entities=6)) +assert validate_results(record) == [] +``` + ## Papers The method and its evaluation are developed in three public papers: @@ -145,8 +183,9 @@ susceptible result are equally worth badging; the second one arguably more, beca means the screen found something and your pipeline now waits for it. **Make it point at something.** A badge is worth reading only if there is evidence behind -it. Commit your screen output — which signals came out benign, which came out susceptible, -and the required completeness each was assigned — and link the badge at that file rather +it. Commit your screen output — `pit-release-gate --export results.json` produces exactly +that file: which signals came out benign, which came out susceptible, and the required +completeness each was assigned — and link the badge at it rather than at this repo. A worked example is the OSAP screen in the [PIT audit registry](https://github.com/MaxWellApexLab/pit-audit-registry/blob/main/audits/2026-08_osap/report.md). diff --git a/docs/results-schema.md b/docs/results-schema.md new file mode 100644 index 0000000..e2fb704 --- /dev/null +++ b/docs/results-schema.md @@ -0,0 +1,214 @@ +# `pit-screen-results` — screen-result interchange format + +**Schema name:** `pit-screen-results` +**Version:** `1.0` +**Media type:** `application/json` +**Reference implementation:** [`src/pit_release_gate/results.py`](../src/pit_release_gate/results.py) +**Status:** stable + +A `pit-screen-results` record states what an incomplete-cross-section +susceptibility screen found: for each signal that was screened, how many periods +it was screened over, how susceptible it looked, how much completeness the +release controller therefore required, and whether the verdict was **benign** or +**susceptible**. It is the evidence a *screened with* badge should point at. + +The format is deliberately small. It is not a report format, not a results +database, and not a telemetry envelope. + +## Design rules + +These three rules are what make the record safe to commit to a public +repository or hand to a third party, and they are binding on any producer: + +1. **Summary statistics only.** A record carries per-signal aggregates. + It must never carry input rows, entity identifiers, residuals, file paths, + usernames, hostnames, or any environment detail beyond the producing tool's + name and version. +2. **Offline by construction.** Writing a record is local file I/O. In the + reference implementation the module that builds and writes records imports + no transport machinery at all; sending one is a separate, explicitly + requested act (see [Submission envelope](#submission-envelope-non-normative)). +3. **No clocks.** A producer must not read the system clock while building a + record: two runs of the same screen must produce byte-identical bytes. If a + date belongs in the record, the caller passes it in (`date`, below). + +## Top-level fields + +| field | type | required | meaning | +|---|---|---|---| +| `schema` | string | yes | Always `"pit-screen-results"`. Identifies the format, not the producer. | +| `schema_version` | string | yes | `"1.0"` for this document. | +| `tool` | string | yes | Name of the producing tool, e.g. `"pit-release-gate"`. Any tool may emit this format under its own name. | +| `tool_version` | string | yes | Version of the producing tool. The only environment detail permitted anywhere in the record. | +| `config` | object | yes | The screen settings that determine the verdicts. See below. | +| `signals` | array of objects | yes | One entry per screened signal, non-empty. See below. | +| `totals` | object | yes | Aggregates over `signals`, derived — never independently asserted. | +| `date` | string | no | Caller-supplied date (ISO 8601 `YYYY-MM-DD` recommended). Absent unless the caller passed one; producers must not fill it in from a clock. | + +## `config` + +The five settings a reader needs in order to interpret a verdict. All are +required. + +| field | type | meaning | +|---|---|---| +| `rho_threshold` | number | A signal is susceptible when \|ρ̂\| exceeds this. | +| `phi_min` | number | Completeness floor: the earliest completeness at which anything is released. | +| `kappa` | number | Slope of the graded requirement `phi_req = min(1, phi_min + kappa·\|ρ̂\|)`. | +| `trailing_k` | integer | Number of prior **completed** periods the susceptibility estimate was fitted on. | +| `min_entities` | integer | Minimum arrived entity count below which nothing is released. | + +## `signals[]` + +| field | type | meaning | +|---|---|---| +| `name` | string | Caller-chosen signal name. Must not encode a path, a file name, or an identity. | +| `periods_screened` | integer ≥ 0 | Number of periods the signal was screened over. | +| `periods_flagged` | integer | Of those, how many had a *per-period* \|ρ̂\| above `rho_threshold`. Must be ≤ `periods_screened`. | +| `mean_rho` | number | Mean of the per-period susceptibility estimates (signed). | +| `max_abs_rho` | number | Largest per-period \|ρ̂\| observed. | +| `mean_phi_req` | number | Mean required completeness the controller assigned across those periods. | +| `verdict` | string | `"benign"` or `"susceptible"` — no other value is valid. | + +**`periods_flagged` is a noise gauge, not the verdict.** A single period's ρ̂ is +a small-sample estimate and will cross the threshold now and then on a perfectly +benign signal; that is exactly why a screen pools over `trailing_k` completed +periods before deciding. In the worked example below, `clean` has 3 of 5 periods +flagged and is still — correctly — `benign`: its pooled estimate is far under the +threshold, while `max_abs_rho = 0.164` records how noisy a single period was. +Read `verdict` for the decision, `periods_flagged` and `max_abs_rho` for how +stable that decision was. + +## `totals` + +Derived from `signals`; a producer computes them rather than accepting them, and +a consumer may treat a mismatch as a corrupt record. + +| field | type | meaning | +|---|---|---| +| `signal_cycles` | integer | Sum of `periods_screened` over all signals — the total screening work the record represents. | +| `signals_benign` | integer | Number of signals with `verdict == "benign"`. | +| `signals_susceptible` | integer | Number with `verdict == "susceptible"`. | + +## Validity + +A record is valid when all of the following hold. The reference implementation +is `validate_results(obj) -> list[str]`, which returns one human-readable string +per problem and an empty list for a valid record. + +1. The record is a JSON object with `schema == "pit-screen-results"`. +2. `schema_version` is present and known to the reader (`"1.0"`). +3. Every required top-level, `config`, `signals[]`, and `totals` field is present. +4. Every `verdict` is `"benign"` or `"susceptible"`. +5. `totals.signal_cycles` equals the sum of `periods_screened` over `signals`, + and the two verdict counts equal the corresponding counts in `signals`. +6. For every signal, `0 <= periods_flagged <= periods_screened`. + +Validation is a check on the record, not on the science: a record can be +perfectly valid and report a thoroughly susceptible pipeline. That is the point. + +## Worked example + +Produced by `pit-release-gate --train 3 --eval 5 --export results.json` +(reduced settings, so the file fits here): + +```json +{ + "schema": "pit-screen-results", + "schema_version": "1.0", + "tool": "pit-release-gate", + "tool_version": "0.1.1", + "config": { + "rho_threshold": 0.1, + "phi_min": 0.35, + "kappa": 1.0, + "trailing_k": 3, + "min_entities": 6 + }, + "signals": [ + { + "name": "clean", + "periods_screened": 5, + "periods_flagged": 3, + "mean_rho": -0.018502844357097172, + "max_abs_rho": 0.16437940321670516, + "mean_phi_req": 0.40870844204483336, + "verdict": "benign" + }, + { + "name": "composition", + "periods_screened": 5, + "periods_flagged": 1, + "mean_rho": 0.054842113923429116, + "max_abs_rho": 0.1916906690936213, + "mean_phi_req": 0.3622517271310478, + "verdict": "benign" + }, + { + "name": "mild_leak", + "periods_screened": 5, + "periods_flagged": 5, + "mean_rho": -0.5050242025985237, + "max_abs_rho": 0.6073065398308837, + "mean_phi_req": 0.8244168498651367, + "verdict": "susceptible" + }, + { + "name": "strong_leak", + "periods_screened": 5, + "periods_flagged": 5, + "mean_rho": -0.868283463437835, + "max_abs_rho": 0.8934707972913439, + "mean_phi_req": 1.0, + "verdict": "susceptible" + } + ], + "totals": { + "signal_cycles": 20, + "signals_benign": 2, + "signals_susceptible": 2 + } +} +``` + +How to read it: four signals were screened over five periods each (20 signal +cycles). Two came out benign and release at roughly the completeness floor +(`mean_phi_req` ≈ 0.36–0.41). `mild_leak` is susceptible and is held until 82% +of its cross-section has arrived; `strong_leak` is held to the +deadline-complete cross-section (`mean_phi_req == 1.0`). + +## Transport (deliberately unspecified) + +This spec describes a **file format**, not a protocol. `pit-release-gate` writes +a record with `--export` and does nothing else with it: the package contains no +submission path and imports no transport at all. Where a record travels — a +commit next to a badge, an artifact in CI, an attachment, an endpoint of your own +— is the emitter's choice and outside this document. + +If you build something that receives records, validate them the way §"Validity +rules" describes and treat everything in a record as publishable. A record +carries summary statistics only: it is designed so that publishing one leaks +nothing about the data it was computed from. + +## Versioning + +`schema_version` is `MAJOR.MINOR`. A **minor** bump only adds optional fields; +a reader for `1.0` may ignore fields it does not know and keep working. A +**major** bump may remove or repurpose fields, and readers should refuse a major +version they do not know rather than guess. The schema name never changes +meaning: a document identified as `pit-screen-results` always means a screen +result in the sense described here. + +## Adoption + +**This format is free for any tool to emit or consume**, with no attribution +requirement, no coordination with this project, and no compatibility obligation +in either direction. A screen result is more useful when it is comparable across +tools, so put your own name in `tool`, keep `schema` and `schema_version` as +specified, and the record will read the same everywhere. Extensions are welcome +under a namespaced key of your own (e.g. `"x_yourtool"`); a `1.0` reader must +ignore what it does not recognize. + +If you extend the format in a way you think belongs in the core schema, open an +issue at +[github.com/MaxWellApexLab/pit-release-gate](https://github.com/MaxWellApexLab/pit-release-gate/issues). diff --git a/src/pit_release_gate/__init__.py b/src/pit_release_gate/__init__.py index d9e5f0f..7675904 100644 --- a/src/pit_release_gate/__init__.py +++ b/src/pit_release_gate/__init__.py @@ -18,16 +18,38 @@ make_group one synthetic staggered-arrival cross-section (known truth) run_demo the full known-ground-truth worked example (returns dict) demo same, console-table form +build_results assemble a pit-screen-results record (summary stats only) +validate_results check such a record; returns a list of problems """ +# defined first: results.tool_version() reads it while the submodules below +# are still importing +__version__ = "0.1.1" + from .controller import ReleaseController, ReleaseDecision from .gate import SusceptibilityGate from .monitor import CompletenessMonitor +from .results import ( + SCHEMA, + SCHEMA_VERSION, + build_results, + screen_config, + summarize_signal, + validate_results, + write_results, +) from .reweight import PropensityReweighter -from .simulate import DEMO_POLICIES, DEMO_SIGNALS, SEED, demo, main, make_group, run_demo +from .simulate import ( + DEMO_POLICIES, + DEMO_SIGNALS, + SEED, + demo, + main, + make_group, + results_from_demo, + run_demo, +) from .store import AsOfDataStore -__version__ = "0.1.1" - __all__ = [ "AsOfDataStore", "CompletenessMonitor", @@ -42,5 +64,13 @@ "SEED", "DEMO_SIGNALS", "DEMO_POLICIES", + "SCHEMA", + "SCHEMA_VERSION", + "summarize_signal", + "screen_config", + "build_results", + "validate_results", + "write_results", + "results_from_demo", "__version__", ] diff --git a/src/pit_release_gate/controller.py b/src/pit_release_gate/controller.py index 070a15d..6cded86 100644 --- a/src/pit_release_gate/controller.py +++ b/src/pit_release_gate/controller.py @@ -10,6 +10,11 @@ from .reweight import PropensityReweighter from .store import AsOfDataStore +#: Minimum number of arrived entities before anything is released; below it a +#: cross-section is too small for the residualization to mean much. Named here +#: so a screen record can report the floor it ran under. +MIN_ENTITIES = 6 + def _ols_resid(X: np.ndarray, y: np.ndarray, w: np.ndarray = None) -> np.ndarray: if w is None: @@ -70,11 +75,11 @@ def decide(self, store: AsOfDataStore, t: float, policy: str = 'gated') -> Relea # fixed baseline policies (for comparison / fallback configs) ---- if policy == 'naive': - if comp < self.phi_min or m.sum() < 6: + if comp < self.phi_min or m.sum() < MIN_ENTITIES: return ReleaseDecision('WITHHOLD', t, comp, policy=policy) return self._emit(store, t, m, None, 'RELEASE', policy) if policy == 'threshold': - if comp < self.phi_high or m.sum() < 6: + if comp < self.phi_high or m.sum() < MIN_ENTITIES: return ReleaseDecision('WITHHOLD', t, comp, policy=policy) return self._emit(store, t, m, None, 'RELEASE', policy) if policy == 'deadline': @@ -82,7 +87,7 @@ def decide(self, store: AsOfDataStore, t: float, policy: str = 'gated') -> Relea return ReleaseDecision('WITHHOLD', t, comp, policy=policy) return self._emit(store, t, m, None, 'RELEASE', policy) if policy == 'reweight': - if comp < self.phi_min or m.sum() < 6: + if comp < self.phi_min or m.sum() < MIN_ENTITIES: return ReleaseDecision('WITHHOLD', t, comp, policy=policy) w = self.reweighter.weights(store, t) return self._emit(store, t, m, w, 'REWEIGHT_RELEASE', policy) @@ -95,7 +100,7 @@ def decide(self, store: AsOfDataStore, t: float, policy: str = 'gated') -> Relea # bias, up to the statutory deadline (completeness=1.0). rho = self.gate.rho_hat(store) phi_req = self.required_completeness(rho) - if (comp >= phi_req or t >= 1.0) and m.sum() >= 6: + if (comp >= phi_req or t >= 1.0) and m.sum() >= MIN_ENTITIES: return self._emit(store, t, m, None, 'RELEASE', f'gated(phi_req={phi_req:.2f})') return ReleaseDecision('WITHHOLD', t, comp, policy=f'gated(phi_req={phi_req:.2f})') diff --git a/src/pit_release_gate/results.py b/src/pit_release_gate/results.py new file mode 100644 index 0000000..456650e --- /dev/null +++ b/src/pit_release_gate/results.py @@ -0,0 +1,256 @@ +"""The ``pit-screen-results`` record: build it, validate it, write it. + +A screen result is a *summary*: per signal, how many periods were screened, +how many of them the susceptibility measure flagged, the mean and maximum +susceptibility, the required completeness the controller assigned, and the +verdict. Nothing else. No input rows, no file paths, no identities, no +environment details beyond the tool version -- so a record is safe to commit +next to the badge, or to hand to a third party. + +This module is deliberately **offline**: it imports no transport machinery, +so nothing that builds or writes a record can send it anywhere. Neither does +anything else in this package -- there is no submission path at all, and a +record goes where you put it and nowhere else. + +It is also deliberately **timeless**: no clock is read here, so two runs of +the same screen produce byte-identical records. A caller that wants a date +in the record passes one in explicitly (``build_results(..., date=...)``). + +The schema is documented as a standalone interchange format in +``docs/results-schema.md``; other tools are free to emit it. +""" +from __future__ import annotations + +import json +from pathlib import Path + +#: Self-identifying name of the interchange format (not of the producing tool). +SCHEMA = 'pit-screen-results' +SCHEMA_VERSION = '1.0' +KNOWN_SCHEMA_VERSIONS = ('1.0',) + +#: The tool that produces the record in this package. +TOOL = 'pit-release-gate' + +#: The only two verdicts a screened signal can carry. +VERDICTS = ('benign', 'susceptible') + +_TOP_FIELDS = ('schema', 'schema_version', 'tool', 'tool_version', + 'config', 'signals', 'totals') +_CONFIG_FIELDS = ('rho_threshold', 'phi_min', 'kappa', 'trailing_k', 'min_entities') +_SIGNAL_FIELDS = ('name', 'periods_screened', 'periods_flagged', 'mean_rho', + 'max_abs_rho', 'mean_phi_req', 'verdict') +_TOTALS_FIELDS = ('signal_cycles', 'signals_benign', 'signals_susceptible') + + +def tool_version() -> str: + """Version of the code that actually ran. + + The in-tree ``__version__`` is preferred over installed distribution + metadata, because a source checkout on ``PYTHONPATH`` can shadow an older + installed wheel; the metadata is the fallback. Never a hardcoded literal. + """ + try: + from . import __version__ + return str(__version__) + except ImportError: # pragma: no cover - only if the package is half-built + from importlib.metadata import PackageNotFoundError, version + try: + return version(TOOL) + except PackageNotFoundError: + return 'unknown' + + +def screen_config(rho_threshold: float, phi_min: float, kappa: float, + trailing_k: int, min_entities: int) -> dict: + """The screen settings that determine a verdict, in record form. + + These five are what another party needs in order to read a verdict: + the susceptibility threshold, the completeness floor and slope of + ``phi_req = min(1, phi_min + kappa*|rho_hat|)``, how many prior completed + periods the estimate was fitted on, and the minimum arrived count below + which the controller releases nothing. + """ + return { + 'rho_threshold': float(rho_threshold), + 'phi_min': float(phi_min), + 'kappa': float(kappa), + 'trailing_k': int(trailing_k), + 'min_entities': int(min_entities), + } + + +def summarize_signal(name: str, rhos, phi_reqs, rho_threshold: float = 0.10, + susceptible: bool = None) -> dict: + """Reduce one signal's per-period screen to the record's summary row. + + ``rhos`` are the per-period susceptibility estimates and ``phi_reqs`` the + required completeness the controller assigned in each of those periods; + they must be the same length, and that length is ``periods_screened``. + A period is *flagged* when ``|rho|`` exceeds ``rho_threshold``, so + ``periods_flagged <= periods_screened`` by construction. + + The verdict defaults to the same test applied to the mean susceptibility; + a caller that gates on a frozen trailing estimate should pass its own + verdict as ``susceptible`` so the record states what the screen actually + decided. + """ + rhos = [float(r) for r in rhos] + phi_reqs = [float(p) for p in phi_reqs] + if len(rhos) != len(phi_reqs): + raise ValueError(f'{name}: got {len(rhos)} rho values but ' + f'{len(phi_reqs)} required-completeness values') + if not rhos: + raise ValueError(f'{name}: no screened periods') + mean_rho = sum(rhos) / len(rhos) + if susceptible is None: + susceptible = abs(mean_rho) > rho_threshold + return { + 'name': str(name), + 'periods_screened': len(rhos), + 'periods_flagged': sum(1 for r in rhos if abs(r) > rho_threshold), + 'mean_rho': mean_rho, + 'max_abs_rho': max(abs(r) for r in rhos), + 'mean_phi_req': sum(phi_reqs) / len(phi_reqs), + 'verdict': VERDICTS[1] if susceptible else VERDICTS[0], + } + + +def build_results(signals, config: dict, tool: str = TOOL, + version: str = None, date: str = None) -> dict: + """Assemble a complete ``pit-screen-results`` record. + + ``signals`` is a list of :func:`summarize_signal` rows. The totals are + derived here rather than accepted from the caller, so they cannot + disagree with the rows. ``date`` is optional and purely caller-supplied + -- this module never reads a clock. + """ + signals = [dict(s) for s in signals] + record = { + 'schema': SCHEMA, + 'schema_version': SCHEMA_VERSION, + 'tool': str(tool), + 'tool_version': version or tool_version(), + 'config': dict(config), + 'signals': signals, + 'totals': { + 'signal_cycles': sum(int(s['periods_screened']) for s in signals), + 'signals_benign': sum(1 for s in signals if s['verdict'] == VERDICTS[0]), + 'signals_susceptible': sum(1 for s in signals if s['verdict'] == VERDICTS[1]), + }, + } + if date is not None: + record['date'] = str(date) + return record + + +def validate_results(obj) -> list[str]: + """Return a list of human-readable problems with ``obj``; empty == valid. + + Written so that a reader of the format -- not just this package -- can + check a record before relying on it, and so that any receiver of the + format can refuse something malformed. + """ + problems: list[str] = [] + if not isinstance(obj, dict): + return [f'record must be a JSON object, got {type(obj).__name__}'] + + version = obj.get('schema_version') + if version is None: + problems.append('missing required field: schema_version') + elif version not in KNOWN_SCHEMA_VERSIONS: + problems.append(f'unknown schema_version {version!r} ' + f'(known: {", ".join(KNOWN_SCHEMA_VERSIONS)})') + if obj.get('schema') != SCHEMA: + problems.append(f'schema must be {SCHEMA!r}, got {obj.get("schema")!r}') + for field in _TOP_FIELDS: + if field not in obj: + if field != 'schema_version': # already reported above + problems.append(f'missing required field: {field}') + + config = obj.get('config') + if config is not None and not isinstance(config, dict): + problems.append('config must be a JSON object') + elif isinstance(config, dict): + for field in _CONFIG_FIELDS: + if field not in config: + problems.append(f'missing required config field: {field}') + + signals = obj.get('signals') + cycles = 0 + if signals is not None and not isinstance(signals, list): + problems.append('signals must be a list') + elif isinstance(signals, list): + if not signals: + problems.append('signals is empty: nothing was screened') + for i, sig in enumerate(signals): + where = f'signals[{i}]' + if not isinstance(sig, dict): + problems.append(f'{where} must be a JSON object') + continue + where = f'signals[{i}] ({sig.get("name", "unnamed")})' + for field in _SIGNAL_FIELDS: + if field not in sig: + problems.append(f'{where}: missing required field: {field}') + if sig.get('verdict') not in VERDICTS and 'verdict' in sig: + problems.append(f'{where}: verdict must be one of ' + f'{", ".join(VERDICTS)}, got {sig["verdict"]!r}') + screened, flagged = sig.get('periods_screened'), sig.get('periods_flagged') + if isinstance(screened, int) and isinstance(flagged, int): + cycles += screened + if flagged > screened: + problems.append(f'{where}: periods_flagged ({flagged}) exceeds ' + f'periods_screened ({screened})') + if flagged < 0 or screened < 0: + problems.append(f'{where}: periods_screened and periods_flagged ' + f'must not be negative') + elif screened is not None or flagged is not None: + problems.append(f'{where}: periods_screened and periods_flagged ' + f'must be integers') + + totals = obj.get('totals') + if totals is not None and not isinstance(totals, dict): + problems.append('totals must be a JSON object') + elif isinstance(totals, dict): + for field in _TOTALS_FIELDS: + if field not in totals: + problems.append(f'missing required totals field: {field}') + if isinstance(signals, list) and 'signal_cycles' in totals: + if totals['signal_cycles'] != cycles: + problems.append(f'totals.signal_cycles ({totals["signal_cycles"]}) ' + f'does not equal the sum over signals ({cycles})') + if isinstance(signals, list): + benign = sum(1 for s in signals + if isinstance(s, dict) and s.get('verdict') == VERDICTS[0]) + suspect = sum(1 for s in signals + if isinstance(s, dict) and s.get('verdict') == VERDICTS[1]) + if totals.get('signals_benign') != benign: + problems.append(f'totals.signals_benign ({totals.get("signals_benign")}) ' + f'does not equal the number of benign signals ({benign})') + if totals.get('signals_susceptible') != suspect: + problems.append(f'totals.signals_susceptible ' + f'({totals.get("signals_susceptible")}) does not equal ' + f'the number of susceptible signals ({suspect})') + return problems + + +def dumps_results(obj) -> str: + """Canonical JSON text of a record -- exactly what ``--export`` writes.""" + return json.dumps(obj, indent=2, ensure_ascii=False) + + +def write_results(obj, path) -> Path: + """Write a validated record to ``path`` as JSON. Local file I/O only. + + Raises ``ValueError`` rather than writing a record that would not survive + :func:`validate_results`. + """ + problems = validate_results(obj) + if problems: + raise ValueError('refusing to write an invalid ' + SCHEMA + ' record: ' + + '; '.join(problems)) + path = Path(path) + if path.parent and not path.parent.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(dumps_results(obj) + '\n', encoding='utf-8', newline='\n') + return path diff --git a/src/pit_release_gate/simulate.py b/src/pit_release_gate/simulate.py index 516193f..e53806e 100644 --- a/src/pit_release_gate/simulate.py +++ b/src/pit_release_gate/simulate.py @@ -15,13 +15,25 @@ import pandas as pd from scipy.stats import rankdata -from .controller import ReleaseController, ReleaseDecision, _ols_resid +from .controller import MIN_ENTITIES, ReleaseController, ReleaseDecision, _ols_resid from .gate import SusceptibilityGate +from .results import ( + SCHEMA, + SCHEMA_VERSION, + build_results, + screen_config, + summarize_signal, + write_results, +) from .reweight import PropensityReweighter from .store import AsOfDataStore SEED = 20260601 +#: Susceptibility threshold the demo screen runs at (|rho_hat| above it is +#: susceptible). Named so the exported screen record cannot drift from it. +DEMO_RHO_THRESHOLD = 0.10 + #: The four planted-truth signal configurations reported in the demo: #: (key, label, c_a, c_x) DEMO_SIGNALS = [ @@ -113,10 +125,17 @@ def run_demo(n_train=10, n_eval=60, verbose=True) -> dict: for key, name, c_a, c_x in DEMO_SIGNALS: # --- honest trailing estimation on prior completed periods --- - gate = SusceptibilityGate(threshold=0.10) + gate = SusceptibilityGate(threshold=DEMO_RHO_THRESHOLD) train = [make_group(n=120, c_a=c_a, c_x=c_x, rng=rng) for _ in range(n_train)] rho_tr = gate.fit_trailing(train) ctrl = ReleaseController(gate=gate) + # the settings that produced the verdicts, recorded for --export + # (identical for every signal; the kappa sweep below is separate) + results['config'] = screen_config(rho_threshold=gate.threshold, + phi_min=ctrl.phi_min, + kappa=ctrl.suscept_slope, + trailing_k=n_train, + min_entities=MIN_ENTITIES) # --- evaluation on fresh periods, gated with the FROZEN estimate --- agg = {p: {'comp': [], 'flip': [], 'bias': [], 'act': []} for p in policies} rho_realized = [] @@ -133,8 +152,11 @@ def run_demo(n_train=10, n_eval=60, verbose=True) -> dict: sig = {'label': name, 'c_a': c_a, 'c_x': c_x, 'rho_trailing': rho_tr, + 'rho_realized': [float(r) for r in rho_realized], 'rho_realized_mean': float(np.mean(rho_realized)), 'rho_realized_std': float(np.std(rho_realized)), + # constant across periods: the gate runs on the FROZEN estimate + 'phi_req': ctrl.required_completeness(rho_tr), 'susceptible': bool(gate.is_susceptible(rho_tr)), 'policies': {}} for p in policies: @@ -164,7 +186,7 @@ def run_demo(n_train=10, n_eval=60, verbose=True) -> dict: if verbose: print("\n" + "-" * 96) print("Sensitivity: required-completeness slope kappa on Mild-leak (c_a=0.3, c_x=0.7)") - gate = SusceptibilityGate(threshold=0.10) + gate = SusceptibilityGate(threshold=DEMO_RHO_THRESHOLD) train = [make_group(n=120, c_a=0.3, c_x=0.7, rng=rng) for _ in range(n_train)] rho_tr = gate.fit_trailing(train) for kappa in [0.5, 1.0, 2.0]: @@ -212,17 +234,59 @@ def demo(n_train=10, n_eval=60): return run_demo(n_train=n_train, n_eval=n_eval, verbose=True) +def results_from_demo(run: dict, date: str = None) -> dict: + """Reduce a :func:`run_demo` result to a ``pit-screen-results`` record. + + Only summary statistics survive the reduction: per signal, the number of + screened periods, how many of them the susceptibility measure flagged, + the mean and maximum |rho_hat|, the required completeness the controller + assigned, and the verdict. The simulated cross-sections themselves stay + on this machine. + + ``date`` is optional and caller-supplied; nothing here reads a clock, so + the same screen always reduces to the same record. + """ + threshold = run['config']['rho_threshold'] + signals = [ + summarize_signal( + key, + rhos=sig['rho_realized'], + phi_reqs=[sig['phi_req']] * len(sig['rho_realized']), + rho_threshold=threshold, + # the verdict the screen actually acted on: the frozen trailing + # estimate, not the ex-post realized ones summarized above + susceptible=sig['susceptible'], + ) + for key, sig in run['signals'].items() + ] + return build_results(signals, run['config'], date=date) + + def main(argv=None): ap = argparse.ArgumentParser( prog='pit-release-gate', description='Run the self-contained known-ground-truth demo of the ' - 'completeness-aware release controller.') + 'completeness-aware release controller.', + epilog='This tool never reports anything, anywhere. --export writes a ' + 'local file and makes no network call; the package opens no ' + 'socket at all.') ap.add_argument('--train', type=int, default=10, help='number of prior completed periods used to fit rho_hat (default 10)') ap.add_argument('--eval', dest='n_eval', type=int, default=60, help='number of fresh evaluation periods (default 60)') + ap.add_argument('--export', metavar='PATH', + help=f'write the screen result to PATH as a {SCHEMA} ' + f'v{SCHEMA_VERSION} JSON record (fully offline)') a = ap.parse_args(argv) - run_demo(n_train=a.train, n_eval=a.n_eval, verbose=True) + + run = run_demo(n_train=a.train, n_eval=a.n_eval, verbose=True) + if not a.export: + return + + record = results_from_demo(run) + path = write_results(record, a.export) + print(f'\nwrote {SCHEMA} v{SCHEMA_VERSION} to {path} ' + f'(local file only -- no network call was made)') if __name__ == '__main__': diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..b508561 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,287 @@ +"""Tests for the ``pit-screen-results`` record and the ``--export`` path. + +One property matters more than the schema details and is asserted from +several directions here: **this package never talks to the network.** The +urlopen entry point and the socket constructor are monkeypatched to raise, +and every path must still succeed; a structural test then walks every module +in the package and fails if any of them so much as imports a transport. + +There is deliberately no submission path on this branch. The ``--submit`` +half of this work is parked on the ``submit-cli`` branch until a receiving +endpoint exists -- a command pointing at an endpoint that is not there must +not ship. +""" +import getpass +import json +import os +import re +import socket +import sys +import urllib.request +from pathlib import Path + +import pytest + +import pit_release_gate +from pit_release_gate import results as results_mod +from pit_release_gate.results import ( + SCHEMA, + SCHEMA_VERSION, + TOOL, + VERDICTS, + build_results, + screen_config, + summarize_signal, + validate_results, + write_results, +) +from pit_release_gate.simulate import main + + +# --------------------------------------------------------------------------- +# no test in this file may reach the real network +# --------------------------------------------------------------------------- +def _exploding(*a, **k): + raise AssertionError("network call attempted") + + +@pytest.fixture +def no_network(monkeypatch): + """Any attempt to open a socket or a URL blows up the test.""" + monkeypatch.setattr(urllib.request, "urlopen", _exploding) + monkeypatch.setattr(socket, "socket", _exploding) + return None + + +def _tiny_record(**kw): + """A small valid record built through the public API (no demo run).""" + sig = summarize_signal("alpha", rhos=[0.01, -0.02, 0.30], + phi_reqs=[0.35, 0.35, 0.35], rho_threshold=0.10) + cfg = screen_config(rho_threshold=0.10, phi_min=0.35, kappa=1.0, + trailing_k=4, min_entities=6) + return build_results([sig], cfg, **kw) + + +# --------------------------------------------------------------------------- +# 1. the record: summary statistics, totals consistent by construction +# --------------------------------------------------------------------------- +def test_signal_summary_reports_only_summary_statistics(): + sig = summarize_signal("alpha", rhos=[0.01, -0.02, 0.30], + phi_reqs=[0.35, 0.35, 0.35], rho_threshold=0.10) + assert set(sig) == {"name", "periods_screened", "periods_flagged", "mean_rho", + "max_abs_rho", "mean_phi_req", "verdict"} + assert sig["periods_screened"] == 3 + assert sig["periods_flagged"] == 1 # only |0.30| exceeds 0.10 + assert sig["max_abs_rho"] == pytest.approx(0.30) + assert sig["mean_rho"] == pytest.approx((0.01 - 0.02 + 0.30) / 3) + assert sig["mean_phi_req"] == pytest.approx(0.35) + assert sig["verdict"] in VERDICTS + assert sig["periods_flagged"] <= sig["periods_screened"] + + +def test_verdict_follows_the_threshold_and_can_be_overridden(): + benign = summarize_signal("a", [0.01, 0.0], [0.35, 0.35], rho_threshold=0.10) + leaky = summarize_signal("b", [-0.9, -0.8], [1.0, 1.0], rho_threshold=0.10) + assert benign["verdict"] == "benign" + assert leaky["verdict"] == "susceptible" + # the screen's own frozen verdict wins when the caller supplies it + forced = summarize_signal("c", [0.01, 0.0], [0.35, 0.35], + rho_threshold=0.10, susceptible=True) + assert forced["verdict"] == "susceptible" + + +def test_build_results_totals_are_consistent_by_construction(): + sigs = [ + summarize_signal("a", [0.01, 0.0, 0.0], [0.35] * 3, rho_threshold=0.10), + summarize_signal("b", [-0.9, -0.8], [1.0, 1.0], rho_threshold=0.10), + ] + rec = build_results(sigs, screen_config(0.10, 0.35, 1.0, 4, 6)) + assert rec["schema"] == SCHEMA == "pit-screen-results" + assert rec["schema_version"] == SCHEMA_VERSION == "1.0" + assert rec["tool"] == TOOL == "pit-release-gate" + assert rec["tool_version"] == pit_release_gate.__version__ + assert rec["config"] == {"rho_threshold": 0.10, "phi_min": 0.35, "kappa": 1.0, + "trailing_k": 4, "min_entities": 6} + assert rec["totals"] == {"signal_cycles": 5, "signals_benign": 1, + "signals_susceptible": 1} + assert validate_results(rec) == [] + + +def test_record_carries_no_timestamp_unless_the_caller_passes_a_date(): + a = _tiny_record() + b = _tiny_record() + assert a == b # reproducible: nothing time-varying inside + assert json.dumps(a) == json.dumps(b) + assert "date" not in a + assert not [k for k in a if "time" in k.lower() or "date" in k.lower()] + dated = _tiny_record(date="2026-08-16") + assert dated["date"] == "2026-08-16" + + +# --------------------------------------------------------------------------- +# 2. the validator +# --------------------------------------------------------------------------- +def test_validate_accepts_a_built_record(): + assert validate_results(_tiny_record()) == [] + + +@pytest.mark.parametrize("mutate, needle", [ + (lambda r: r.pop("schema_version"), "schema_version"), + (lambda r: r.update(schema_version="9.9"), "9.9"), + (lambda r: r.pop("tool_version"), "tool_version"), + (lambda r: r.pop("config"), "config"), + (lambda r: r["config"].pop("kappa"), "kappa"), + (lambda r: r["signals"][0].pop("mean_rho"), "mean_rho"), + (lambda r: r["signals"][0].update(verdict="probably fine"), "verdict"), + (lambda r: r["signals"][0].update(periods_flagged=99), "periods_flagged"), + (lambda r: r["totals"].update(signal_cycles=999), "signal_cycles"), + (lambda r: r.update(signals="not a list"), "signals"), +]) +def test_validate_reports_each_kind_of_problem(mutate, needle): + rec = _tiny_record() + mutate(rec) + problems = validate_results(rec) + assert problems, "validator missed a broken record" + assert any(needle in p for p in problems), problems + + +def test_validate_rejects_non_mapping(): + assert validate_results([1, 2, 3]) + assert validate_results(None) + + +# --------------------------------------------------------------------------- +# 3. --export is fully offline +# --------------------------------------------------------------------------- +def test_export_writes_a_valid_results_json(tmp_path, capsys): + path = tmp_path / "results.json" + main(["--train", "3", "--eval", "4", "--export", str(path)]) + capsys.readouterr() + + rec = json.loads(path.read_text(encoding="utf-8")) + assert validate_results(rec) == [] + assert rec["schema"] == "pit-screen-results" + assert rec["schema_version"] == "1.0" + assert rec["tool"] == "pit-release-gate" + assert rec["tool_version"] == pit_release_gate.__version__ + assert rec["config"]["trailing_k"] == 3 + + names = [s["name"] for s in rec["signals"]] + assert names == ["clean", "composition", "mild_leak", "strong_leak"] + by_name = {s["name"]: s for s in rec["signals"]} + assert by_name["clean"]["verdict"] == "benign" + assert by_name["mild_leak"]["verdict"] == "susceptible" + assert by_name["strong_leak"]["verdict"] == "susceptible" + # the susceptible signals are graded to a higher required completeness + assert by_name["strong_leak"]["mean_phi_req"] > by_name["clean"]["mean_phi_req"] + + for s in rec["signals"]: + assert s["periods_screened"] == 4 + assert s["periods_flagged"] <= s["periods_screened"] + assert rec["totals"]["signal_cycles"] == 16 + assert rec["totals"]["signals_benign"] + rec["totals"]["signals_susceptible"] == 4 + + +def test_export_performs_no_network_io(tmp_path, capsys, no_network): + """The red line: --export must not touch the network at all.""" + path = tmp_path / "offline.json" + main(["--train", "2", "--eval", "2", "--export", str(path)]) + capsys.readouterr() + assert validate_results(json.loads(path.read_text(encoding="utf-8"))) == [] + + +def test_results_module_has_no_network_machinery(): + # the module that builds and writes the record cannot import a transport + assert not hasattr(results_mod, "urllib") + assert not hasattr(results_mod, "socket") + src = Path(results_mod.__file__).read_text(encoding="utf-8") + assert "urllib" not in src + assert "atexit" not in src + + +def _imported_modules(path: Path) -> set: + src = path.read_text(encoding="utf-8") + return {m.lstrip(".").split(".")[0] + for m in re.findall(r"^\s*(?:from|import)\s+([\w.]+)", src, re.M)} + + +def test_no_module_in_the_package_can_reach_the_network(): + """Structural guard on the no-telemetry red line. + + Not "no module reports by default" -- *no module can*. Nothing in the + package may import a transport, register an exit hook, or spawn a worker + that could report in the background. This is asserted over every module, + so adding a phone-home later fails the suite rather than shipping. + """ + pkg = Path(pit_release_gate.__file__).parent + modules = sorted(pkg.glob("*.py")) + assert len(modules) >= 8 + for py in modules: + imported = _imported_modules(py) + assert "atexit" not in imported, f"{py.name} registers an exit hook" + assert not imported & {"threading", "multiprocessing", "concurrent", + "asyncio", "subprocess"}, f"{py.name} spawns work" + assert not imported & {"urllib", "socket", "http", "ssl", "requests", + "smtplib", "ftplib"}, f"{py.name} imports a transport" + + +def test_the_package_ships_no_submission_path(): + """The --submit half stays parked until a receiving endpoint exists.""" + pkg = Path(pit_release_gate.__file__).parent + assert not (pkg / "submit.py").exists() + assert not hasattr(pit_release_gate, "submit_results") + help_text = Path(pkg / "simulate.py").read_text(encoding="utf-8") + for flag in ("'--submit'", "'--contact'", "'--dry-run'"): + assert flag not in help_text, f"{flag} is still wired into the CLI" + + +def test_the_export_path_never_reads_a_clock(): + """No timestamps generated inside the library: a date must be passed in.""" + pkg = Path(pit_release_gate.__file__).parent + for name in ("results.py", "simulate.py"): + imported = _imported_modules(pkg / name) + assert not imported & {"time", "datetime", "calendar"}, f"{name} reads a clock" + + +def test_export_is_byte_identical_across_runs(tmp_path, capsys): + a, b = tmp_path / "a.json", tmp_path / "b.json" + main(["--train", "2", "--eval", "2", "--export", str(a)]) + main(["--train", "2", "--eval", "2", "--export", str(b)]) + capsys.readouterr() + assert a.read_bytes() == b.read_bytes() + + +def test_exported_payload_carries_no_identifying_details(tmp_path, capsys): + path = tmp_path / "results.json" + main(["--train", "2", "--eval", "2", "--export", str(path)]) + capsys.readouterr() + text = path.read_text(encoding="utf-8") + for secret in (getpass.getuser(), socket.gethostname(), os.getcwd(), + str(Path.home()), sys.executable, str(path)): + if secret and len(secret) > 3: + assert secret not in text, f"payload leaks {secret!r}" + for banned in ("path", "user", "host", "cwd", "platform", "python"): + assert banned not in text.lower() + + +# --------------------------------------------------------------------------- +# 4. the submission flags are not wired in on this branch +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("argv", [ + ["--submit", "https://example.invalid/submit"], + ["--contact", "a@b.org"], + ["--dry-run"], +]) +def test_cli_does_not_accept_submission_flags(argv, capsys, no_network): + """argparse must reject them outright -- they are not shipped.""" + with pytest.raises(SystemExit) as exc: + main(["--train", "2", "--eval", "2", *argv]) + assert exc.value.code != 0 + assert "unrecognized arguments" in capsys.readouterr().err + + +def test_plain_cli_run_writes_no_file_and_opens_no_socket(tmp_path, capsys, no_network): + main(["--train", "2", "--eval", "2"]) + out = capsys.readouterr().out + assert "gated" in out + assert list(tmp_path.iterdir()) == [] From be0aea1168c641707e80cdbe959d88243b62ab1d Mon Sep 17 00:00:00 2001 From: Max Well Apex LLC Date: Mon, 17 Aug 2026 00:17:31 -0400 Subject: [PATCH 09/14] ci: weekly scheduled reproduction run; dependabot for pip and actions --- .github/dependabot.yml | 24 ++++++++++++++++++++++++ .github/workflows/ci.yml | 7 +++++++ 2 files changed, 31 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..dc026ef --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 + +updates: + # The package pins no runtime dependencies beyond numpy/scipy, but the test + # and CI toolchain moves. Weekly PRs keep the reproduction honest against + # current versions rather than the ones that happened to be installed on the + # day of release. + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 3 + commit-message: + prefix: deps + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 3 + commit-message: + prefix: ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce15c7f..662695f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,13 @@ on: push: branches: [master] pull_request: + # A package whose claim is reproducibility should keep proving it. The + # weekly run re-asserts the paper's numbers against whatever numpy, scipy + # and Python resolve to that week, so drift surfaces here rather than in a + # reader's environment. + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: permissions: contents: read From a1188a085b3ee1a7dab77c817fd670f90474a962 Mon Sep 17 00:00:00 2001 From: Max Well Apex LLC Date: Mon, 17 Aug 2026 01:19:08 -0400 Subject: [PATCH 10/14] meta: searchable summary, license classifier surface, richer keywords and project-urls --- pyproject.toml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2a705bf..890e598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "pit-release-gate" version = "0.1.1" -description = "Completeness-aware release control for staggered-arrival cross-sectional data: a susceptibility-graded gate that blocks incomplete-cross-section leakage without a blanket timeliness penalty" +description = "Screen quant/ML feature pipelines for look-ahead bias from late-arriving data: measure each signal's leakage susceptibility and gate release until the cross-section is safe" readme = "README.md" requires-python = ">=3.10" license = "MIT" @@ -23,6 +23,11 @@ keywords = [ "selection-bias", "quantitative-finance", "ml-pipelines", + "lookahead-bias", + "quantitative-finance", + "backtesting", + "feature-store", + "data-snooping", ] classifiers = [ "Development Status :: 4 - Beta", @@ -46,6 +51,9 @@ dependencies = [ Homepage = "https://github.com/MaxWellApexLab/pit-release-gate" Repository = "https://github.com/MaxWellApexLab/pit-release-gate" Paper = "https://doi.org/10.6084/m9.figshare.33158615" +Changelog = "https://github.com/MaxWellApexLab/pit-release-gate/blob/master/CHANGELOG.md" +"Audit Registry" = "https://github.com/MaxWellApexLab/pit-audit-registry" +"Issue Tracker" = "https://github.com/MaxWellApexLab/pit-release-gate/issues" [project.optional-dependencies] dev = [ From 8d072963b7cdd472abaad83c52e4be8b7b0bf5d1 Mon Sep 17 00:00:00 2001 From: Max Well Apex LLC Date: Mon, 17 Aug 2026 01:28:49 -0400 Subject: [PATCH 11/14] =?UTF-8?q?docs:=20conversion-first=20README=20?= =?UTF-8?q?=E2=80=94=20demo=20chart,=20consequence=20line,=20nav,=20compar?= =?UTF-8?q?ison=20table;=20chart=20generator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .coverage | Bin 53248 -> 53248 bytes README.md | 129 ++++++++++++++++++++++++++----------- docs/assets/demo_bias.png | Bin 0 -> 61179 bytes tools/make_readme_chart.py | 56 ++++++++++++++++ 4 files changed, 148 insertions(+), 37 deletions(-) create mode 100644 docs/assets/demo_bias.png create mode 100644 tools/make_readme_chart.py diff --git a/.coverage b/.coverage index 0fe9c8d2a377822a37787f679acdcf065e13c3a1..810e643b1b111c0ef60f12f09e295ecb979b48b7 100644 GIT binary patch delta 398 zcmZozz}&Eac>{}s7$^5D2L5h-5x&KIhP-=uJ$SD0#B;yeEGTe+o2i&{a)M9E6r~oI=9Coc6;u|p0p&9`Z}JUd0-E!hfxn0QHNPm|65f40S9v{o z68MaO=3L~S93NA{$O$CD#xQbB-WQw6jWC5BB(r%_Y><)&2Y(;~|7ZTY{O9?P@Xz7z zkoN-y=06e) zbN=-+{y)!o=l<>AbN|cV_*=h(Wiulu3&={=u(XYgJd-c?iyHIq{Cxhyeuw(!|Lq^# zw&!MK;pCKJVqkc|$hLrsfq{X8{}coNZ~ia*Z~0I0KjXi_f1m#%(D9r2r9sv+vT%SY S4t6lb#s;R?H$R;(Z2$mcREJmq delta 283 zcmZozz}&Eac>{}s2nSCb1AjNa2;X8pL*Bi-9z2(M;x-EkxbaMm_sQaokIzla%!`lL zE2u1HpPcAZ%v8(WydM@8OB(7v)>RyN}nC=L(+@P_H}BZlMnYR>7Acj z-}ayNm-YYujQ9UBaxk)Ra*8l999YlAz`(%Hf02RzH~$y@xBM6RpYh-4zX7y#H@^tT UB1RT=FvZRWrr0(=oiA+w0M-#-HUIzs diff --git a/README.md b/README.md index 565e9ce..e17ca1a 100644 --- a/README.md +++ b/README.md @@ -3,42 +3,50 @@ [![CI](https://github.com/MaxWellApexLab/pit-release-gate/actions/workflows/ci.yml/badge.svg)](https://github.com/MaxWellApexLab/pit-release-gate/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/MaxWellApexLab/pit-release-gate/branch/master/graph/badge.svg)](https://codecov.io/gh/MaxWellApexLab/pit-release-gate) [![PyPI](https://img.shields.io/pypi/v/pit-release-gate)](https://pypi.org/project/pit-release-gate/) -[![Downloads](https://img.shields.io/pypi/dm/pit-release-gate)](https://pypi.org/project/pit-release-gate/) -[![Python](https://img.shields.io/pypi/pyversions/pit-release-gate)](https://pypi.org/project/pit-release-gate/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![PIT Hygiene](https://img.shields.io/badge/PIT%20Hygiene-pledged-2ea44f)](https://github.com/MaxWellApexLab/pit-hygiene) Completeness-aware release control for staggered-arrival cross-sectional data. -When the entities of a cross-section report on staggered dates — companies filing -financial statements are the canonical case — any same-period cross-sectional signal -computed before the last filer arrives is estimated from an incomplete, and possibly -*selectively* incomplete, cross-section. If filing timing depends on the very -disturbance the signal measures, releasing early produces a systematic bias -(incomplete-cross-section leakage), while a blanket wait-for-the-deadline rule removes -the bias at a timeliness cost paid by every signal, biased or not. `pit-release-gate` -measures each signal's susceptibility to this bias — a disturbance-conditional partial -correlation fitted honestly on prior *completed* periods — and grades the required -completeness per signal, so benign signals release early and susceptible signals are -withheld until enough of the cross-section has arrived to suppress the bias. - -## Statement of need - -Point-in-time discipline in ML pipelines currently rests on tooling that answers one -question: *was this value readable at time t?* Feature-store as-of joins, bitemporal -and vintage-aware storage, and purged or embargoed cross-validation all enforce -read-time correctness, and they do it well. - -None of them answers a second question: given that every value read was legitimately -readable, was the *set* of entities that had reported by t a selected sample? An -as-of join over an incomplete cross-section is a correct join over a biased sample. -The two failures need different remedies — the first is fixed by timestamp hygiene, -the second only by waiting or by an explicit correction. Researchers building -cross-sectional signals on staggered-arrival panels have had no routine, per-signal -screen for the second. `pit-release-gate` is that screen, plus the release controller -that acts on it: one `fit_trailing` call per signal, so reporting a susceptibility -estimate alongside a released signal costs about as much as reporting a standard -error. +**[Audit registry](https://github.com/MaxWellApexLab/pit-audit-registry) · +[Pledge](https://github.com/MaxWellApexLab/pit-hygiene) · +[Papers](#papers) · +[Result schema](docs/results-schema.md)** + +Rebuilt from as-filed SEC EDGAR filings, on observed filing dates, **7 of 14 +standard fundamental signals are contaminated** by incomplete-cross-section +leakage — [measured, reproducibly, in the companion registry](https://github.com/MaxWellApexLab/pit-audit-registry/blob/main/methodology/2026-08_sec-edgar/report.md). +This tool measures each signal's susceptibility *before* release and withholds +only the signals that need it. The screen costs one `fit_trailing` call per +signal. + +![Known-ground-truth demo: naive release is biased exactly when the leak is strong; the gate routes that signal to the deadline and its bias is exactly zero, while benign signals still release at ~36% completeness](docs/assets/demo_bias.png) + +*The shipped fixed-seed demo, where the right answer is planted: naive early +release carries a systematic bias of −0.386 on the strong-leak signal; the gate +routes it to the deadline (bias exactly 0.0) while releasing the two benign +signals at 36–39% completeness. `tests/test_reproduces_paper.py` pins these +numbers; [`tools/make_readme_chart.py`](tools/make_readme_chart.py) redraws +this figure from the live demo.* + +**You need this if:** + +- you build **same-period cross-sectional signals** — industry-adjusted ratios, + cross-sectional ranks, peer medians — on entities that report on their own + schedule; +- you **rebuild panels from as-filed sources** (EDGAR `companyfacts`, raw + filings) instead of using a vendor's curated release; +- you own a **feature-store pipeline** where an as-of join reads whatever has + arrived by *t*; +- you want a **per-signal susceptibility number** published next to every + released signal, the way a standard error is. + +**Not for you if** you are hunting look-ahead bugs in a backtest engine, or your +data has no arrival times — that is a different failure mode +([scope statement](https://github.com/MaxWellApexLab/pit-audit-registry#what-gets-screened)). + +**No telemetry — structurally, not merely by default:** no module in this +package imports a transport, and [a test enforces that over every module](tests/test_export.py). ## Install @@ -76,6 +84,20 @@ values, and per-entity filing-arrival times, then call `ReleaseController.decide(store, t)` at each evaluation time — it returns `WITHHOLD`, `REWEIGHT_RELEASE`, or `RELEASE` plus the released values. +## What this catches that your current tools don't + +| guarantee | as-of join / bitemporal store | purged & embargoed CV | **pit-release-gate** | +|---|---|---|---| +| No value was read before it was available | ✅ | — | assumed as input (bring your arrival times) | +| Train and test don't overlap through time | — | ✅ | — | +| The **set of entities** present at *t* was not selected on the disturbance | ❌ | ❌ | **✅ measured per signal (ρ̂), gated per signal** | + +A point-in-time-correct join over an incomplete cross-section is a correct join +over a biased sample. [Statement of need](#statement-of-need) has the full +argument; the +[as-of join methodology page](https://github.com/MaxWellApexLab/pit-audit-registry/blob/main/methodology/2026-08_feast-pit-join/report.md) +has the measured demonstration. + ## API overview Five public components, all importable from the top-level `pit_release_gate` package: @@ -100,6 +122,16 @@ so the right answer is known exactly and no licensed data is needed: pit-release-gate # or: python -m pit_release_gate ``` +Real output, abridged to the signal the gate exists for: + +```text +Strong-leak (c_a=1.0, c_x=0.7) rho_trailing=-0.868 (fitted ex ante) (SUSCEPTIBLE -> wait) + policy comp% [95%CI] flip% [95%CI] biasB(signed) [CI] route + naive 35 ± 0.0 63.6 ± 2.8 -0.386 ±0.037 naive + deadline 100 ± 0.0 0.0 ± 0.0 +0.000 ±0.000 deadline + gated 100 ± 0.0 0.0 ± 0.0 +0.000 ±0.000 gated(phi_req=1.00) <-- gated +``` + It compares five release policies (`naive`, `threshold`, `reweight`, `deadline`, `gated`) on four signal types. Headline behavior: @@ -115,6 +147,32 @@ A sensitivity sweep of the policy slope κ shows the timeliness–bias dial: κ = 2.0 → 100% (bias exactly 0). The demo is deterministic (fixed seed), and `tests/test_reproduces_paper.py` asserts these numbers. +## Statement of need + +When the entities of a cross-section report on staggered dates — companies filing +financial statements are the canonical case — any same-period cross-sectional signal +computed before the last filer arrives is estimated from an incomplete, and possibly +*selectively* incomplete, cross-section. If filing timing depends on the very +disturbance the signal measures, releasing early produces a systematic bias +(incomplete-cross-section leakage), while a blanket wait-for-the-deadline rule removes +the bias at a timeliness cost paid by every signal, biased or not. + +Point-in-time discipline in ML pipelines currently rests on tooling that answers one +question: *was this value readable at time t?* Feature-store as-of joins, bitemporal +and vintage-aware storage, and purged or embargoed cross-validation all enforce +read-time correctness, and they do it well. + +None of them answers a second question: given that every value read was legitimately +readable, was the *set* of entities that had reported by t a selected sample? An +as-of join over an incomplete cross-section is a correct join over a biased sample. +The two failures need different remedies — the first is fixed by timestamp hygiene, +the second only by waiting or by an explicit correction. Researchers building +cross-sectional signals on staggered-arrival panels have had no routine, per-signal +screen for the second. `pit-release-gate` is that screen, plus the release controller +that acts on it: one `fit_trailing` call per signal, so reporting a susceptibility +estimate alongside a released signal costs about as much as reporting a standard +error. + ## Export your screen result ```bash @@ -136,12 +194,9 @@ clock is read while building one, so the same screen always exports byte-identic bytes. Where the record goes afterwards is entirely your business — commit it next to -your badge, publish it, or keep it. This tool does not send it anywhere. - -**No telemetry — structurally, not merely by default.** No module in this -package imports a transport at all, and a test walks every module in the package -and fails the suite if one ever does. There is no background thread, no `atexit` -hook, no anonymous usage counter, and nothing to opt out of. +your badge, publish it, or keep it. This tool does not send it anywhere. There is +no background thread, no `atexit` hook, no anonymous usage counter, and nothing +to opt out of. Programmatic use, for screens on your own data: diff --git a/docs/assets/demo_bias.png b/docs/assets/demo_bias.png new file mode 100644 index 0000000000000000000000000000000000000000..6598180dd6c9fba43d99a9489e26d45adfea5a99 GIT binary patch literal 61179 zcmafa2UJsAyDb(_L~JN1-2xVx^j<_!ItbEBL?Dz7iv%B^{l9ncIyzhNK{={|!6?+P6%Aa#m{*SfPFST6g1J_#l%70#;gPa5 zMd^AKv;A!?`84(Epr5DR+3Z*FOx{pU*V=HC|-Cj@_?KV_xgr z;N)QQ`4cc6E)+!JE2=`+t61>^W+x7gITJ zFSlg>q;&e)Fdr zrd_dI@rdMgxzp$TkI3%(;cK5BIg35@C9%E)PXPAvPvW0HuRZ4zt!CpPev!ik+UWPx zbUAS1Xp;jy1%(5)!YUk*Od<7WqS_wjAm`lXP4S5uY_5#c>a;Xrx)FJ>hGL5nu{Ycx zT1RuL`Al*Dbxfjo+V7qI33p$azUYE8m~Y{YslP3g|o$v)x(K#gka5Leg2Bx z$vPLbwuZFjD8W|F0`>a;Xbr}sLp=b@^))~ zRHnjV^yhHonQ+X~nC(bmBF-@12v^!B^rfl-gPsz3l9yN~3>%HvBn&0H2{}ya>y{XD zl#%f1{>r4q5srKJI+*XOC5wE1or26lb<{fHW`6~-iL4@D6lf)GXCBJ^d~gcw^oJCE zS3|ikO`1a^O)830jq$!wb@9Q$M&FhteC7hPSihgoV$xNm|T$b5Weyv+Pr5K9y36deJRw}+&+4^sb4XeRtDE(NdLxB_&vet8iR3Yg z*ROqT7USu)((l<@e~wrO!3$gWvrqh{9Q7-ozU##;=8#-eYF@S*8nV1^iYdUIfMGnO zYls^JZ$ceDZNvHxxi*b!)Aw*tg>PIIwHfpyBI1(G@RvxRt#PY)-(R1wHI8Toh>uy= zA$X8m69$cP429t`Bd71uW^WpjY)Ar zntaw<=`=d>)RKQMU;o_gwCjJki&}z7Q;~d&?LXO^-^hLzp0x3a)x18OREvR?KhJ)u z;BEi<*LTVa*-eSZ;XKko1gkfG^G~*ArfZ$D#hXgZ%bn-CE|+RcT}#Kjl}j0?U=J86 z((B91Pu`yKpK;rmS2Nm}@980fGmjlbK-iYe!E!prvGBio&-3`h`D!7X!LbK=rFQ6K zQAA>C4OsXAf;lSmhPg`Qtv~}#^Y+l{2Remg&iLNtb*IeCOz19Hqw!KmGpm^?IHSwq z-n(=0x4ZYjZ1#SBe%0T;$ze;3YL%qwQ}r(#A+*aT+i0D_i7?x3h5sHr}jtCMJ(IKxK(;$b4yE0 zbr7ppN1ULQI(EJ%xyKneyc!;0QQaEJA|>N^N*ek~*{y+RY*kZ@-lE^@rvctRAUfDaA7*^ey zR8N;-F-s;xr(SQ}ghs$lK4J>K}r@z`y^)i`SPGMXt0N;@mIa-(IajKp>CFkn|rD z&nDFMR!bJbh&3~QlHWH${2Iq*+c&-bCI5ccYX##8qVS{lgi&+u3AZUK-(`3`D@_FbLTL{B5&y=P!#;GdiE~R2#n_tM+2QcLp zol)~ndmd9q5>paE^^fC6T$*tBSHN_ZQJ>PDRoi3d*QI5Eqp7%9vZ0(QAGoyKPrjB& z@?`Wx7KL1x8#ead}ioUJH5 zXd-MdR|Px6U_Vj$Y0afyz-I7{3%3FO#T479caHzjA+M}?iTPHno+&?G$KT(7h>Cph zbu=GafKW2-vnlO8vj_t3{AB4+742TL>D>hp{pGKv%~{!qs-}5e$<2+HHT`~bj#5-r z`9uyT+7cZn96m&T*{GMVp2}EcF2p&Cd-j>pGE7H0XrZoi!nE=Dn5nZ#y3DKY!;8|; zl~a4;FYSpxF3K)#!cA)&QpsXq@ys9hjfbI7VEMA89NI%~Y`KIqH8Ugb?`& zx1}*NnLi~ViYSWd)#f9Ii`PS0r6s=OfO$ak`1HTBe|II{iUpa7%jGNvwy-?a#8<4^ z&3@#wLnZ8Qs!jMLn42>e819g@ayDkXh&;*pROhe1WK9Ns)+N$h++{x0CJ4TE#Uw*> zyAK>7*xOUeM~e!>;}upt3XQzkk3<^3Rdpwe_8kU3Zhq0~p7Bs*zwqg2MrfL;E1Rs} zq>RaS2&>g-)1Nz<*F7P1uk0&KH5UU3vz^h)8?F$;eG!oWc@1m^s**K*FR;rQ*G@~9 zFu2R8zxBRpbTmE*ywEf~E2~2u?CTK-Jr%2ZRsr8ZUYX#z7dXD=>xb`U*^hrt9&}on z6wgNml_J6Eu13f6Vey1`mehRg7ls3{d{wCu@GcohbBxixE%112&N3hEGy2zrKd+E4 z#p7hE_r)ak+AtHS3ah?qjb<-W;c^*@wW4{xw_0i2e|F=mVWo8>+4l?fY9cAA&dYIO z4Z0k3H{EA2C7qk`p~F;t6&W3kobOE~Zc1mO-d5HP>699a3;GfUbJc5FM#|`> z>h4yjJ2`pIz}~z75BHkAM9fZo+dT_1S9JBvBId-nQM$7Fs_|TyY=66GXCVJ`w0w@#8VR= zn!Qa#oZxcj*t80O9}dHUJ07u{gp=ynJlSfUL0-5IZxx;KF<+4!?8BFtqv}cG9_zD8 z`y0fy98|0VCKHhi53M_Qb$t4AWH=PtVuG9ET<@%+3WCPG2oTd!XxBSiXcx4kmuAU(QwC6{41l20g> z#PPgYQ*8fA$)lWwllFV)!ferLj~}Z38$F`SLt)I~uZ~DarD)E1m1JBzBN5#j#OAjk zbT*>~1hpqN@2DCa9A(!I(lAe>E z(9fj@a(N6tbpLR!$Iqrpx+iUaJd|I(KI}AHZEv)pk6Jo|!aWxBzvWLou-$y^+dxLK zxu%#ir-o1J#TdW3QT#FnkTFZ{wC((d~q? z|EL1qrmuFaX%&twDIQ|;+qv0=bHx^K8_f0z=>>crOXErsvA4|wp}U7(FX#e`K!CEz z3`o+lyD{6JsIIs+QN2%XRA)SJI)C-aVJL?Cru)gjQJGPkLWS9F;NlZKQ>elxVqur%o87 z6`+)VZdr-8qr`g#D*9w#dA=Zpsowdi1RfeE!8v-7*2_0mGeF)qTr4|1P(s%Ep^<-w z^VX?qg$iL3`3t5bEMOjE>fIw%YEMHT0qH%4FUADi@;Rx2x|ds`FpN zf7Ce6$n3kqmUvKWWyMEu;|Gm|s!-ekhf3TN4?7D0NNZ|_QmauGATT5^D5;0LrlNxV$=4y9S`e|xeSYHRO z-{3fD5_}qQ+xLiA>se?}0fiRlc7PP|Dgu8>*y;BZai`z!L$CA8UkT1f`T8v3I^0&L z?c3c4$xBO<8RjntwXX)meRjV30BE^BW*)Be;{$Ean3321ViBVIpeI>0B%f0)$wGE@ zXa8BF-3YFg5jQ$zU)3%;w6@BOvKvAX$Gncs*#H4|(32QVTiIX~woUN8JR?Ou1{{6> z@yrW~Y3Hf(Lad~>x!l`xL(p~ds9P_?(Nw09R$gOb&X1& zq(mCTShk3?Gw5`<>2X8a%Qc4o1_QVL%XVZ4fqa!AjhRkjD?JV8>eJhSb9^e3zOa^! zOK5+Bsv6?oDK)OUV#zJ9VStDi9?h@VR#)8=yzPH@z}j5B23M~LBY|Z~+zu6O+p3*z z@tjpJA?;f}tnoNKg2%f^Y%wmaFGYOlVAZx;gT+xZ+_%vI&TKxg z=i@{YW7#%DwT-x-*XPjL#@Y>kzSF4pVI`Vw|H;T+Rmkw`6NXi{$8(ck?3;*5i?gmtALqf}RtaM8j3YBRLopGDDxpr9Fi^}Yqz2WYBS zi=(f>FbxMx(>yir(LLXW3+sqeJVp!;ootYj#d04m)GsZCRKTALLAw*IG1;f)BrDuk zr>(bBb-16LNgDOVFR2*V9iOzfw0m>?HyYmzJu1;im*JsSdRMX6))8L`ex4aQ9Ky9v zncE*7lDGKYm2Q<35hN0tg|F9Fe3$EDtTbs(k-%{;6czg}**0Ah3PEfH)OdJJqEIq! zaMJ-kZ3&77!QOK|`b`JxDxS+#m@L|zg+o$IRf*v+4!PRoQXfhsCOxi83}PX354zN) z{cx4@Xsoiwj-E!lHru|z0kUAew)}0xz%^KK$e5qH$6ixS@9LfpW3ls7S)&+9N~hn> zrTwszHra_23+Hj`%Mi)D#!CKJ0~VobIHgSd2g@S2Qw~I)3Qp9v8wjJ zqGWXKKY4a%FPN1JF1XG}mGCj|i z*$Ty0?SxljyrytOpR`N%qb?Dw@m|u!fmgD*^ndS&XPz+PdRZzTyb{_Tj}KX;MFBw1>{G9q(i*=(78;;J0I0X6RH6CbolIKpi!7YyeNB&eaOZuJMSU}Uw)uEM>;@^ zKM6~mqi%`r6LV+9FP4EUFdX=YQ`<=c1=k>?pdUx%RsVTt&rl)COWp06kDZ-U`#W3A?dBk;Bx3fPXhlBi$Y@0p3~ErQ`93|EkpK4xNupEv9D(H z&&*2piZF{wmkV^>lLU1uEJ!<_Vf3$ks>UV!geg`#*NFO-h7cxqHhgbYK2;aV`yJ^v z#~*gU>3%D!L>HP05h>$Lg>3sZ`D%-K1J_#CUTMkZkbX=g3R?9(F__+a`z1i0{OZfN zbyCS-ldrc+Ku+_eMZ`D8Kf@^0@UobQ&_`R#4zXi$jZ9;p3iQ{WxD7nK7)|`To*S)0&&!9rx`b)AK|=QtwHM(y)ufG5}?T z4XB&wF&mFw#a!AKG!VT@n%$$v7Z6Pp_@@|z#$y$wTSGPI5950AA5~W zs7MoO0(+@&DsTe629hXFkGz)n*6&`lRi8m_9){^_1Vr5QB%og{O;yNFI{>5$i1wTu z*{-U%seY?QL+}UPViL*-FVxu7&z?dxw%L%4+5?q= zXX*WAXX)J4p(dx%_8o_J`BQ$yrOto3S>0_@aBoc;$>)+0_3i{mt4wt-m)D^aDHl89 zseoG30&oW|0YUDx>&mle?4=DmovVT+_?cl`8vUME)|c8+sGL%knBsSt)N!9`-8_Wj z4@&W`Sg`0DFbHzP^dq|NdnDC$lFKGY*o9FBA@n@myZZoGMD6TMI;Su|R@V|15AF&>sH+&kP~rE6QegtgzEBL%0$3*-81kw^^Hs6Hyquc_5_ zxd7w3Hoh#z?-$2TxD9ONu29i4lyOtSq2wyt6P)Hl_?o_k!|6Sx z1W>AUjo-qV?^PaRiqABD-Excj6lRkRaTZ>fUBdX&dZ;Fg@bvDhBWc8mhFK7|&G;8A zbc)+LvvyLAnlAL001QjDs*rc_ z>D1Fy8VhTdpLKFkk1QC4K*B{BOV!oe1uj+T)us52M|K7X6Zr-Q>auWnw-(D1j!kUBXVteq(>s2sP7DTa?yYXM4H4{ z>C$wS(fy$z2NbpE`PMjx=vd*ECHMel+{@8uHTk`SUTqd4AXZ^vW!Fe>Q)nko!Lw3d z(Sq?yMZGYx!UQZ4_3-prugNe*;jRV<99wxWDF4cOg&ah7nXyRvM)B_p)JlDVy7A`j zrVyfwX}F%1Y}g@SKG&w(Yg2}#a&aOLG)i@@tWREEE-XRrLlO#x#{HhV;< z4Nm(i1CipQOM_Mg=i&H@S#Ay$=gcr_}Bt^Jw(%9 z;cnpbQ-XU%CFb+*sfvr=v`DTt(9M;NiUsZk8k`DD`aCQ4vMkmn%jD^1mJZ*#RHwiv zF5eY=ns@JXl@juSQ6d*Y;>%K9%hPoEp> zj}%m~zcbg3W8Kz}T6^-o%=tI-8eUAEUECRUNulD0e>t}t%(yEkKeIoP`Plzl#FLv&@Q7a+44X%lkq)Q+B>kDxefjA27_v86!=G%K` zxK5rc*;cQ+<3@3P!?lkCeMasJJ5GjEBN9*{qn<2TV}taNy~l43S9vDrb1Bs-jdEBY z2cEmO?jcRRYmkBCB6_4M%WjZR-#Q+7P6Z{cL3tSHq*n>mL~+d87$&0&xMEwsu_2c* zodOvNFXqK+VpSNc#^^(C+k>4o=-CfDP1PUK;ypK5_IFTh{-J{`I`7_refsUuLz^@$>a<%)*&1lD-_?%cQh+ZBTo*=SA?G_L7xILvAuc zik=g%M@GKO_A1Gcm3p#rr4*F|y<BtEJ;7#>96p~X1NT%7DoF{u zi2|18(wqH{tED{G6ZXBle+4oaSMQe8OqcV4v@ZYNMzW}*L%OWTpO^W>{$==xNJ!NM z4U=ZSDcuUF+CEMt=AK#6i-IT~ox&H@#Re4<)4tm#f{j*vX_r~ucm9~wq`9=brL4}# zV6QNqsI;*Mx0PLc@S@{zOvHX-41i3!IVXUd%W?q#=8gu$^H3hBy6pRX4GZi*SQ&4P z9Fq6#0bM7wu}Lb`FUuNd^J)h5t!l8?lLrV?aK0DFby=A%U3xiMw2Z+5uDi!)>Ov|4 zW#j4!A;=6{lF4Lyz)aS}@8!iLWfO$OszEZKz3$ZG?p5PY6{!udB=>@< zRwBlLEm_9b%c#nBq`RKE@2->hia9de+|~u@w!9S1qyIHS+<{~q0m^1rK(f8c6SW(A z_#G$z`0+BfaK9G7zUp#IbQ12)aqR<~x{2K?IG%Y z3ZRxEiiRPJ3H2%Y_yJIoonSlO+Or!N1jI2tAKwD1Ro%t)Pq_*?Ud>BqVNQ<944E$g zWGCVd+)8J|nPRsXZiB%FwqlI_%xzHSa;ZxXlvMG~M8TnPVgaUP zZ``2~x|f)_Z_vjx>gTpKJyvNGJ$@91vD(|sLWG-YBPE(OQYA+G)G|@;al#5SO}@*h zoV)2N1C3{X*E^ojhy*kWzLF*wD!hPLnjotP(%`ZsQ=v~6`1+H|a!u%je#rTgymx*D zsbMA;>K}_UMfXSVQmfTGLps3PBiwdJAb3)|O;1d}ssv0`WSNzj$`EZ2O_)P#%FxvJuwkxirEHFSV|Eviv>oMx8yjTKLAtiIq9n1&S+Yy?Fd&gAEPxFpLGVt===FT#2> z3@KD(QXyK!lo=*lfT7#-%ozXR6}sT2;Lgn%lP2vwfn}KT6yyXaJ7ZkO>%zd2apQ-6 zUn|RRFwR71h;0CkyY+k?F9Ofj*gm8xC5>P7R2F;(uZ?*0j;d}-WXjPitgQYsaf?{> z2}&Rk?XQ)6To%@_a1zfK&o+0o{g9SMv$^+VyCSW5Q)(ZSj*`hsox3WCc=*B%-}FU% z0+fESECYX3I|#e#yGnrN??1v6jJNPL{CPXRweUb^f89$#Hovl@UY0}}AQ$1U`c7>c z7hg+BrB%tmv$14r;)&ua6;`ATg+2ud4T$8_N(Uh4o@-y#2%)fXD|SFMIc_Gn3U@0R z?>Nug@?0OX&O|Vsp!f*!-P`1c6p~&kAZJd>8QVn2AsvVhPmb1xQDo&qR!HQmfViE* zM<=&Y>BAYB^j$+h(!;*eaqsk-J5N#GShj^dK+g4cP9 zi}zS0pQ7dloa|kFcvxywiZ%!~Cac69pGef?$u|oN2?i2}&jDR_2b99|40FPS3n5)q zi&Zcoacs5<1}sCvjOdtO;H~{4 zpD#R?*je&kZM2g4q8h5@+wGwcKeFb? z3CK{6l6m`fzx>&~Knl6v+d$r;f@}Y*7B9zg7RxwxG@=I)hP*g?<>a5?WXC3(a}3re zSbNxq`EU{*doF#_R=r%OsRI@WRWA8>QeVft&0h%|hQc~U$^^ug#B{iBi&3**BlQ@;wZwYv!t!8-PUYq+Q?~5b zX(1*#N5=QV%7KRP;pZM6wXO^OEk3oj*Q!B5ERkegB}U6=%NeCZ{f~@GiS0(9jDSVQ zCA#y&6zOi)4v2_mi>Z3JfX~irLHYrIzKxN^M_tp~77+f2#w9$@f z`3&k~d>|f0e`XR@1`CjFf&tk8$FZ!3Y>H%=?}+>%vkHPb)F(59fv*eXHr+0Upt^5a ze;xK!lZH*qb|~*NIG#UFUj(6(%@Irc*K}`MLqg@*^s8)RsjtxU8jsmf3N2r;)7c5` zZ1CJr6@9f_zjXc13kluKFu@%2%Zop8^d(V+kK~tMO#+frOj`EuXO5ye87PyXaOOp> za%@U(O&(0wOuiWH6{;&T+|q?xKluADMaHvN_ujZ&)}62%y8)}$B`JS|x8XPbR?)HB zDE=Z+-Fb#7H4*e5%2Ok{`e|xL>@qPjCb1KP`|x_XD)DX-Ca0nd#|==De{1XHem~8) zL_NPUg)D#Baxy-k^-NC>-L@cqK}Bbj=WVCoDUlu=+Z$B)(AJQR zr@vz+`)&jue{(v1L?pklcnOju>T*-?7C=KKv9aC$nMoZF0cqd?C?Wa2E#{I*m#u#{ zbnX)8a0}6%C|R=!r~4`2d_`lop8EBxkuxvU@C}h-PQ8i^QTv|nT%P`J19K{`;Ljg_ z7fSfW0ku~vqxsc6ImHe#gh4$;?3Zhkur#2msmn`ueEj~|dfLRttfug{Hj7~N7_br8B#bgot zD=&%v%zgcNck8ohPdtl3?d!~9(?-K!dY%XH#o*c~x3{iorqEM~hj47?rF zjox!W^7VX_G#3x)ny7+VuFrOICg|rXU(=R*f}|(b3-4Sv0JURFAc_+Ju3;nL*rxIN z)pm(FO3_c-!&tcPYrXdPXZ0C?;VHg+nZa#P{^U5%R{{bTKsofBKIgr;VAg(?`^A?C zEU_z&C%G8XY-;L7;D|Q(($dVOSK5K*Yqra?>Wt#(GOt9g1EaRw`SUAE(%m6Z7#3$Y z(_}JH}q4f>IHP=t(7QP;u^o^f;)V8+CUe5IMgE`f;SKgEQ7z zcyU7Ie>%Z0;0N3g(Gwqq1^(a4)BjR^{$F&XPqcRG|Iw^otX~z>iJmGbM>9f1Pk%i1 zA7$uAJr(!V7<&|N2fC*VS#>a)&0x{>;c+=>jB?z2F=~>grt|Q!i3zW))AEU3PXe#F z(^}GO*VQ{yi&x_v;L1-7R=Vey*4H*ampiZ8P}h8V!58&-+N-ksy@i@1WPYph;jUdf zXJXav#~kI$?BN*h2Ye#;narf~`5^Jo^xl|(K9QI%$_f=N1&sOMTh&@*iPY%opu|cW z`u(my_51Fv-SWH;w{cm$)#w_|;@B*4!A@^`wy0pr^3I&RwH za*NBlF-~Bx#z1@=zMkc}yX9EtO0M>;OOIcQDqy;(grLgJ@g0$0Uw*qR;4<{=Ua7i4D12b4s&oK#)=|>B@Uk^ z2Y+uE2zU{AL9EM4thT@_5eyJHo__GXby|bUjrWy>FZw5EJQh+6pwmnxcM1T%_SdpR zRdo{39Wk4hvlT8b7KZhM<-!l+PY0RAhxhe~Zs_H8^0~YVMt8>CxD(5x+BGe6Tf=z# zfx!>QCrDFTQ*zU*a8-S8xiB)*1Hvh7C7gh#XVWE!W>67cKg>;U=7pJPvuw}1oriWweh}DQ zxoZ@zROe%`{77C$?AgGW0T!iI_ZQ%l=3o$ zq0I6o%`$!#+xs{C!ZG(A;7ccCboSNJU+>zsbANZGuvyYs^M=*g&iq)Q}{(NrZZcVGY z<)En?^{=0}b;WtRe6#KVGWC1ogpFFY8LEGIaDIY!e>2D-HKOKSa@Y5atjl?lgDZt} zqbw1>Ol*~iUE9d74|wthHc)MxKcQ-D166U-Od2+q)Zc~r7f;_MC+S5D-#__K)@o z2P+@i5iEC7c$^mp;hm}CiVx_|#=UJJsAcqbt%UI55z{dg{<6`X3yL8T7Uz^XtPskU4*YOpQ5EjMoQ=9gR5rZ;-5EYd15Bn;SX56SYz#r=RL6Lij5#;?Ft6c>6V1zxSt z3tq^=iQKikD)_fEy%hOW^um+cX8wnWu~v=3DJjm(!-Az6)nB3BqP5i5#>J*ubx^B;s+)(e#Po005>s}^bJ=e?5^ML+gz|0n; zH&)_ZUPs1#CRJUiR*cm5%)gy4e}~_y*ZX}dtsI8r^z`!`1A~P1`zs%i#+ZSP%qw2R z(ii@YQA4{&rbx>p|D#0odRt0K`TVyVboX4ZdJ)5Ri(=nY{cK;bL;}P8!5K03#nseC zyDp=#=qvq7{~HsWMxLZ^Z_J^e0|I@mhjF;HC&WD^f=hJui5*c5GxZf05?1FS+P&-7 z?8~RBmEXX2y7EwhX2D>{)r6i_Cax>G&F|>vI2Y$Bm+xq`xpFt@L(flA*sql@xIoak z-u$%5sP^%m2p69c?SHx{dW51sj>CWbzUwEF+~DSvxC1-Q8#OF~8TUvUj~6z{IKOI$ zCLC}-+D5i6CcH6eB4aJS+WfqK2PQ!`JK^~|>%Pb<7msOC>^U0JLfOsnLG&WoD#tnCr!}f(h_}O#8<)%q&$a!+a zz~>Hi?5^V%$%D*3-R+nbi+J~A&p$e3zR$ZXDxy<@Zv z?bf&*N1b7j);+(vg*#FvYk4jWxidFgpNt*#p^c&Mi&b)CJ`(McsAeT&EOcyd(pk~3 zN|_Zt;`6EaT<5>lrAC)gIolD@uBsAzJ^ODI_Kljy@V6sP{C$E7wus$m)9*_<(V^b( z&J)3zCbGA8jOX_E?`oWxGqpBuPb>RkF2&k-$#%xd@O-;L zWK7N*P@DYgbJzL3iQu*Hqb^O;-u$ys4@5PTGnPwJE-Kz-8k&v#fVrp~LiA3R(M6RH z1%I1Jb>Hq($@}st>|H+YY%kX9#k7M_+v%bQ*UrX;+GuqW{Y&E<;K9kNQp<~sbquQ$ zWUIL!_nBrR*KcaEi@hUIN%`W;=&lbJwksE1-1g3CIG^08pl;g0`x!kOoAE7%Q*v#U z1(mB!${H+sbiW3i%uMDRYNNmN1w|4=0T-a%bzqOm3f1}2HQOjVJp=No9JjSe=oAkZ z{p&i1k};{}^dIv_%cxZbg|fHBo&sk&vyULs1{H!3&zuIIho0{mHus$iw58(hq+Ct& zj$Fnp)6y|LQn^N5 zVSlWJr6}>u_>SnDI+V<-EVQUtH? z^)h|Zc9ylecl4Q8 z5JR{j2n{7w|DAaJYda4ww4SEOpw**fwbxLj?nFX z7En)#)r0tsoIV4ZHf{;q4nG{)UdG-9I$EI8*0mT4qpc+V}tn`OD5wmn=1bYFyHhu>L|RC^~hWW=Z#65%-SJyvC)gI_ zW}^U_S$%Jk5+Zz)`s^^xy1i0L0i|_}jQv6X>%o55v)Wu7*IZ z*0U2hL3u-Nz1$U}H14+awqdfk!3cd+1#MP6!hdC!aMw8o9^wLdk%fUS9mhu=1f?u) z|C^h6KH~00o@pwg4l1wrgtW4))pv6?{ACRfTy|2eF7)Bll8+;ciKHQXaLcp}gaCB>ov+^!jdv62O<10BsE7Yvu%CFKMX%G^h7B9d%o7 zFl+D2URlLnJbs}+Kpx9C_;Lyql)8^RH|F8>q3Rg9Osefy=dKz)`VRV)Q~>dYCYjW} zZYf6CjhA1ZUmmY`Mn%hb)yz;)@uO;r*uV=jDxYPLXhw+$=ihhS{rY%#+c#QZH)ol_ z#d4zk@w#2Mz!qUu-Pr1_ulAnwt;Ba)k|s0rw;5(fr@YtIw(FxLEZ+?CL>e)kPlT4! z&{#I7>xfxQJoPO0x_m!LQ2V6AUncX7#pl#^7T}J?k2I3En!A~w>3-uA5*l76)fvVY zk!*fNAM1)J99A()Ke7!gLo`tiIVmAtEnz>B@C+Pvq^bqo@3x$ z8kKm1z}v@0(o~~UFos=Un%z6rrStD-#IYyxX9On7`r>Zx!gpE4U(W}h&O0A5(dN=g zEP0?*+THtZHtx^soW=+cb#n@q$P{r(=5DtJ`MyZr9Gh|OI-bssXOM{ z_=m@Mi|%0Qa?%22-6b!T^1O>N&^dVvxs7vI5*`HlHnbNoT}V6%*WB)U40&m!fm5VtJO!B&Z|efz#<^~0y;=JcHNAH|kRsHnB`&pv!4AIrZ?wg;f1 z`+)4d?^My;yrPTB7WvC{T%ih(zX$}IxZoLrOTz~#mv_@cS)Bw`TB&49-bcWNBnD<(~1@}_sr+s>5=As&WX_8~8n znJxlmQzvdr=f+i*8u!aIdPOo1{=cF1!$hYuy{7!QE#IleFe$CW=1?P^tzS0YQwH%| zh^pf2nI(Xyiah`j;g7h{>~JnIO423b1@|Rx%6CJ_a%O;XX{D=@P2qN52~0i`HvcnK zt)u!hXRaf#XdbbWUcOu2r8l*hfFH?twY^H;RUE*>p;ma6^W~0hNH8aCedtEADs&E& zTA1uP!!vo)Mk_A@1*0c@v6u51TEddf^mYxpWB2OsgH?Rfclq9Cgj?rl9@_J^1D9Cx z{{W6vtMEm^J@kU>oO%s}MMLwret-IO>SyhR&b=1{S=-H(umHMm47bjN(c8)L{t`*< z>>cWs3V~M4{ezcp+)F`{zI47U(vi}8kFq6AWenf9m7{KT*{MN z&#chP1@I*gTW(l~uN>w}M4fK9GP=Gf4bG;Z=7Hq!#Xai{N}|v| zhUO+Rb4OL{4$v~jmh$=O?O@A!`5c!k!a1|gcv3VP~ zQo0Hx?}Ee-=6l07pl46lp~ey9b@Ps(05<7&{!)7JcLw^N-i-}p`rQ2WnDU&@oOBv& z$^Sc=lU7(AZ50pGb6qJU6BC2O2M=|m{H*Vor!YTn@9K+ZQY&ER4GtD1?K572x8kUc= zv=cT z%{{*gSee`0`BPPJS*Q}eLF^NiaggfNy(wbc!xM_RlzQb>2YlFv-c&q`SDYH(aJ^9R ze_fdCDCN_e!o*B-n-G3~VtJ<`G1&r?9-!U@aTh2kei|K_w0-9S?YFl;ZHpc>yAt{{ z-wD}`X-c>)-2;-Y_)}*tD1ziS*N?pSoHd{F#b(}AGz$SIb9CTR=eu7`eJJcWecXKX ze5h@A#{Vex^6jUJk+&3XNkJ!TowUj=lqy1H+<}y!$CnJMs1Lw!LL;dGtv`P`-^|Ne z0mYMB7F+il5s+S!fzmQF;B2;5m!Hp1(a?xO%41`9@wHyGUZ0H``M#T15>sS)pO+aY zw$AkRajz;J#|Oa^M{EZbWPb%~SR>=`$8N^${)yfNYopT1kM(@QU%swxTHa4y^?R+U z!xQzx!qztK*D~^`3J%IfP=6T7+=|mt><%dKgg?y7fpRQfk63ijukB%3-e*{iE;vTHeBXrI22X`M>_J<+s}%kF-+Xl>Ql9szBE)@$?T zskXgctM;YMZn~!QcFX*kuNz%lo_Na3&n9jaHm=!XS@kk=%bFI)AK!M?R;gG=J2poz zyv#$O4qcaZq)ZQcZ#`gyPH^yqL$<9_c#t`7XPB7 zS2jzUo%?F7E3GbMPH(dNRIK8vQ)@M|M$MPqUoUZ#wauzvZD`t9baw08`*b`SylePb zt6%!m&+7_+kCO^=XfhVG5&D`sT~TuPlw@GyBt7i~;a1luD;dkhx3p^;e8)4B1uxKd z7MUikPraXD@;gH5iCUM{-u1?B6bj4o5Q!A~e`J(r>S*w{+2%+MwD@M;iaa(-KNVs6 z*@LGjdQpDyrd7d0J;B4Km6lt|ZL0cS?8w#Py!e_$yq(8WE3w`qb@FnC)5*H{c3IEB z9IZcT(K~w& z)fPee+cwpo+o4AE$+*&;nd8(^_Oi%!v%=NFZGBY{4>A_5re|+IcFQbJpjqG>e|i{u zwBC&QhEijC*Mmp0+Rff6XDv7XzOsK+pp=p~lyHp-bpcIVu1g39@fwu1n%}%5op(4f zl;>8F$i`<{yB}PADr{gb!F)Jswk6AL>SjN7fZqSWJ=G=er6cP<7>^zW8x>KT(75(- zt?LsL_HsZyr@3pZ|A~=%c7t_LGxnc1doLgEy13UwbZP3Q1i^U)-=veT1I*L9{dUa| zjuKrK0_VfsmY4su>9aAbJR3f6YkXB}L~W@`X(@=G@*7os`HxzAgDnreV?M2PVy3pr zPFcQHNISa&Sdeo41;$MCGv_1v6DcwI>Wrnxp>S7CUcJnesjMsA#|@;G(ss!6%xGpXDY&*Mx91@hSM0MI? z`utySwGRl>My>XfRVfWJYwh(DZcWp3v}@tbxG^2)RG*wXRe9uAOqlKKORpp5Qyeb% zyn39Qd!^Cup0XQv`<=h~v}toy*$bj(`d*pH1fRquRF>UtVpem1QOS--l{T4H%h^u`G3^!0O< zf^knzE7dRFyle5yi`d5ls6DjKIB6((z2b`>Slz#BbH?ICb%7jp?ry{9+Mc0#=8Hoa zk9AtJaz2dHzV_rt#==oB6i!QzLSRdjLBxlAU4tQjRq2%L)svXEj5NDGpe%1Q^Fw0l z%9w1ipQQiFU#jIje-{4K`}SgYXpbJG4e()0JPp^r_9N!8{BD4=h;1uIg zY=}Cs1>K-2X~7F+6mpCZxBTxP?uKwav){`hn#Za;qHj>%JmKM0Ht2Sx_n#&7zPw~7a+{HYy|DOM76lrM2j zTjE|>sjQGg!4+u~f9QEp|C38!zkFv+;tIHFa6^|CfQ+xSzd7xRAVAL@Xt4fmQce8~ zqjC$ke7MRe|Y zrm~&7iRax}g!FZJlmpLZQnfQQig#Gun_AQ1Dnm<0xhmI=%{$+yMcm)K?^=z{iRF|F z;1pR#zkjlANn^sfTV-hQ)j2`EJI=(1C!=BEVR1i5X80pBlvB*bu;Q7V-s`1a7pqer zqCyer(D+EiFD2X4s$fS=BK`bC-h6RGqTvfYS$_U9o&WsOKKxRaUGL$o6s($tGh;cu z%XGh#)xLGqC-G6c?5`u-k3;|Qgqz6ZnCNS)j_oA4BD$6R>DTcp0=VK<2^OdODoMSG zjL|Af$m|VEp$XTxG@6AzduFb#Sqk6qUJR_0YV#eyo!2y zUvwVE7UEiW@e+MttyN6Ybw)OE;e{PZtj)4(Pg5_oaajBC_qO-9BJlCYZ{2j7%|NYf4ndsTBs^{ z;J)NjW6IqP!5*H878f86wN!I;0{0j8Rw@YP&5S%~^g_dlH)(dE>9fk4UEgj<7#{Y$ z_Ttl0SVTN>(|C)=92^|@)8NBMepd6w8?$JO|5F=%a{CpfIGz(v`C+vpx@Zr(7cq!S zLT57ZpzAT;haQf!`c#|FV?DJo0fiU8D62R#;%%AT^jk{L;tf*-B?NgRTrx4YX%v&C zye{wGjuJ1{Y(t17lJaAknt{r};zu6~>Y2}(OB$m@F@iYGBsj~aqi8G9cEkBb`>oYC z!JO+8p2H$onf^o%%lZAEwcClXmPa_$RxjO5xrgE-H;q}$?Srcv)|taQeH-uawf^|3>b= zSH3vH%&+QzFWuN2ouy^Bzgj_9F zTf&RuI$UW^g&rtu#i|L~r}OzF=mElp0VCPvCHp7(rQ+J@@=MKcW6k1s08KW$pGkRn zG(Ig$+|jNTK=5O-cMh&t27^7-!yX&Y<$cSvvj`G5^P#1sJ(wk1zHz26xx(o8&q^DZ zu_ZQIM*gtEp|`-q!T!)!&!MM^n^~sjZ0r2o#QtJvGi7FfSC2q=8su@{gjs4E7S*-i zfjHqBV(9lCua-P)5BH8yDJcGZR8!b94c^#)N@Zm07~`s@>bviG<)u9}#nVK;()w5E z$#QN&7DM!$Je;)|S1z2|O^|U>0J*0164)Ts5$b4sj-7mR_3NWU57q~@)epB9sw8@n zLhtk9 zQl9YJZWHA>c`Gk*-)r9|{Ao{0Qv4yYnG~iG#9HOcmp|NLmf}N)U*Rk*g({*YXnXPs?keQEaA_NXi|t(6icazK6xPWM3^9q0=*n`NCUD#zdm~-TIM=%npU9;o0kcVj^YqqcVh3?m~Si~}cHd(JYS*yElG_@wBP$SiB16FRz&O0QGXK^^F@yfIj$=co3{d?GT3#n9ymFn zf+yH-$R?dcA8k6(QS|yuJjb(!`&F0fkk8~y<>BNehvl>=FfVb#!{%MjWy977R}Qa0 zlL}4TTVhv1mo!U#u3Za8Sc(J}zyZnQYlUcHB9l+L1p_KM8=yM}g2?ORmx|+vfVVh* z9$}-F033S>O}5GWM;GKX`Xyf~BXq5yLhk(Bbdqh4ny+5;QvDQqI~+MgPwWFpL#E^Kl`+a$x^!vr>isiklX^B6{>-xJyuV9^>4~eWt7h7PC1>Hr z#B2})X?fX`z4iT_sn={QyADMW2CZ-xRdzqpo+|gu{^}D{4V(Aa9Q+)%->RVbfo_Cm zVR?wOo%!v!l+PVV3y&3qEGzY7--~jqHN@Y(I~D1*_bzH;HYuuVT6rup?sboOfYDWY z57UIoNA_BE)??zQhS0_CPdXr3R-T0O;Rx1wu*h^tfRL3a8#N)Aj7C`d1M5ISotw1e zT?liod{) z=WO^8@JIqRlI#LT1)csBN(_gk57*kAWk~d2MfYi@b0vYgDgN>40XNo+{zbB~Twi`q z1wb|X{*-g;O2`b3i}>CS|D+^4)xm65_3|F*cHLifv764#VV4=bb6v#aJqb_I={HZv zmxD}cwNrb`p2aC7#Wy%^*k!qg_eH?K;{)AsimrKKEagE*p$+3k&G;~(1wyZmUdLu5 z@K%t12^2pSc_=REd*-Q?u7>FH+4_*a!Tv(yhQ4x}fnO?)(W>@Dg4&=k zTy<*IC0FXuCeZAxc37}sV0;96-AB2G8j4-EuR3cVuj_Q@G3Evz=GfI8A?ngBGHp|D zg%_9`EX443OTdvB7N3bG^+$iWM~fQ#r(aG_FhB%t@|X~)S_T>3Z$`id*?<~#G@J=u zQIT=JVprqkq0rwJ`dc~DHq-dTaXmV@apOkQ*BoAOH^O`4CN{JvcAC1oO9Lv5n9McK zmH6#)Zqgu^dGCWj9t~ZJ`&1@talyRCfWHk$D6bRs8}{$tFBW?sHD9g!!!A*~4_nQ& z1wSh5q_p1-&9>FM(Wky&A@W>7%Bv1AfW{NAl+>iH)@x-AhYPwFK(j(v7 zdwR=ma%nzM|G(wg<(HKy5_uCeVm3X0 zE;6^7PpEEn%(Z5v8=8-Q5n%Gk`TrDWS4T)X2cI7AjI@&B$`kyyefekINALU=sbx6` zIs&XH_TN*~Zg=SI_b+>VXVd;)MD$_CfmhFG3!AoNxg0zb(3@l58^^NZ^V!u*ysZXi zry_)#&l(Jj>k$@Jd@aY8(C>537FvueOcbFBmu>wf8B!FRHK81?y2SVU_wOS{PnZOo zX=#Sk!i728@|bg?ejjuh{F~U*!C1iT&Wz^Hs7Us2_R$JDx zdhu~B7sZ=uQGQXs{QXj1ohpKKt|P{=S$U}MVV!@uC}_>%Q~UWJXHDdH6gPb;Prr5{ zu0_~zk$HFp{lNc);}(@q+>xutS-m_(_`TTG`p&Wzfg_z=Pp*#~EI;qFYiBC!UDf({ zuPu$Ua}GmPS+=<0idEO2N9s)HYJcA>YSk>leA`k}I-c%#&$ZNP>xsuV$=f^8B{_pP5Oy1O-zmlD?OS z_Ekv3#AeYn)b)7lfjgSM^;K`lv{U%H z>Jl%*t^3E8Z0K!5cY?C!Rk=G?UvbCsZAdv0ug`V&_l$KSUAk8F5!+bfih@&Yx2rkZ zx5P{Y4Ueitt?*uQA-?F|gZhr%0Xs++r}|5CYf?gI=Y6GJ<|KP26qouwG_AapMdiAK z{#)Z6Ow;oJB&gX3-#Ql=om%xYv$Dl+JrC=slVP&#gY~;vs}h5qq_6nOjE}jvHN9QE z&c}*;hh#ZHK|f;Ep*yG;`4xw z-jy^C|KD!AA~c3|d15LudnO&_v2-(+%s&hn*yvErub_8)iJsKp?PAc@{jHa4OZG*n z91auHZrlFPI+jFy%?)W90pqjKGe&@8<(?Y@3Db~NB2GR&A zVY)WzxpHHcnz@UxR;E+dJ6hF6H22PW=Z#ZqY<5vFaR6iCrNls+LCj zL5UP}>1(i^Y30fzIa=a6!^3Q!ls6bNGCj?8I*@xkGne($aAR0N(TtR~5`E_OC9YOW z+#xsfM}mKtj!C-8@gbXpk9B*2Bg@Vj`WnT-$?&A z6Oa7d@)9M*I@`#Gg3rtwR1(*+KQA6J2`PyzD$akhLs{Jb#*GxU`)M0vdeMqk+a+fI z%H1EWUuJz(4;B)4mAnQwlyq*o`ts_BDz)85mL2uwbq~$4IiNp(<)PzT!xsgi(uq(} zz5DDv8~^cVFbQy36_$3Q`ngbJ)zYS0x7V}R!)3|}??EB_OJ!gAkq(IbM?O@KmA@@( zy{3G_`Ry~UGnJcJJL6MfVN}V$bD5rI=-fhHrv?-3jR8teq*HUwCuXJWwa&2V?2}hG zL(j646Qar?(^j%>eBw4w#07cew_UI2KBM_@AYO#7__4mm;QsCY475jn#%&6+m#<5l z0*z607~1_m#n`VI=;LGx};e5X!#i9dmcn4Voe zY7`)-=L6n_W%2c(1z9c@lQD)v$%0U70B47H{99 zpC`~*6?HE$hsImi5!fVJ#{*8tP8|#uH`|6VEM-V9tqp*9AdgSugx)&q%&AjL;eI=JbcQ$9P+q_33D!uq)$lGDn+wY!f zXU)HH+HHJGMoso0VN$j1tED(3-Q;7<-=zGxoOh(8G@((aqvKO=Rl2hN0(Z+iPvLHP zbJ+ZBEc8CJlsu|561X94=!@NO3eLm3M$_0Ih-y6J^_{r!?$S^VBl5CK$peqqG3?fh zx33&fO-*>k)Y_lrK61Hn4I$F#9)Hc;#8V;3d(q&nMgMtG$60yD@qmcP*NL9{)92du zFzV$Q)}2r=zNBi6cG;vmt=>s#6^ao$m8&0!ryEKz3s+l@9WHA; zfAjSJuFQ{gEhJ((p{`33@&}$6ywE(}Gxg#8bmp;+&-`HReLn3y5y+_*e0q^dQ$^0E zz}FuhU)ba?@;>j|su;J%7S*q)(Ac>W#Jz-(tBhUK6+Q5QF&+(Oe>fAeHzDf zOW7Xntn?H%$-VhWs{=|eEOtyz+x)0!ezT<8i8F2rW9RO^j$~x6-{SrK%D@?+y2K^> zU9wGX3pTnZUGRG~yG3OBSba>RJ6qfJ%o|6=3SNkXeV#n>>v7S=*BnP2K98$+#?o0B zq4Nw~7&))P-yprc{JCQN9)o?0%<8{Etx*(x@`ZB5`6E*!onk?^gVh5?jgNWN#VkE0 zt{J%F1jiH@s!lSeqW4R9+t11uDpyTr=ONEcvTi>t_H-+geoxheP!dG1J-w0SNnh*T zyEi_Q;ad!)-F!D`J^vBvwmj&}5_eBZH8SvS>(;pHZ>`VX7s5FUpm6$2;C~*60wHQ2 zo|p(g1sMGMh92gYJiQksAg8<_hfntsnz^^Z;LjsX>#vHTyDzlKccq@^d1SWi9Pt7Y zh?7?s!zmmXR}O*!Na916J9qAsfDMYkY?jDg5qSl&4v0p`kVU!)LTb`H4b1dx(r4|o zOBW+8R8LqDo~rg5sXq&NYdD|9=_E5G8<+AdTI3;5E%>>DJ@Nrz90QjY0l;OX{`bT5kb^E1iC+iHFlJOWs96qZ`b>kLnTqbfZuDTp?3$c+n(n545J z6u#li~e#lrKP<_pYn&C zdxo{chhUGYhSvR!0a;M$LBaFkgdWC>qL~{8B6%)d22sVI+m9ObUAZGN5dV1}c z{*5VP;+Re+4Q}Mj%qTxI)F&v41e=;Hpdo)#tQ2P~tN z*js6R>UVB(cTLZutbN&mg%#+*r-|Ba#dSPX zY>0Ez{P45wj14e#DlE;!yo`NOZt|2zM{^GdJZ$iN<)IVQprfZxI+17F<3mQPL^fFi z7RT`q-?uyipg5cuG9vX!QM`8uu8KqdqN867 zf%S|0k@y1KQDEXy;+0m6X&|pOWURi*1ns>VS1fCL2Fs*XTM*);$lfbh@-MLfy7o{v zpo55NwBcHl62Iab23c(Jx*2L1#js)P*)zFLj=p+7@4=oUIPVePmc2Hi69DW=U<+ti zaM-ES|IGP}L)5FT`koqB2z9<6DW3-CM=SR8IA~%z!iv&Qk=GLAV8Na1v+WQ)ybvA~ zpKig}r;PH@J*P75`^~#@;Gx2^dy4qRz@cGRDCAN%9U5gRA50>WheH3rUW+>ZAjylt z;^s9)EK-%>XK@}uwP&~L6=eK)S zz0tcpJ3DW2(~~_A?rP}f2|CgXO<0ShXj0`b;m|$0O?46sQBl4-!$OhW8Xz%g zyp+ak%YH$!)`RYYBOSr`nZ_(Wh(XSPNBUX&RzKU}UFXi7<2g&5c%~;k>Neij zZX;sKOUtMA9lkF`H_v?;I0KH1)f-rZPC#2HmCHr=n~&+sB6^eu0?KQePSWaRpl^jh_qf(>Yl_zM-YfO>7l zeyQUqe7H~1bm@%vCbcNDgfFdf=TNkjmH*h?wVm>P-ai++pNZ2I@xN#1ln)ukQ80t7 zQo{Hro31j}>B*ZKoE4`K%OthjA+Qi{pEp~h+VdyX`xudY@`nL-XAT{G3raa84}AAO zqVeFOm;jr;Jy3X}W}dQ%UAflnocUdS!Wc&fPwyZ?gg1;ikIxZJ1B50js+Re;=|&1R zSt;UV4>h5^#w1*$b3WCsS7S5{hwjNPv!N7>UW)u#g6x4PROp)4_Yce;#dn2`K=bIY?iq}NBj!0?l%d|0Tvz{mqz>vxg?r8HO2VUJMLGk ze-h`G4>2r{L-=KwwuqX3313mIprBy%va~g~U)q?L7&_pSvG_#X{<@ z24%tx7Fukn*El)(&G_Ci) z+J_2k>ChpRfCTFZW+d=}(zMRn1a>jv$?^J2ZTI$MQI#FCD_G3y!rKF{BWV$Kv2#+0 zQAjND;eNnsC@j72+HYajd&IlYO`-ROvhC)BdV+#(3XI3iKN+1cv;1G?w0!3>QVc+h!UGHI+E%VSVcQC;x9(NyXJ^ z5lT}D^?6!{tMYNoW87AxQtYz#+NtK;kQF3lm<6-|z$(IjZzF&gCQ5B^!Y;0%-OfPD_TFSp5^YNhI2z#A@cCtat5 zxznF%`$^88`OSPSbA-SB*f_>U1dFU}jgU{lZT^!t??UI(Ug#y|?=7JTH!*c|vn*AB zzfy0t+%xlUxj8*IWMG-5m1Q4`fKbRHNqU}a2fwzqkHJe^up|YBD^9w=gHgjK1QVPkx1cmfK5laDLo64+Wu$M2Ee&w(N@ijUj&-GDPxJs6xZQB9o=TLhjG2Oo=K}ZR1www6G+Fp=DhaLnP##} zA#KUG*>OD7I}~FOZ5Svw>H4x1(m7PqfpK}#>-Ciz-H>jKnYK#DJ@u52mOossxfwa5 z<5S#OQ$VHlB$1F5ET)7Hnzk2m&#L(I0z;+kjzQYiUU|(fJ7>|7WksmVjEOjq)R)M= z%6I?sn2wnzu%rmb(S7x1>N)YU%;J+WthdYycxo2&fY-LoR7NuX>La`X&AjU8VWAzu zn=d{7EcwkRppe0w=P~8Xu$F3L#<^=$`|vrOF{I^{94G0zU2qBW1C*c$FEYci&2Xo) zZ%oyC@Dj32S%t@2DR)dGbb%#zq=ZitBY38ejTj@OBazu8HVYZU2oz{VFnKjb%dHxA zC`Q$#{kv{cV84oSeeP8+j;to>EOP0u$_BuNGF}l)B%U@m^4kBBar)acAi;WT*_x@o zo?nxbL^S(qsC4e$gW1Ov@rms*wiHOOT(!+(&jCZRGsvS~Nn|;mB=vmbleeMI=;H;2R z{MTbgpyEX}?u%z%0!^$@S6R@>$HhWHohKU~Wp7MVlJ7FjiP^_%k!H#axI06wT`H)Cws3%e~8Il`898)=^Bg4EaX?ueU$ z01pzFb!eym^JiC+z!av?(N*1nDohEFoY`mr04DranT5I+tSZ|zXjGTn$LI#n`tCaO zLY%7pP#gumIZU|-8rhPaZ&aV%v}{QiX%ft5<7&xvOeVRcNSqKS=erxHh*|t?Gf%1V zZTzPqJwo?g*TD3iVTV>h%HMPD8o~wvy-8jQk182C1?7^??I|3KlJ3U`zUJ^%$c!p` z^g=`tHaRsFH1EZ}mxtt#NyiHZ%CvVzU|zroc1U6Mdg>ZIN;Dp9oZ$4?xN3Mm5m89> z84N*1fBqvhLKh<^i9qUAg?GKNzFX6ecmt9~Bcxl$zg(o=mJfNCwTQGYapaC*lb7CH z%21XvJ3SGigCe67cFjIy7FYY&uZCNwK1Qj+h`kQdT&R{#u6;<4#W)` z__PPSFTp#O(hs8wSscDN3aC0jbmqG!a1B_u?Wh|>^+Np1O|E3{|8_PrVelR*CDj~hFRqnWuQgL2QVFN+Xm1N5&IpBC502+0~G3i|c4P@h16 z2xx&|p2L}!sPAphkaoE0ty@j|((y7DoEEvbxDKMF@`+&lXp0@j*(K}4estkS4#>#* zovQCVR!6rlzk%UpU0wGDqYT+HAJ9Oc|3$vM?=P_Da8-WO6CaPkZKLGo`y zau1rMsINdS>A-(NR~0>rpk{&`W*GCqb$MaRarKG)7sI4n=A3$9^oYZ!B#N5Q()g%{ zt3J2~L*$d$5v1#nOd-Z=<`x$}P1d_)NV)8O6gwlt$D)BpNQt?y(60^%0q01OnvrBW`dk^95ORQTcsn;JRqiJyz z(8}x^1FKfFe2Wt&*;><_I81mr5irkyyVObXBH)p2U&G0p03RW`ReNix&yD>A}Pv_-HoS zR*sL3KA5;jIfvv#8CYZyuShF`Y;L&M}$Q#+5X2PdUItoPD5M`}yradx{ zV((2vd7A`Cb$6=#L~89Pk_cDoe9MS{ed~tTo?)H6JT%AuqTr9uvbwWnS98`P15(~# zgof0D7Oa2v!HS6XJ~NG0lGTfjggRpDW6AMVK|4Ck&3@$Mp59e3&t;%nxzZ2ydXAzqCzb{F6y$2-tSly^FCwx*&d`k*f+g3vJOMX(H8{tCu@R%6_A_ zp90S4J02?V+G0g-{d4kKaBLJYgEep(b}z1}R^Vl$?M0e2Sp^AZxxqyxBkg|F3>Og3yjAPYI7&6jgg5jN<^O5UF@U%@u1l=A0QEXE*^18eb4qlShV~nzixWMk-saQ zX*G*B5shKSqW|XImpSu)qeMLhd1NQfH?QhHsd9I}G981lvN42@9Q!a|O+=sJwE5bN zv8LIOosWhNoDQPA0vbf(n!a4)+^eq4Vi;+=z1uy;X4RtA*S9!^99Yh$wHy72+->}|BLD;cD43*HJs-#y?}(v z5%hB?;kQEtH^6p$vHKt0&js=xO4N5Xbe7Np*i#a2NkDmzLD~Mi%nF4xLT@826MSyi zFNP=JIO1tn&7#@Br%`s}o9{qI?(^2?=*+`GxzjB_>*`<$l%UE(r_xH9$ri1Uoi6hj zijXU zshsIU1Z<3v%NuX9=$c1Kb>p0X9MlnSfWW5$wi?}KLEFi>62qy42_Y&P z-vM`=PW*15B!Vn$zNYv}NE2Jz8IhLsHqh57jZLyS(^#4YKT2@G7=1l#Cvp z^HK65<=LYX6B`Wv8w7y9&B0xJOl!h73wv2s zWl!y!Ylo-Al|HkUH&`^KyrNSx=UhqWwP3_I@rc#W;Su}okN4pO{&CNe)uQncbxUb! zi+}-kqBst~iVVJ#99$VG6~hqwc~o368jV5b9D}@y(Y8-zlq*os(Ea}O*z;x?2%G}`)=Hd@JWg&5I|kD) zq$`im`BI7u{K6$qOUV#WaSQW;=z}S5fkyErtNUvRk$A2TVOG39G4sY*Tdpz>NpZI^ zGFxJEW*|kyO%?roVD@$B)=j{lBX(3Y*LC@EpW>a=Bz6c!a1(>y(7rAM_T+XCKwEz- zEu~D`9-@6GG0^INniI1)Rzq4%I)3)pbo}Go1n8SE&S*>JMcs`j#UbX|hDi7lw=Gz& z9<91Hc?QrJ=@012&H8Ok=D?1QQ*Y(yJ(N#G>Ok7>zGgf2S0l^3c=I+&p8$X~nkZ7Y z13C#HtUXRLy|_|(NzgP>`Oef2xF@6P0LcFpV5i$=1a0I5^*re?H#0?Rv6uSiHK@BL z!QdXl9CCt+negYMm?}2(+Q&mC;|e9CyZqFnbf6}caeQd5q3lUebm4SCxF>1ljduCQ(~hR>-r`Uxwa8WNc6c3$~-&@4p7>uiG!coZt0Ss1(zV z&#VsK&<+X9q7+ua5xrQ7<$<=pDig&o>fBB*+w*J|ioj;u> zpq-8Nz7S_rM`&eju}Jvx>kfqeuOktu9l zxpP3EJ~>zkQGVN7*CF=TF#Js-ut!PiP1>GW_%**M54L0^qs~xQdzro^8GsZ_DCz{7 z?999?MZO?U-co`$#pLi`z#v4qfpG<-bMcL4q5}4vVWih4spH5g4C(bvIju2ALG=8` ze@yGqOeehuSyq5HTt~-BKiN9s!(8+LNu#u?u`&UfV0?dwpXl?D| zhp%)J)X1|wkRgUrYo zy6({d1%>mv$Z)1e8>{HNMYw8xRL~vDbD&|e4j&@=nx!fb+`uT#toF4(;U`gH8zn6n z?I_-kMx#drwM4rP1Qs~#xX|`NS7%T80M1t+bceuai0gTQud-O|jy^g9UOM(A78(Kf zTAR6!R7D)IS+K@gJJ35y%3sjL7YBb(7tlKimth~Q>QX|A;Nhg6us}dB77hjU?Fqy4=$5s4il9HxYVn%)$Oa4=nMW~qDV_>8 zC<-$1t4@;F>1u%VbX&`b71Dy=k=~ux9Y7T3F{J{e@08r~SIbUvHcbz|f^KmANsrJtrbLRT z0ugwTC}m%wZd`8`2MJXN)V^XG-@&V$pSud-iF8k6a@0yT@uGHX+9it@8<9yBXW!9G z;d48ILWx&PYJOpvIaJfaJ^Vme? zlWsTxQVCr|+Q^V!{5G8hSULg(Bsg&mxHR5>0p8-KU)~uBP}jo{7(p?*R6e2_GA0xV zJc?F~0(sd^C?`6WUrE8U8#aM`wXj#wYVQ0F4#LCawfa1>D$B6F&n$0Z8Aep;YjI;2Dx9$VHYQHCrxU`N3bs}r;NM3CqwBW5Hh-2`=A zF$#!J047X+SU|xGW!o@!zjiB9B<8l7l_f~pi&2>pw;-N$GCVJ>?g~LVC0>cQMZeF- zgu&|0YmkMBq6>QT%o2E!NDV?uawurPY%7(olbM{c)K^Zd_Q=DF3C?5NS&D|zoL|GF zXY>(s`R(dog-@e3VuSn8jnE=K*@-mZf->jHtgZ1nX~&%oWvN>fbRgYOd6$5@Hu*ym zDjZZq(4mD6&{u2vj}!0{8~rVv=Q3K_z#cZ&nw!E~oPR$8)2@#vZ*=pKCtMkp_0z|# zd(M0em`wRM6eK(ige>4%hr(he@&8g-FumzbTo$|So9#;Ze(g7l`1{%31X*ZEea8`3 zg!G~lW+MJ9yJVDXNiFy)vJdHZ%HE4eBg>L`O4-Al1b z6V-plddoB4a6TJ>wDJ9|U=6i>rqRvOhiP9x=JDn|RaybLNsX z(@F&(y2eOJY;X#Xx>)wSHK)-?<|ipxlq4m2<0Kv3&+M(n)|ZD?Co&4@rmuPSAa?tc zsM4QFxhek=Ol=E|29@3Q_ODTyNb3~@$F)lqd;+$la>$7B z&mKO_=TPZxNB^%6=5H6XsP;4@z92R!wil=%(#Q|U#^`$7H|To<;Vee@BLdUMZEbBU z+|^J!327%2F_a1V^5BLtJCC$OTJLbodOmg?VZlWNo(Z0x9_!r!nDxH!e$3?=0U{A{ zQV@T3vQQQC zY=P$j;H3*;TYx|C3RrwmW^n#^=)XW2;(IWB2X@9-RS!UQeUphYS$R0p?SKG(do*z- zojK^-_itZPeKTr};UsjFmfn9E4Vs0|USivsLhsN4IT(w^J)yg+ari69AO&R{m4dgz z+Q|PCripAr4|AEO2ixSRZT5T6Wu>(Am|=`wP4P{YzRCwEZ;ea#D39t-#U;0fSvT9h z)Uz>ET5?NU{9xfO8S5XnL2U7sh)U~aH22q65>&8YQ_aPBv5S*2wycGM#ks1;dq%G!@qs;C&h;}yz==?<_C{00s}9(&>J(b! ziKh*Z;>KpV9$+-vJp3sMIFLNt{1Nok7p%(0Z1GZKV&CwX93>wEh{NIB@nCUCE&4^@ zG=EuW(zXpSSqm{|FL4T}HrC8yFRVjQFaHLB;?p%M|L!ovbx-b@#@@Q&p_H5Pd`;zN zKtNABy#h`W$uNRI3xsypcu#Q8BfF1*kqG!MQYp!$Ft9LParmc3r zf1V^AwmR*D0+`t3v{4&nzG%#)z*(0;h=et-Rra^GuvxR^R&%U><_|l&CLy%dYIkeGZWO znXJqW%v^4v(G1JOj`7S7`S~r{pDQX(AafGPt%Fcd3z*zWR?&k{sqlfh6=C&)=>RFI zvvN3ML*IC*vz@IC5rdJ&KWla(B&X3XUmlXrkCRMzT5m87IrC|iy0{9;*DC7IpaL)7 zbmC>Bg*ogXZ`o^K_QEdBy!IFp5kt|ndY|@@xy;GRcnkZqsrZZc zW{22bYX$q&bw9Qcm;f`v6o?s$BaS|@(@nl&%Y(?dA)@ybOpIBCR!|k7%*QIlxc~%1;&08@lQL38Eip9cDlugytY@6ojBh=_k`zC?0 zL&lBl@`~@CpRcc9My0b)-vzjSxuydWc9C_rAr8D0Nt%OMy1yu}gOV6d5}-UsIkk3Z z*NHO;4);==#4iE(S7KFV+VKUs9Hse0Oj=rwoV@w*K|ZbGoBs%ZwGTf_%UW)6b_A3U z$5lfrAndap6;Mv=K23(unL(F%v33q>3Z-}uSiRdjAfCZG3ZrWA=9F)5bL$@LrT#6A zVm_-oG!kMiC^QU{R>V#ZoVS#x{2nJAEjgB<~>8YuVF-<+s#HG~q zlxxk-H%OT1CL>frB=eoEdf?Df6L0X%qJk6P;+ee zvx`tY0L&LcndFTeRSB=R{5Oz*5S=?KS)E#`Nhar%#=c7^NLI2-ix+f)=P-FZRs-B4 z0+|Pa7##;Px;uzm0r9@Edma^v4@HXqwD9kJ`bgs$rS=YEkGIfI3h2DW@4NW}-Dlsq?kuIH z$n%L&%y5G?_4%18o_5QJ-mC=h5jXHNj(x|onBlH+9HLOxej8$5L;NBV{VBgMSLGZ2 zNg2=&%lA1b`*k(;=VB*;wy&G%hfe88#T78SX}p`iA^4(B_+CjJn1-qbJPGpK#Q8xO zUy;#;QWqd~+=aqJQvLpZSO9oE-GM^b21Lom06gBX!p8g5@3ip_*Yo=D;X}WafFRIQ zuNBpst%xE6kZvhl`ML2($9_7B|4Y1Y>gGqNL-gmjpDU%l%$J`m3|SDa<42(D+))qCqIoIrnep_n- z#8_FwU2K0}*i!6Hp?b?H{vm__Jk`L`>kzjP(P@%G-4C!GXwgFc$wj&YkU$5$E^M;f zEtt}5efjd`>{RNhrT8mb^6+=~b1I)XD`1{<{u4d!)iX#IMw`Sv1xo5_TBfT&PeD?m zClCU7AC%icB6Kgrz_sksO}`xamKw(t?-Sg?dI?S(v-`ojPf-Ds(ETp@1VHhsbM7cw zyXSJ7>q3tzQmjnf<5}B#lc>+miDjn@5E$+zw9?b{!J?Gc|$2higP`3}*BjeI$< zI%s$``Rg zlR@pu!+vHeN^xZGyA2=hG1KJwC6q*ZL zqYfeJt1I91{-k5NLBTIa>K1xg0_6&F=wraEjd7wTu3Do_T&L##cVIgD&_SXj?dE)z z{DWI$9d$q%&6<6{p75Yb@fq1;#8Q>~Fd2pqA)<@4dUltGbjLseV#?Ocr8q0m{-OVf1TE46gIoyzv_Ytu zx{J8DA#^>BsObv}tP$PGVHLAxDw(yD8WsK3jvxOEXl`P?`DQwlnf~?1f+F} zsGk60_yBp>rStZU6JDLi6eVl+UP&5F)4tvCjS8E~8y}$kT<~Xheo{iAF6ne#pHAHx z-lRiGrE0tp&Myu=K6aFeN$vFWB5egfy`-01NoU4YPj)E1_3oV6GFUNh9rkUH*oA_i zPpo446@`Wz7rJC8GAg=rFMQrNk@0yfBkrzV+iQdw4LDwNnOSc8c4InL572#FOwt8N zOYM>4_M+f{#G(Qmgdtk&l28^mprq&DgGW-nRA8O=sjd$7GkV4Z?}2U5S73%`5jW_s z>kuidGw@>#;4oKpVVQCXMM5H?dn2GsOFRTYUomOpv%IP?oUd>CA>i4_7orSnMs7qT zDeKp*^Z$6AQna}~Q1=sx^GoJB4J}eL>TrgJT1cgiraghgC{1N)n+#e8Y#ML7%+IFn z=X8YTAuCKmeNV<_46j%5k_AJvDPC%wHmf(r%p`zGX@(^6gY02qV2A4|hrD@Hif;4C zH6xJOq+mg60}+~;cp~fEbPO>T8YNq|Q%jfUcvLiB$|_&FmSB*pWrpOQkEbGmn=3nn zmHY9car1nZ{)bNyzoL?TuCI_&43!c@-{VmNu8ydO-h$m^AqKCe5>OL0sdgnsZQP6JYOf7hXH3#R-+SZj*ZJI>a(9q$P(w z(+Z5gpdtyVF5arPZ|f-Etpc$_EPzj~Kt^C{AU4_9(y@a)8;CStJj8i2U zT}{c{QNUNSnxV`nOpiwe3YU##^OMY_F^$og?%zG-Q&&j{=rW#VX`yh8?;^IIcTv>;B zZg=n6QrzJS-CF*;m7 z9X*q6Ngb!>Pm~`+FWlxAYYP!G^zJ-c{7>l`i?}JwKms<>r(O|II)ykN0 z8A_Bf%0h+=%|#@oNQlgHnTIG*l*l|+Dl#U+3gP^3?DxFqyytv5pH3g%cmEXDde-w? z_jUcJ>vra2EpJ82>&M`lP;jPwWEUI(npxMGSU~Tdj`z-EItzuYu|GNQ+HFmjT8QR< zxw-XZk{e`R&Y=*BE%8&Mt;7bU&oS$}v7q}gmLm0`PY=C6P_q|D+t&@cC(9!&+lSvS z$UTa;P@{GTzh3*`W~meIjPBm8W!;Iu;0DuKRv2ISc5iS>Zszx@}29Ki1h$KM~OL; z;QgH}#}{kTcwg};eLBZ?dj5)0dH&`XqNbU8CyqdnDw_{y*Z_K%@lGP_Bh?5`ie!~- z0i@b{+{SGZk&#DA);N^jO;8r}L#4g3Q4TBN`&iJdd4fQB)NcQZ4cTwf*Fznmf_lVGzn!OSGco=_hzXCGm{1}C~ierhJ-?>#%%s|$ycndkv{>K zT@rc=JCP)*UXe@3w9d=m3`&QRxCJ;xSKDo?K_VtVb??a&dhzoms}7n<$;@m7IO;$F zOaylM#{-U5TGz#$KuCsTt(C+|+E=n~OY|fEg|B}}^7CCn4QhMg4o^t~ZfkmBFUvvQ z1Z`n@CJ@|+Z-|PFNtEPn$F0&2Q3$P*7&;Z`!s>?5DK`pFBI=JNfzpH*q_@y0c-3vK z;Np(Y_o}->_g>0x-|UXaUdM-CA!`%O(Y%eP#^$-O42l{&7fKpH-}aIiVS@eWK!HoiIkIRMKYg1xS{8HdRJmo>#@C>7%cz|6A4c+Mu zGO9f9aqkyf2%`H3K(N+e-}O+@fwA8~Vb0K@H}3r!Vx4khWFk-rM|6Ma*&>Iq8xrOQ zC54zh^H->zm3#Trt_(%NiK3(3sjeT=(h0FjzJLuXt)lE8_(&)HYatg4PrY5krKjzc z1v*)*YiIhu*7E3Wf3b3iTnB%aJibGF)ipK6mE4G_cFi1^zXKVf zGqCk$k1aLI7v3@123lRAUM3ToQ{MO!3;pu-Q+_W=8lCL)s1`5K=)Rmp3*HQ~@|A9V z$=NnIGg7P_(3Fr{$FDAJ;m01>sUDYi34$NTFqisLGsp$)CIET0%}w+~q)6;C|NXCp zdJjtD(KCE;v5?5*P$JC1Pdx*9mM?GucBM`G7IIP<-ePeM;YK|cUSCdW$iq718^b8+ z(lG|A^J~NW3BwS;vWo?a;7+0skQ#D@|1BLz;=xk{5g~v6ex$*jf~gsI(A%Ax7ACTs zQ%0DyaXUAmXAo6;Ed5r7FrvNrLEnX#U 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 BRet~g literal 0 HcmV?d00001 diff --git a/tools/make_readme_chart.py b/tools/make_readme_chart.py new file mode 100644 index 0000000..6dd08bb --- /dev/null +++ b/tools/make_readme_chart.py @@ -0,0 +1,56 @@ +"""Regenerate docs/assets/demo_bias.png from the shipped fixed-seed demo. + +The chart hardcodes nothing: it runs `run_demo` (the same call the CLI makes) +and plots the systematic size-coefficient bias of naive early release vs the +graded gate for each planted signal type. `tests/test_reproduces_paper.py` +pins the underlying numbers. +""" +import pathlib + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +from pit_release_gate import run_demo + +OUT = pathlib.Path(__file__).resolve().parents[1] / "docs" / "assets" +OUT.mkdir(parents=True, exist_ok=True) + + +def main(): + res = run_demo(verbose=False)["signals"] + order = ["clean", "composition", "mild_leak", "strong_leak"] + labels = ["Clean", "Composition\n(obs. selection)", "Mild leak", "Strong leak"] + naive = [res[k]["policies"]["naive"]["bias_mean"] for k in order] + gated = [res[k]["policies"]["gated"]["bias_mean"] for k in order] + comp = [res[k]["policies"]["gated"]["comp_mean"] for k in order] + + fig, ax = plt.subplots(figsize=(8.6, 4.2), dpi=150) + x = range(len(order)) + w = 0.38 + ax.bar([i - w / 2 for i in x], naive, w, label="naive early release", + color="#c0392b", alpha=0.85) + ax.bar([i + w / 2 for i in x], gated, w, label="graded gate", + color="#2471a3", alpha=0.9) + ax.axhline(0, color="black", lw=0.8) + for i, (g, c) in enumerate(zip(gated, comp)): + ax.annotate(f"releases at {c:.0%}", (i + w / 2, g), + textcoords="offset points", xytext=(0, -14), + ha="center", fontsize=8, color="#2471a3") + ax.set_xticks(list(x)) + ax.set_xticklabels(labels, fontsize=10) + ax.set_ylabel("systematic bias of released signal\n(size-coefficient, signed)") + ax.set_title("Known-ground-truth demo: the gate withholds only the signals that need it", + fontsize=11) + ax.legend(frameon=False, fontsize=9) + ax.spines[["top", "right"]].set_visible(False) + fig.tight_layout() + fig.savefig(OUT / "demo_bias.png", facecolor="white") + print("wrote", OUT / "demo_bias.png") + for k, n, g, c in zip(order, naive, gated, comp): + print(f" {k:<12} naive {n:+.3f} gated {g:+.3f} at {c:.0%}") + + +if __name__ == "__main__": + main() From eedff315941c051ad9d66819d79d282bae3d271e Mon Sep 17 00:00:00 2001 From: Max Well Apex LLC Date: Mon, 17 Aug 2026 01:29:57 -0400 Subject: [PATCH 12/14] docs: absolute image URL so the chart renders on PyPI --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e17ca1a..d605bd9 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This tool measures each signal's susceptibility *before* release and withholds only the signals that need it. The screen costs one `fit_trailing` call per signal. -![Known-ground-truth demo: naive release is biased exactly when the leak is strong; the gate routes that signal to the deadline and its bias is exactly zero, while benign signals still release at ~36% completeness](docs/assets/demo_bias.png) +![Known-ground-truth demo: naive release is biased exactly when the leak is strong; the gate routes that signal to the deadline and its bias is exactly zero, while benign signals still release at ~36% completeness](https://raw.githubusercontent.com/MaxWellApexLab/pit-release-gate/master/docs/assets/demo_bias.png) *The shipped fixed-seed demo, where the right answer is planted: naive early release carries a systematic bias of −0.386 on the strong-leak signal; the gate From 21a0e62078d094392c7597963b5ad56d097e25ec Mon Sep 17 00:00:00 2001 From: Max Well Apex LLC Date: Mon, 17 Aug 2026 05:22:20 -0400 Subject: [PATCH 13/14] =?UTF-8?q?feat:=20screen=5Fdataframe=20=E2=80=94=20?= =?UTF-8?q?screen=20your=20own=20panel=20from=20any=20table,=20plus=20a=20?= =?UTF-8?q?--csv=20CLI=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the main adoption barrier: using the screen on real data no longer requires constructing AsOfDataStore objects by hand. Columns are read by duck typing, so pandas, polars, pyarrow and plain dicts all work and no dataframe library is imported. The CLI gains --csv/--value so a panel can be screened without writing Python at all. Same frozen protocol as the published audits (fit on prior completed periods, freeze, apply forward), returning a pit-screen-results v1.0 record. The verdict convention and its sampling-noise floor are documented rather than left implicit. No change to package behavior: tests/test_reproduces_paper.py untouched, 62 tests green. --- .coverage | Bin 53248 -> 53248 bytes CHANGELOG.md | 20 ++++ README.md | 46 ++++++++ src/pit_release_gate/__init__.py | 5 + src/pit_release_gate/frame.py | 186 +++++++++++++++++++++++++++++++ src/pit_release_gate/simulate.py | 96 ++++++++++++++++ tests/test_csv_cli.py | 84 ++++++++++++++ tests/test_frame.py | 142 +++++++++++++++++++++++ 8 files changed, 579 insertions(+) create mode 100644 src/pit_release_gate/frame.py create mode 100644 tests/test_csv_cli.py create mode 100644 tests/test_frame.py diff --git a/.coverage b/.coverage index 810e643b1b111c0ef60f12f09e295ecb979b48b7..ceb605872a72cc08759449da9af6fc953bd968f7 100644 GIT binary patch delta 460 zcmZozz}&Eac>{}s1Q&M&1AjNa2;X8pL*Bi-9z550lDOY;S8Ns(h~Z``=9--7lffGw zpPQJO7ay-zP+80g6e(fkn7rRNW3rtu7h3^43qxb->8NmoRcoPKYUCVh zCNJ(+s%Hfmz#5jek&%Z5q=tDL&x2p#(_CWpOW{C7R%`SGVJ`xG++2;BJh zzMNfz2`H__6vn^v^Z5_^9qOO|w|{Wko`;czlT(I?fnfo^0Rt-+P%$U}VFv!+{9pK= z@gL^D&wr8sE&mPvQ($wz#AfXYOZl}pK@MW%|lz64NS4Jf+=PeFvY(4^L$k delta 356 zcmZozz}&Eac>{}s7$^5D2L5h-5x&KIhP-=uJ$SD0#B;yeEGTe+d-7ZFEZ+F|+{Dbh z_;|g7%3_Ym@4ZVH*(dM!&6s?~hl{D04JeSYd6RDt6VRO34E#OZulYs!mhkT5xytLw zlfY*LH0L7sg2&woZW zRv^!heR6ZZQZNfh6Kh!7Mn)cHkQ(M~JP&?_PnQ&70`jz&!uWT7KL26AL;dsr_785` zb2GAVa!N5VFuY)7TfoJ@z`((Oih=(({}=wZ{HOSz@!#OT&wml results record +stores_from_frame the per-period stores that screen builds, for inspection AsOfDataStore staggered-arrival records for one (period, group) CompletenessMonitor arrived-fraction and composition-shift gauges PropensityReweighter optional IPW composition-correction module @@ -27,6 +29,7 @@ __version__ = "0.1.1" from .controller import ReleaseController, ReleaseDecision +from .frame import screen_dataframe, stores_from_frame from .gate import SusceptibilityGate from .monitor import CompletenessMonitor from .results import ( @@ -54,6 +57,8 @@ __all__ = [ "AsOfDataStore", + "screen_dataframe", + "stores_from_frame", "CompletenessMonitor", "PropensityReweighter", "SusceptibilityGate", diff --git a/src/pit_release_gate/frame.py b/src/pit_release_gate/frame.py new file mode 100644 index 0000000..ad13b07 --- /dev/null +++ b/src/pit_release_gate/frame.py @@ -0,0 +1,186 @@ +"""Screen a tabular panel directly, without building stores by hand. + +`screen_dataframe` takes one long table — an entity, a period, a filing +arrival time, one or more signal values, and a conditioning covariate — and +runs the same frozen protocol the published audits use: fit the +susceptibility estimate on prior *completed* periods, freeze it, apply it +forward, and report per-signal verdicts as a `pit-screen-results` record. + +The table can be anything that hands over a column: a pandas DataFrame, a +polars DataFrame, a pyarrow Table, or a plain dict of arrays. Nothing is +imported from any dataframe library — columns are read by duck typing and +converted to numpy, so the screen itself carries no dataframe dependency. +""" +from __future__ import annotations + +import numpy as np + +from .controller import ReleaseController +from .gate import SusceptibilityGate +from .results import build_results, screen_config, summarize_signal +from .store import AsOfDataStore + +__all__ = ["screen_dataframe", "stores_from_frame"] + + +def _column(data, name): + """One column as a 1-D numpy array, from any column-addressable table.""" + try: + col = data[name] + except (KeyError, IndexError, TypeError) as exc: + raise KeyError(f"column {name!r} not found in the table") from exc + for attr in ("to_numpy", "__array__"): # pandas / polars / pyarrow / numpy + if hasattr(col, attr): + arr = np.asarray(col.to_numpy() if attr == "to_numpy" else col) + break + else: + arr = np.asarray(col) + return arr.reshape(-1) + + +def _as_float(arr, name): + """Numeric view of a column, accepting datetimes for arrival times.""" + if np.issubdtype(arr.dtype, np.datetime64): + return arr.astype("datetime64[s]").astype(np.float64) + if np.issubdtype(arr.dtype, np.timedelta64): + return arr.astype("timedelta64[s]").astype(np.float64) + try: + return arr.astype(np.float64) + except (TypeError, ValueError) as exc: + raise TypeError(f"column {name!r} is not numeric (dtype {arr.dtype})") from exc + + +def _ols_resid(X, y): + beta, *_ = np.linalg.lstsq(X, y, rcond=None) + return y - X @ beta + + +def stores_from_frame(data, *, period="period", arrival="arrival", + value="value", size="size", min_entities=6): + """Build one `AsOfDataStore` per period, oldest first. + + Rows with a missing value, size or arrival are dropped for that signal, + and periods left with fewer than `min_entities` rows are skipped — + a cross-section too small to residualize honestly is not screened + rather than screened badly. + """ + per = _column(data, period) + arr = _as_float(_column(data, arrival), arrival) + val = _as_float(_column(data, value), value) + siz = _as_float(_column(data, size), size) + if not (len(per) == len(arr) == len(val) == len(siz)): + raise ValueError("columns have different lengths") + + stores, kept = [], [] + for key in _ordered_unique(per): + m = (per == key) & np.isfinite(arr) & np.isfinite(val) & np.isfinite(siz) + if m.sum() < min_entities: + continue + a, v, s = arr[m], val[m], siz[m] + + span = a.max() - a.min() + # arrival on [0, 1] within the period, 1 = the last filer (the deadline) + norm = np.ones_like(a) if span <= 0 else (a - a.min()) / span + sd = s.std() + s_std = np.zeros_like(s) if sd < 1e-12 else (s - s.mean()) / sd + X = np.column_stack([np.ones(m.sum()), s_std]) + + stores.append(AsOfDataStore( + X=X, y=v, arrival=norm, size=s_std, + # the estimand: the residual the COMPLETE cross-section implies + truth_resid=_ols_resid(X, v), + )) + kept.append(key) + return stores, kept + + +def _ordered_unique(a): + """Unique period keys, sorted — periods must be screened in time order.""" + return sorted(set(a.tolist())) + + +def screen_dataframe(data, *, period="period", arrival="arrival", + value="value", size="size", trailing_k=5, + rho_threshold=0.10, phi_min=0.35, kappa=1.0, + min_entities=6, date=None) -> dict: + """Screen one or more signals in a long table for incomplete-cross-section + leakage, and return a `pit-screen-results` v1.0 record. + + Parameters + ---------- + data + A long table: one row per (entity, period). Any object whose columns + are addressable by name — pandas, polars, pyarrow, or a dict of + arrays. + period, arrival, size + Column names. `period` groups the cross-section, `arrival` is when + that entity's record became available (a date or any increasing + number), `size` is the observable the screen conditions on. + value + The signal column, or a list of them to screen several at once. + trailing_k + How many prior completed periods the estimate is fitted on before + it is frozen and applied forward. The first `trailing_k` periods + are therefore used for fitting only and are not screened. + rho_threshold, phi_min, kappa, min_entities + Screen settings, recorded in the returned record so a reader can + tell which settings produced the verdicts. + + Returns + ------- + dict + A validated `pit-screen-results` record. Write it with + `write_results`, publish it, and point a *screened with* badge at it. + + Notes + ----- + A signal's verdict is ``susceptible`` if the frozen estimate exceeded the + threshold in **any** screened period — the convention the published + audit reports use, because a channel that opens in one year is not + closed by averaging it against years where it did not. + + Because the verdict fires on any single period, it inherits that period's + sampling noise: the standard error of the estimate is roughly + ``1 / sqrt(trailing_k * entities_per_period)``, so on small panels a + reading just over the threshold may be noise rather than a channel. + Establish the noise floor for your panel — screen a signal you have + reason to believe is unexposed, or shuffle arrival order within periods + and re-screen — before treating a marginal verdict as a finding. The + published audit reports do this and state the floor they measured. + + The screen is honest by construction: the estimate applied to a period is + never fitted on that period. Nothing is sent anywhere; this function + performs no network I/O. + """ + names = [value] if isinstance(value, str) else list(value) + if not names: + raise ValueError("no signal column given") + if trailing_k < 1: + raise ValueError("trailing_k must be at least 1") + + controller = ReleaseController(phi_min=phi_min, suscept_slope=kappa) + signals = [] + for name in names: + stores, kept = stores_from_frame( + data, period=period, arrival=arrival, value=name, + size=size, min_entities=min_entities) + if len(stores) <= trailing_k: + raise ValueError( + f"{name}: {len(stores)} usable periods, need more than " + f"trailing_k={trailing_k} so at least one period can be screened " + f"(periods with fewer than {min_entities} entities are skipped)") + + rhos, phi_reqs = [], [] + for i in range(trailing_k, len(stores)): + gate = SusceptibilityGate(threshold=rho_threshold) + rho = gate.fit_trailing(stores[i - trailing_k:i]) # frozen before use + rhos.append(rho) + phi_reqs.append(controller.required_completeness(rho)) + + signals.append(summarize_signal( + name, rhos, phi_reqs, rho_threshold=rho_threshold, + susceptible=any(abs(r) > rho_threshold for r in rhos))) + + config = screen_config(rho_threshold, phi_min, kappa, + trailing_k=trailing_k, min_entities=min_entities) + return build_results(signals, config, date=date) diff --git a/src/pit_release_gate/simulate.py b/src/pit_release_gate/simulate.py index c37b5c2..8b26f68 100644 --- a/src/pit_release_gate/simulate.py +++ b/src/pit_release_gate/simulate.py @@ -307,6 +307,80 @@ def badge_snippet(results) -> str: ]) +def read_csv_columns(path): + """Read a CSV into {column: numpy array} using the standard library only. + + Numeric columns become floats; anything else stays a string array, which + is all the screen needs for period keys. + """ + import csv as _csv + with open(path, newline='', encoding='utf-8-sig') as fh: + rows = list(_csv.DictReader(fh)) + if not rows: + raise SystemExit(f'{path}: no data rows') + out = {} + for name in rows[0]: + raw = [r[name] for r in rows] + try: + out[name] = np.array([float(v) if v not in ('', 'NA', 'NaN', 'nan') + else np.nan for v in raw], dtype=float) + except ValueError: + out[name] = np.array(raw, dtype=object) + return out + + +def _version(): + try: + from . import __version__ + return __version__ + except ImportError: # pragma: no cover + return '' + + +def _screen_csv(a): + """Screen a user-supplied panel and print one row per signal.""" + from .frame import screen_dataframe + + data = read_csv_columns(a.csv) + record = screen_dataframe( + data, period=a.period, arrival=a.arrival, value=a.value, + size=a.size, trailing_k=a.trailing_k, rho_threshold=a.threshold) + + print(f'screened {a.csv}: {len(record["signals"])} signal(s), ' + f'trailing_k={a.trailing_k}, threshold={a.threshold}') + print(f' {"signal":<24} {"periods":>8} {"flagged":>8} {"mean rho":>10} ' + f'{"max |rho|":>10} {"phi_req":>8} verdict') + for s in record['signals']: + print(f' {s["name"]:<24} {s["periods_screened"]:>8} {s["periods_flagged"]:>8} ' + f'{s["mean_rho"]:>+10.4f} {s["max_abs_rho"]:>10.4f} ' + f'{s["mean_phi_req"]:>8.3f} {s["verdict"]}') + t = record['totals'] + print(f' totals: {t["signals_benign"]} benign, {t["signals_susceptible"]} susceptible, ' + f'{t["signal_cycles"]} signal-cycles screened') + + if a.badge: + rule = '-' * 72 + print() + print('\n'.join([ + rule, + 'Badge snippet (paste into your README):', + '', + BADGE_MARKDOWN, + '', + f'', + '', + 'The badge states that the screen was RUN, not that anything passed.', + 'Point it at your exported record, not at this repo.', + rule, + ])) + if a.export: + path = write_results(record, a.export) + print(f'\nwrote {SCHEMA} v{SCHEMA_VERSION} to {path} ' + f'(local file only -- no network call was made)') + + def main(argv=None): ap = argparse.ArgumentParser( prog='pit-release-gate', @@ -322,11 +396,33 @@ def main(argv=None): ap.add_argument('--export', metavar='PATH', help=f'write the screen result to PATH as a {SCHEMA} ' f'v{SCHEMA_VERSION} JSON record (fully offline)') + ap.add_argument('--csv', metavar='PATH', + help='screen your own panel instead of running the demo: a CSV ' + 'with one row per (entity, period). Requires --value') + ap.add_argument('--value', metavar='COL', action='append', + help='signal column in --csv; repeat to screen several') + ap.add_argument('--period', metavar='COL', default='period', + help='period column in --csv (default: period)') + ap.add_argument('--arrival', metavar='COL', default='arrival', + help='arrival-time column in --csv (default: arrival)') + ap.add_argument('--size', metavar='COL', default='size', + help='conditioning covariate column in --csv (default: size)') + ap.add_argument('--trailing-k', type=int, default=5, metavar='K', + help='prior completed periods the estimate is fitted on (default 5)') + ap.add_argument('--threshold', type=float, default=0.10, + help='flag a period when |rho_hat| exceeds this (default 0.10)') ap.add_argument('--badge', action='store_true', help='after the demo, print a README badge snippet recording ' 'that the screen was run (does not change the demo output)') a = ap.parse_args(argv) + if a.csv: + if not a.value: + ap.error('--csv requires at least one --value COLUMN') + return _screen_csv(a) + if a.value: + ap.error('--value is only meaningful together with --csv') + run = run_demo(n_train=a.train, n_eval=a.n_eval, verbose=True) if a.badge: print() diff --git a/tests/test_csv_cli.py b/tests/test_csv_cli.py new file mode 100644 index 0000000..dd285e1 --- /dev/null +++ b/tests/test_csv_cli.py @@ -0,0 +1,84 @@ +"""The `--csv` path: screening a user's own panel from the command line.""" +import csv +import json + +import numpy as np +import pytest + +from pit_release_gate.simulate import main, read_csv_columns +from test_frame import planted_panel + + +def write_panel(path, **kw): + p = planted_panel(**kw) + with open(path, "w", newline="", encoding="utf-8") as fh: + w = csv.writer(fh) + w.writerow(list(p)) + for row in zip(*p.values()): + w.writerow(row) + return path + + +def test_reads_a_csv_into_typed_columns(tmp_path): + f = write_panel(tmp_path / "p.csv", n_periods=7, n=20) + cols = read_csv_columns(f) + assert set(cols) == {"period", "entity", "arrival", "value", "size"} + assert cols["value"].dtype == float + assert len(cols["value"]) == 7 * 20 + + +def test_blank_and_na_cells_become_nan(tmp_path): + f = tmp_path / "gappy.csv" + f.write_text("period,arrival,value,size\n1,0.1,,1.0\n1,0.2,NA,2.0\n", + encoding="utf-8") + cols = read_csv_columns(f) + assert np.isnan(cols["value"]).all() + + +def test_cli_screens_a_planted_leak(tmp_path, capsys): + f = write_panel(tmp_path / "leak.csv", leak=2.0) + main(["--csv", str(f), "--value", "value"]) + out = capsys.readouterr().out + assert "susceptible" in out + assert "signal-cycles screened" in out + + +def test_cli_exports_a_valid_record(tmp_path, capsys): + f = write_panel(tmp_path / "leak.csv", leak=2.0) + out_json = tmp_path / "results.json" + main(["--csv", str(f), "--value", "value", "--export", str(out_json)]) + record = json.loads(out_json.read_text(encoding="utf-8")) + assert record["schema"] == "pit-screen-results" + assert record["signals"][0]["verdict"] == "susceptible" + assert "no network call" in capsys.readouterr().out + + +def test_cli_screens_several_signals_and_honours_settings(tmp_path, capsys): + p = planted_panel(leak=2.0, n=200) + p["quiet"] = p["size"] * 0.3 + np.random.default_rng(5).normal(size=len(p["size"])) + f = tmp_path / "two.csv" + with open(f, "w", newline="", encoding="utf-8") as fh: + w = csv.writer(fh) + w.writerow(list(p)) + for row in zip(*p.values()): + w.writerow(row) + main(["--csv", str(f), "--value", "value", "--value", "quiet", + "--trailing-k", "4", "--threshold", "0.15"]) + out = capsys.readouterr().out + assert "trailing_k=4" in out and "threshold=0.15" in out + assert "1 benign, 1 susceptible" in out + + +def test_cli_rejects_incoherent_flags(tmp_path): + f = write_panel(tmp_path / "p.csv") + with pytest.raises(SystemExit): + main(["--csv", str(f)]) # --csv without --value + with pytest.raises(SystemExit): + main(["--value", "value"]) # --value without --csv + + +def test_demo_path_is_untouched_by_the_csv_flags(capsys): + """The default invocation must still be the known-ground-truth demo.""" + main(["--train", "2", "--eval", "4"]) + out = capsys.readouterr().out + assert "Strong-leak" in out and "gated" in out diff --git a/tests/test_frame.py b/tests/test_frame.py new file mode 100644 index 0000000..01b9f7f --- /dev/null +++ b/tests/test_frame.py @@ -0,0 +1,142 @@ +"""The tabular entry point: planted-truth behaviour, framework independence, +and the honest-estimation contract.""" +import numpy as np +import pytest + +from pit_release_gate import screen_dataframe, stores_from_frame, validate_results + + +def planted_panel(n_periods=12, n=60, leak=0.0, seed=0): + """A long table whose leakage strength is known by construction. + + ``leak`` couples filing latency to the signal's disturbance: at 0 the + arrival order carries no information about the disturbance, and the + screen should read ~0; raise it and the early cross-section becomes a + selected sample, which is what the screen is built to catch. + """ + rng = np.random.default_rng(seed) + cols = {k: [] for k in ("period", "entity", "arrival", "value", "size")} + for p in range(n_periods): + size = rng.normal(size=n) + u = rng.normal(size=n) + cols["period"] += [p] * n + cols["entity"] += list(range(n)) + cols["arrival"] += list(rng.normal(size=n) - leak * u) + cols["value"] += list(0.5 * size + u) + cols["size"] += list(size) + return {k: np.array(v) for k, v in cols.items()} + + +def test_clean_panel_reads_benign(): + r = screen_dataframe(planted_panel(leak=0.0)) + sig = r["signals"][0] + assert sig["verdict"] == "benign" + assert sig["periods_flagged"] == 0 + assert abs(sig["mean_rho"]) < 0.10 + + +def test_planted_leak_is_flagged_and_gated_to_the_deadline(): + r = screen_dataframe(planted_panel(leak=2.0)) + sig = r["signals"][0] + assert sig["verdict"] == "susceptible" + assert sig["periods_flagged"] == sig["periods_screened"] + # a strongly susceptible signal must be held until the cross-section is complete + assert sig["mean_phi_req"] == pytest.approx(1.0) + + +def test_record_validates_against_the_published_schema(): + r = screen_dataframe(planted_panel(leak=1.0)) + assert validate_results(r) == [] + assert r["config"]["trailing_k"] == 5 + assert r["config"]["rho_threshold"] == pytest.approx(0.10) + + +def test_first_k_periods_are_used_for_fitting_only(): + # 12 periods, k=5 -> exactly 7 screened; the fitted periods are never graded + r = screen_dataframe(planted_panel(n_periods=12), trailing_k=5) + assert r["signals"][0]["periods_screened"] == 7 + r8 = screen_dataframe(planted_panel(n_periods=12), trailing_k=8) + assert r8["signals"][0]["periods_screened"] == 4 + + +def test_several_signals_in_one_pass(): + # 200 entities per period: the sampling noise floor of rho is ~1/sqrt(k*n), + # small enough here that a genuinely unrelated signal stays under threshold + p = planted_panel(n=200, leak=2.0) + p["quiet"] = p["size"] * 0.3 + np.random.default_rng(1).normal(size=len(p["size"])) + r = screen_dataframe(p, value=["value", "quiet"]) + by_name = {s["name"]: s for s in r["signals"]} + assert by_name["value"]["verdict"] == "susceptible" + assert by_name["quiet"]["verdict"] == "benign" + assert r["totals"]["signals_susceptible"] == 1 + assert r["totals"]["signals_benign"] == 1 + + +def test_pandas_polars_and_dict_agree(): + """The screen reads columns, not a dataframe library.""" + base = planted_panel(leak=1.5) + want = screen_dataframe(base) + + pd = pytest.importorskip("pandas") + assert screen_dataframe(pd.DataFrame(base)) == want + + pl = pytest.importorskip("polars") + assert screen_dataframe(pl.DataFrame(base)) == want + + +def test_datetime_arrivals_are_accepted(): + p = planted_panel(leak=2.0) + days = (p["arrival"] - p["arrival"].min()) * 5 + p["filed"] = np.datetime64("2020-01-01") + days.astype("timedelta64[D]") + r = screen_dataframe(p, arrival="filed") + assert r["signals"][0]["verdict"] == "susceptible" + + +def test_custom_column_names(): + p = planted_panel(leak=2.0) + renamed = {"fy": p["period"], "filed_at": p["arrival"], + "accruals": p["value"], "logme": p["size"]} + r = screen_dataframe(renamed, period="fy", arrival="filed_at", + value="accruals", size="logme") + assert r["signals"][0]["name"] == "accruals" + assert r["signals"][0]["verdict"] == "susceptible" + + +def test_short_periods_are_skipped_not_screened_badly(): + p = planted_panel(n_periods=8, n=60) + # starve one period down to 3 entities + keep = ~((p["period"] == 3) & (p["entity"] >= 3)) + p = {k: v[keep] for k, v in p.items()} + stores, kept = stores_from_frame(p, value="value", min_entities=6) + assert 3 not in kept + assert len(stores) == 7 + + +def test_missing_values_are_dropped_per_signal(): + p = planted_panel(leak=2.0) + p["value"] = p["value"].astype(float) + p["value"][:5] = np.nan + r = screen_dataframe(p) # must not raise, must not poison + assert r["signals"][0]["verdict"] == "susceptible" + + +def test_errors_are_actionable(): + p = planted_panel(n_periods=4) + with pytest.raises(KeyError, match="no_such_column"): + screen_dataframe(p, value="no_such_column") + with pytest.raises(ValueError, match="trailing_k"): + screen_dataframe(p, trailing_k=5) # 4 periods, none screenable + with pytest.raises(ValueError, match="at least 1"): + screen_dataframe(p, trailing_k=0) + + +def test_screen_is_deterministic(): + p = planted_panel(leak=1.0) + assert screen_dataframe(p) == screen_dataframe(p) + + +def test_no_network_machinery_in_the_frame_module(): + import pit_release_gate.frame as m + src = open(m.__file__, encoding="utf-8").read() + for forbidden in ("urllib", "requests", "http", "socket", "atexit", "threading"): + assert forbidden not in src, f"frame.py must not reference {forbidden!r}" From 912d014d5ba472ff04af2cb8032357111e9a77ed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:19:19 +0000 Subject: [PATCH 14/14] deps: update setuptools requirement from >=77 to >=84.0.0 Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/setuptools/compare/v77.0.0...v84.0.0) --- updated-dependencies: - dependency-name: setuptools dependency-version: 84.0.0 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 890e598..1b5302c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=77"] +requires = ["setuptools>=84.0.0"] build-backend = "setuptools.build_meta" [project]