diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d88bc32 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf + +*.gif binary +*.jpg binary +*.png binary diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..ba1bc18 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,35 @@ +name: Bug report +description: Report a reproducible PhaseProbe defect. +title: "[Bug]: " +labels: ["bug"] +body: + - type: textarea + attributes: + label: Result first + description: What happened, and what should have happened? + validations: + required: true + - type: textarea + attributes: + label: Minimal reproduction + description: Include the command and a minimal non-sensitive configuration. + validations: + required: true + - type: input + attributes: + label: PhaseProbe and Python versions + placeholder: "phaseprobe 0.1.0; Python 3.12.13" + validations: + required: true + - type: dropdown + attributes: + label: Operating system + options: [Windows, Linux, Other] + validations: + required: true + - type: checkboxes + attributes: + label: Safety + options: + - label: I removed credentials, private model data, and personal information. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..cbc6a16 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Private security report + url: https://github.com/aliengineering-byte/phaseprobe/security/advisories/new + about: Report vulnerabilities without exposing exploit details publicly. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..7d18aeb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,23 @@ +name: Feature request +description: Propose a focused simulation-testing improvement. +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + attributes: + label: Testing job + description: Which transition, replay, evidence, or regression-testing job is missing? + validations: + required: true + - type: textarea + attributes: + label: Proposed evidence + description: Describe positive cases, negative controls, scientific terminology, and validation. + validations: + required: true + - type: textarea + attributes: + label: Alternatives and prior art + description: Link relevant tools or primary papers and explain the remaining gap. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/good_first_accessibility.yml b/.github/ISSUE_TEMPLATE/good_first_accessibility.yml new file mode 100644 index 0000000..fdde20b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/good_first_accessibility.yml @@ -0,0 +1,17 @@ +name: "Good first issue: report accessibility" +description: Improve keyboard, contrast, and semantic HTML evidence without external assets. +title: "[Good first issue] Improve offline report accessibility" +labels: ["good first issue", "documentation"] +body: + - type: markdown + attributes: + value: | + Audit the self-contained HTML report for semantic headings, table captions, contrast, print layout, and screen-reader labels. Add tests and before/after screenshots. Keep the report script-free, offline, and free of CDN resources. + - type: checkboxes + attributes: + label: Contributor checklist + options: + - label: I will document the audit method and measured changes. + required: true + - label: I will preserve HTML escaping and offline operation. + required: true diff --git a/.github/ISSUE_TEMPLATE/good_first_pendulum.yml b/.github/ISSUE_TEMPLATE/good_first_pendulum.yml new file mode 100644 index 0000000..e344450 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/good_first_pendulum.yml @@ -0,0 +1,17 @@ +name: "Good first issue: damped pendulum adapter" +description: Add a small deterministic adapter with positive/negative controls. +title: "[Good first issue] Add a damped-pendulum adapter" +labels: ["good first issue", "example"] +body: + - type: markdown + attributes: + value: | + Implement a typed damped-pendulum adapter, an energy-decay invariant, a positive transition/check, a negative control, a primary citation, deterministic tests, replay coverage, and documentation. Do not label numerical damping as physical evidence without a solver-refinement comparison. + - type: checkboxes + attributes: + label: Contributor checklist + options: + - label: I will add configuration, tolerances, invalid-state policy, and bounded trace retention. + required: true + - label: I will add positive, negative-control, replay, and generated-test coverage. + required: true diff --git a/.github/ISSUE_TEMPLATE/good_first_schema.yml b/.github/ISSUE_TEMPLATE/good_first_schema.yml new file mode 100644 index 0000000..bed8681 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/good_first_schema.yml @@ -0,0 +1,17 @@ +name: "Good first issue: JSON Schema fixtures" +description: Publish schema documents and compatibility fixtures for config/replay/report JSON. +title: "[Good first issue] Add JSON Schema validation fixtures" +labels: ["good first issue", "tooling"] +body: + - type: markdown + attributes: + value: | + Add repository-owned JSON Schema documents for configuration, replay, and report version 1.0. Include valid and invalid fixtures, cross-platform tests, migration notes, and no new runtime dependency. + - type: checkboxes + attributes: + label: Contributor checklist + options: + - label: I will preserve existing version and integrity semantics. + required: true + - label: I will add actionable failure diagnostics and tests. + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..5d4236f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ +## Outcome + +Describe the user-visible testing outcome first. + +## Scientific interpretation + +- What does the evidence establish? +- What does it not establish? +- Which primary sources support model or algorithm claims? + +## Validation + +- [ ] `python -m ruff format --check .` +- [ ] `python -m ruff check .` +- [ ] `python -m mypy src tests` +- [ ] `python -m pytest --cov=phaseprobe` +- [ ] Positive case and negative control +- [ ] Replay and generated pytest execution +- [ ] Package build and clean packed-install smoke +- [ ] `python scripts/check_links.py` +- [ ] `python scripts/hygiene.py` + +List exact commands and measured results. Do not include private paths, credentials, PDFs, or fabricated evidence. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..af7f9c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: [main, "release/**"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + quality-and-tests: + name: ${{ matrix.os }} / Python ${{ matrix.python }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python: ["3.10", "3.12", "3.14"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + - run: python -m pip install --upgrade pip + - run: python -m pip install -e ".[dev]" + - run: python -m ruff format --check . + - run: python -m ruff check . + - run: python -m mypy src tests + - run: python -m pytest --cov=phaseprobe --cov-report=term + - run: python -m phaseprobe --version + - run: python -m phaseprobe scan --example logistic-negative + + package-and-hygiene: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install --upgrade pip build + - run: python -m build + - run: python -m venv .packed-smoke + - run: .packed-smoke/bin/python -m pip install dist/*.whl + - run: .packed-smoke/bin/python -m phaseprobe --version + - run: .packed-smoke/bin/python -m phaseprobe scan --example logistic-negative + - run: python scripts/check_links.py + - run: python scripts/hygiene.py + - uses: actions/upload-artifact@v4 + with: + name: phaseprobe-packages + path: dist/* + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..251df8b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: Tagged release validation + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + validate-tagged-package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: python -m pip install --upgrade pip build + - run: python -m build + - run: python -m venv .tag-smoke + - run: .tag-smoke/bin/python -m pip install dist/*.whl + - run: .tag-smoke/bin/python -m phaseprobe --version + - run: .tag-smoke/bin/python -m phaseprobe scan --example logistic + - run: python scripts/hygiene.py + - uses: actions/upload-artifact@v4 + with: + name: phaseprobe-${{ github.ref_name }} + path: dist/* + if-no-files-found: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a7f81e --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.research_private/ +.venv/ +.tools/ +.cache/ +.phaseprobe/ +dist/ +build/ +*.egg-info/ +__pycache__/ +*.pyc +.coverage +htmlcov/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8081b14 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# PhaseProbe contributor instructions + +- Work exclusively inside the PhaseProbe repository root; never inspect or modify neighboring projects or workspaces. +- Use the public author identity `Ali` only: no surname, personal email, workstation paths, credentials, or provider configuration in tracked files. +- Keep scientific language precise: distinguish finite-time trajectory divergence, sensitive dependence, numerical instability, invariant violation, qualitative regime change, bifurcation evidence, stochastic variation, invalid integration, and solver failure. Never claim exact or formal results beyond the implemented evidence. +- Support Windows and Linux on Python 3.10+ with no runtime LLM, API key, GPU, Docker, account, telemetry, or hosted service. +- Keep copyrighted books, PDFs, extracted text, research notes, caches, environments, tools, build output, and run output out of Git. +- Required validation: `python -m ruff format --check .`, `python -m ruff check .`, `python -m mypy src tests`, `python -m pytest`, `python -m build`, packed-install smoke test, quick start, examples, replay, generated-test execution, privacy/secret/large-file scans, and clean Git status. +- Do not fabricate tests, performance, scientific evidence, benchmark results, users, or adoption claims. +- Do not publish to PyPI without separate authorization. +- Release only after all local gates pass; then use the authenticated `aliengineering-byte` GitHub account, pass CI, tag an annotated `v0.1.0`, create the release, and verify a fresh unauthenticated clone. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..10b9700 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,55 @@ +# Architecture + +PhaseProbe separates simulation semantics from search, evidence, and test materialization. + +```text +JSON config / built-in example + | + v + typed adapter ----> deterministic step + observe + | | + v v + bounded engine ----> finite validation ----> capped trace + SHA-256 + | | + | +---- scan / perturb / check policy + v + outcome ----> run artifacts ----> replay verification ----> fixed pytest template + | + +---- terminal / JSON / self-contained HTML +``` + +## Modules + +- `config.py` validates schema `1.0`, loads packaged examples, and emits canonical JSON. +- `types.py` defines the public adapter protocol, state shape, trace point, and invariant result. +- `models/` contains four independent reference adapters. They are examples, not engine special cases. +- `engine.py` owns bounded execution, NaN/Inf/hard-limit checks, trace retention, scanning, perturbation, bracket refinement, repeatability confirmation, and CI policy evaluation. +- `artifacts.py` creates one finite run directory and hashes each evidence file. +- `replay.py` verifies fixture integrity before re-executing exact model/config/seed/state/parameter inputs and comparing classifications plus retained trace hashes. +- `generate.py` uses a fixed code template and sanitized names. It never evaluates configuration text. +- `reporting.py` renders terminal, JSON, and offline HTML with explicit limitations. +- `cli.py` maps the six public commands to stable exit codes. + +## Adapter design + +The engine deliberately does not require NumPy. A state is a tuple of finite floats; this keeps serialization and perturbation explicit. Downstream adapters may wrap larger simulators, but their `step` boundary must return a bounded tuple suitable for deterministic replay. Observations are scalar mappings and cannot carry arbitrary executable objects. + +An adapter supplies scientific judgment: initial conditions, state advance, observables, qualitative classification, and invariants. The engine supplies operational judgment: search bounds, retention, failure containment, hashes, artifacts, and policy exits. + +## Determinism boundary + +PhaseProbe controls seeds, canonical configuration serialization, fixed command order, search grids, state perturbations, trace retention, and fixture hashing. An external adapter remains responsible for deterministic solver settings, thread behavior, native library versions, and hardware-sensitive arithmetic. Exact replay hashes intentionally expose drift; users may choose a classification-only policy in a future version, but v0.1.0 replay is strict. + +## Artifact safety + +Trace points are capped per series. Run IDs combine UTC time and an evidence digest. Replay fixtures carry a schema version and SHA-256 over every unsigned field. Generated tests copy a validated fixture into `tests/generated/fixtures/` and contain only a sanitized identifier plus a fixed relative path. + +## Extension checklist + +1. Give the adapter a stable `identity` version. +2. Make `initial_state` deterministic for the declared seed. +3. Return the same state dimension after every step. +4. Define classifier thresholds in configuration tolerances. +5. Distinguish mathematical invariants from diagnostic bounds in invariant details. +6. Add positive, negative-control, invalid-state, repeatability, and replay tests. +7. Cite a primary technical source for the model and document solver limitations. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f196884 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes are documented here. PhaseProbe follows semantic versioning. + +## 0.1.0 — 2026-08-01 + +- Added `scan`, `perturb`, `check`, `replay`, `generate-test`, and `report` commands. +- Added typed adapters for logistic map, Lorenz, Lotka–Volterra predator–prey, and a mutually repressing genetic toggle. +- Added deterministic seeds, canonical configuration, fixed-step execution, invalid-integration diagnostics, capped trace retention, and exact trace hashes. +- Added transition brackets, bounded initial-state search, repeatability confirmation, replay fixtures with integrity hashes, and fixed-template pytest generation. +- Added terminal, versioned JSON, and self-contained offline HTML evidence. +- Added Windows/Linux CI, packaging and packed-install checks, documentation/hygiene gates, and repository-native demo assets. diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..d64eb3b --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,17 @@ +cff-version: 1.2.0 +message: "If you use PhaseProbe, cite the software release." +title: "PhaseProbe" +type: software +authors: + - name: "Ali" +version: 0.1.0 +date-released: 2026-08-01 +url: "https://github.com/aliengineering-byte/phaseprobe" +repository-code: "https://github.com/aliengineering-byte/phaseprobe" +license: Apache-2.0 +abstract: "PhaseProbe finds bounded qualitative simulation transitions and turns reproducible evidence into replay fixtures and pytest regressions." +keywords: + - simulation testing + - reproducibility + - dynamical systems + - regression testing diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..112d9df --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,9 @@ +# Code of conduct + +PhaseProbe contributors must make technical collaboration safe, respectful, and evidence-led. + +Welcome people regardless of background or identity. Discuss ideas and code rather than personal characteristics. Give reproducible evidence, acknowledge uncertainty, accept correction, and avoid harassment, threats, sexualized conduct, discrimination, doxxing, or sustained disruption. + +Maintainers may edit or remove contributions and restrict participation when behavior harms the community. Report conduct concerns privately through the repository’s security/contact path; do not include sensitive personal information in a public issue. Reports will be reviewed fairly and with as much confidentiality as practical. + +Good-faith disagreement about scientific methods is welcome. Fabricated evidence, misrepresentation of certainty, or retaliation against someone raising a reproducibility concern is not. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2327771 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Contributing + +Contributions are welcome when they preserve PhaseProbe’s narrow testing job and scientific language. + +## Setup + +```bash +python -m venv .venv +# Windows: .venv\Scripts\python -m pip install -e ".[dev]" +# Linux: .venv/bin/python -m pip install -e ".[dev]" +python -m ruff format --check . +python -m ruff check . +python -m mypy src tests +python -m pytest --cov=phaseprobe +``` + +Before a pull request, also run `python -m build`, install the wheel into a clean environment, execute the logistic quick start, run `python scripts/check_links.py`, and run `python scripts/hygiene.py`. + +## Scientific changes + +New adapters or classifiers must include: + +- equation or model definition and a primary technical citation; +- deterministic positive case and negative control; +- seed, solver/iteration settings, tolerances, burn-in, observation window, and invalid-state policy; +- an explanation of what the classifier establishes and what it does not; +- replay and generated-test coverage; +- solver-refinement or convergence evidence where numerical integration matters. + +Do not call finite-time divergence a Lyapunov exponent, a numerical bracket an exact bifurcation point, or a bounded search result globally minimal. + +## Pull requests + +Keep changes focused, update documentation and `CHANGELOG.md`, and report exact validation commands. Never add copyrighted books, PDFs, extracted private text, credentials, personal email addresses, telemetry, runtime API/LLM/GPU/Docker requirements, or unsupported performance/adoption claims. + +At least three starter tasks are available as structured good-first-issue templates: add a damped-pendulum adapter, add JSON Schema validation fixtures, and improve HTML-report accessibility. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ebdb6ae --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Ali + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LIMITATIONS.md b/LIMITATIONS.md new file mode 100644 index 0000000..fa1c5a1 --- /dev/null +++ b/LIMITATIONS.md @@ -0,0 +1,14 @@ +# Limitations + +- A reported bracket is finite-time, classifier-specific numerical evidence, not an exact bifurcation point. +- A smallest result is the smallest reproducible candidate found by the declared finite grid/refinement, not a proof of global minimality. +- The Lorenz metric is a finite-time divergence rate, not a Lyapunov exponent and not by itself proof of chaos. +- Fixed-step RK4 is transparent but not a substitute for adaptive solvers, stiffness detection, convergence studies, interval arithmetic, or rigorous numerics. +- Exact trace hashes can detect harmless floating-point differences across architectures or native math libraries. v0.1.0 has no configurable fuzzy replay mode. +- Only one scan parameter or one initial-state dimension is active per search. Non-monotonic multidimensional delta debugging is future work. +- Built-in models are small validation examples. Their success does not validate a downstream scientific model. +- Seeds are explicit, but an external adapter can still be nondeterministic through threads, native libraries, unordered data, clocks, file systems, or unrecorded external state. +- Trace retention is capped and may omit earlier behavior. Configuration must preserve a scientifically adequate observation window. +- Classification thresholds can create boundary ambiguity. The scan stops refinement when a midpoint matches neither stable endpoint class, but it cannot remove classifier bias. +- Replay fixtures can expose model configuration and state. Review them before committing if a downstream adapter contains sensitive data. +- Name research found no exact package collision on 2026-08-01, but it is not legal or trademark advice. diff --git a/PRIOR_ART.md b/PRIOR_ART.md new file mode 100644 index 0000000..d20d09d --- /dev/null +++ b/PRIOR_ART.md @@ -0,0 +1,33 @@ +# Prior art and name audit + +Last verified: 2026-08-01. + +PhaseProbe is positioned narrowly: simulation-specific transition discovery, bounded counterexample minimization, deterministic replay, and regression-test generation. It is not a replacement for a simulator, ODE solver, sensitivity-analysis package, formal bifurcation package, property-based testing framework, or rigorous proof tool. + +## Name decision + +The `PhaseProbe` name is retained. Exact-name checks returned no repository named `phaseprobe` from the GitHub repository-search API and HTTP 404 (no registered project) from both the PyPI project JSON endpoint and npm registry on 2026-08-01. A broader web search found the descriptive term “phase probe” in accelerator instrumentation, but no material software-package collision for this developer tool. This is a practical collision audit, not a legal opinion or trademark clearance. PyPI publication remains explicitly out of scope. + +Evidence: [GitHub repository search](https://api.github.com/search/repositories?q=phaseprobe+in%3Aname), [PyPI project endpoint](https://pypi.org/pypi/phaseprobe/json), [npm registry endpoint](https://registry.npmjs.org/phaseprobe), and an example of the unrelated [accelerator phase-probe usage](https://indico.jacow.org/event/96/contributions/12067/). + +## Comparison + +| tool | primary job | target user | existing strength | overlap | remaining gap | PhaseProbe differentiation | last verified date | +| --- | --- | --- | --- | --- | --- | --- | --- | +| [Hypothesis](https://hypothesis.readthedocs.io/en/latest/) | Property-based test generation and shrinking | Python developers | Broad input strategies, edge-case generation, mature shrinking | Search and counterexample reduction | Does not provide a simulation trace/classification/replay artifact workflow by itself | Domain-specific bounded scans, trace evidence, replay fixtures, and generated pytest tests | 2026-08-01 | +| [SciPy `solve_ivp`](https://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.solve_ivp.html) | Numerical initial-value integration | Scientific Python users | Multiple adaptive ODE solvers and error controls | Produces trajectories for models | Does not classify regimes or turn discovered transitions into replayable regressions | PhaseProbe can sit above an adapter/solver; v0.1.0 examples use a documented fixed-step method to keep runtime dependencies at zero | 2026-08-01 | +| [Mesa](https://mesa.readthedocs.io/stable/) | Agent-based model construction, execution, visualization, and analysis | Agent-based modelers | Rich ABM components and data collection | Simulation configuration and repeated runs | No focused transition-to-replay-to-pytest pipeline in the core job | Adapter-oriented testing layer, not an ABM framework | 2026-08-01 | +| [NetLogo](https://docs.netlogo.org/) | Programmable multi-agent modeling environment | Researchers and educators | Mature modeling language, model library, and interactive environment | Parameter sweeps and qualitative behavior exploration | Developer-facing Python fixture/test generation is not its central job | Headless local regression evidence for adapters | 2026-08-01 | +| [cadCAD](https://cadcad.readthedocs.io/en/latest/) | Modeling complex adaptive and dynamical systems | System modelers | Structured generalized dynamical-system workflows | Parameterized simulation runs | No dedicated smallest-found transition fixture and pytest generation workflow | Testing pipeline around a small typed adapter | 2026-08-01 | +| [Golly](https://golly.sourceforge.io/) | Explore cellular automata | Cellular-automata users | Fast cross-platform CA exploration and many rule formats | Qualitative state evolution | Not a general simulation regression tool | Model-agnostic adapter and CI artifacts | 2026-08-01 | +| [py-pde](https://py-pde.readthedocs.io/en/latest/) | Solve PDEs including diffusion and stochastic equations | PDE modelers | Field abstractions, boundary conditions, and PDE solvers | Reaction/diffusion trajectory generation | Does not center on qualitative-transition minimization and pytest materialization | Consumes bounded observables/classifiers rather than replacing PDE solvers | 2026-08-01 | +| [PyDSTool / PyCont](https://pydstool.github.io/PyDSTool/UserDocumentation.html) | Dynamical-system simulation, continuation, and bifurcation analysis | Dynamical-systems researchers | Integrated trajectories, continuation, phase planes, and bifurcation tools | Parameter changes and bifurcation evidence | Formal analysis is broader and deeper, while direct developer regression-fixture output is not the primary job | Explicitly reports numerical brackets rather than formal bifurcation points and produces deterministic test artifacts | 2026-08-01 | +| [AUTO-07p](https://archive-dsweb.siam.org/Software/auto-07p.html) | Continuation and bifurcation analysis for ODEs | Applied mathematicians | Established numerical continuation and bifurcation capabilities | Transition analysis | Higher setup cost and a different goal from bounded black-box regression discovery | Lightweight black-box classification and replay; never presents itself as formal continuation | 2026-08-01 | +| [Attractors.jl](https://juliadynamics.github.io/DynamicalSystemsDocs.jl/attractors/stable/) | Find attractors, basins, basin boundaries, stability measures, and continuation | Nonlinear-dynamics researchers | Deep attractor and basin analysis | Regime and multistability exploration | Regression-test generation and Python replay fixtures are not the central workflow | Testing-focused evidence handoff with deliberately simpler analysis | 2026-08-01 | +| [SALib](https://github.com/SALib/SALib) | Global sensitivity analysis | Modelers and uncertainty-quantification practitioners | Sobol, Morris, FAST, and other established methods | Bounded input exploration and sensitivity | Sensitivity indices answer a different question from finding a reproducible class boundary | Searches a declared qualitative predicate and records the smallest case found in that search | 2026-08-01 | +| [Exploratory metamorphic testing](https://pmc.ncbi.nlm.nih.gov/articles/PMC7252536/) | Test relations among related scientific-simulation inputs and outputs | Scientific software teams | Addresses test-oracle difficulty using relations across executions | Baseline/perturbed twin runs and invariants | A research method, not a packaged transition/replay/pytest workflow | Implements a narrow, deterministic operational workflow without claiming to subsume metamorphic testing | 2026-08-01 | +| General record/replay tools (for example [VCR](https://github.com/vcr/vcr)) | Capture and replay external interactions | Application developers | Stable deterministic fixtures for I/O interactions | Fixture integrity and replay concepts | External-call cassettes do not re-execute a scientific model from model/config/seed/tolerances | Re-executes supported simulation adapters and verifies trace/classification hashes | 2026-08-01 | + +## Differentiation boundary + +PhaseProbe v0.1.0 offers bounded empirical evidence. It does not prove chaos, compute a formal Lyapunov exponent, prove global minimality, locate an exact bifurcation point, validate a scientific model against nature, or replace solver-convergence studies. Its “smallest” result always means the smallest reproducible change found by the declared finite search and refinement procedure. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd57571 --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# PhaseProbe + +> Find a simulation behavior boundary. Preserve it as a replay. Turn it into a test. + +Maintaining a simulation is risky when a tiny parameter or initial-condition change can cross a qualitative boundary while ordinary numeric assertions still look plausible. PhaseProbe runs a bounded, deterministic search, records exactly what it tested, and emits an offline report plus an executable pytest regression. + +```console +$ python -m pip install . +$ phaseprobe scan --example logistic +QUALITATIVE TRANSITION FOUND + +Model: logistic-map +Search dimension: r +Baseline regime: period-2 +Changed regime: period-4 +Replay: .phaseprobe/runs//replay.json + +$ phaseprobe generate-test .phaseprobe/runs//replay.json +$ python -m pytest -q tests/generated +1 passed +``` + +[![Terminal demo: scan, refine, replay, and generated pytest](assets/demo-static.png)](assets/demo.gif) + +No API key, LLM, GPU, Docker, account, telemetry, network connection, or hosted service is required at runtime. PhaseProbe 0.1.0 has no third-party runtime dependencies. + +## Five-minute quick start + +Requires Python 3.10 or newer on Windows or Linux. + +```bash +python -m pip install . +phaseprobe scan --example logistic +phaseprobe replay .phaseprobe/runs//replay.json +phaseprobe generate-test .phaseprobe/runs//replay.json +python -m pytest -q tests/generated +phaseprobe report .phaseprobe/runs/ +``` + +Replace `` with the directory printed by `scan`. The scan returns `0` when it successfully finds a transition. Add `--fail-on-finding` only when a finding should fail CI. + +The quick-start evidence is empirical: the built-in classifier finds a finite-time period-2/period-4 bracket for the logistic map, performs bounded binary refinement, repeats both endpoints, saves trace hashes and a versioned fixture, and generates a fixed pytest template. It does not claim an exact bifurcation point. + +## Commands + +| command | job | normal success | +| --- | --- | --- | +| `scan` | Bounded one-dimensional parameter scan, adjacent class-change detection, and stable bracket refinement | Finding or no finding, exit `0` | +| `perturb` | Baseline/perturbed twin runs over bounded initial-state changes | Finding or no finding, exit `0` | +| `check` | Execute a declared configuration policy for CI | Exit `1` only when policy fails | +| `replay` | Validate fixture integrity and re-execute model, parameters, seed, initial state, tolerances, and retention | Matching class and exact retained trace hashes | +| `generate-test` | Validate and copy a fixture into a non-extensible pytest template | Executable test under `tests/generated/` | +| `report` | Regenerate terminal, versioned JSON, and self-contained offline HTML evidence | Local report files | + +Common options: + +```console +phaseprobe scan --config examples/configs/logistic-scan.json +phaseprobe perturb --example lorenz --json +phaseprobe check --example predator-prey +phaseprobe scan --example logistic --fail-on-finding +``` + +Exit codes are stable: `0` completed, `1` declared policy or explicit `--fail-on-finding`, `2` invalid input/configuration, `3` numerical failure, and `4` internal PhaseProbe defect. + +## Included deterministic examples + +| example | command | positive evidence | negative control | direct technical source | +| --- | --- | --- | --- | --- | +| Logistic map | `phaseprobe scan --example logistic` | Finite-time period-2 to period-4 classification bracket | `--example logistic-negative` stays period-2 over its declared range | [May, 1976](https://www.nature.com/articles/261459a0) | +| Lorenz system | `phaseprobe perturb --example lorenz` | Small initial separation exceeds the declared finite-time trajectory-distance threshold | Short window plus unreachable threshold reports no finding | [Lorenz, 1963](https://journals.ametsoc.org/view/journals/atsc/20/2/1520-0469_1963_020_0130_dnf_2_0_co_2.xml) | +| Predator–prey | `phaseprobe check --example predator-prey` | Refined RK4 step preserves the analytic first integral within tolerance | Coarse step fails the invariant-drift policy | [Lotka, 1920](https://doi.org/10.1073/pnas.6.7.410) | +| Genetic toggle | `phaseprobe perturb --example toggle` | Bounded initial-state perturbation reaches the opposite dominant state | Smaller declared range stays in the baseline basin | [Gardner, Cantor & Collins, 2000](https://www.nature.com/articles/35002131) | + +Each configuration records the seed, fixed integration/iteration settings, tolerances, burn-in, observation window, classification rule, refinement rule, invalid-state policy, and trace cap. See [examples/README.md](examples/README.md) for equations and interpretation. + +### Measured reference run + +On 2026-08-01 with CPython 3.12.13 on Windows, the instrumented logistic quick-start scan completed in 10.114 seconds with a 34.81 MiB peak process-tree working set. All eight positive/negative example commands completed in 12.238 seconds. The final 38-test suite with branch coverage completed in 64.86 seconds at 87.92% coverage. These are observations from one local run, not cross-machine performance claims; the generated transcript is in [assets/demo-session.txt](assets/demo-session.txt). + +## Evidence artifacts + +Every execution is bounded under `.phaseprobe/runs//`: + +```text +run.json complete versioned evidence +findings.json compact finding or negative result +replay.json schema-versioned integrity-protected fixture +trace.jsonl capped baseline/changed retained points +report.html self-contained offline report +manifest.json sizes and SHA-256 hashes +``` + +Model names used for generated test paths are sanitized. The generated source comes from a fixed template; configuration strings never become executable Python. + +## What PhaseProbe adds—and what it does not + +| existing category | established strength | PhaseProbe’s narrower job | +| --- | --- | --- | +| Solvers such as SciPy | Integrate differential equations with mature numerical methods | Consume an adapter’s trajectories and preserve a discovered qualitative boundary as test evidence | +| Property-based testing such as Hypothesis | Generate and shrink broad input domains | Search declared simulation dimensions with model-specific observables and classes | +| Bifurcation/attractor tools such as AUTO, PyDSTool, and Attractors.jl | Deep continuation, bifurcation, attractor, and basin analysis | Lightweight black-box numerical brackets plus replay and pytest materialization | +| Sensitivity tools such as SALib | Quantify input contributions to output variation | Find a reproducible qualitative predicate change | +| Simulation environments such as Mesa, NetLogo, cadCAD, and Golly | Build, run, and explore models | Test adapters without becoming another simulation environment | + +The evidence-backed audit is in [PRIOR_ART.md](PRIOR_ART.md). + +## Adapter interface + +Adapters implement a small typed protocol: + +```python +class ModelAdapter(Protocol): + name: str + identity: str + dimensions: tuple[str, ...] + + def initial_state(self, config, seed): ... + def step(self, state, parameters, dt): ... + def observe(self, state): ... + def classify(self, trace, tolerances): ... + def invariants(self, trace, parameters, tolerances): ... +``` + +The explicit serializable state tuple makes bounded perturbation, invalid-value detection, trace hashing, and replay straightforward. See [ARCHITECTURE.md](ARCHITECTURE.md) for extension guidance. + +## Scientific scope + +PhaseProbe uses these terms deliberately: + +- finite-time trajectory divergence: measured separation over the recorded window; +- sensitive-dependence evidence: repeatable finite-time divergence under a declared small perturbation, not by itself a proof of chaos; +- numerical instability: behavior attributable to the numerical method or step size; +- invariant violation: a declared conservation or boundedness rule failed; +- qualitative regime change: the adapter’s declared classifier changed; +- bifurcation evidence: a numerical class-change bracket, not an exact bifurcation point; +- invalid integration: NaN, infinity, overflow, state-shape mismatch, or hard bound breach; +- solver failure: the step method could not advance a valid state. + +The Lorenz example reports `finite-time divergence rate`; it does not compute or claim a Lyapunov exponent. “Smallest” always means smallest found within the declared bounded search. Read [SCIENTIFIC_METHODS.md](SCIENTIFIC_METHODS.md) and [LIMITATIONS.md](LIMITATIONS.md). + +## Development + +```bash +python -m pip install -e ".[dev]" +python -m ruff format --check . +python -m ruff check . +python -m mypy src tests +python -m pytest --cov=phaseprobe +python -m build +python scripts/check_links.py +python scripts/hygiene.py +``` + +See [CONTRIBUTING.md](CONTRIBUTING.md) and the concrete good-first-issue templates in `.github/ISSUE_TEMPLATE/`. + +## License and citation + +Apache-2.0. Copyright 2026 Ali. Citation metadata is in [CITATION.cff](CITATION.cff). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..a2336eb --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,14 @@ +# Roadmap + +## After 0.1.0 + +- External adapter discovery with an explicit safe registration mechanism. +- Non-monotonic multi-dimension search: coarse exploration, stable bracket discovery, local refinement, repeatability, and delta debugging. +- Optional classification/tolerance replay modes alongside strict trace hashes. +- Adapter-provided solver convergence studies and paired step-size evidence. +- Streaming trace summaries for expensive simulators without weakening the configured cap. +- JSON Schema publication and compatibility migration tooling. +- JUnit/SARIF policy output for CI annotation. +- More reference adapters, accepted only with positive cases, negative controls, invalid-integration tests, and primary citations. + +PyPI publication is intentionally absent and requires separate authorization. diff --git a/RUN_LOG.md b/RUN_LOG.md new file mode 100644 index 0000000..b75de24 --- /dev/null +++ b/RUN_LOG.md @@ -0,0 +1,104 @@ +# PhaseProbe v0.1.0 run log + +Evidence is recorded only from executed commands. Private research paths, PDFs, extracted text, credentials, and machine-specific absolute paths are excluded from this public log. + +## Scope and research — 2026-08-01 + +- Created only the PhaseProbe project root, then `.gitignore`, `AGENTS.md`, and this log; read all three back before research or implementation. +- Created the ignored private research ledger and source directories before retrieval. Four requested PDFs were retrieved privately, SHA-256 hashed, and excluded from Git. The unidentified PDF was identified from internal metadata rather than its URL. No PDF, extracted text, quotation, illustration, or chapter substitute is tracked. +- Verified prior art and public technical claims against primary papers or official project documentation. `PRIOR_ART.md` records direct links and a dated comparison. +- Exact-name registry checks on 2026-08-01 returned no `phaseprobe` GitHub repository and HTTP 404 from PyPI and npm project endpoints. The name decision is documented as a practical collision audit, not legal clearance. +- Installed uv 0.11.10 from its official release artifact under ignored `.tools/`; downloaded artifact SHA-256: `7A0C424C7BC55A74751F13592235953EBBE182FA00355F7AE3FB7AB734A51638`. +- Installed GitHub CLI 2.97.0 from its official release artifact under ignored `.tools/`; downloaded artifact SHA-256: `35D7FE05C4DD1411FFDA1E73DFC7C6F44B75C936CA51FA6595C657FDC0350CEC`. +- Installed CPython 3.12.13, environments, dependencies, and caches only within ignored project directories. `uv lock --check` and `uv sync --frozen --all-extras` passed. + +## Architecture freeze + +v0.1.0 uses a zero-runtime-dependency Python `src/` package with typed model adapters, canonical JSON configuration, explicit seeds, deterministic fixed-step execution, finite-state validation, capped traces, exact trace hashes, bounded one-dimensional scan/perturb search, repeatability confirmation, policy checks, integrity-protected replay fixtures, a fixed pytest generator, and terminal/JSON/offline-HTML reports. + +The public commands are `scan`, `perturb`, `check`, `replay`, `generate-test`, and `report`. The stable exit contract is 0 completed, 1 policy/explicit fail-on-finding, 2 invalid input, 3 numerical failure, and 4 internal defect. + +## Measured numerical evidence + +Command suite: all eight built-in positive and negative-control commands, each with JSON output and its expected exit code. + +| example | observed result | baseline | changed/control | exit | +| --- | --- | --- | --- | ---: | +| logistic | qualitative transition found | period-2 | period-4 | 0 | +| logistic-negative | no qualitative transition found | period-2 | n/a | 0 | +| lorenz | finite-time trajectory divergence found | two-lobe finite-time trajectory | same qualitative class; separation predicate triggered | 0 | +| lorenz-negative | no sensitive perturbation found | two-lobe finite-time trajectory | threshold not reached | 0 | +| predator-prey | check policy passed | bounded positive oscillation | no invariant violation | 0 | +| predator-prey-negative | check policy failed | bounded positive oscillation | coarse-step invariant drift | 1 | +| toggle | qualitative state switch found | u-dominant | v-dominant | 0 | +| toggle-negative | no sensitive perturbation found | u-dominant | u-dominant | 0 | + +All eight commands completed in 12.238 seconds. The logistic classification bracket was `3.449477539 .. 3.449478027`, with a `4.882812501e-07` final endpoint separation. This is finite-time classifier evidence, not an exact bifurcation point. The Lorenz result is explicitly a finite-time divergence rate, not a Lyapunov exponent. + +The predator–prey refined RK4 configuration passed its declared `1e-7` invariant-drift tolerance; the deliberately coarse negative control failed that same policy. Invalid-state tests covered hard-limit failure, and the engine checks NaN, infinity, arithmetic/domain failures, and state-dimension mismatch. + +## Performance evidence + +- Instrumented `phaseprobe scan --example logistic`: exit 0, 10.114 seconds wall time, 34.81 MiB peak process-tree working set, and 20.90 MiB peak process-tree private bytes. Monitoring followed the Windows virtual-environment launcher and its child processes. +- Repository-native demo generation: 14.957 seconds total. Its embedded real-command transcript measured scan 9.879 seconds, replay 1.328 seconds, generation 1.330 seconds, and generated pytest 1.631 seconds. +- Full example suite: 12.238 seconds. +- Final coverage suite: 38 tests in 64.86 seconds. The quick-start target of 30 seconds, full-example target of three minutes, and 512 MiB memory target were met on this measured environment. + +These are single-environment observations, not cross-machine benchmark claims. + +## Validation gates + +| gate | exact command or method | observed result | +| --- | --- | --- | +| format | `python -m ruff format --check .` | 28 files already formatted | +| lint | `python -m ruff check .` | passed | +| strict typing | `python -m mypy src tests` | passed; 25 source files | +| tests and branch coverage | `python -m pytest --cov=phaseprobe --cov-report=term-missing` | 38 passed in 64.86 seconds; 87.92% coverage; 85% gate met | +| generated test | `python -m pytest -q tests/generated` | 1 passed; committed fixture replays exact class and trace hashes | +| configuration lock | `uv lock --check` | passed | +| frozen development install | `uv sync --frozen --all-extras` | passed | +| version | `python -m phaseprobe --version` | `phaseprobe 0.1.0` | +| examples | eight positive/negative commands | expected statuses and exits; 12.238 seconds | +| quick start | instrumented positive logistic scan | 10.114 seconds; 34.81 MiB peak working set | +| replay | positive logistic fixture | both endpoints matched classification, model identity, and exact retained trace hashes | +| test generation | positive logistic fixture | fixed-template test and copied integrity fixture created; pytest passed | +| reports | terminal, JSON, HTML regeneration tests | passed; HTML contains no script or CDN reference | +| demo | `python scripts/generate_demo.py` | GIF, static PNG, sanitized real-command transcript created | +| documentation links | `python scripts/check_links.py` | 15 Markdown files, 22 external links inventoried, no missing local links | +| privacy/secret/large file | `python scripts/hygiene.py` | 77 tracked public files scanned, no issues, no file over 1 MB | +| package contents | wheel/SDist archive inspection | wheel 32 files, source archive 76 files, no PDF/private research/tool/environment path | +| runtime dependencies | wheel metadata plus clean uv environment | no runtime `Requires-Dist`; only PhaseProbe installed before test tooling | +| packed install | wheel into isolated project-local environment | version, positive scan, strict replay, test generation, and generated pytest passed | +| packed quick start | isolated wheel environment | 9.131 seconds | + +## Package artifacts + +- Wheel: `phaseprobe-0.1.0-py3-none-any.whl`, 42,313 bytes, final SHA-256 `207F303D70ABB5D9CCBC019BCF1AB9AF51D769838054283DF3A7C5D5D79CBA18`. +- Source distribution: `phaseprobe-0.1.0.tar.gz`, 352,805 bytes, final SHA-256 `4BC793351FBEAC740A4ECC15A681AFE567ACBE3E94D0DCD54262667BAECDA6A5`. + +## Failures and repairs + +1. The first editable build failed because package metadata referenced a README that had not yet been written. Added the README and reran successfully. +2. The first broad Ruff invocation traversed ignored project-local tool/cache files. Added explicit `.tools`, `.cache`, environment, private research, output, and build exclusions; all subsequent `ruff .` gates were bounded to public project content. +3. The first generated-test subprocess passed an absolute Windows test path; pytest misinterpreted it and inspected a protected compatibility directory. Ran the subprocess from the generated directory with a relative filename. +4. Initial measured coverage was 82.19%, below the declared 85% gate. Added command-level replay, test-generation, report, failure-code, and integrity tests; final coverage is 87.92%. +5. A nested generated-pytest process inherited coverage-control variables and produced incompatible combine data. Removed only those instrumentation variables from the nested test environment; functional behavior remained unchanged. +6. The first packed-smoke script used an unavailable old-PowerShell parameter. Replaced it with an explicit artifact count and compatible selection. +7. A uv-created smoke environment intentionally had no `pip` module, so `python -m pip freeze` was not a valid dependency query. Repeated the gate with `uv pip freeze --python`; wheel metadata independently confirmed zero runtime dependencies. +8. The first committed generated test failed Ruff import-block normalization. Updated the generator template, regenerated the test, and restarted the full final gate. +9. The first staged `git diff --check` found extra blank lines at EOF and CRLF-sensitive demo/fixture lines. Normalized the reported text endings, added repository-level LF attributes, and reran the full local gates; the final staged diff check passed. + +## Remote publication evidence + +- Verified GitHub authentication as the required `aliengineering-byte` account before initializing Git. +- Created the public `aliengineering-byte/phaseprobe` repository with `main` as its default branch, then pushed `agent/phaseprobe-v0.1.0`. +- Opened draft pull request #1 from the release branch to `main`. +- GitHub Actions run `30719800789` passed all seven jobs: package-and-hygiene plus Windows and Ubuntu on Python 3.10, 3.12, and 3.14. +- Tag creation, release publication, and the unauthenticated public-clone verification necessarily occur after this release commit is frozen; their evidence is reported in the final release handoff rather than retroactively changing the tagged source. + +## Skipped or bounded checks + +- External documentation links are inventoried in CI but not fetched there to avoid network-flaky builds; the prior-art/name audit used live official/primary sources during this release run. +- No formal trademark or legal clearance was performed. +- No PyPI or npm publication was attempted or authorized. +- The tagged release remains immutable; post-tag release and fresh-public-clone evidence is therefore external to this tracked pre-release log. diff --git a/SCIENTIFIC_METHODS.md b/SCIENTIFIC_METHODS.md new file mode 100644 index 0000000..5bec841 --- /dev/null +++ b/SCIENTIFIC_METHODS.md @@ -0,0 +1,59 @@ +# Scientific methods + +PhaseProbe 0.1.0 produces bounded computational evidence. It does not perform formal verification, model validation against observations, or computer-assisted proof. + +## Execution record + +Every result records the model identity, parameters, initial state, explicit seed, integration or iteration settings, tolerance map, burn-in, retained observation window, classification rule, refinement rule, invalid-state policy, capped trace, trace hash, and repeatability evidence. JSON objects are serialized with sorted keys and disallow NaN. + +## Integration and iteration + +The logistic map advances its exact recurrence once per declared step. The Lorenz, predator–prey, and genetic-toggle examples use classical fixed-step fourth-order Runge–Kutta (RK4). Fixed steps make the execution path transparent and dependency-free; they do not guarantee an accurate solution. The predator–prey positive/negative pair demonstrates why step refinement and an analytic invariant matter. + +PhaseProbe stops a run as `invalid integration` when the adapter returns a wrong state dimension, NaN, infinity, a value beyond the declared hard state limit, or raises an arithmetic/domain failure. This is distinct from an invariant violation on an otherwise finite trajectory. + +## Qualitative classification + +Classification belongs to the adapter and is configured by explicit tolerances: + +- Logistic: test retained tail recurrence for periods 1, 2, 4, 8, and 16 in that order. Failure to converge within the finite burn-in/window is `aperiodic-or-unresolved`, not a declaration of chaos. +- Lorenz: label finite-time lobe visitation only. The perturbation predicate is Euclidean twin-trajectory separation. +- Predator–prey: require retained positive populations; separately evaluate drift of the analytic Lotka–Volterra first integral. +- Genetic toggle: compare terminal `u-v` against a declared dominance tolerance. + +Classifier output is model- and window-specific. A class change is a `qualitative regime change` under that rule, not automatically a physical phase transition. + +## Parameter scanning + +`scan` evaluates an inclusive deterministic one-dimensional grid and finds the first adjacent class change. It then probes midpoints while the midpoint reproduces one of the two stable endpoint classes. If a midpoint reproduces neither class, refinement stops instead of relabeling ambiguity. Final endpoints are rerun and their exact trace hashes compared. + +The output is a numerical transition bracket. It is `bifurcation evidence` only when the classifier and model justify that interpretation, and it is never reported as an exact bifurcation point. + +## Initial-state perturbation + +`perturb` runs a baseline plus candidates ordered from smallest to largest over a linear or logarithmic declared range. A predicate may require a target class or a maximum twin-trajectory distance. When a non-trigger/trigger interval exists, deterministic binary refinement narrows it. The smallest triggering candidate is repeated exactly. + +For Lorenz, PhaseProbe computes + +```text +finite-time divergence rate = log(max separation / initial separation) / elapsed window +``` + +as a descriptive finite-window quantity. The method does not implement tangent-space evolution, renormalization, asymptotic limiting, or convergence validation required to claim a Lyapunov exponent. + +## Counterexample minimization + +For one monotonic declared predicate, bracketed binary refinement is used. PhaseProbe does not assume every simulation predicate is monotonic. v0.1.0 only automates one-dimensional scans and one active perturbation dimension; it therefore does not claim multidimensional delta debugging. The reported phrase is always “smallest reproducible change found within the declared search space.” + +## Invariants + +An invariant result includes its name, pass/fail value, measured value, tolerance, and detail. Some checks, such as the Lorenz finite-state bound, are explicitly diagnostic bounds rather than mathematical invariants. Passing an invariant does not validate the model or prove solver convergence. + +## Primary sources for examples + +- Robert M. May, “Simple mathematical models with very complicated dynamics,” *Nature* 261, 459–467 (1976), [doi:10.1038/261459a0](https://www.nature.com/articles/261459a0). +- Edward N. Lorenz, “Deterministic Nonperiodic Flow,” *Journal of the Atmospheric Sciences* 20, 130–141 (1963), [publisher record](https://journals.ametsoc.org/view/journals/atsc/20/2/1520-0469_1963_020_0130_dnf_2_0_co_2.xml). +- Alfred J. Lotka, “Analytical Note on Certain Rhythmic Relations in Organic Systems,” *PNAS* 6, 410–415 (1920), [doi:10.1073/pnas.6.7.410](https://doi.org/10.1073/pnas.6.7.410). +- Timothy S. Gardner, Charles R. Cantor, and James J. Collins, “Construction of a genetic toggle switch in *Escherichia coli*,” *Nature* 403, 339–342 (2000), [doi:10.1038/35002131](https://www.nature.com/articles/35002131). + +Private books informed only bounded conceptual orientation. No public claim depends on them, and no PDF, extracted text, quotation, illustration, or chapter substitute is distributed. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dba023a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Security policy + +## Supported version + +Security fixes target the latest 0.1.x release until a newer minor release replaces it. + +## Reporting + +Use GitHub’s private vulnerability reporting feature for the public repository. Do not open a public issue with an exploit, credential, sensitive fixture, or personal data. No password or token is ever required by PhaseProbe maintainers in chat. + +Include the affected version, operating system, minimal reproduction, impact, and suggested remediation if known. Ali will acknowledge a valid report through GitHub and coordinate disclosure after a fix is available. + +## Security boundary + +PhaseProbe executes installed Python adapter code with the user’s permissions. Configuration files are data, but an adapter is code and must be reviewed before installation. Generated tests use a fixed template and sanitized names; replay fixtures are integrity checked before execution. HTML reports contain escaped data and no script or external resource. + +Review downstream fixtures before committing because model parameters and state may be sensitive. PhaseProbe has no telemetry and performs no runtime network calls. diff --git a/assets/demo-session.txt b/assets/demo-session.txt new file mode 100644 index 0000000..6795736 --- /dev/null +++ b/assets/demo-session.txt @@ -0,0 +1,35 @@ +$ phaseprobe scan --example logistic +QUALITATIVE TRANSITION FOUND + +Model: logistic-map +Evidence: qualitative-regime-change +Search dimension: r +Stable bracket: 3.449477539 .. 3.449478027 +Smallest reproducible change found: 4.882812501e-07 +Baseline regime: period-2 +Changed regime: period-4 +Invariant violations: 0 +Repeatable: true +Replay: .phaseprobe/runs//replay.json + +Scope: Smallest reproducible separation found by the declared bounded scan and binary refinement; not a proof of a globally minimal perturbation or exact bifurcation point. +HTML report: .phaseprobe/runs//report.html + +$ phaseprobe replay .phaseprobe/runs//replay.json +REPLAY VERIFIED + +Model: logistic-map +baseline: classification=True, trace-hash=True, model-identity=True +changed: classification=True, trace-hash=True, model-identity=True + +$ phaseprobe generate-test .phaseprobe/runs//replay.json +PYTEST REGRESSION GENERATED + +Test: tests/generated/test_logistic_map_transition.py +Replay fixture: tests/generated/fixtures/logistic_map-replay.json + +$ python -m pytest -q tests/generated +. [100%] +1 passed in 0.97s + +Measured generation run: scan=9.879s, replay=1.328s, generate=1.330s, pytest=1.631s. diff --git a/assets/demo-static.png b/assets/demo-static.png new file mode 100644 index 0000000..fccdfc7 Binary files /dev/null and b/assets/demo-static.png differ diff --git a/assets/demo.gif b/assets/demo.gif new file mode 100644 index 0000000..8ca0471 Binary files /dev/null and b/assets/demo.gif differ diff --git a/assets/social-preview.jpg b/assets/social-preview.jpg new file mode 100644 index 0000000..a6cc9ff Binary files /dev/null and b/assets/social-preview.jpg differ diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..85e1752 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,40 @@ +# Deterministic examples + +The `configs/` files are readable copies of the examples embedded in the wheel. All commands run without network access. + +## Logistic map + +```text +x[n+1] = r x[n] (1 - x[n]) +``` + +`phaseprobe scan --example logistic` searches a narrow declared `r` range and classifies retained tail recurrence. Its positive case brackets a finite-time period-2/period-4 classification change; its negative control stays period-2. The long burn-in reduces, but cannot eliminate, critical-slowing bias. Source: [May (1976)](https://www.nature.com/articles/261459a0). + +## Lorenz system + +```text +dx/dt = sigma (y - x) +dy/dt = x (rho - z) - y +dz/dt = x y - beta z +``` + +`phaseprobe perturb --example lorenz` compares twin fixed-step RK4 trajectories separated only in initial `x`. The finding is a finite-time Euclidean-separation threshold and rate, not a Lyapunov exponent. The negative control shortens the window and declares an unreachable threshold. Source: [Lorenz (1963)](https://journals.ametsoc.org/view/journals/atsc/20/2/1520-0469_1963_020_0130_dnf_2_0_co_2.xml). + +## Predator–prey + +```text +dx/dt = alpha x - beta x y +dy/dt = delta x y - gamma y +H = delta x - gamma log(x) + beta y - alpha log(y) +``` + +`phaseprobe check --example predator-prey` checks positive populations and retained drift in the analytic first integral. Its positive case uses `dt=0.005`; the deliberate coarse-step negative control uses `dt=0.25` and fails policy. This tests numerical integration quality, not ecological validity. Source: [Lotka (1920)](https://doi.org/10.1073/pnas.6.7.410). + +## Genetic toggle + +```text +du/dt = alpha_u / (1 + v^hill_v) - u +dv/dt = alpha_v / (1 + u^hill_u) - v +``` + +`phaseprobe perturb --example toggle` increases initial `v` from a `u`-dominant baseline, then refines the first perturbation that ends `v`-dominant. The negative control remains in the original basin. This is a dimensionless illustrative mutual-repression model, not a calibrated biological prediction. Source: [Gardner, Cantor & Collins (2000)](https://www.nature.com/articles/35002131). diff --git a/examples/configs/logistic-negative.json b/examples/configs/logistic-negative.json new file mode 100644 index 0000000..edd9863 --- /dev/null +++ b/examples/configs/logistic-negative.json @@ -0,0 +1,27 @@ +{ + "schema_version": "1.0", + "model": "logistic-map", + "seed": 17, + "parameters": {"r": 3.2}, + "model_config": {"initial_state": {"x": 0.2}}, + "simulation": { + "steps": 128, + "burn_in": 1500, + "dt": 1.0, + "sample_every": 1, + "trace_cap": 128, + "hard_state_limit": 10.0 + }, + "tolerances": {"period": 1e-9}, + "scan": { + "parameter": "r", + "start": 3.2, + "stop": 3.4, + "points": 5, + "refine_iterations": 6, + "repeatability": 2 + }, + "classification_rule": "Finite-period tail classifier used by the positive logistic example.", + "refinement_rule": "Refine only if an adjacent qualitative class change is observed.", + "invalid_state_policy": "Abort on invalid integration." +} diff --git a/examples/configs/logistic-scan.json b/examples/configs/logistic-scan.json new file mode 100644 index 0000000..9ddce25 --- /dev/null +++ b/examples/configs/logistic-scan.json @@ -0,0 +1,27 @@ +{ + "schema_version": "1.0", + "model": "logistic-map", + "seed": 17, + "parameters": {"r": 3.448}, + "model_config": {"initial_state": {"x": 0.2}}, + "simulation": { + "steps": 256, + "burn_in": 500000, + "dt": 1.0, + "sample_every": 1, + "trace_cap": 256, + "hard_state_limit": 10.0 + }, + "tolerances": {"period": 1e-9}, + "scan": { + "parameter": "r", + "start": 3.448, + "stop": 3.452, + "points": 3, + "refine_iterations": 12, + "repeatability": 2 + }, + "classification_rule": "Smallest detected period in {1,2,4,8,16} whose retained tail repeats within absolute tolerance; otherwise unresolved/aperiodic.", + "refinement_rule": "First adjacent coarse class change, followed by up to twelve deterministic binary probes. Refinement stops rather than relabeling a midpoint that reproduces neither stable endpoint class.", + "invalid_state_policy": "Abort as invalid integration on NaN, infinity, overflow, dimension mismatch, or hard-state-limit breach." +} diff --git a/examples/configs/lorenz-negative.json b/examples/configs/lorenz-negative.json new file mode 100644 index 0000000..248aadd --- /dev/null +++ b/examples/configs/lorenz-negative.json @@ -0,0 +1,30 @@ +{ + "schema_version": "1.0", + "model": "lorenz", + "seed": 23, + "parameters": {"sigma": 10.0, "rho": 28.0, "beta": 2.6666666666666665}, + "model_config": {"initial_state": {"x": 1.0, "y": 1.0, "z": 1.0}}, + "simulation": { + "steps": 1000, + "burn_in": 0, + "dt": 0.005, + "sample_every": 2, + "trace_cap": 500, + "hard_state_limit": 1000.0 + }, + "tolerances": {"state_bound": 100.0}, + "perturb": { + "dimension": "x", + "start": 1e-8, + "stop": 0.0001, + "points": 5, + "scale": "log", + "predicate": "finite-time-divergence", + "divergence_threshold": 1000.0, + "refine_iterations": 4, + "repeatability": 2 + }, + "classification_rule": "Finite-time lobe visitation only.", + "refinement_rule": "No refinement when the declared separation threshold is not reached.", + "invalid_state_policy": "Abort on invalid integration." +} diff --git a/examples/configs/lorenz-perturb.json b/examples/configs/lorenz-perturb.json new file mode 100644 index 0000000..ebb514e --- /dev/null +++ b/examples/configs/lorenz-perturb.json @@ -0,0 +1,30 @@ +{ + "schema_version": "1.0", + "model": "lorenz", + "seed": 23, + "parameters": {"sigma": 10.0, "rho": 28.0, "beta": 2.6666666666666665}, + "model_config": {"initial_state": {"x": 1.0, "y": 1.0, "z": 1.0}}, + "simulation": { + "steps": 8000, + "burn_in": 0, + "dt": 0.005, + "sample_every": 2, + "trace_cap": 4000, + "hard_state_limit": 1000.0 + }, + "tolerances": {"state_bound": 100.0}, + "perturb": { + "dimension": "x", + "start": 1e-8, + "stop": 0.0001, + "points": 5, + "scale": "log", + "predicate": "finite-time-divergence", + "divergence_threshold": 1.0, + "refine_iterations": 8, + "repeatability": 2 + }, + "classification_rule": "Report retained finite-time lobe visitation; the finding predicate is Euclidean twin-trajectory separation, not a qualitative-class change.", + "refinement_rule": "Evaluate ascending logarithmic perturbations; refine a non-trigger/trigger bracket when one exists and repeat the smallest trigger.", + "invalid_state_policy": "Abort on NaN, infinity, overflow, or declared hard-state-limit breach." +} diff --git a/examples/configs/predator-prey-check.json b/examples/configs/predator-prey-check.json new file mode 100644 index 0000000..043497b --- /dev/null +++ b/examples/configs/predator-prey-check.json @@ -0,0 +1,25 @@ +{ + "schema_version": "1.0", + "model": "predator-prey", + "seed": 31, + "parameters": {"alpha": 1.1, "beta": 0.4, "delta": 0.1, "gamma": 0.4}, + "model_config": {"initial_state": {"prey": 10.0, "predator": 5.0}}, + "simulation": { + "steps": 4000, + "burn_in": 0, + "dt": 0.005, + "sample_every": 1, + "trace_cap": 4000, + "hard_state_limit": 1000.0 + }, + "tolerances": {"invariant_drift": 1e-7}, + "check": {"analysis": "invariants"}, + "policy": { + "forbid_findings": true, + "require_finding": false, + "require_invariants": true + }, + "classification_rule": "Both retained populations remain strictly positive, classified as a bounded positive oscillation.", + "refinement_rule": "Fixed-step RK4 at dt=0.005; compare the analytic first integral against its initial retained value.", + "invalid_state_policy": "Abort on invalid integration; fail policy on invariant drift or non-positive population." +} diff --git a/examples/configs/predator-prey-negative.json b/examples/configs/predator-prey-negative.json new file mode 100644 index 0000000..68cd6c3 --- /dev/null +++ b/examples/configs/predator-prey-negative.json @@ -0,0 +1,25 @@ +{ + "schema_version": "1.0", + "model": "predator-prey", + "seed": 31, + "parameters": {"alpha": 1.1, "beta": 0.4, "delta": 0.1, "gamma": 0.4}, + "model_config": {"initial_state": {"prey": 10.0, "predator": 5.0}}, + "simulation": { + "steps": 80, + "burn_in": 0, + "dt": 0.25, + "sample_every": 1, + "trace_cap": 80, + "hard_state_limit": 1000.0 + }, + "tolerances": {"invariant_drift": 1e-7}, + "check": {"analysis": "invariants"}, + "policy": { + "forbid_findings": true, + "require_finding": false, + "require_invariants": true + }, + "classification_rule": "Same boundedness classifier as the refined-solver positive control.", + "refinement_rule": "Deliberately coarse fixed step demonstrates the invariant-drift policy control.", + "invalid_state_policy": "Abort on invalid integration; otherwise fail the declared invariant policy." +} diff --git a/examples/configs/toggle-negative.json b/examples/configs/toggle-negative.json new file mode 100644 index 0000000..51d4b3a --- /dev/null +++ b/examples/configs/toggle-negative.json @@ -0,0 +1,30 @@ +{ + "schema_version": "1.0", + "model": "genetic-toggle", + "seed": 47, + "parameters": {"alpha_u": 3.0, "alpha_v": 3.0, "hill_u": 2.0, "hill_v": 2.0}, + "model_config": {"initial_state": {"u": 3.0, "v": 0.2}}, + "simulation": { + "steps": 1500, + "burn_in": 0, + "dt": 0.02, + "sample_every": 2, + "trace_cap": 750, + "hard_state_limit": 100.0 + }, + "tolerances": {"dominance": 0.05, "state_bound": 10.0}, + "perturb": { + "dimension": "v", + "start": 0.01, + "stop": 0.5, + "points": 6, + "scale": "linear", + "predicate": "classification-change", + "divergence_threshold": 1.0, + "refine_iterations": 6, + "repeatability": 2 + }, + "classification_rule": "Same terminal dominance classifier as the positive toggle example.", + "refinement_rule": "No refinement if all bounded perturbations retain the baseline qualitative class.", + "invalid_state_policy": "Abort on invalid integration." +} diff --git a/examples/configs/toggle-perturb.json b/examples/configs/toggle-perturb.json new file mode 100644 index 0000000..1049856 --- /dev/null +++ b/examples/configs/toggle-perturb.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "model": "genetic-toggle", + "seed": 47, + "parameters": {"alpha_u": 3.0, "alpha_v": 3.0, "hill_u": 2.0, "hill_v": 2.0}, + "model_config": {"initial_state": {"u": 3.0, "v": 0.2}}, + "simulation": { + "steps": 1500, + "burn_in": 0, + "dt": 0.02, + "sample_every": 2, + "trace_cap": 750, + "hard_state_limit": 100.0 + }, + "tolerances": {"dominance": 0.05, "state_bound": 10.0}, + "perturb": { + "dimension": "v", + "start": 0.1, + "stop": 4.0, + "points": 9, + "scale": "linear", + "predicate": "classification-change", + "target_classification": "v-dominant", + "divergence_threshold": 1.0, + "refine_iterations": 8, + "repeatability": 2 + }, + "classification_rule": "Classify the terminal state as u-dominant, v-dominant, or balanced using a declared absolute dominance tolerance.", + "refinement_rule": "Ascending bounded v perturbations, then bracketed binary refinement and exact repeatability confirmation.", + "invalid_state_policy": "Abort on invalid integration or concentration outside the hard state limit." +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..75e27d3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,114 @@ +[build-system] +requires = ["hatchling>=1.27,<2"] +build-backend = "hatchling.build" + +[project] +name = "phaseprobe" +version = "0.1.0" +description = "Find reproducible qualitative simulation transitions and turn them into regression tests." +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +authors = [{ name = "Ali" }] +keywords = ["simulation", "testing", "dynamical-systems", "reproducibility", "regression-testing"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "License :: OSI Approved :: Apache Software License", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering", + "Topic :: Software Development :: Testing", +] +dependencies = [] + +[project.optional-dependencies] +dev = [ + "build==1.3.0", + "mypy==1.17.1", + "pytest==8.4.1", + "pytest-cov==6.2.1", + "ruff==0.12.7", +] +demo = ["Pillow==11.3.0"] + +[project.scripts] +phaseprobe = "phaseprobe.cli:main" + +[project.urls] +Documentation = "https://github.com/aliengineering-byte/phaseprobe#readme" +Issues = "https://github.com/aliengineering-byte/phaseprobe/issues" +Source = "https://github.com/aliengineering-byte/phaseprobe" + +[tool.hatch.build.targets.wheel] +packages = ["src/phaseprobe"] + +[tool.hatch.build.targets.sdist] +include = [ + "/.github", + "/assets", + "/src", + "/tests", + "/examples", + "/scripts", + "/README.md", + "/LICENSE", + "/AGENTS.md", + "/CHANGELOG.md", + "/CONTRIBUTING.md", + "/CODE_OF_CONDUCT.md", + "/SECURITY.md", + "/CITATION.cff", + "/ARCHITECTURE.md", + "/SCIENTIFIC_METHODS.md", + "/PRIOR_ART.md", + "/LIMITATIONS.md", + "/ROADMAP.md", + "/pyproject.toml", + "/uv.lock", +] + +[tool.pytest.ini_options] +addopts = "--strict-config --strict-markers -ra" +testpaths = ["tests"] +markers = [ + "numerical: deterministic numerical validation", + "integration: CLI, replay, packaging, or generated-test integration", +] + +[tool.coverage.run] +branch = true +source = ["phaseprobe"] + +[tool.coverage.report] +fail_under = 85 +show_missing = true + +[tool.mypy] +python_version = "3.10" +strict = true +warn_unreachable = true +mypy_path = "src" + +[tool.ruff] +target-version = "py310" +line-length = 100 +extend-exclude = [ + ".cache", + ".phaseprobe", + ".research_private", + ".tools", + ".venv", + "build", + "dist", +] + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP", "SIM", "RUF"] +ignore = ["E501"] diff --git a/scripts/check_links.py b/scripts/check_links.py new file mode 100644 index 0000000..3659d57 --- /dev/null +++ b/scripts/check_links.py @@ -0,0 +1,58 @@ +"""Deterministic local Markdown-link and external-link inventory check.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from urllib.parse import unquote, urlparse + +ROOT = Path(__file__).resolve().parents[1] +EXCLUDED = {".cache", ".git", ".phaseprobe", ".research_private", ".tools", ".venv"} +LINK = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)") + + +def main() -> int: + issues: list[str] = [] + external: set[str] = set() + markdown_files = [ + path + for path in ROOT.rglob("*.md") + if not any(part in EXCLUDED for part in path.relative_to(ROOT).parts) + ] + for path in markdown_files: + text = path.read_text(encoding="utf-8") + for raw in LINK.findall(text): + target = raw.strip().strip("<>") + parsed = urlparse(target) + if parsed.scheme in {"http", "https"}: + external.add(target) + continue + if parsed.scheme or target.startswith("#"): + continue + local_text = unquote(target.split("#", 1)[0]) + if not local_text: + continue + local_path = (path.parent / local_text).resolve() + try: + local_path.relative_to(ROOT) + except ValueError: + issues.append(f"{path.relative_to(ROOT)}: link escapes repository: {target}") + continue + if not local_path.exists(): + issues.append(f"{path.relative_to(ROOT)}: missing local target: {target}") + result = { + "schema_version": "1.0", + "status": "PASS" if not issues else "FAIL", + "markdown_files": len(markdown_files), + "external_links_inventoried": len(external), + "note": "External links are inventoried but not fetched in CI to avoid network-flaky gates.", + "issues": issues, + } + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 if not issues else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_demo.py b/scripts/generate_demo.py new file mode 100644 index 0000000..4c2034d --- /dev/null +++ b/scripts/generate_demo.py @@ -0,0 +1,180 @@ +"""Run the real quick-start workflow and render repository-native terminal assets.""" + +from __future__ import annotations + +import subprocess +import sys +import tempfile +import textwrap +import time +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +ROOT = Path(__file__).resolve().parents[1] +ASSETS = ROOT / "assets" +CACHE = ROOT / ".cache" +WIDTH = 1200 +HEIGHT = 700 + + +def run(command: list[str], cwd: Path) -> tuple[str, float]: + started = time.perf_counter() + completed = subprocess.run( + command, + cwd=cwd, + check=False, + capture_output=True, + text=True, + timeout=90, + ) + elapsed = time.perf_counter() - started + if completed.returncode != 0: + raise RuntimeError(completed.stdout + completed.stderr) + return completed.stdout.strip(), elapsed + + +def font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + for name in ("CascadiaMono.ttf", "consola.ttf", "DejaVuSansMono.ttf"): + try: + return ImageFont.truetype(name, size) + except OSError: + continue + return ImageFont.load_default() + + +def frame(lines: list[str], active: int) -> Image.Image: + image = Image.new("RGB", (WIDTH, HEIGHT), "#0b171d") + draw = ImageDraw.Draw(image) + draw.rounded_rectangle((24, 24, WIDTH - 24, HEIGHT - 24), radius=18, fill="#10252d") + draw.ellipse((48, 48, 64, 64), fill="#ef5b35") + draw.ellipse((74, 48, 90, 64), fill="#f2c14e") + draw.ellipse((100, 48, 116, 64), fill="#52b788") + draw.text( + (142, 45), "PhaseProbe · deterministic simulation evidence", font=font(22), fill="#d9f2ec" + ) + y = 92 + terminal_font = font(20) + for index, line in enumerate(lines[:24]): + color = "#efb366" if line.startswith("$") else "#d9f2ec" + if index == active: + draw.rounded_rectangle((42, y - 3, WIDTH - 42, y + 27), radius=5, fill="#173b46") + draw.text((52, y), line, font=terminal_font, fill=color) + y += 25 + draw.text( + (52, HEIGHT - 58), + "bounded search → replay fixture → generated pytest → pass", + font=font(19), + fill="#62c6cf", + ) + return image + + +def main() -> int: + ASSETS.mkdir(exist_ok=True) + CACHE.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(prefix="phaseprobe-demo-", dir=CACHE) as raw_temp: + workspace = Path(raw_temp) + output_root = workspace / ".phaseprobe" / "runs" + scan_output, scan_time = run( + [ + sys.executable, + "-m", + "phaseprobe", + "scan", + "--example", + "logistic", + "--output-root", + str(output_root), + ], + ROOT, + ) + run_directory = max(output_root.iterdir(), key=lambda path: path.stat().st_mtime_ns) + replay_output, replay_time = run( + [sys.executable, "-m", "phaseprobe", "replay", str(run_directory / "replay.json")], + ROOT, + ) + generated_directory = workspace / "tests" / "generated" + generate_output, generate_time = run( + [ + sys.executable, + "-m", + "phaseprobe", + "generate-test", + str(run_directory / "replay.json"), + "--output-directory", + str(generated_directory), + ], + ROOT, + ) + generated_test = next(generated_directory.glob("test_*_transition.py")) + pytest_output, pytest_time = run( + [sys.executable, "-m", "pytest", "-q", generated_test.name], + generated_directory, + ) + + transcript = "\n\n".join( + ( + "$ phaseprobe scan --example logistic\n" + scan_output, + "$ phaseprobe replay .phaseprobe/runs//replay.json\n" + replay_output, + "$ phaseprobe generate-test .phaseprobe/runs//replay.json\n" + + generate_output, + "$ python -m pytest -q tests/generated\n" + pytest_output, + ) + ) + transcript = transcript.replace(str(run_directory), ".phaseprobe/runs/") + transcript = transcript.replace(str(generated_directory), "tests/generated") + transcript = transcript.replace("\\", "/") + timing = ( + f"\n\nMeasured generation run: scan={scan_time:.3f}s, replay={replay_time:.3f}s, " + f"generate={generate_time:.3f}s, pytest={pytest_time:.3f}s." + ) + (ASSETS / "demo-session.txt").write_text(transcript + timing + "\n", encoding="utf-8") + + selected = [ + "$ phaseprobe scan --example logistic", + "QUALITATIVE TRANSITION FOUND", + "Model: logistic-map", + "Stable bracket: refined and repeated", + "Baseline regime: period-2", + "Changed regime: period-4", + "Replay: .phaseprobe/runs//replay.json", + "", + "$ phaseprobe replay .phaseprobe/runs//replay.json", + "REPLAY VERIFIED", + "baseline: classification=True, trace-hash=True", + "changed: classification=True, trace-hash=True", + "", + "$ phaseprobe generate-test .phaseprobe/runs//replay.json", + "PYTEST REGRESSION GENERATED", + "Test: tests/generated/test_logistic_map_transition.py", + "", + "$ python -m pytest -q tests/generated", + ". [100%]", + "1 passed", + ] + images = [frame(selected[: limit + 1], limit) for limit in (1, 6, 11, 15, 19)] + images[-1].save(ASSETS / "demo-static.png", optimize=True) + images[0].save( + ASSETS / "demo.gif", + save_all=True, + append_images=images[1:], + duration=[900, 1200, 1200, 1200, 2200], + loop=0, + optimize=True, + ) + print( + textwrap.dedent( + f"""\ + Demo assets generated from real commands: + {ASSETS / "demo.gif"} + {ASSETS / "demo-static.png"} + {ASSETS / "demo-session.txt"} + """ + ).strip() + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/hygiene.py b/scripts/hygiene.py new file mode 100644 index 0000000..7d138d5 --- /dev/null +++ b/scripts/hygiene.py @@ -0,0 +1,135 @@ +"""Repository privacy, secret, large-file, and tracked-output gate.""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +EXCLUDED_PARTS = { + ".cache", + ".git", + ".mypy_cache", + ".phaseprobe", + ".pytest_cache", + ".research_private", + ".ruff_cache", + ".tools", + ".venv", + "__pycache__", + "build", + "dist", + "htmlcov", +} +FORBIDDEN_TRACKED_PARTS = { + ".phaseprobe", + ".research_private", + ".tools", + ".venv", + "build", + "dist", + "htmlcov", +} +TEXT_SUFFIXES = { + ".cff", + ".css", + ".html", + ".ini", + ".json", + ".md", + ".py", + ".toml", + ".txt", + ".yaml", + ".yml", +} +PRIVATE_TERMS = ( + "Ali" + "Prime", + "AgentReliability" + "Lab", + "Resili" + "Replay", + "AI-" + "Workbench", +) +SECRET_PATTERNS = { + "private key": re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"), + "GitHub token": re.compile(r"\b(?:ghp|github_pat)_[A-Za-z0-9_]{20,}\b"), + "AWS access key": re.compile(r"\bAKIA[0-9A-Z]{16}\b"), + "assigned secret": re.compile( + r"(?i)\b(?:api[_-]?key|secret|password|token)\b\s*[:=]\s*['\"][^'\"]{8,}['\"]" + ), +} +EMAIL = re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE) +WINDOWS_PATH = re.compile(r"\b[A-Za-z]:\\") + + +def tracked_files() -> list[Path]: + """Use Git's index when available; otherwise walk only public project content.""" + + completed = subprocess.run( + ["git", "-C", str(ROOT), "ls-files", "-z"], + check=False, + capture_output=True, + ) + if completed.returncode == 0 and completed.stdout: + return [ROOT / item.decode("utf-8") for item in completed.stdout.split(b"\0") if item] + return [ + path + for path in ROOT.rglob("*") + if path.is_file() + and not any(part in EXCLUDED_PARTS for part in path.relative_to(ROOT).parts) + ] + + +def main() -> int: + files = tracked_files() + issues: list[str] = [] + large_files: list[dict[str, object]] = [] + for path in files: + relative = path.relative_to(ROOT) + if any(part in FORBIDDEN_TRACKED_PARTS for part in relative.parts): + issues.append(f"tracked generated/private path: {relative.as_posix()}") + if path.suffix.lower() == ".pdf": + issues.append(f"tracked PDF: {relative.as_posix()}") + size = path.stat().st_size + if size > 1_000_000: + large_files.append({"path": relative.as_posix(), "bytes": size}) + if size > 5_000_000: + issues.append(f"tracked file exceeds 5 MB: {relative.as_posix()} ({size} bytes)") + if path.suffix.lower() not in TEXT_SUFFIXES and path.name not in { + ".gitignore", + "LICENSE", + }: + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + issues.append(f"declared text file is not UTF-8: {relative.as_posix()}") + continue + if EMAIL.search(text): + issues.append(f"personal email-like string: {relative.as_posix()}") + if WINDOWS_PATH.search(text): + issues.append(f"absolute Windows path: {relative.as_posix()}") + for term in PRIVATE_TERMS: + if term in text: + issues.append(f"private neighboring-workspace term {term!r}: {relative.as_posix()}") + for label, pattern in SECRET_PATTERNS.items(): + if pattern.search(text): + issues.append(f"possible {label}: {relative.as_posix()}") + result = { + "schema_version": "1.0", + "status": "PASS" if not issues else "FAIL", + "files_scanned": len(files), + "author_identity_policy": "public author metadata is Ali only; no email-like strings found" + if not any("email" in issue for issue in issues) + else "failed", + "large_files_over_1mb": large_files, + "issues": issues, + } + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 if not issues else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/phaseprobe/__init__.py b/src/phaseprobe/__init__.py new file mode 100644 index 0000000..f5cb0f6 --- /dev/null +++ b/src/phaseprobe/__init__.py @@ -0,0 +1,7 @@ +"""PhaseProbe: deterministic transition discovery for simulation regression tests.""" + +from phaseprobe.types import InvariantResult, ModelAdapter, TracePoint + +__all__ = ["InvariantResult", "ModelAdapter", "TracePoint", "__version__"] + +__version__ = "0.1.0" diff --git a/src/phaseprobe/__main__.py b/src/phaseprobe/__main__.py new file mode 100644 index 0000000..6173616 --- /dev/null +++ b/src/phaseprobe/__main__.py @@ -0,0 +1,5 @@ +"""Allow ``python -m phaseprobe``.""" + +from phaseprobe.cli import main + +raise SystemExit(main()) diff --git a/src/phaseprobe/artifacts.py b/src/phaseprobe/artifacts.py new file mode 100644 index 0000000..7932879 --- /dev/null +++ b/src/phaseprobe/artifacts.py @@ -0,0 +1,124 @@ +"""Bounded run-directory persistence and artifact manifests.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +from phaseprobe.config import canonical_json +from phaseprobe.engine import ProbeOutcome, SimulationResult +from phaseprobe.replay import fixture_payload +from phaseprobe.reporting import html_report, json_report + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65_536), b""): + digest.update(chunk) + return digest.hexdigest() + + +@dataclass(frozen=True, slots=True) +class ArtifactBundle: + """Paths written for one bounded PhaseProbe execution.""" + + run_directory: Path + run_json: Path + findings_json: Path + replay_json: Path + trace_jsonl: Path + report_html: Path + manifest_json: Path + + +def _trace_lines(label: str, result: SimulationResult) -> list[str]: + lines: list[str] = [] + for point in result.trace: + payload = { + "series": label, + "step": point.step, + "time": point.time, + "state": list(point.state), + "observations": dict(point.observations), + } + lines.append(canonical_json(payload)) + return lines + + +def write_artifacts(outcome: ProbeOutcome, output_root: Path) -> ArtifactBundle: + """Write finite evidence files and a hash manifest under one run directory.""" + + output_root.mkdir(parents=True, exist_ok=True) + signature = hashlib.sha256(canonical_json(outcome.as_dict()).encode("utf-8")).hexdigest()[:8] + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + run_directory = output_root / f"{timestamp}-{signature}" + suffix = 1 + while run_directory.exists(): + run_directory = output_root / f"{timestamp}-{signature}-{suffix}" + suffix += 1 + run_directory.mkdir() + + run_json = run_directory / "run.json" + findings_json = run_directory / "findings.json" + replay_json = run_directory / "replay.json" + trace_jsonl = run_directory / "trace.jsonl" + report_html = run_directory / "report.html" + manifest_json = run_directory / "manifest.json" + + data = outcome.as_dict() + run_json.write_text(json_report(data), encoding="utf-8") + findings_json.write_text( + json.dumps( + { + "schema_version": "1.0", + "status": outcome.status, + "finding": dict(outcome.finding) if outcome.finding is not None else None, + "reproducible": outcome.reproducible, + }, + allow_nan=False, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + replay_json.write_text( + json.dumps(fixture_payload(outcome), allow_nan=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + trace_lines = _trace_lines("baseline", outcome.baseline) + if outcome.changed is not None: + trace_lines.extend(_trace_lines("changed", outcome.changed)) + trace_jsonl.write_text("\n".join(trace_lines) + "\n", encoding="utf-8") + report_html.write_text(html_report(data), encoding="utf-8") + + files = [run_json, findings_json, replay_json, trace_jsonl, report_html] + manifest = { + "schema_version": "1.0", + "run_id": run_directory.name, + "bounded_trace": { + "baseline_points": len(outcome.baseline.trace), + "changed_points": len(outcome.changed.trace) if outcome.changed is not None else 0, + "configured_cap_per_series": outcome.baseline.settings.trace_cap, + }, + "files": { + path.name: {"bytes": path.stat().st_size, "sha256": _sha256(path)} for path in files + }, + } + manifest_json.write_text( + json.dumps(manifest, allow_nan=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return ArtifactBundle( + run_directory=run_directory, + run_json=run_json, + findings_json=findings_json, + replay_json=replay_json, + trace_jsonl=trace_jsonl, + report_html=report_html, + manifest_json=manifest_json, + ) diff --git a/src/phaseprobe/cli.py b/src/phaseprobe/cli.py new file mode 100644 index 0000000..f705243 --- /dev/null +++ b/src/phaseprobe/cli.py @@ -0,0 +1,234 @@ +"""PhaseProbe command-line interface and stable exit semantics.""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections.abc import Callable, Sequence +from pathlib import Path + +from phaseprobe import __version__ +from phaseprobe.artifacts import ArtifactBundle, write_artifacts +from phaseprobe.config import EXAMPLE_FILES, ProbeConfig, load_config, load_example +from phaseprobe.engine import ProbeOutcome, run_check, run_perturb, run_scan +from phaseprobe.errors import ( + ConfigurationError, + ExitCode, + IntegrityError, + NumericalFailure, + PhaseProbeError, +) +from phaseprobe.generate import generate_regression_test +from phaseprobe.replay import verify_replay +from phaseprobe.reporting import json_report, regenerate_reports, terminal_report + + +def _add_config_source(parser: argparse.ArgumentParser) -> None: + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--config", type=Path, help="versioned JSON configuration") + source.add_argument( + "--example", choices=sorted(EXAMPLE_FILES), help="built-in deterministic example" + ) + parser.add_argument( + "--output-root", + type=Path, + default=Path(".phaseprobe") / "runs", + help="bounded run directory root (default: .phaseprobe/runs)", + ) + parser.add_argument("--json", action="store_true", help="emit versioned JSON to stdout") + + +def build_parser() -> argparse.ArgumentParser: + """Build the public CLI parser.""" + + parser = argparse.ArgumentParser( + prog="phaseprobe", + description="Find bounded simulation transitions and turn them into deterministic tests.", + ) + parser.add_argument("--version", action="version", version=f"phaseprobe {__version__}") + commands = parser.add_subparsers(dest="command", required=True) + + scan = commands.add_parser("scan", help="scan one parameter for adjacent qualitative changes") + _add_config_source(scan) + scan.add_argument( + "--fail-on-finding", + action="store_true", + help="return exit 1 when a transition is found", + ) + + perturb = commands.add_parser( + "perturb", help="search bounded initial-state perturbations with twin trajectories" + ) + _add_config_source(perturb) + perturb.add_argument( + "--fail-on-finding", + action="store_true", + help="return exit 1 when a sensitive perturbation is found", + ) + + check = commands.add_parser("check", help="run a declared policy for CI enforcement") + _add_config_source(check) + + replay = commands.add_parser("replay", help="re-execute an integrity-protected replay fixture") + replay.add_argument("fixture", type=Path) + replay.add_argument("--json", action="store_true") + + generate = commands.add_parser( + "generate-test", help="generate fixed-template pytest from validated evidence" + ) + generate.add_argument("fixture", type=Path) + generate.add_argument("--output-directory", type=Path, default=Path("tests") / "generated") + generate.add_argument("--json", action="store_true") + + report = commands.add_parser( + "report", help="regenerate terminal, JSON, and offline HTML evidence" + ) + report.add_argument("run_directory", type=Path) + report.add_argument("--format", choices=("terminal", "json", "html", "all"), default="all") + return parser + + +def _config_from_args(args: argparse.Namespace) -> ProbeConfig: + config_path = getattr(args, "config", None) + example = getattr(args, "example", None) + if isinstance(config_path, Path): + return load_config(config_path) + if isinstance(example, str): + return load_example(example) + raise ConfigurationError("a configuration or built-in example is required") + + +def _with_artifacts(outcome: ProbeOutcome, bundle: ArtifactBundle) -> dict[str, object]: + data = outcome.as_dict() + data["artifacts"] = { + "run_directory": str(bundle.run_directory), + "replay": str(bundle.replay_json), + "html_report": str(bundle.report_html), + "manifest": str(bundle.manifest_json), + } + return data + + +def _run_evidence_command( + args: argparse.Namespace, execute: Callable[[ProbeConfig], ProbeOutcome] +) -> int: + config = _config_from_args(args) + outcome = execute(config) + output_root = args.output_root + if not isinstance(output_root, Path): + raise ConfigurationError("output root must be a path") + bundle = write_artifacts(outcome, output_root) + data = _with_artifacts(outcome, bundle) + if bool(getattr(args, "json", False)): + sys.stdout.write(json_report(data)) + else: + print(terminal_report(data, replay=str(bundle.replay_json))) + print(f"HTML report: {bundle.report_html}") + if outcome.policy_failed: + return int(ExitCode.POLICY_FAILED) + if bool(getattr(args, "fail_on_finding", False)) and outcome.finding is not None: + return int(ExitCode.POLICY_FAILED) + return int(ExitCode.OK) + + +def _replay_command(args: argparse.Namespace) -> int: + fixture = args.fixture + if not isinstance(fixture, Path): + raise ConfigurationError("fixture must be a path") + verification = verify_replay(fixture) + data = verification.as_dict() + if bool(getattr(args, "json", False)): + sys.stdout.write(json_report(data)) + else: + print(str(data["status"])) + print() + print(f"Model: {verification.model}") + for comparison in verification.comparisons: + print( + f"{comparison['series']}: classification={comparison['classification_match']}, " + f"trace-hash={comparison['trace_hash_match']}, " + f"model-identity={comparison['model_identity_match']}" + ) + return int(ExitCode.OK if verification.ok else ExitCode.POLICY_FAILED) + + +def _generate_command(args: argparse.Namespace) -> int: + fixture = args.fixture + output_directory = args.output_directory + if not isinstance(fixture, Path) or not isinstance(output_directory, Path): + raise ConfigurationError("fixture and output directory must be paths") + generated = generate_regression_test(fixture, output_directory) + data: dict[str, object] = { + "schema_version": "1.0", + "status": "PYTEST REGRESSION GENERATED", + "test": str(generated.test_path), + "fixture": str(generated.fixture_path), + } + if bool(getattr(args, "json", False)): + sys.stdout.write(json_report(data)) + else: + print("PYTEST REGRESSION GENERATED") + print() + print(f"Test: {generated.test_path}") + print(f"Replay fixture: {generated.fixture_path}") + return int(ExitCode.OK) + + +def _report_command(args: argparse.Namespace) -> int: + run_directory = args.run_directory + report_format = args.format + if not isinstance(run_directory, Path) or not isinstance(report_format, str): + raise ConfigurationError("invalid report arguments") + json_path, html_path = regenerate_reports(run_directory) + parsed = json.loads((run_directory / "run.json").read_text(encoding="utf-8")) + if not isinstance(parsed, dict): + raise ConfigurationError("run.json must contain an object") + data: dict[str, object] = parsed + if report_format in {"terminal", "all"}: + print(terminal_report(data, replay=str(run_directory / "replay.json"))) + if report_format in {"json", "all"}: + if report_format == "json": + sys.stdout.write(json_report(data)) + else: + print(f"JSON report: {json_path}") + if report_format in {"html", "all"}: + print(f"HTML report: {html_path}") + return int(ExitCode.OK) + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the CLI and translate expected defects into the documented exit contract.""" + + parser = build_parser() + args = parser.parse_args(argv) + try: + if args.command == "scan": + return _run_evidence_command(args, run_scan) + if args.command == "perturb": + return _run_evidence_command(args, run_perturb) + if args.command == "check": + return _run_evidence_command(args, run_check) + if args.command == "replay": + return _replay_command(args) + if args.command == "generate-test": + return _generate_command(args) + if args.command == "report": + return _report_command(args) + raise ConfigurationError(f"unknown command {args.command!r}") + except NumericalFailure as exc: + print(f"NUMERICAL FAILURE: {exc}", file=sys.stderr) + return int(ExitCode.NUMERICAL_FAILURE) + except (ConfigurationError, IntegrityError, ValueError, OSError) as exc: + print(f"INVALID INPUT: {exc}", file=sys.stderr) + return int(ExitCode.INVALID_INPUT) + except PhaseProbeError as exc: + print(f"PHASEPROBE ERROR: {exc}", file=sys.stderr) + return int(ExitCode.INTERNAL_ERROR) + except Exception as exc: # pragma: no cover - final CLI containment boundary + print(f"INTERNAL PHASEPROBE DEFECT: {type(exc).__name__}: {exc}", file=sys.stderr) + return int(ExitCode.INTERNAL_ERROR) + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/phaseprobe/config.py b/src/phaseprobe/config.py new file mode 100644 index 0000000..e852354 --- /dev/null +++ b/src/phaseprobe/config.py @@ -0,0 +1,115 @@ +"""Versioned JSON configuration loading and deterministic serialization.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from dataclasses import dataclass +from importlib import resources +from pathlib import Path +from typing import Any, cast + +from phaseprobe.errors import ConfigurationError + +CONFIG_SCHEMA_VERSION = "1.0" + + +def canonical_json(value: object) -> str: + """Serialize configuration data deterministically for hashing and replay.""" + + return json.dumps(value, allow_nan=False, separators=(",", ":"), sort_keys=True) + + +def _as_object(value: Any, context: str) -> dict[str, object]: + if not isinstance(value, dict): + raise ConfigurationError(f"{context} must be a JSON object") + return cast(dict[str, object], value) + + +@dataclass(frozen=True, slots=True) +class ProbeConfig: + """Validated top-level configuration with typed access helpers.""" + + data: Mapping[str, object] + source: str + + @property + def model(self) -> str: + value = self.data.get("model") + if not isinstance(value, str) or not value: + raise ConfigurationError("model must be a non-empty string") + return value + + @property + def seed(self) -> int: + value = self.data.get("seed", 0) + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ConfigurationError("seed must be a non-negative integer") + return value + + def section(self, name: str, *, required: bool = True) -> Mapping[str, object]: + value = self.data.get(name) + if value is None and not required: + return {} + if not isinstance(value, dict): + raise ConfigurationError(f"{name} must be a JSON object") + return cast(Mapping[str, object], value) + + def string(self, name: str, default: str | None = None) -> str: + value = self.data.get(name, default) + if not isinstance(value, str): + raise ConfigurationError(f"{name} must be a string") + return value + + +def parse_config(text: str, source: str) -> ProbeConfig: + """Parse and validate a versioned JSON configuration.""" + + try: + parsed: Any = json.loads(text) + except json.JSONDecodeError as exc: + raise ConfigurationError(f"invalid JSON in {source}: {exc}") from exc + data = _as_object(parsed, source) + version = data.get("schema_version") + if version != CONFIG_SCHEMA_VERSION: + raise ConfigurationError( + f"unsupported configuration schema {version!r}; expected {CONFIG_SCHEMA_VERSION!r}" + ) + config = ProbeConfig(data=data, source=source) + _ = config.model + _ = config.seed + return config + + +def load_config(path: Path) -> ProbeConfig: + """Load a UTF-8 JSON configuration from a user-selected path.""" + + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise ConfigurationError(f"cannot read configuration {path}: {exc}") from exc + return parse_config(text, str(path)) + + +EXAMPLE_FILES: Mapping[str, str] = { + "logistic": "logistic-scan.json", + "logistic-negative": "logistic-negative.json", + "lorenz": "lorenz-perturb.json", + "lorenz-negative": "lorenz-negative.json", + "predator-prey": "predator-prey-check.json", + "predator-prey-negative": "predator-prey-negative.json", + "toggle": "toggle-perturb.json", + "toggle-negative": "toggle-negative.json", +} + + +def load_example(name: str) -> ProbeConfig: + """Load one of the immutable examples embedded in the installed wheel.""" + + filename = EXAMPLE_FILES.get(name) + if filename is None: + choices = ", ".join(sorted(EXAMPLE_FILES)) + raise ConfigurationError(f"unknown example {name!r}; choose one of: {choices}") + package = resources.files("phaseprobe.data.examples") + text = package.joinpath(filename).read_text(encoding="utf-8") + return parse_config(text, f"built-in example {name}") diff --git a/src/phaseprobe/data/__init__.py b/src/phaseprobe/data/__init__.py new file mode 100644 index 0000000..309d8d7 --- /dev/null +++ b/src/phaseprobe/data/__init__.py @@ -0,0 +1 @@ +"""Packaged PhaseProbe data.""" diff --git a/src/phaseprobe/data/examples/__init__.py b/src/phaseprobe/data/examples/__init__.py new file mode 100644 index 0000000..14fc264 --- /dev/null +++ b/src/phaseprobe/data/examples/__init__.py @@ -0,0 +1 @@ +"""Deterministic built-in example configurations.""" diff --git a/src/phaseprobe/data/examples/logistic-negative.json b/src/phaseprobe/data/examples/logistic-negative.json new file mode 100644 index 0000000..edd9863 --- /dev/null +++ b/src/phaseprobe/data/examples/logistic-negative.json @@ -0,0 +1,27 @@ +{ + "schema_version": "1.0", + "model": "logistic-map", + "seed": 17, + "parameters": {"r": 3.2}, + "model_config": {"initial_state": {"x": 0.2}}, + "simulation": { + "steps": 128, + "burn_in": 1500, + "dt": 1.0, + "sample_every": 1, + "trace_cap": 128, + "hard_state_limit": 10.0 + }, + "tolerances": {"period": 1e-9}, + "scan": { + "parameter": "r", + "start": 3.2, + "stop": 3.4, + "points": 5, + "refine_iterations": 6, + "repeatability": 2 + }, + "classification_rule": "Finite-period tail classifier used by the positive logistic example.", + "refinement_rule": "Refine only if an adjacent qualitative class change is observed.", + "invalid_state_policy": "Abort on invalid integration." +} diff --git a/src/phaseprobe/data/examples/logistic-scan.json b/src/phaseprobe/data/examples/logistic-scan.json new file mode 100644 index 0000000..9ddce25 --- /dev/null +++ b/src/phaseprobe/data/examples/logistic-scan.json @@ -0,0 +1,27 @@ +{ + "schema_version": "1.0", + "model": "logistic-map", + "seed": 17, + "parameters": {"r": 3.448}, + "model_config": {"initial_state": {"x": 0.2}}, + "simulation": { + "steps": 256, + "burn_in": 500000, + "dt": 1.0, + "sample_every": 1, + "trace_cap": 256, + "hard_state_limit": 10.0 + }, + "tolerances": {"period": 1e-9}, + "scan": { + "parameter": "r", + "start": 3.448, + "stop": 3.452, + "points": 3, + "refine_iterations": 12, + "repeatability": 2 + }, + "classification_rule": "Smallest detected period in {1,2,4,8,16} whose retained tail repeats within absolute tolerance; otherwise unresolved/aperiodic.", + "refinement_rule": "First adjacent coarse class change, followed by up to twelve deterministic binary probes. Refinement stops rather than relabeling a midpoint that reproduces neither stable endpoint class.", + "invalid_state_policy": "Abort as invalid integration on NaN, infinity, overflow, dimension mismatch, or hard-state-limit breach." +} diff --git a/src/phaseprobe/data/examples/lorenz-negative.json b/src/phaseprobe/data/examples/lorenz-negative.json new file mode 100644 index 0000000..248aadd --- /dev/null +++ b/src/phaseprobe/data/examples/lorenz-negative.json @@ -0,0 +1,30 @@ +{ + "schema_version": "1.0", + "model": "lorenz", + "seed": 23, + "parameters": {"sigma": 10.0, "rho": 28.0, "beta": 2.6666666666666665}, + "model_config": {"initial_state": {"x": 1.0, "y": 1.0, "z": 1.0}}, + "simulation": { + "steps": 1000, + "burn_in": 0, + "dt": 0.005, + "sample_every": 2, + "trace_cap": 500, + "hard_state_limit": 1000.0 + }, + "tolerances": {"state_bound": 100.0}, + "perturb": { + "dimension": "x", + "start": 1e-8, + "stop": 0.0001, + "points": 5, + "scale": "log", + "predicate": "finite-time-divergence", + "divergence_threshold": 1000.0, + "refine_iterations": 4, + "repeatability": 2 + }, + "classification_rule": "Finite-time lobe visitation only.", + "refinement_rule": "No refinement when the declared separation threshold is not reached.", + "invalid_state_policy": "Abort on invalid integration." +} diff --git a/src/phaseprobe/data/examples/lorenz-perturb.json b/src/phaseprobe/data/examples/lorenz-perturb.json new file mode 100644 index 0000000..ebb514e --- /dev/null +++ b/src/phaseprobe/data/examples/lorenz-perturb.json @@ -0,0 +1,30 @@ +{ + "schema_version": "1.0", + "model": "lorenz", + "seed": 23, + "parameters": {"sigma": 10.0, "rho": 28.0, "beta": 2.6666666666666665}, + "model_config": {"initial_state": {"x": 1.0, "y": 1.0, "z": 1.0}}, + "simulation": { + "steps": 8000, + "burn_in": 0, + "dt": 0.005, + "sample_every": 2, + "trace_cap": 4000, + "hard_state_limit": 1000.0 + }, + "tolerances": {"state_bound": 100.0}, + "perturb": { + "dimension": "x", + "start": 1e-8, + "stop": 0.0001, + "points": 5, + "scale": "log", + "predicate": "finite-time-divergence", + "divergence_threshold": 1.0, + "refine_iterations": 8, + "repeatability": 2 + }, + "classification_rule": "Report retained finite-time lobe visitation; the finding predicate is Euclidean twin-trajectory separation, not a qualitative-class change.", + "refinement_rule": "Evaluate ascending logarithmic perturbations; refine a non-trigger/trigger bracket when one exists and repeat the smallest trigger.", + "invalid_state_policy": "Abort on NaN, infinity, overflow, or declared hard-state-limit breach." +} diff --git a/src/phaseprobe/data/examples/predator-prey-check.json b/src/phaseprobe/data/examples/predator-prey-check.json new file mode 100644 index 0000000..043497b --- /dev/null +++ b/src/phaseprobe/data/examples/predator-prey-check.json @@ -0,0 +1,25 @@ +{ + "schema_version": "1.0", + "model": "predator-prey", + "seed": 31, + "parameters": {"alpha": 1.1, "beta": 0.4, "delta": 0.1, "gamma": 0.4}, + "model_config": {"initial_state": {"prey": 10.0, "predator": 5.0}}, + "simulation": { + "steps": 4000, + "burn_in": 0, + "dt": 0.005, + "sample_every": 1, + "trace_cap": 4000, + "hard_state_limit": 1000.0 + }, + "tolerances": {"invariant_drift": 1e-7}, + "check": {"analysis": "invariants"}, + "policy": { + "forbid_findings": true, + "require_finding": false, + "require_invariants": true + }, + "classification_rule": "Both retained populations remain strictly positive, classified as a bounded positive oscillation.", + "refinement_rule": "Fixed-step RK4 at dt=0.005; compare the analytic first integral against its initial retained value.", + "invalid_state_policy": "Abort on invalid integration; fail policy on invariant drift or non-positive population." +} diff --git a/src/phaseprobe/data/examples/predator-prey-negative.json b/src/phaseprobe/data/examples/predator-prey-negative.json new file mode 100644 index 0000000..68cd6c3 --- /dev/null +++ b/src/phaseprobe/data/examples/predator-prey-negative.json @@ -0,0 +1,25 @@ +{ + "schema_version": "1.0", + "model": "predator-prey", + "seed": 31, + "parameters": {"alpha": 1.1, "beta": 0.4, "delta": 0.1, "gamma": 0.4}, + "model_config": {"initial_state": {"prey": 10.0, "predator": 5.0}}, + "simulation": { + "steps": 80, + "burn_in": 0, + "dt": 0.25, + "sample_every": 1, + "trace_cap": 80, + "hard_state_limit": 1000.0 + }, + "tolerances": {"invariant_drift": 1e-7}, + "check": {"analysis": "invariants"}, + "policy": { + "forbid_findings": true, + "require_finding": false, + "require_invariants": true + }, + "classification_rule": "Same boundedness classifier as the refined-solver positive control.", + "refinement_rule": "Deliberately coarse fixed step demonstrates the invariant-drift policy control.", + "invalid_state_policy": "Abort on invalid integration; otherwise fail the declared invariant policy." +} diff --git a/src/phaseprobe/data/examples/toggle-negative.json b/src/phaseprobe/data/examples/toggle-negative.json new file mode 100644 index 0000000..51d4b3a --- /dev/null +++ b/src/phaseprobe/data/examples/toggle-negative.json @@ -0,0 +1,30 @@ +{ + "schema_version": "1.0", + "model": "genetic-toggle", + "seed": 47, + "parameters": {"alpha_u": 3.0, "alpha_v": 3.0, "hill_u": 2.0, "hill_v": 2.0}, + "model_config": {"initial_state": {"u": 3.0, "v": 0.2}}, + "simulation": { + "steps": 1500, + "burn_in": 0, + "dt": 0.02, + "sample_every": 2, + "trace_cap": 750, + "hard_state_limit": 100.0 + }, + "tolerances": {"dominance": 0.05, "state_bound": 10.0}, + "perturb": { + "dimension": "v", + "start": 0.01, + "stop": 0.5, + "points": 6, + "scale": "linear", + "predicate": "classification-change", + "divergence_threshold": 1.0, + "refine_iterations": 6, + "repeatability": 2 + }, + "classification_rule": "Same terminal dominance classifier as the positive toggle example.", + "refinement_rule": "No refinement if all bounded perturbations retain the baseline qualitative class.", + "invalid_state_policy": "Abort on invalid integration." +} diff --git a/src/phaseprobe/data/examples/toggle-perturb.json b/src/phaseprobe/data/examples/toggle-perturb.json new file mode 100644 index 0000000..1049856 --- /dev/null +++ b/src/phaseprobe/data/examples/toggle-perturb.json @@ -0,0 +1,31 @@ +{ + "schema_version": "1.0", + "model": "genetic-toggle", + "seed": 47, + "parameters": {"alpha_u": 3.0, "alpha_v": 3.0, "hill_u": 2.0, "hill_v": 2.0}, + "model_config": {"initial_state": {"u": 3.0, "v": 0.2}}, + "simulation": { + "steps": 1500, + "burn_in": 0, + "dt": 0.02, + "sample_every": 2, + "trace_cap": 750, + "hard_state_limit": 100.0 + }, + "tolerances": {"dominance": 0.05, "state_bound": 10.0}, + "perturb": { + "dimension": "v", + "start": 0.1, + "stop": 4.0, + "points": 9, + "scale": "linear", + "predicate": "classification-change", + "target_classification": "v-dominant", + "divergence_threshold": 1.0, + "refine_iterations": 8, + "repeatability": 2 + }, + "classification_rule": "Classify the terminal state as u-dominant, v-dominant, or balanced using a declared absolute dominance tolerance.", + "refinement_rule": "Ascending bounded v perturbations, then bracketed binary refinement and exact repeatability confirmation.", + "invalid_state_policy": "Abort on invalid integration or concentration outside the hard state limit." +} diff --git a/src/phaseprobe/engine.py b/src/phaseprobe/engine.py new file mode 100644 index 0000000..72c5463 --- /dev/null +++ b/src/phaseprobe/engine.py @@ -0,0 +1,641 @@ +"""Deterministic simulation, bounded search, refinement, and policy evaluation.""" + +from __future__ import annotations + +import hashlib +import math +from collections import deque +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +from phaseprobe.config import ProbeConfig, canonical_json +from phaseprobe.errors import ConfigurationError, NumericalFailure +from phaseprobe.models import get_model +from phaseprobe.types import InvariantResult, ModelAdapter, State, TracePoint + + +def _number(values: Mapping[str, object], name: str, default: float | None = None) -> float: + value = values.get(name, default) + if not isinstance(value, int | float) or isinstance(value, bool): + raise ConfigurationError(f"{name} must be a number") + result = float(value) + if not math.isfinite(result): + raise ConfigurationError(f"{name} must be finite") + return result + + +def _integer(values: Mapping[str, object], name: str, default: int | None = None) -> int: + value = values.get(name, default) + if not isinstance(value, int) or isinstance(value, bool): + raise ConfigurationError(f"{name} must be an integer") + return value + + +def _boolean(values: Mapping[str, object], name: str, default: bool) -> bool: + value = values.get(name, default) + if not isinstance(value, bool): + raise ConfigurationError(f"{name} must be a boolean") + return value + + +def _string(values: Mapping[str, object], name: str, default: str | None = None) -> str: + value = values.get(name, default) + if not isinstance(value, str): + raise ConfigurationError(f"{name} must be a string") + return value + + +def _numeric_mapping(values: Mapping[str, object], context: str) -> dict[str, float]: + result: dict[str, float] = {} + for name, raw in values.items(): + if not isinstance(raw, int | float) or isinstance(raw, bool): + raise ConfigurationError(f"{context}.{name} must be a number") + number = float(raw) + if not math.isfinite(number): + raise ConfigurationError(f"{context}.{name} must be finite") + result[name] = number + return result + + +@dataclass(frozen=True, slots=True) +class SimulationSettings: + """Bounded execution settings shared by every adapter.""" + + steps: int + burn_in: int + dt: float + sample_every: int + trace_cap: int + hard_state_limit: float + + @classmethod + def from_config(cls, values: Mapping[str, object]) -> SimulationSettings: + settings = cls( + steps=_integer(values, "steps"), + burn_in=_integer(values, "burn_in", 0), + dt=_number(values, "dt", 1.0), + sample_every=_integer(values, "sample_every", 1), + trace_cap=_integer(values, "trace_cap", 2048), + hard_state_limit=_number(values, "hard_state_limit", 1e100), + ) + if settings.steps <= 0 or settings.burn_in < 0: + raise ConfigurationError("simulation steps must be positive and burn_in non-negative") + if settings.dt <= 0.0 or settings.sample_every <= 0: + raise ConfigurationError("simulation dt and sample_every must be positive") + if settings.trace_cap < 16 or settings.trace_cap > 100_000: + raise ConfigurationError("simulation trace_cap must be between 16 and 100000") + if settings.hard_state_limit <= 0.0: + raise ConfigurationError("simulation hard_state_limit must be positive") + return settings + + def as_dict(self) -> dict[str, object]: + return { + "steps": self.steps, + "burn_in": self.burn_in, + "dt": self.dt, + "sample_every": self.sample_every, + "trace_cap": self.trace_cap, + "hard_state_limit": self.hard_state_limit, + } + + +def _invariant_dict(result: InvariantResult) -> dict[str, object]: + return { + "name": result.name, + "passed": result.passed, + "measured": result.measured, + "tolerance": result.tolerance, + "detail": result.detail, + } + + +def _point_dict(point: TracePoint) -> dict[str, object]: + return { + "step": point.step, + "time": point.time, + "state": list(point.state), + "observations": dict(point.observations), + } + + +def trace_hash(trace: Sequence[TracePoint]) -> str: + """Hash the retained canonical trace exactly for deterministic replay.""" + + payload = [_point_dict(point) for point in trace] + return hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest() + + +@dataclass(frozen=True, slots=True) +class SimulationResult: + """One bounded deterministic execution.""" + + model: str + model_identity: str + seed: int + parameters: Mapping[str, float] + initial_state: State + final_state: State + settings: SimulationSettings + tolerances: Mapping[str, float] + classification: str + invariants: tuple[InvariantResult, ...] + trace: tuple[TracePoint, ...] + trace_sha256: str + + @property + def invariant_violations(self) -> int: + return sum(not result.passed for result in self.invariants) + + def as_dict(self, *, include_trace: bool = False) -> dict[str, object]: + payload: dict[str, object] = { + "model": self.model, + "model_identity": self.model_identity, + "seed": self.seed, + "parameters": dict(self.parameters), + "initial_state": list(self.initial_state), + "final_state": list(self.final_state), + "simulation": self.settings.as_dict(), + "tolerances": dict(self.tolerances), + "classification": self.classification, + "invariants": [_invariant_dict(result) for result in self.invariants], + "invariant_violations": self.invariant_violations, + "trace_points_retained": len(self.trace), + "trace_sha256": self.trace_sha256, + } + if include_trace: + payload["trace"] = [_point_dict(point) for point in self.trace] + return payload + + +@dataclass(frozen=True, slots=True) +class ProbeOutcome: + """Serializable result of a scan, perturbation search, or declared check.""" + + command: str + status: str + config: ProbeConfig + baseline: SimulationResult + changed: SimulationResult | None + finding: Mapping[str, object] | None + history: tuple[Mapping[str, object], ...] + reproducible: bool + policy_failed: bool = False + + def as_dict(self) -> dict[str, object]: + return { + "schema_version": "1.0", + "command": self.command, + "status": self.status, + "model": self.baseline.model, + "source": self.config.source, + "configuration": dict(self.config.data), + "baseline": self.baseline.as_dict(), + "changed": self.changed.as_dict() if self.changed is not None else None, + "finding": dict(self.finding) if self.finding is not None else None, + "history": [dict(item) for item in self.history], + "reproducible": self.reproducible, + "policy_failed": self.policy_failed, + } + + +def _validate_state( + model: ModelAdapter, state: State, settings: SimulationSettings, step: int +) -> None: + if len(state) != len(model.dimensions): + raise NumericalFailure( + f"solver failure at step {step}: adapter returned {len(state)} dimensions; " + f"expected {len(model.dimensions)}" + ) + for dimension, value in zip(model.dimensions, state, strict=False): + if not math.isfinite(value): + raise NumericalFailure( + f"invalid integration at step {step}: {dimension} is NaN or infinite" + ) + if abs(value) > settings.hard_state_limit: + raise NumericalFailure( + f"invalid integration at step {step}: {dimension} exceeded hard_state_limit" + ) + + +def simulate( + config: ProbeConfig, + *, + parameters_override: Mapping[str, float] | None = None, + initial_override: State | None = None, +) -> SimulationResult: + """Execute one model with explicit seed, parameters, retention, and tolerances.""" + + model = get_model(config.model) + parameters = _numeric_mapping(config.section("parameters"), "parameters") + if parameters_override is not None: + parameters.update(parameters_override) + tolerances = _numeric_mapping(config.section("tolerances", required=False), "tolerances") + settings = SimulationSettings.from_config(config.section("simulation")) + model_config = config.section("model_config", required=False) + state = ( + initial_override + if initial_override is not None + else model.initial_state(model_config, config.seed) + ) + initial = tuple(state) + _validate_state(model, initial, settings, 0) + retained: deque[TracePoint] = deque(maxlen=settings.trace_cap) + total_steps = settings.burn_in + settings.steps + for step in range(1, total_steps + 1): + try: + state = model.step(state, parameters, settings.dt) + except (ArithmeticError, ValueError) as exc: + raise NumericalFailure(f"solver failure at step {step}: {exc}") from exc + _validate_state(model, state, settings, step) + if step > settings.burn_in and (step - settings.burn_in) % settings.sample_every == 0: + retained.append( + TracePoint( + step=step, + time=step * settings.dt, + state=tuple(state), + observations=dict(model.observe(state)), + ) + ) + trace = tuple(retained) + if not trace: + raise ConfigurationError("simulation retention settings produced an empty trace") + classification = model.classify(trace, tolerances) + invariants = tuple(model.invariants(trace, parameters, tolerances)) + return SimulationResult( + model=model.name, + model_identity=model.identity, + seed=config.seed, + parameters=parameters, + initial_state=initial, + final_state=tuple(state), + settings=settings, + tolerances=tolerances, + classification=classification, + invariants=invariants, + trace=trace, + trace_sha256=trace_hash(trace), + ) + + +def _linspace(start: float, stop: float, points: int) -> list[float]: + if points < 2: + raise ConfigurationError("search points must be at least 2") + return [start + (stop - start) * index / (points - 1) for index in range(points)] + + +def _logspace(start: float, stop: float, points: int) -> list[float]: + if start <= 0.0 or stop <= 0.0: + raise ConfigurationError("logarithmic search bounds must be positive") + log_start = math.log(start) + log_stop = math.log(stop) + return [math.exp(value) for value in _linspace(log_start, log_stop, points)] + + +def run_scan(config: ProbeConfig) -> ProbeOutcome: + """Scan a one-dimensional parameter and refine the first adjacent class change.""" + + scan = config.section("scan") + parameter_name = _string(scan, "parameter") + start = _number(scan, "start") + stop = _number(scan, "stop") + points = _integer(scan, "points") + refinements = _integer(scan, "refine_iterations", 12) + repeatability = _integer(scan, "repeatability", 2) + if stop <= start or refinements < 0 or repeatability < 1: + raise ConfigurationError("scan requires stop > start and non-negative refinement") + + history: list[Mapping[str, object]] = [] + runs: list[SimulationResult] = [] + values = _linspace(start, stop, points) + for value in values: + run = simulate(config, parameters_override={parameter_name: value}) + runs.append(run) + history.append({"phase": "coarse", "value": value, "classification": run.classification}) + + transition_index: int | None = None + for index in range(len(runs) - 1): + if runs[index].classification != runs[index + 1].classification: + transition_index = index + break + if transition_index is None: + return ProbeOutcome( + command="scan", + status="NO QUALITATIVE TRANSITION FOUND", + config=config, + baseline=runs[0], + changed=None, + finding=None, + history=tuple(history), + reproducible=True, + ) + + coarse_left = values[transition_index] + coarse_right = values[transition_index + 1] + low = coarse_left + high = coarse_right + left_run = runs[transition_index] + right_run = runs[transition_index + 1] + left_class = left_run.classification + right_class = right_run.classification + unresolved_midpoint: float | None = None + for _ in range(refinements): + midpoint = (low + high) / 2.0 + midpoint_run = simulate(config, parameters_override={parameter_name: midpoint}) + history.append( + { + "phase": "refine", + "low": low, + "high": high, + "value": midpoint, + "classification": midpoint_run.classification, + } + ) + if midpoint_run.classification == left_class: + low = midpoint + left_run = midpoint_run + elif midpoint_run.classification == right_class: + high = midpoint + right_run = midpoint_run + else: + unresolved_midpoint = midpoint + history.append( + { + "phase": "refine-stopped", + "value": midpoint, + "classification": midpoint_run.classification, + "reason": "midpoint did not reproduce either stable endpoint class", + } + ) + break + + reproducible = True + confirmations: list[dict[str, object]] = [] + for side, _expected in (("baseline", left_run), ("changed", right_run)): + hashes: list[str] = [] + classes: list[str] = [] + for _ in range(repeatability): + value = low if side == "baseline" else high + confirmed = simulate(config, parameters_override={parameter_name: value}) + hashes.append(confirmed.trace_sha256) + classes.append(confirmed.classification) + stable = len(set(hashes)) == 1 and len(set(classes)) == 1 + reproducible = reproducible and stable + confirmations.append( + {"side": side, "runs": repeatability, "stable": stable, "trace_sha256": hashes[0]} + ) + finding: dict[str, object] = { + "kind": "qualitative-regime-change", + "parameter": parameter_name, + "coarse_bracket": [coarse_left, coarse_right], + "stable_bracket": [low, high], + "bracket_width": high - low, + "smallest_reproducible_change_found": high - low, + "baseline_regime": left_run.classification, + "changed_regime": right_run.classification, + "classification_rule": config.string("classification_rule"), + "refinement_rule": config.string("refinement_rule"), + "repeatability": confirmations, + "unresolved_midpoint": unresolved_midpoint, + "minimality_statement": "Smallest reproducible separation found by the declared bounded scan and binary refinement; not a proof of a globally minimal perturbation or exact bifurcation point.", + } + return ProbeOutcome( + command="scan", + status="QUALITATIVE TRANSITION FOUND", + config=config, + baseline=left_run, + changed=right_run, + finding=finding, + history=tuple(history), + reproducible=reproducible, + ) + + +def _distance(left: State, right: State) -> float: + return math.sqrt(sum((a - b) ** 2 for a, b in zip(left, right, strict=False))) + + +def _pair_metrics( + baseline: SimulationResult, changed: SimulationResult, delta: float +) -> dict[str, object]: + count = min(len(baseline.trace), len(changed.trace)) + if count == 0: + raise NumericalFailure("cannot compare empty twin traces") + left = baseline.trace[-count:] + right = changed.trace[-count:] + distances = [_distance(a.state, b.state) for a, b in zip(left, right, strict=False)] + max_index = max(range(count), key=distances.__getitem__) + max_distance = distances[max_index] + elapsed = max(right[max_index].time - right[0].time, changed.settings.dt) + initial_separation = abs(delta) + rate: float | None = None + if initial_separation > 0.0 and max_distance > 0.0: + rate = math.log(max_distance / initial_separation) / elapsed + return { + "initial_separation": initial_separation, + "max_trajectory_distance": max_distance, + "terminal_trajectory_distance": distances[-1], + "finite_time_divergence_rate": rate, + "rate_observation_window": elapsed, + } + + +def run_perturb(config: ProbeConfig) -> ProbeOutcome: + """Search bounded initial-state perturbations using a deterministic predicate.""" + + search = config.section("perturb") + model = get_model(config.model) + dimension = _string(search, "dimension") + try: + dimension_index = model.dimensions.index(dimension) + except ValueError as exc: + raise ConfigurationError( + f"unknown perturbation dimension {dimension!r}; choose one of {model.dimensions}" + ) from exc + start = _number(search, "start") + stop = _number(search, "stop") + points = _integer(search, "points") + scale = _string(search, "scale", "linear") + predicate = _string(search, "predicate", "classification-change") + target_classification_raw = search.get("target_classification") + target_classification = ( + _string(search, "target_classification") if target_classification_raw is not None else None + ) + threshold = _number(search, "divergence_threshold", 1.0) + refinements = _integer(search, "refine_iterations", 10) + repeatability = _integer(search, "repeatability", 2) + if stop <= start or start < 0.0 or refinements < 0 or repeatability < 1: + raise ConfigurationError("perturb requires stop > start >= 0 and valid refinement counts") + if predicate not in {"classification-change", "finite-time-divergence"}: + raise ConfigurationError( + "perturb predicate must be classification-change or finite-time-divergence" + ) + deltas = _logspace(start, stop, points) if scale == "log" else _linspace(start, stop, points) + if scale not in {"linear", "log"}: + raise ConfigurationError("perturb scale must be linear or log") + + baseline = simulate(config) + initial = baseline.initial_state + history: list[Mapping[str, object]] = [] + previous_delta = 0.0 + previous_triggered = False + found_delta: float | None = None + found_run: SimulationResult | None = None + found_metrics: dict[str, object] | None = None + + def evaluate(delta: float, phase: str) -> tuple[bool, SimulationResult, dict[str, object]]: + candidate_state = list(initial) + candidate_state[dimension_index] += delta + changed = simulate(config, initial_override=tuple(candidate_state)) + metrics = _pair_metrics(baseline, changed, delta) + if predicate == "classification-change": + triggered = ( + changed.classification == target_classification + if target_classification is not None + else changed.classification != baseline.classification + ) + else: + distance = metrics["max_trajectory_distance"] + if not isinstance(distance, float): + raise NumericalFailure("internal distance metric type failure") + triggered = distance >= threshold + history.append( + { + "phase": phase, + "delta": delta, + "classification": changed.classification, + "triggered": triggered, + **metrics, + } + ) + return triggered, changed, metrics + + for delta in deltas: + triggered, changed, metrics = evaluate(delta, "coarse") + if triggered: + found_delta, found_run, found_metrics = delta, changed, metrics + break + previous_delta = delta + previous_triggered = triggered + + if found_delta is None or found_run is None or found_metrics is None: + last_delta = deltas[-1] + _, last_run, _ = evaluate(last_delta, "negative-control-confirmation") + return ProbeOutcome( + command="perturb", + status="NO SENSITIVE PERTURBATION FOUND", + config=config, + baseline=baseline, + changed=last_run, + finding=None, + history=tuple(history), + reproducible=True, + ) + + low = previous_delta + high = found_delta + if low > 0.0 and not previous_triggered: + for _ in range(refinements): + midpoint = (low + high) / 2.0 + triggered, changed, metrics = evaluate(midpoint, "refine") + if triggered: + high, found_run, found_metrics = midpoint, changed, metrics + else: + low = midpoint + + hashes: list[str] = [] + classes: list[str] = [] + triggered_confirmations: list[bool] = [] + for _ in range(repeatability): + triggered, confirmed, _ = evaluate(high, "repeatability") + hashes.append(confirmed.trace_sha256) + classes.append(confirmed.classification) + triggered_confirmations.append(triggered) + reproducible = len(set(hashes)) == 1 and len(set(classes)) == 1 and all(triggered_confirmations) + finding = { + "kind": predicate, + "dimension": dimension, + "search_bounds": [start, stop], + "stable_bracket": [low, high], + "smallest_reproducible_change_found": high, + "baseline_regime": baseline.classification, + "changed_regime": found_run.classification, + "target_classification": target_classification, + "metrics": found_metrics, + "classification_rule": config.string("classification_rule"), + "refinement_rule": config.string("refinement_rule"), + "repeatability": { + "runs": repeatability, + "stable": reproducible, + "trace_sha256": hashes[0], + }, + "minimality_statement": "Smallest reproducible perturbation found within the declared finite search; not a proof of global minimality.", + } + status = ( + "FINITE-TIME TRAJECTORY DIVERGENCE FOUND" + if predicate == "finite-time-divergence" + else "QUALITATIVE STATE SWITCH FOUND" + ) + return ProbeOutcome( + command="perturb", + status=status, + config=config, + baseline=baseline, + changed=found_run, + finding=finding, + history=tuple(history), + reproducible=reproducible, + ) + + +def run_check(config: ProbeConfig) -> ProbeOutcome: + """Execute a declared CI policy and mark only policy violations as failure.""" + + check = config.section("check") + analysis = _string(check, "analysis", "invariants") + policy = config.section("policy") + forbid_findings = _boolean(policy, "forbid_findings", False) + require_finding = _boolean(policy, "require_finding", False) + require_invariants = _boolean(policy, "require_invariants", True) + + if analysis == "scan": + outcome = run_scan(config) + elif analysis == "perturb": + outcome = run_perturb(config) + elif analysis == "invariants": + baseline = simulate(config) + violations = [_invariant_dict(item) for item in baseline.invariants if not item.passed] + finding: Mapping[str, object] | None = None + if violations: + finding = {"kind": "invariant-violation", "violations": violations} + outcome = ProbeOutcome( + command="check", + status="CHECK EVIDENCE COLLECTED", + config=config, + baseline=baseline, + changed=None, + finding=finding, + history=(), + reproducible=True, + ) + else: + raise ConfigurationError("check.analysis must be invariants, scan, or perturb") + + finding_present = outcome.finding is not None + policy_failed = (forbid_findings and finding_present) or ( + require_finding and not finding_present + ) + if require_invariants: + policy_failed = policy_failed or outcome.baseline.invariant_violations > 0 + if outcome.changed is not None: + policy_failed = policy_failed or outcome.changed.invariant_violations > 0 + return ProbeOutcome( + command="check", + status="CHECK POLICY FAILED" if policy_failed else "CHECK POLICY PASSED", + config=config, + baseline=outcome.baseline, + changed=outcome.changed, + finding=outcome.finding, + history=outcome.history, + reproducible=outcome.reproducible, + policy_failed=policy_failed, + ) diff --git a/src/phaseprobe/errors.py b/src/phaseprobe/errors.py new file mode 100644 index 0000000..e70b141 --- /dev/null +++ b/src/phaseprobe/errors.py @@ -0,0 +1,31 @@ +"""PhaseProbe exception hierarchy and documented process exit codes.""" + +from __future__ import annotations + +from enum import IntEnum + + +class ExitCode(IntEnum): + """Stable CLI exit-code contract.""" + + OK = 0 + POLICY_FAILED = 1 + INVALID_INPUT = 2 + NUMERICAL_FAILURE = 3 + INTERNAL_ERROR = 4 + + +class PhaseProbeError(Exception): + """Base class for expected PhaseProbe failures.""" + + +class ConfigurationError(PhaseProbeError): + """Raised when a configuration or user input is invalid.""" + + +class NumericalFailure(PhaseProbeError): + """Raised when integration produces an invalid state or solver failure.""" + + +class IntegrityError(PhaseProbeError): + """Raised when a replay fixture fails integrity validation.""" diff --git a/src/phaseprobe/generate.py b/src/phaseprobe/generate.py new file mode 100644 index 0000000..2398f83 --- /dev/null +++ b/src/phaseprobe/generate.py @@ -0,0 +1,57 @@ +"""Safe deterministic pytest generation from validated replay fixtures.""" + +from __future__ import annotations + +import re +import shutil +from dataclasses import dataclass +from pathlib import Path + +from phaseprobe.replay import validate_fixture, verify_replay + + +@dataclass(frozen=True, slots=True) +class GeneratedTest: + """Generated fixed-template test and copied integrity-protected fixture.""" + + test_path: Path + fixture_path: Path + + +def _safe_name(value: object) -> str: + candidate = re.sub(r"[^a-z0-9]+", "_", str(value).lower()).strip("_") + return candidate[:64] or "model" + + +def generate_regression_test(fixture: Path, output_directory: Path) -> GeneratedTest: + """Validate evidence, copy its fixture, and emit a non-extensible pytest template.""" + + payload = validate_fixture(fixture) + verification = verify_replay(fixture) + if not verification.ok: + raise ValueError( + "replay fixture does not reproduce; refusing to generate a regression test" + ) + model_name = _safe_name(payload.get("model")) + output_directory.mkdir(parents=True, exist_ok=True) + fixture_directory = output_directory / "fixtures" + fixture_directory.mkdir(exist_ok=True) + copied_fixture = fixture_directory / f"{model_name}-replay.json" + shutil.copyfile(fixture, copied_fixture) + test_path = output_directory / f"test_{model_name}_transition.py" + source = f'''"""Generated by PhaseProbe from a validated replay fixture.""" + +from pathlib import Path + +from phaseprobe.replay import verify_replay + +FIXTURE = Path(__file__).parent / "fixtures" / "{model_name}-replay.json" + + +def test_{model_name}_transition_replays() -> None: + """Re-execute model/config/seed and verify class and exact retained trace hashes.""" + result = verify_replay(FIXTURE) + assert result.ok, result.as_dict() +''' + test_path.write_text(source, encoding="utf-8", newline="\n") + return GeneratedTest(test_path=test_path, fixture_path=copied_fixture) diff --git a/src/phaseprobe/models/__init__.py b/src/phaseprobe/models/__init__.py new file mode 100644 index 0000000..2948416 --- /dev/null +++ b/src/phaseprobe/models/__init__.py @@ -0,0 +1,36 @@ +"""Built-in deterministic model adapters.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import cast + +from phaseprobe.errors import ConfigurationError +from phaseprobe.models.logistic import LogisticMapAdapter +from phaseprobe.models.lorenz import LorenzAdapter +from phaseprobe.models.predator_prey import PredatorPreyAdapter +from phaseprobe.models.toggle import GeneticToggleAdapter +from phaseprobe.types import ModelAdapter + +_MODELS: Mapping[str, ModelAdapter] = { + "logistic-map": cast(ModelAdapter, LogisticMapAdapter()), + "lorenz": cast(ModelAdapter, LorenzAdapter()), + "predator-prey": cast(ModelAdapter, PredatorPreyAdapter()), + "genetic-toggle": cast(ModelAdapter, GeneticToggleAdapter()), +} + + +def get_model(name: str) -> ModelAdapter: + """Return a built-in model adapter by stable identity.""" + + model = _MODELS.get(name) + if model is None: + choices = ", ".join(sorted(_MODELS)) + raise ConfigurationError(f"unknown model {name!r}; choose one of: {choices}") + return model + + +def model_names() -> tuple[str, ...]: + """Return stable built-in model names.""" + + return tuple(sorted(_MODELS)) diff --git a/src/phaseprobe/models/_common.py b/src/phaseprobe/models/_common.py new file mode 100644 index 0000000..41c64fb --- /dev/null +++ b/src/phaseprobe/models/_common.py @@ -0,0 +1,56 @@ +"""Shared validation and fixed-step integration helpers for built-in adapters.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence + +from phaseprobe.errors import ConfigurationError +from phaseprobe.types import State + +Derivative = Callable[[State], State] + + +def float_value(values: Mapping[str, object], name: str, default: float | None = None) -> float: + """Read a finite-number-shaped value; runtime finite checks happen in the engine.""" + + value = values.get(name, default) + if not isinstance(value, int | float) or isinstance(value, bool): + raise ConfigurationError(f"{name} must be a number") + return float(value) + + +def initial_state( + config: Mapping[str, object], dimensions: Sequence[str], defaults: State +) -> State: + """Read an optional named initial-state object.""" + + raw = config.get("initial_state") + if raw is None: + return defaults + if not isinstance(raw, dict): + raise ConfigurationError("model.initial_state must be an object") + values = raw + return tuple(float_value(values, dimension) for dimension in dimensions) + + +def parameter(parameters: Mapping[str, float], name: str, default: float) -> float: + """Read a model parameter with a documented default.""" + + return float(parameters.get(name, default)) + + +def rk4_step(state: State, dt: float, derivative: Derivative) -> State: + """Advance an autonomous ODE with a deterministic classical RK4 step.""" + + k1 = derivative(state) + k2 = derivative( + tuple(value + 0.5 * dt * slope for value, slope in zip(state, k1, strict=False)) + ) + k3 = derivative( + tuple(value + 0.5 * dt * slope for value, slope in zip(state, k2, strict=False)) + ) + k4 = derivative(tuple(value + dt * slope for value, slope in zip(state, k3, strict=False))) + return tuple( + value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d) + for value, a, b, c, d in zip(state, k1, k2, k3, k4, strict=False) + ) diff --git a/src/phaseprobe/models/logistic.py b/src/phaseprobe/models/logistic.py new file mode 100644 index 0000000..e665bc9 --- /dev/null +++ b/src/phaseprobe/models/logistic.py @@ -0,0 +1,63 @@ +"""Logistic-map adapter and finite-period classifier.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping, Sequence + +from phaseprobe.models._common import initial_state, parameter +from phaseprobe.types import InvariantResult, Parameters, State, Tolerances, TracePoint + + +class LogisticMapAdapter: + """The discrete logistic map ``x[n+1] = r*x[n]*(1-x[n])``.""" + + name = "logistic-map" + identity = "phaseprobe.builtin.logistic-map:v1" + dimensions = ("x",) + + def initial_state(self, config: Mapping[str, object], seed: int) -> State: + del seed + return initial_state(config, self.dimensions, (0.2,)) + + def step(self, state: State, parameters: Parameters, dt: float) -> State: + del dt + r = parameter(parameters, "r", 3.44) + x = state[0] + return (r * x * (1.0 - x),) + + def observe(self, state: State) -> Mapping[str, float]: + return {"x": state[0]} + + def classify(self, trace: Sequence[TracePoint], tolerances: Tolerances) -> str: + values = [point.state[0] for point in trace] + tolerance = tolerances.get("period", 1e-8) + for period in (1, 2, 4, 8, 16): + if len(values) < 4 * period: + continue + tail = values[-4 * period :] + error = max( + abs(tail[index] - tail[index - period]) for index in range(period, len(tail)) + ) + if error <= tolerance: + return f"period-{period}" + return "aperiodic-or-unresolved" + + def invariants( + self, + trace: Sequence[TracePoint], + parameters: Parameters, + tolerances: Tolerances, + ) -> list[InvariantResult]: + del parameters, tolerances + values = [point.state[0] for point in trace] + violation = max((max(-value, value - 1.0, 0.0) for value in values), default=0.0) + return [ + InvariantResult( + name="unit-interval-boundedness", + passed=math.isfinite(violation) and violation == 0.0, + measured=violation, + tolerance=0.0, + detail="For 0 <= r <= 4 and 0 <= x <= 1, retained states should remain in [0, 1].", + ) + ] diff --git a/src/phaseprobe/models/lorenz.py b/src/phaseprobe/models/lorenz.py new file mode 100644 index 0000000..8ca7f26 --- /dev/null +++ b/src/phaseprobe/models/lorenz.py @@ -0,0 +1,64 @@ +"""Lorenz-system adapter for finite-time trajectory-divergence evidence.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping, Sequence + +from phaseprobe.models._common import initial_state, parameter, rk4_step +from phaseprobe.types import InvariantResult, Parameters, State, Tolerances, TracePoint + + +class LorenzAdapter: + """Classical three-variable Lorenz equations integrated with fixed-step RK4.""" + + name = "lorenz" + identity = "phaseprobe.builtin.lorenz:v1" + dimensions = ("x", "y", "z") + + def initial_state(self, config: Mapping[str, object], seed: int) -> State: + del seed + return initial_state(config, self.dimensions, (1.0, 1.0, 1.0)) + + def step(self, state: State, parameters: Parameters, dt: float) -> State: + sigma = parameter(parameters, "sigma", 10.0) + rho = parameter(parameters, "rho", 28.0) + beta = parameter(parameters, "beta", 8.0 / 3.0) + + def derivative(current: State) -> State: + x, y, z = current + return (sigma * (y - x), x * (rho - z) - y, x * y - beta * z) + + return rk4_step(state, dt, derivative) + + def observe(self, state: State) -> Mapping[str, float]: + x, y, z = state + return {"x": x, "y": y, "z": z, "radius": math.sqrt(x * x + y * y + z * z)} + + def classify(self, trace: Sequence[TracePoint], tolerances: Tolerances) -> str: + del tolerances + signs = {point.state[0] >= 0.0 for point in trace} + if len(signs) > 1: + return "two-lobe-finite-time-trajectory" + if trace and trace[-1].state[0] >= 0.0: + return "positive-lobe-finite-time-trajectory" + return "negative-lobe-finite-time-trajectory" + + def invariants( + self, + trace: Sequence[TracePoint], + parameters: Parameters, + tolerances: Tolerances, + ) -> list[InvariantResult]: + del parameters + bound = tolerances.get("state_bound", 100.0) + measured = max((abs(value) for point in trace for value in point.state), default=0.0) + return [ + InvariantResult( + name="declared-finite-state-bound", + passed=math.isfinite(measured) and measured <= bound, + measured=measured, + tolerance=bound, + detail="A diagnostic finite-time bound, not a mathematical invariant of the Lorenz system.", + ) + ] diff --git a/src/phaseprobe/models/predator_prey.py b/src/phaseprobe/models/predator_prey.py new file mode 100644 index 0000000..6724627 --- /dev/null +++ b/src/phaseprobe/models/predator_prey.py @@ -0,0 +1,86 @@ +"""Lotka-Volterra predator-prey adapter with a conserved-quantity diagnostic.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping, Sequence + +from phaseprobe.models._common import initial_state, parameter, rk4_step +from phaseprobe.types import InvariantResult, Parameters, State, Tolerances, TracePoint + + +class PredatorPreyAdapter: + """Conservative two-species Lotka-Volterra equations.""" + + name = "predator-prey" + identity = "phaseprobe.builtin.predator-prey:v1" + dimensions = ("prey", "predator") + + def initial_state(self, config: Mapping[str, object], seed: int) -> State: + del seed + return initial_state(config, self.dimensions, (10.0, 5.0)) + + def step(self, state: State, parameters: Parameters, dt: float) -> State: + alpha = parameter(parameters, "alpha", 1.1) + beta = parameter(parameters, "beta", 0.4) + delta = parameter(parameters, "delta", 0.1) + gamma = parameter(parameters, "gamma", 0.4) + + def derivative(current: State) -> State: + prey, predator = current + return ( + alpha * prey - beta * prey * predator, + delta * prey * predator - gamma * predator, + ) + + return rk4_step(state, dt, derivative) + + def observe(self, state: State) -> Mapping[str, float]: + return {"prey": state[0], "predator": state[1]} + + def classify(self, trace: Sequence[TracePoint], tolerances: Tolerances) -> str: + del tolerances + if not trace: + return "unresolved" + minima = [min(point.state[index] for point in trace) for index in (0, 1)] + return "bounded-positive-oscillation" if min(minima) > 0.0 else "invalid-population" + + def invariants( + self, + trace: Sequence[TracePoint], + parameters: Parameters, + tolerances: Tolerances, + ) -> list[InvariantResult]: + alpha = parameter(parameters, "alpha", 1.1) + beta = parameter(parameters, "beta", 0.4) + delta = parameter(parameters, "delta", 0.1) + gamma = parameter(parameters, "gamma", 0.4) + tolerance = tolerances.get("invariant_drift", 1e-5) + + def conserved(state: State) -> float: + prey, predator = state + if prey <= 0.0 or predator <= 0.0: + return math.inf + return ( + delta * prey - gamma * math.log(prey) + beta * predator - alpha * math.log(predator) + ) + + values = [conserved(point.state) for point in trace] + drift = max((abs(value - values[0]) for value in values), default=0.0) + min_population = min((value for point in trace for value in point.state), default=0.0) + return [ + InvariantResult( + name="lotka-volterra-conserved-quantity", + passed=math.isfinite(drift) and drift <= tolerance, + measured=drift, + tolerance=tolerance, + detail="Maximum retained drift from the initial value of the analytic first integral.", + ), + InvariantResult( + name="positive-populations", + passed=min_population > 0.0, + measured=min_population, + tolerance=0.0, + detail="Both populations must remain strictly positive over the retained trajectory.", + ), + ] diff --git a/src/phaseprobe/models/toggle.py b/src/phaseprobe/models/toggle.py new file mode 100644 index 0000000..63d3699 --- /dev/null +++ b/src/phaseprobe/models/toggle.py @@ -0,0 +1,73 @@ +"""Small mutually repressing genetic-toggle adapter.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping, Sequence + +from phaseprobe.models._common import initial_state, parameter, rk4_step +from phaseprobe.types import InvariantResult, Parameters, State, Tolerances, TracePoint + + +class GeneticToggleAdapter: + """Dimensionless two-gene mutual-repression model.""" + + name = "genetic-toggle" + identity = "phaseprobe.builtin.genetic-toggle:v1" + dimensions = ("u", "v") + + def initial_state(self, config: Mapping[str, object], seed: int) -> State: + del seed + return initial_state(config, self.dimensions, (3.0, 0.2)) + + def step(self, state: State, parameters: Parameters, dt: float) -> State: + alpha_u = parameter(parameters, "alpha_u", 3.0) + alpha_v = parameter(parameters, "alpha_v", 3.0) + hill_u = parameter(parameters, "hill_u", 2.0) + hill_v = parameter(parameters, "hill_v", 2.0) + + def derivative(current: State) -> State: + u, v = current + return ( + alpha_u / (1.0 + v**hill_v) - u, + alpha_v / (1.0 + u**hill_u) - v, + ) + + return rk4_step(state, dt, derivative) + + def observe(self, state: State) -> Mapping[str, float]: + u, v = state + return {"u": u, "v": v, "difference": u - v} + + def classify(self, trace: Sequence[TracePoint], tolerances: Tolerances) -> str: + if not trace: + return "unresolved" + threshold = tolerances.get("dominance", 0.05) + difference = trace[-1].state[0] - trace[-1].state[1] + if difference > threshold: + return "u-dominant" + if difference < -threshold: + return "v-dominant" + return "balanced" + + def invariants( + self, + trace: Sequence[TracePoint], + parameters: Parameters, + tolerances: Tolerances, + ) -> list[InvariantResult]: + del parameters + bound = tolerances.get("state_bound", 10.0) + values = [value for point in trace for value in point.state] + low = min(values, default=0.0) + high = max(values, default=0.0) + violation = max(-low, high - bound, 0.0) + return [ + InvariantResult( + name="nonnegative-declared-bound", + passed=math.isfinite(violation) and violation == 0.0, + measured=violation, + tolerance=0.0, + detail=f"Concentrations must remain in the declared interval [0, {bound:g}].", + ) + ] diff --git a/src/phaseprobe/replay.py b/src/phaseprobe/replay.py new file mode 100644 index 0000000..e2c9061 --- /dev/null +++ b/src/phaseprobe/replay.py @@ -0,0 +1,183 @@ +"""Versioned replay fixtures, integrity validation, and deterministic re-execution.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any, cast + +from phaseprobe import __version__ +from phaseprobe.config import canonical_json, parse_config +from phaseprobe.engine import ProbeOutcome, SimulationResult, simulate +from phaseprobe.errors import ConfigurationError, IntegrityError + +REPLAY_SCHEMA_VERSION = "1.0" + + +def _execution_payload(result: SimulationResult) -> dict[str, object]: + return { + "parameters": dict(result.parameters), + "initial_state": list(result.initial_state), + "classification": result.classification, + "trace_sha256": result.trace_sha256, + "invariant_violations": result.invariant_violations, + } + + +def fixture_payload(outcome: ProbeOutcome) -> dict[str, object]: + """Create an integrity-protected replay fixture from validated evidence.""" + + payload: dict[str, object] = { + "schema_version": REPLAY_SCHEMA_VERSION, + "created_by": f"phaseprobe {__version__}", + "model": outcome.baseline.model, + "model_identity": outcome.baseline.model_identity, + "seed": outcome.baseline.seed, + "configuration": dict(outcome.config.data), + "baseline": _execution_payload(outcome.baseline), + "changed": _execution_payload(outcome.changed) if outcome.changed is not None else None, + "finding": dict(outcome.finding) if outcome.finding is not None else None, + "reproducible": outcome.reproducible, + } + integrity = hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest() + payload["integrity_sha256"] = integrity + return payload + + +def _load_fixture(path: Path) -> dict[str, object]: + try: + parsed: Any = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise ConfigurationError(f"cannot read replay fixture {path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise IntegrityError(f"invalid replay JSON in {path}: {exc}") from exc + if not isinstance(parsed, dict): + raise IntegrityError("replay fixture must be a JSON object") + return cast(dict[str, object], parsed) + + +def validate_fixture(path: Path) -> dict[str, object]: + """Validate replay schema and SHA-256 integrity before any execution.""" + + payload = _load_fixture(path) + if payload.get("schema_version") != REPLAY_SCHEMA_VERSION: + raise IntegrityError( + f"unsupported replay schema {payload.get('schema_version')!r}; " + f"expected {REPLAY_SCHEMA_VERSION!r}" + ) + expected = payload.get("integrity_sha256") + if not isinstance(expected, str): + raise IntegrityError("replay fixture has no integrity_sha256") + unsigned = dict(payload) + del unsigned["integrity_sha256"] + actual = hashlib.sha256(canonical_json(unsigned).encode("utf-8")).hexdigest() + if actual != expected: + raise IntegrityError( + f"replay fixture integrity mismatch: expected {expected}, got {actual}" + ) + return payload + + +def _mapping(value: object, context: str) -> Mapping[str, object]: + if not isinstance(value, dict): + raise IntegrityError(f"replay {context} must be an object") + return cast(Mapping[str, object], value) + + +def _float_mapping(value: object, context: str) -> dict[str, float]: + values = _mapping(value, context) + result: dict[str, float] = {} + for name, raw in values.items(): + if not isinstance(raw, int | float) or isinstance(raw, bool): + raise IntegrityError(f"replay {context}.{name} must be numeric") + result[name] = float(raw) + return result + + +def _state(value: object, context: str) -> tuple[float, ...]: + if not isinstance(value, list): + raise IntegrityError(f"replay {context} must be an array") + state: list[float] = [] + for raw in value: + if not isinstance(raw, int | float) or isinstance(raw, bool): + raise IntegrityError(f"replay {context} contains a non-number") + state.append(float(raw)) + return tuple(state) + + +@dataclass(frozen=True, slots=True) +class ReplayVerification: + """Deterministic replay verdict with comparisons suitable for CI diagnostics.""" + + ok: bool + model: str + baseline: SimulationResult + changed: SimulationResult | None + comparisons: tuple[Mapping[str, object], ...] + + def as_dict(self) -> dict[str, object]: + return { + "schema_version": "1.0", + "status": "REPLAY VERIFIED" if self.ok else "REPLAY MISMATCH", + "model": self.model, + "ok": self.ok, + "comparisons": [dict(item) for item in self.comparisons], + "baseline": self.baseline.as_dict(), + "changed": self.changed.as_dict() if self.changed is not None else None, + } + + +def verify_replay(path: Path) -> ReplayVerification: + """Re-execute the recorded model/config/seed and compare exact trace evidence.""" + + fixture = validate_fixture(path) + configuration = fixture.get("configuration") + if not isinstance(configuration, dict): + raise IntegrityError("replay configuration must be an object") + config = parse_config(canonical_json(configuration), f"replay fixture {path.name}") + expected_model = fixture.get("model") + if config.model != expected_model: + raise IntegrityError("replay model does not match embedded configuration") + expected_identity = fixture.get("model_identity") + + comparisons: list[Mapping[str, object]] = [] + + def execute(label: str, raw: object) -> SimulationResult: + expected = _mapping(raw, label) + run = simulate( + config, + parameters_override=_float_mapping(expected.get("parameters"), f"{label}.parameters"), + initial_override=_state(expected.get("initial_state"), f"{label}.initial_state"), + ) + classification_match = run.classification == expected.get("classification") + hash_match = run.trace_sha256 == expected.get("trace_sha256") + identity_match = run.model_identity == expected_identity + comparisons.append( + { + "series": label, + "classification_match": classification_match, + "trace_hash_match": hash_match, + "model_identity_match": identity_match, + "expected_trace_sha256": expected.get("trace_sha256"), + "actual_trace_sha256": run.trace_sha256, + } + ) + return run + + baseline = execute("baseline", fixture.get("baseline")) + changed_raw = fixture.get("changed") + changed = execute("changed", changed_raw) if changed_raw is not None else None + ok = all( + all(value is True for key, value in item.items() if key.endswith("_match")) + for item in comparisons + ) + return ReplayVerification( + ok=ok, + model=config.model, + baseline=baseline, + changed=changed, + comparisons=tuple(comparisons), + ) diff --git a/src/phaseprobe/reporting.py b/src/phaseprobe/reporting.py new file mode 100644 index 0000000..22936b9 --- /dev/null +++ b/src/phaseprobe/reporting.py @@ -0,0 +1,172 @@ +"""Offline terminal, JSON, and self-contained HTML evidence reports.""" + +from __future__ import annotations + +import html +import json +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import cast + + +def _mapping(value: object) -> Mapping[str, object]: + return cast(Mapping[str, object], value) if isinstance(value, dict) else {} + + +def _sequence(value: object) -> Sequence[object]: + return cast(Sequence[object], value) if isinstance(value, list) else () + + +def _format_float(value: object) -> str: + return f"{value:.10g}" if isinstance(value, float) else str(value) + + +def _int_value(value: object) -> int: + return value if isinstance(value, int) and not isinstance(value, bool) else 0 + + +def terminal_report( + data: Mapping[str, object], + *, + replay: str | None = None, + generated_test: str | None = None, +) -> str: + """Lead with the result and present compact, scientifically qualified evidence.""" + + status = str(data.get("status", "PHASEPROBE RESULT")) + model = str(data.get("model", "unknown")) + baseline = _mapping(data.get("baseline")) + changed = _mapping(data.get("changed")) + finding = _mapping(data.get("finding")) + lines = [status, "", f"Model: {model}"] + kind = finding.get("kind") + if kind is not None: + lines.append(f"Evidence: {kind}") + parameter = finding.get("parameter") or finding.get("dimension") + if parameter is not None: + lines.append(f"Search dimension: {parameter}") + bracket = _sequence(finding.get("stable_bracket")) + if len(bracket) == 2: + lines.append(f"Stable bracket: {_format_float(bracket[0])} .. {_format_float(bracket[1])}") + smallest = finding.get("smallest_reproducible_change_found") + if smallest is not None: + lines.append(f"Smallest reproducible change found: {_format_float(smallest)}") + lines.extend( + [ + f"Baseline regime: {baseline.get('classification', 'n/a')}", + f"Changed regime: {changed.get('classification', 'n/a') if changed else 'n/a'}", + ] + ) + violations = _int_value(baseline.get("invariant_violations", 0)) + if changed: + violations += _int_value(changed.get("invariant_violations", 0)) + lines.append(f"Invariant violations: {violations}") + lines.append(f"Repeatable: {str(bool(data.get('reproducible', False))).lower()}") + if replay is not None: + lines.append(f"Replay: {replay}") + if generated_test is not None: + lines.append(f"Generated test: {generated_test}") + minimality = finding.get("minimality_statement") + if minimality is not None: + lines.extend(["", f"Scope: {minimality}"]) + return "\n".join(lines) + + +def json_report(data: Mapping[str, object]) -> str: + """Render the complete versioned JSON evidence document.""" + + return json.dumps(data, allow_nan=False, indent=2, sort_keys=True) + "\n" + + +def html_report(data: Mapping[str, object]) -> str: + """Render a standalone offline report with no scripts, fonts, or CDN assets.""" + + status = html.escape(str(data.get("status", "PhaseProbe result"))) + model = html.escape(str(data.get("model", "unknown"))) + baseline = _mapping(data.get("baseline")) + changed = _mapping(data.get("changed")) + finding = _mapping(data.get("finding")) + history = _sequence(data.get("history")) + config = _mapping(data.get("configuration")) + + history_rows: list[str] = [] + for item in history: + row = _mapping(item) + phase = html.escape(str(row.get("phase", ""))) + value = row.get("value", row.get("delta", "")) + classification = html.escape(str(row.get("classification", ""))) + interval = "" + if "low" in row and "high" in row: + interval = f"{_format_float(row['low'])} .. {_format_float(row['high'])}" + history_rows.append( + "" + f"{phase}{html.escape(_format_float(value))}" + f"{classification}{html.escape(interval)}" + "" + ) + if not history_rows: + history_rows.append('No refinement history for this check.') + + finding_json = html.escape(json.dumps(finding, allow_nan=False, indent=2, sort_keys=True)) + config_json = html.escape(json.dumps(config, allow_nan=False, indent=2, sort_keys=True)) + limitations = ( + "This report contains bounded numerical evidence. It does not prove global minimality, " + "an exact bifurcation point, chaos, a formal Lyapunov exponent, or scientific validity " + "outside the declared model, integration settings, search space, and tolerances." + ) + return f""" + + + + +PhaseProbe — {status} + + +
+

PhaseProbe evidence report · schema {html.escape(str(data.get("schema_version", "unknown")))}

+

{status}

Model: {model}

+
+

Baseline

{html.escape(str(baseline.get("classification", "n/a")))}

Trace SHA-256: {html.escape(str(baseline.get("trace_sha256", "n/a")))}

+

Changed

{html.escape(str(changed.get("classification", "n/a")))}

Trace SHA-256: {html.escape(str(changed.get("trace_sha256", "n/a")))}

+
+

Finding

{finding_json}
+

Search and refinement history

+{"".join(history_rows)}
PhaseValue / deltaClassificationBracket
+

Configuration

{config_json}
+

Replay and generated test

The run directory contains a versioned replay.json fixture. The generate-test command validates and copies that fixture into a fixed pytest template.

+

Scientific limitations

{html.escape(limitations)}

+
+""" + + +def regenerate_reports(run_directory: Path) -> tuple[Path, Path]: + """Regenerate JSON and HTML evidence from a bounded run directory.""" + + run_path = run_directory / "run.json" + try: + parsed = json.loads(run_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"cannot load run evidence from {run_path}: {exc}") from exc + if not isinstance(parsed, dict): + raise ValueError(f"run evidence in {run_path} is not a JSON object") + data = cast(dict[str, object], parsed) + json_path = run_directory / "report.json" + html_path = run_directory / "report.html" + json_path.write_text(json_report(data), encoding="utf-8") + html_path.write_text(html_report(data), encoding="utf-8") + return json_path, html_path diff --git a/src/phaseprobe/types.py b/src/phaseprobe/types.py new file mode 100644 index 0000000..88dea81 --- /dev/null +++ b/src/phaseprobe/types.py @@ -0,0 +1,68 @@ +"""Typed public model-adapter and result interfaces.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Protocol, TypeAlias, runtime_checkable + +Scalar: TypeAlias = float | int | str +State: TypeAlias = tuple[float, ...] +Parameters: TypeAlias = Mapping[str, float] +ModelConfig: TypeAlias = Mapping[str, object] +Tolerances: TypeAlias = Mapping[str, float] + + +@dataclass(frozen=True, slots=True) +class TracePoint: + """One retained point from a bounded simulation trace.""" + + step: int + time: float + state: State + observations: Mapping[str, Scalar] + + +@dataclass(frozen=True, slots=True) +class InvariantResult: + """Result of evaluating a declared model invariant or boundedness rule.""" + + name: str + passed: bool + measured: float + tolerance: float + detail: str + + +@runtime_checkable +class ModelAdapter(Protocol): + """Small interface implemented by built-in and downstream simulation adapters. + + The explicit state tuple makes perturbation, serialization, and finite-value validation + deterministic. Classifiers and invariants receive declared tolerances rather than hiding + thresholds inside the engine. + """ + + name: str + identity: str + dimensions: tuple[str, ...] + + def initial_state(self, config: ModelConfig, seed: int) -> State: + """Return a deterministic initial state for ``config`` and ``seed``.""" + + def step(self, state: State, parameters: Parameters, dt: float) -> State: + """Advance exactly one declared step.""" + + def observe(self, state: State) -> Mapping[str, Scalar]: + """Expose bounded scalar observables used in reports and classification.""" + + def classify(self, trace: Sequence[TracePoint], tolerances: Tolerances) -> str: + """Return a qualitative class using only retained trace evidence.""" + + def invariants( + self, + trace: Sequence[TracePoint], + parameters: Parameters, + tolerances: Tolerances, + ) -> list[InvariantResult]: + """Evaluate declared invariants or boundedness conditions.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6d1e545 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +"""Shared deterministic outcomes for the numerical test suite.""" + +from __future__ import annotations + +import pytest + +from phaseprobe.config import load_example +from phaseprobe.engine import ProbeOutcome, run_scan + + +@pytest.fixture(scope="session") +def logistic_outcome() -> ProbeOutcome: + """Run the slower critical-transition example once per test session.""" + + return run_scan(load_example("logistic")) diff --git a/tests/generated/fixtures/logistic_map-replay.json b/tests/generated/fixtures/logistic_map-replay.json new file mode 100644 index 0000000..59ff405 --- /dev/null +++ b/tests/generated/fixtures/logistic_map-replay.json @@ -0,0 +1,100 @@ +{ + "baseline": { + "classification": "period-2", + "initial_state": [ + 0.2 + ], + "invariant_violations": 0, + "parameters": { + "r": 3.4494775390625003 + }, + "trace_sha256": "59fad16c37082ac14eb62b9e11fc7e6695c80896ea99bb1889d6e7a207271e4b" + }, + "changed": { + "classification": "period-4", + "initial_state": [ + 0.2 + ], + "invariant_violations": 0, + "parameters": { + "r": 3.4494780273437504 + }, + "trace_sha256": "0564695f9706df0e839e4aee48e720d82d02e17498da8db597e1f78508f7b3f0" + }, + "configuration": { + "classification_rule": "Smallest detected period in {1,2,4,8,16} whose retained tail repeats within absolute tolerance; otherwise unresolved/aperiodic.", + "invalid_state_policy": "Abort as invalid integration on NaN, infinity, overflow, dimension mismatch, or hard-state-limit breach.", + "model": "logistic-map", + "model_config": { + "initial_state": { + "x": 0.2 + } + }, + "parameters": { + "r": 3.448 + }, + "refinement_rule": "First adjacent coarse class change, followed by up to twelve deterministic binary probes. Refinement stops rather than relabeling a midpoint that reproduces neither stable endpoint class.", + "scan": { + "parameter": "r", + "points": 3, + "refine_iterations": 12, + "repeatability": 2, + "start": 3.448, + "stop": 3.452 + }, + "schema_version": "1.0", + "seed": 17, + "simulation": { + "burn_in": 500000, + "dt": 1.0, + "hard_state_limit": 10.0, + "sample_every": 1, + "steps": 256, + "trace_cap": 256 + }, + "tolerances": { + "period": 1e-09 + } + }, + "created_by": "phaseprobe 0.1.0", + "finding": { + "baseline_regime": "period-2", + "bracket_width": 4.88281250099476e-07, + "changed_regime": "period-4", + "classification_rule": "Smallest detected period in {1,2,4,8,16} whose retained tail repeats within absolute tolerance; otherwise unresolved/aperiodic.", + "coarse_bracket": [ + 3.448, + 3.45 + ], + "kind": "qualitative-regime-change", + "minimality_statement": "Smallest reproducible separation found by the declared bounded scan and binary refinement; not a proof of a globally minimal perturbation or exact bifurcation point.", + "parameter": "r", + "refinement_rule": "First adjacent coarse class change, followed by up to twelve deterministic binary probes. Refinement stops rather than relabeling a midpoint that reproduces neither stable endpoint class.", + "repeatability": [ + { + "runs": 2, + "side": "baseline", + "stable": true, + "trace_sha256": "59fad16c37082ac14eb62b9e11fc7e6695c80896ea99bb1889d6e7a207271e4b" + }, + { + "runs": 2, + "side": "changed", + "stable": true, + "trace_sha256": "0564695f9706df0e839e4aee48e720d82d02e17498da8db597e1f78508f7b3f0" + } + ], + "smallest_reproducible_change_found": 4.88281250099476e-07, + "stable_bracket": [ + 3.4494775390625003, + 3.4494780273437504 + ], + "unresolved_midpoint": null + }, + "integrity_sha256": "8e6d9a8ad624fe5339848168c8a329aafc5abf79a4b90e4e2b29e46bfcee7d20", + "model": "logistic-map", + "model_identity": "phaseprobe.builtin.logistic-map:v1", + "reproducible": true, + "schema_version": "1.0", + "seed": 17 +} diff --git a/tests/generated/test_logistic_map_transition.py b/tests/generated/test_logistic_map_transition.py new file mode 100644 index 0000000..e613935 --- /dev/null +++ b/tests/generated/test_logistic_map_transition.py @@ -0,0 +1,13 @@ +"""Generated by PhaseProbe from a validated replay fixture.""" + +from pathlib import Path + +from phaseprobe.replay import verify_replay + +FIXTURE = Path(__file__).parent / "fixtures" / "logistic_map-replay.json" + + +def test_logistic_map_transition_replays() -> None: + """Re-execute model/config/seed and verify class and exact retained trace hashes.""" + result = verify_replay(FIXTURE) + assert result.ok, result.as_dict() diff --git a/tests/test_artifacts_replay.py b/tests/test_artifacts_replay.py new file mode 100644 index 0000000..2ba1875 --- /dev/null +++ b/tests/test_artifacts_replay.py @@ -0,0 +1,86 @@ +"""Artifact, integrity, replay, report, and generated-test integration tests.""" + +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from phaseprobe.artifacts import write_artifacts +from phaseprobe.config import load_example +from phaseprobe.engine import run_check +from phaseprobe.errors import IntegrityError +from phaseprobe.generate import generate_regression_test +from phaseprobe.replay import validate_fixture, verify_replay +from phaseprobe.reporting import regenerate_reports + + +@pytest.fixture +def artifact_run(tmp_path: Path) -> Path: + outcome = run_check(load_example("predator-prey")) + return write_artifacts(outcome, tmp_path / "runs").run_directory + + +def test_artifact_manifest_hashes_every_evidence_file(artifact_run: Path) -> None: + manifest = json.loads((artifact_run / "manifest.json").read_text(encoding="utf-8")) + assert manifest["bounded_trace"]["baseline_points"] == 4000 + for name, evidence in manifest["files"].items(): + digest = hashlib.sha256((artifact_run / name).read_bytes()).hexdigest() + assert digest == evidence["sha256"] + + +@pytest.mark.integration +def test_replay_reexecutes_exact_trace(artifact_run: Path) -> None: + result = verify_replay(artifact_run / "replay.json") + assert result.ok + assert result.comparisons[0]["trace_hash_match"] is True + + +def test_replay_rejects_tampering(artifact_run: Path) -> None: + fixture = artifact_run / "replay.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + payload["seed"] = 999 + fixture.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(IntegrityError, match="integrity mismatch"): + validate_fixture(fixture) + + +@pytest.mark.integration +def test_generated_pytest_genuinely_executes(artifact_run: Path, tmp_path: Path) -> None: + generated = generate_regression_test(artifact_run / "replay.json", tmp_path / "generated") + environment = { + name: value + for name, value in os.environ.items() + if not name.startswith("COV_CORE") and name != "COVERAGE_PROCESS_START" + } + completed = subprocess.run( + [sys.executable, "-m", "pytest", "-q", generated.test_path.name], + check=False, + capture_output=True, + cwd=generated.test_path.parent, + env=environment, + text=True, + timeout=60, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + assert "1 passed" in completed.stdout + + +def test_html_report_is_self_contained_and_offline(artifact_run: Path) -> None: + report = (artifact_run / "report.html").read_text(encoding="utf-8") + assert "" in report + assert "Scientific limitations" in report + assert "cdn" not in report.lower() + assert " None: + json_path, html_path = regenerate_reports(artifact_run) + assert json_path.exists() + assert html_path.exists() + assert json.loads(json_path.read_text(encoding="utf-8"))["schema_version"] == "1.0" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..ffabb2f --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,165 @@ +"""CLI integration and documented exit-code tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from phaseprobe.artifacts import write_artifacts +from phaseprobe.cli import main +from phaseprobe.config import load_example +from phaseprobe.engine import run_check +from phaseprobe.errors import ExitCode + + +@pytest.fixture +def cli_artifact(tmp_path: Path) -> Path: + outcome = run_check(load_example("predator-prey")) + return write_artifacts(outcome, tmp_path / "artifact-runs").run_directory + + +def test_scan_success_is_zero(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + code = main( + [ + "scan", + "--example", + "logistic-negative", + "--output-root", + str(tmp_path / "runs"), + ] + ) + assert code == ExitCode.OK + assert "NO QUALITATIVE TRANSITION FOUND" in capsys.readouterr().out + + +def test_check_policy_failure_is_one(tmp_path: Path) -> None: + code = main( + [ + "check", + "--example", + "predator-prey-negative", + "--output-root", + str(tmp_path / "runs"), + ] + ) + assert code == ExitCode.POLICY_FAILED + + +def test_invalid_configuration_is_two(tmp_path: Path) -> None: + path = tmp_path / "invalid.json" + path.write_text("{}", encoding="utf-8") + code = main(["scan", "--config", str(path), "--output-root", str(tmp_path / "runs")]) + assert code == ExitCode.INVALID_INPUT + + +def test_numerical_failure_is_three(tmp_path: Path) -> None: + data = { + "schema_version": "1.0", + "model": "logistic-map", + "seed": 1, + "parameters": {"r": 1e50}, + "model_config": {"initial_state": {"x": 0.2}}, + "simulation": { + "steps": 20, + "burn_in": 0, + "dt": 1, + "sample_every": 1, + "trace_cap": 20, + "hard_state_limit": 10, + }, + "tolerances": {"period": 1e-6}, + "scan": {"parameter": "r", "start": 1e50, "stop": 2e50, "points": 2}, + "classification_rule": "test", + "refinement_rule": "test", + "invalid_state_policy": "abort", + } + path = tmp_path / "numerical.json" + path.write_text(json.dumps(data), encoding="utf-8") + code = main(["scan", "--config", str(path), "--output-root", str(tmp_path / "runs")]) + assert code == ExitCode.NUMERICAL_FAILURE + + +def test_json_output_is_versioned(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + code = main( + [ + "check", + "--example", + "predator-prey", + "--output-root", + str(tmp_path / "runs"), + "--json", + ] + ) + assert code == ExitCode.OK + payload = json.loads(capsys.readouterr().out) + assert payload["schema_version"] == "1.0" + assert payload["artifacts"]["replay"].endswith("replay.json") + + +def test_replay_generate_and_report_commands( + cli_artifact: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + replay_code = main(["replay", str(cli_artifact / "replay.json")]) + assert replay_code == ExitCode.OK + assert "REPLAY VERIFIED" in capsys.readouterr().out + + generated_directory = tmp_path / "generated-tests" + generate_code = main( + [ + "generate-test", + str(cli_artifact / "replay.json"), + "--output-directory", + str(generated_directory), + "--json", + ] + ) + assert generate_code == ExitCode.OK + generated_payload = json.loads(capsys.readouterr().out) + assert Path(generated_payload["test"]).exists() + + report_code = main(["report", str(cli_artifact), "--format", "all"]) + assert report_code == ExitCode.OK + report_output = capsys.readouterr().out + assert "CHECK POLICY PASSED" in report_output + assert (cli_artifact / "report.json").exists() + + +def test_fail_on_finding_is_one(tmp_path: Path) -> None: + code = main( + [ + "perturb", + "--example", + "toggle", + "--output-root", + str(tmp_path / "runs"), + "--fail-on-finding", + ] + ) + assert code == ExitCode.POLICY_FAILED + + +def test_tampered_replay_is_invalid_input(cli_artifact: Path) -> None: + fixture = cli_artifact / "replay.json" + payload = json.loads(fixture.read_text(encoding="utf-8")) + payload["seed"] = 999 + fixture.write_text(json.dumps(payload), encoding="utf-8") + assert main(["replay", str(fixture)]) == ExitCode.INVALID_INPUT + + +def test_internal_defect_is_four(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def broken_execute(_config: object) -> object: + raise RuntimeError("synthetic containment test") + + monkeypatch.setattr("phaseprobe.cli.run_scan", broken_execute) + code = main( + [ + "scan", + "--example", + "logistic-negative", + "--output-root", + str(tmp_path / "runs"), + ] + ) + assert code == ExitCode.INTERNAL_ERROR diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..ea2c168 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,69 @@ +"""Configuration and registry unit tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from phaseprobe.config import canonical_json, load_config, load_example, parse_config +from phaseprobe.errors import ConfigurationError +from phaseprobe.models import get_model, model_names +from phaseprobe.types import ModelAdapter + + +def test_canonical_json_is_stable() -> None: + assert canonical_json({"b": 1, "a": [3, 2]}) == '{"a":[3,2],"b":1}' + + +def test_parse_rejects_wrong_schema() -> None: + with pytest.raises(ConfigurationError, match="unsupported configuration schema"): + parse_config('{"schema_version":"9","model":"lorenz"}', "test") + + +def test_parse_rejects_non_object() -> None: + with pytest.raises(ConfigurationError, match="must be a JSON object"): + parse_config("[]", "test") + + +def test_parse_rejects_bad_seed() -> None: + with pytest.raises(ConfigurationError, match="seed"): + parse_config('{"schema_version":"1.0","model":"lorenz","seed":-1}', "test") + + +def test_load_config_reports_missing_file(tmp_path: Path) -> None: + with pytest.raises(ConfigurationError, match="cannot read"): + load_config(tmp_path / "missing.json") + + +def test_all_built_in_examples_are_versioned_json() -> None: + for name in ( + "logistic", + "logistic-negative", + "lorenz", + "lorenz-negative", + "predator-prey", + "predator-prey-negative", + "toggle", + "toggle-negative", + ): + config = load_example(name) + assert config.data["schema_version"] == "1.0" + json.loads(canonical_json(config.data)) + + +def test_unknown_example_is_actionable() -> None: + with pytest.raises(ConfigurationError, match="unknown example"): + load_example("missing") + + +def test_registry_models_implement_protocol() -> None: + assert model_names() == ("genetic-toggle", "logistic-map", "lorenz", "predator-prey") + for name in model_names(): + assert isinstance(get_model(name), ModelAdapter) + + +def test_unknown_model_is_actionable() -> None: + with pytest.raises(ConfigurationError, match="unknown model"): + get_model("missing") diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..b19d4d3 --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,124 @@ +"""Numerical validation, positive cases, and negative controls.""" + +from __future__ import annotations + +import math + +import pytest + +from phaseprobe.config import canonical_json, load_example, parse_config +from phaseprobe.engine import ProbeOutcome, run_check, run_perturb, run_scan, simulate +from phaseprobe.errors import ConfigurationError, NumericalFailure + + +@pytest.mark.numerical +def test_logistic_transition_is_refined_and_repeatable(logistic_outcome: ProbeOutcome) -> None: + finding = logistic_outcome.finding + assert finding is not None + bracket = finding["stable_bracket"] + assert isinstance(bracket, list) + assert 3.449 < bracket[0] < bracket[1] < 3.45 + assert logistic_outcome.baseline.classification == "period-2" + assert logistic_outcome.changed is not None + assert logistic_outcome.changed.classification == "period-4" + assert logistic_outcome.reproducible + + +@pytest.mark.numerical +def test_logistic_negative_control_has_no_transition() -> None: + outcome = run_scan(load_example("logistic-negative")) + assert outcome.finding is None + assert outcome.status == "NO QUALITATIVE TRANSITION FOUND" + + +@pytest.mark.numerical +def test_lorenz_reports_finite_time_divergence_without_class_change() -> None: + outcome = run_perturb(load_example("lorenz")) + assert outcome.finding is not None + assert outcome.finding["kind"] == "finite-time-divergence" + metrics = outcome.finding["metrics"] + assert isinstance(metrics, dict) + assert metrics["max_trajectory_distance"] >= 1.0 + assert isinstance(metrics["finite_time_divergence_rate"], float) + assert outcome.changed is not None + assert outcome.baseline.classification == outcome.changed.classification + + +@pytest.mark.numerical +def test_lorenz_short_window_negative_control() -> None: + outcome = run_perturb(load_example("lorenz-negative")) + assert outcome.finding is None + assert outcome.status == "NO SENSITIVE PERTURBATION FOUND" + + +@pytest.mark.numerical +def test_predator_prey_refined_solver_preserves_declared_invariant() -> None: + outcome = run_check(load_example("predator-prey")) + assert not outcome.policy_failed + assert outcome.baseline.classification == "bounded-positive-oscillation" + assert outcome.baseline.invariant_violations == 0 + drift = outcome.baseline.invariants[0] + assert drift.measured <= drift.tolerance + + +@pytest.mark.numerical +def test_predator_prey_coarse_solver_is_a_policy_failure() -> None: + outcome = run_check(load_example("predator-prey-negative")) + assert outcome.policy_failed + assert outcome.finding is not None + assert outcome.finding["kind"] == "invariant-violation" + + +@pytest.mark.numerical +def test_toggle_perturbation_switches_stable_state() -> None: + outcome = run_perturb(load_example("toggle")) + assert outcome.finding is not None + assert outcome.baseline.classification == "u-dominant" + assert outcome.changed is not None + assert outcome.changed.classification == "v-dominant" + assert outcome.reproducible + + +@pytest.mark.numerical +def test_toggle_negative_control_stays_in_baseline_basin() -> None: + outcome = run_perturb(load_example("toggle-negative")) + assert outcome.finding is None + assert outcome.changed is not None + assert outcome.changed.classification == "u-dominant" + + +def test_simulation_is_exactly_repeatable() -> None: + config = load_example("predator-prey") + first = simulate(config) + second = simulate(config) + assert first.trace_sha256 == second.trace_sha256 + assert first.final_state == second.final_state + + +def test_trace_retention_is_bounded() -> None: + result = simulate(load_example("lorenz-negative")) + assert len(result.trace) == result.settings.trace_cap == 500 + + +def test_invalid_integration_is_diagnostic() -> None: + base = load_example("logistic-negative") + data = dict(base.data) + data["parameters"] = {"r": 1e50} + data["simulation"] = dict(base.section("simulation"), hard_state_limit=10.0) + config = parse_config(canonical_json(data), "invalid-state-test") + with pytest.raises(NumericalFailure, match="invalid integration"): + simulate(config) + + +def test_invalid_search_bounds_are_rejected() -> None: + base = load_example("logistic-negative") + data = dict(base.data) + data["scan"] = dict(base.section("scan"), stop=3.0) + config = parse_config(canonical_json(data), "bad-scan") + with pytest.raises(ConfigurationError, match="stop > start"): + run_scan(config) + + +def test_every_retained_scalar_is_finite() -> None: + result = simulate(load_example("toggle")) + assert all(math.isfinite(value) for point in result.trace for value in point.state) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9f9f8a4 --- /dev/null +++ b/uv.lock @@ -0,0 +1,522 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "build" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/1c/23e33405a7c9eac261dff640926b8b5adaed6a6eb3e1767d441ed611d0c0/build-1.3.0.tar.gz", hash = "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", size = 48544, upload-time = "2025-08-01T21:27:09.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/8c/2b30c12155ad8de0cf641d76a8b396a16d2c36bc6d50b621a62b7c4567c1/build-1.3.0-py3-none-any.whl", hash = "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4", size = 23382, upload-time = "2025-08-01T21:27:07.844Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/060ce69008ac97bbc01b1411b3e55b61f6f015659400b46749b662107831/coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d", size = 221284, upload-time = "2026-07-15T18:53:29.52Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a3/d936e8b53edd9684100a6aefaf3fcabaa54728fe33324436c8d279c047aa/coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846", size = 221799, upload-time = "2026-07-15T18:53:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a3/ca234b06aec7ee28226f11d39a696b4481fe5eddfce8e03bf39979bb8ffb/coverage-7.15.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf", size = 248544, upload-time = "2026-07-15T18:53:33.212Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/dda79527bb7573ba91828b2fb91b3105d87378d6a2749ca0c0924ce0addd/coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376", size = 250374, upload-time = "2026-07-15T18:53:34.683Z" }, + { url = "https://files.pythonhosted.org/packages/67/c6/c33755a34572f81f49a8c0cdf6b622f35ccb3238b136e1909daf0cdd4319/coverage-7.15.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd", size = 252239, upload-time = "2026-07-15T18:53:36.205Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6f/dc341741b375be53a5baeee5b4bf0f0e525d38caed428f7932d23bb7bcb1/coverage-7.15.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb", size = 254150, upload-time = "2026-07-15T18:53:37.863Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8d/966a18a5b195cb4e77b14c53f5f3dce22b5da05e6de7fafd1e08f2d2067a/coverage-7.15.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1", size = 249234, upload-time = "2026-07-15T18:53:39.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/8b2e367496ab48484d48e79984fec76cdc1b7cb5d3a00ee799a5602e3ec9/coverage-7.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9", size = 250276, upload-time = "2026-07-15T18:53:41.027Z" }, + { url = "https://files.pythonhosted.org/packages/63/92/1199318a200eb6c8c6ce0192c892c8710ac791abbe0f35099294620bbfda/coverage-7.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a", size = 248283, upload-time = "2026-07-15T18:53:42.557Z" }, + { url = "https://files.pythonhosted.org/packages/56/da/be284a55c5619bda891a89c27dfd59324a2c6a14d755cf6aac6960ceebeb/coverage-7.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287", size = 252093, upload-time = "2026-07-15T18:53:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/d4/53/ee112da833ddd77b73c6d781a98029b45b584b136615b4900ed0569f887e/coverage-7.15.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89", size = 248552, upload-time = "2026-07-15T18:53:45.7Z" }, + { url = "https://files.pythonhosted.org/packages/82/6a/802cfc802e9113494c80bf3f284cd4d72faeb1f24e244f61046af364f2ca/coverage-7.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88", size = 249154, upload-time = "2026-07-15T18:53:47.256Z" }, + { url = "https://files.pythonhosted.org/packages/2c/65/529808e91d651147edae408fd9e894abc3b8cad7f3e594bbc36719a3e13a/coverage-7.15.2-cp310-cp310-win32.whl", hash = "sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443", size = 223334, upload-time = "2026-07-15T18:53:48.768Z" }, + { url = "https://files.pythonhosted.org/packages/68/0f/0e1829d7001130876dfbc0b4e1c737ea7c155b809e3e4a98a0aa268e2369/coverage-7.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629", size = 223959, upload-time = "2026-07-15T18:53:50.429Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mypy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570, upload-time = "2025-07-31T07:54:19.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/a9/3d7aa83955617cdf02f94e50aab5c830d205cfa4320cf124ff64acce3a8e/mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972", size = 11003299, upload-time = "2025-07-31T07:54:06.425Z" }, + { url = "https://files.pythonhosted.org/packages/83/e8/72e62ff837dd5caaac2b4a5c07ce769c8e808a00a65e5d8f94ea9c6f20ab/mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7", size = 10125451, upload-time = "2025-07-31T07:53:52.974Z" }, + { url = "https://files.pythonhosted.org/packages/7d/10/f3f3543f6448db11881776f26a0ed079865926b0c841818ee22de2c6bbab/mypy-1.17.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df", size = 11916211, upload-time = "2025-07-31T07:53:18.879Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/63e83ed551282d67bb3f7fea2cd5561b08d2bb6eb287c096539feb5ddbc5/mypy-1.17.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390", size = 12652687, upload-time = "2025-07-31T07:53:30.544Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/68f2eeef11facf597143e85b694a161868b3b006a5fbad50e09ea117ef24/mypy-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94", size = 12896322, upload-time = "2025-07-31T07:53:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/a3/87/8e3e9c2c8bd0d7e071a89c71be28ad088aaecbadf0454f46a540bda7bca6/mypy-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b", size = 9507962, upload-time = "2025-07-31T07:53:08.431Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/eadc80c4e0a70db1c08921dcc220357ba8ab2faecb4392e3cebeb10edbfa/mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58", size = 10921009, upload-time = "2025-07-31T07:53:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c1/c869d8c067829ad30d9bdae051046561552516cfb3a14f7f0347b7d973ee/mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5", size = 10047482, upload-time = "2025-07-31T07:53:26.151Z" }, + { url = "https://files.pythonhosted.org/packages/98/b9/803672bab3fe03cee2e14786ca056efda4bb511ea02dadcedde6176d06d0/mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd", size = 11832883, upload-time = "2025-07-31T07:53:47.948Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/fcdac695beca66800918c18697b48833a9a6701de288452b6715a98cfee1/mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b", size = 12566215, upload-time = "2025-07-31T07:54:04.031Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/a932da3d3dace99ee8eb2043b6ab03b6768c36eb29a02f98f46c18c0da0e/mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5", size = 12751956, upload-time = "2025-07-31T07:53:36.263Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/6438a429e0f2f5cab8bc83e53dbebfa666476f40ee322e13cac5e64b79e7/mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b", size = 9507307, upload-time = "2025-07-31T07:53:59.734Z" }, + { url = "https://files.pythonhosted.org/packages/17/a2/7034d0d61af8098ec47902108553122baa0f438df8a713be860f7407c9e6/mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb", size = 11086295, upload-time = "2025-07-31T07:53:28.124Z" }, + { url = "https://files.pythonhosted.org/packages/14/1f/19e7e44b594d4b12f6ba8064dbe136505cec813549ca3e5191e40b1d3cc2/mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403", size = 10112355, upload-time = "2025-07-31T07:53:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/5b/69/baa33927e29e6b4c55d798a9d44db5d394072eef2bdc18c3e2048c9ed1e9/mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056", size = 11875285, upload-time = "2025-07-31T07:53:55.293Z" }, + { url = "https://files.pythonhosted.org/packages/90/13/f3a89c76b0a41e19490b01e7069713a30949d9a6c147289ee1521bcea245/mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341", size = 12737895, upload-time = "2025-07-31T07:53:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/c4ee79ac484241301564072e6476c5a5be2590bc2e7bfd28220033d2ef8f/mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb", size = 12931025, upload-time = "2025-07-31T07:54:17.125Z" }, + { url = "https://files.pythonhosted.org/packages/89/b8/7409477be7919a0608900e6320b155c72caab4fef46427c5cc75f85edadd/mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19", size = 9584664, upload-time = "2025-07-31T07:54:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/5b/82/aec2fc9b9b149f372850291827537a508d6c4d3664b1750a324b91f71355/mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7", size = 11075338, upload-time = "2025-07-31T07:53:38.873Z" }, + { url = "https://files.pythonhosted.org/packages/07/ac/ee93fbde9d2242657128af8c86f5d917cd2887584cf948a8e3663d0cd737/mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81", size = 10113066, upload-time = "2025-07-31T07:54:14.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/68/946a1e0be93f17f7caa56c45844ec691ca153ee8b62f21eddda336a2d203/mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6", size = 11875473, upload-time = "2025-07-31T07:53:14.504Z" }, + { url = "https://files.pythonhosted.org/packages/9f/0f/478b4dce1cb4f43cf0f0d00fba3030b21ca04a01b74d1cd272a528cf446f/mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849", size = 12744296, upload-time = "2025-07-31T07:53:03.896Z" }, + { url = "https://files.pythonhosted.org/packages/ca/70/afa5850176379d1b303f992a828de95fc14487429a7139a4e0bdd17a8279/mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14", size = 12914657, upload-time = "2025-07-31T07:54:08.576Z" }, + { url = "https://files.pythonhosted.org/packages/53/f9/4a83e1c856a3d9c8f6edaa4749a4864ee98486e9b9dbfbc93842891029c2/mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a", size = 9593320, upload-time = "2025-07-31T07:53:01.341Z" }, + { url = "https://files.pythonhosted.org/packages/38/56/79c2fac86da57c7d8c48622a05873eaab40b905096c33597462713f5af90/mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733", size = 11040037, upload-time = "2025-07-31T07:54:10.942Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c3/adabe6ff53638e3cad19e3547268482408323b1e68bf082c9119000cd049/mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd", size = 10131550, upload-time = "2025-07-31T07:53:41.307Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c5/2e234c22c3bdeb23a7817af57a58865a39753bde52c74e2c661ee0cfc640/mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0", size = 11872963, upload-time = "2025-07-31T07:53:16.878Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/c13c130f35ca8caa5f2ceab68a247775648fdcd6c9a18f158825f2bc2410/mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a", size = 12710189, upload-time = "2025-07-31T07:54:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/c7d79d09f6de8383fe800521d066d877e54d30b4fb94281c262be2df84ef/mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91", size = 12900322, upload-time = "2025-07-31T07:53:10.551Z" }, + { url = "https://files.pythonhosted.org/packages/b8/98/3d5a48978b4f708c55ae832619addc66d677f6dc59f3ebad71bae8285ca6/mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed", size = 9751879, upload-time = "2025-07-31T07:52:56.683Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411, upload-time = "2025-07-31T07:53:24.664Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "phaseprobe" +version = "0.1.0" +source = { editable = "." } + +[package.optional-dependencies] +demo = [ + { name = "pillow" }, +] +dev = [ + { name = "build" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "build", marker = "extra == 'dev'", specifier = "==1.3.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = "==1.17.1" }, + { name = "pillow", marker = "extra == 'demo'", specifier = "==11.3.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==8.4.1" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = "==6.2.1" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.12.7" }, +] +provides-extras = ["dev", "demo"] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554, upload-time = "2025-07-01T09:13:39.342Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548, upload-time = "2025-07-01T09:13:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742, upload-time = "2025-07-03T13:09:47.439Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087, upload-time = "2025-07-03T13:09:51.796Z" }, + { url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350, upload-time = "2025-07-01T09:13:43.865Z" }, + { url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840, upload-time = "2025-07-01T09:13:46.161Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005, upload-time = "2025-07-01T09:13:47.829Z" }, + { url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372, upload-time = "2025-07-01T09:13:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090, upload-time = "2025-07-01T09:13:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988, upload-time = "2025-07-01T09:13:55.699Z" }, + { url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899, upload-time = "2025-07-01T09:13:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, + { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, + { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556, upload-time = "2025-07-01T09:16:09.961Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625, upload-time = "2025-07-01T09:16:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207, upload-time = "2025-07-03T13:11:10.201Z" }, + { url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939, upload-time = "2025-07-03T13:11:15.68Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166, upload-time = "2025-07-01T09:16:13.74Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482, upload-time = "2025-07-01T09:16:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596, upload-time = "2025-07-01T09:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, + { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, + { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, + { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, +] + +[[package]] +name = "ruff" +version = "0.12.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/81/0bd3594fa0f690466e41bd033bdcdf86cba8288345ac77ad4afbe5ec743a/ruff-0.12.7.tar.gz", hash = "sha256:1fc3193f238bc2d7968772c82831a4ff69252f673be371fb49663f0068b7ec71", size = 5197814, upload-time = "2025-07-29T22:32:35.877Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/d2/6cb35e9c85e7a91e8d22ab32ae07ac39cc34a71f1009a6f9e4a2a019e602/ruff-0.12.7-py3-none-linux_armv6l.whl", hash = "sha256:76e4f31529899b8c434c3c1dede98c4483b89590e15fb49f2d46183801565303", size = 11852189, upload-time = "2025-07-29T22:31:41.281Z" }, + { url = "https://files.pythonhosted.org/packages/63/5b/a4136b9921aa84638f1a6be7fb086f8cad0fde538ba76bda3682f2599a2f/ruff-0.12.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:789b7a03e72507c54fb3ba6209e4bb36517b90f1a3569ea17084e3fd295500fb", size = 12519389, upload-time = "2025-07-29T22:31:54.265Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c9/3e24a8472484269b6b1821794141f879c54645a111ded4b6f58f9ab0705f/ruff-0.12.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e1c2a3b8626339bb6369116e7030a4cf194ea48f49b64bb505732a7fce4f4e3", size = 11743384, upload-time = "2025-07-29T22:31:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/26/7c/458dd25deeb3452c43eaee853c0b17a1e84169f8021a26d500ead77964fd/ruff-0.12.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32dec41817623d388e645612ec70d5757a6d9c035f3744a52c7b195a57e03860", size = 11943759, upload-time = "2025-07-29T22:32:01.95Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8b/658798472ef260ca050e400ab96ef7e85c366c39cf3dfbef4d0a46a528b6/ruff-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47ef751f722053a5df5fa48d412dbb54d41ab9b17875c6840a58ec63ff0c247c", size = 11654028, upload-time = "2025-07-29T22:32:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/a8/86/9c2336f13b2a3326d06d39178fd3448dcc7025f82514d1b15816fe42bfe8/ruff-0.12.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a828a5fc25a3efd3e1ff7b241fd392686c9386f20e5ac90aa9234a5faa12c423", size = 13225209, upload-time = "2025-07-29T22:32:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/76/69/df73f65f53d6c463b19b6b312fd2391dc36425d926ec237a7ed028a90fc1/ruff-0.12.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5726f59b171111fa6a69d82aef48f00b56598b03a22f0f4170664ff4d8298efb", size = 14182353, upload-time = "2025-07-29T22:32:10.053Z" }, + { url = "https://files.pythonhosted.org/packages/58/1e/de6cda406d99fea84b66811c189b5ea139814b98125b052424b55d28a41c/ruff-0.12.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74e6f5c04c4dd4aba223f4fe6e7104f79e0eebf7d307e4f9b18c18362124bccd", size = 13631555, upload-time = "2025-07-29T22:32:12.644Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ae/625d46d5164a6cc9261945a5e89df24457dc8262539ace3ac36c40f0b51e/ruff-0.12.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d0bfe4e77fba61bf2ccadf8cf005d6133e3ce08793bbe870dd1c734f2699a3e", size = 12667556, upload-time = "2025-07-29T22:32:15.312Z" }, + { url = "https://files.pythonhosted.org/packages/55/bf/9cb1ea5e3066779e42ade8d0cd3d3b0582a5720a814ae1586f85014656b6/ruff-0.12.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06bfb01e1623bf7f59ea749a841da56f8f653d641bfd046edee32ede7ff6c606", size = 12939784, upload-time = "2025-07-29T22:32:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/55/7f/7ead2663be5627c04be83754c4f3096603bf5e99ed856c7cd29618c691bd/ruff-0.12.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e41df94a957d50083fd09b916d6e89e497246698c3f3d5c681c8b3e7b9bb4ac8", size = 11771356, upload-time = "2025-07-29T22:32:20.134Z" }, + { url = "https://files.pythonhosted.org/packages/17/40/a95352ea16edf78cd3a938085dccc55df692a4d8ba1b3af7accbe2c806b0/ruff-0.12.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4000623300563c709458d0ce170c3d0d788c23a058912f28bbadc6f905d67afa", size = 11612124, upload-time = "2025-07-29T22:32:22.645Z" }, + { url = "https://files.pythonhosted.org/packages/4d/74/633b04871c669e23b8917877e812376827c06df866e1677f15abfadc95cb/ruff-0.12.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:69ffe0e5f9b2cf2b8e289a3f8945b402a1b19eff24ec389f45f23c42a3dd6fb5", size = 12479945, upload-time = "2025-07-29T22:32:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/be/34/c3ef2d7799c9778b835a76189c6f53c179d3bdebc8c65288c29032e03613/ruff-0.12.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a07a5c8ffa2611a52732bdc67bf88e243abd84fe2d7f6daef3826b59abbfeda4", size = 12998677, upload-time = "2025-07-29T22:32:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/77/ab/aca2e756ad7b09b3d662a41773f3edcbd262872a4fc81f920dc1ffa44541/ruff-0.12.7-py3-none-win32.whl", hash = "sha256:c928f1b2ec59fb77dfdf70e0419408898b63998789cc98197e15f560b9e77f77", size = 11756687, upload-time = "2025-07-29T22:32:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/b4/71/26d45a5042bc71db22ddd8252ca9d01e9ca454f230e2996bb04f16d72799/ruff-0.12.7-py3-none-win_amd64.whl", hash = "sha256:9c18f3d707ee9edf89da76131956aba1270c6348bfee8f6c647de841eac7194f", size = 12912365, upload-time = "2025-07-29T22:32:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9b/0b8aa09817b63e78d94b4977f18b1fcaead3165a5ee49251c5d5c245bb2d/ruff-0.12.7-py3-none-win_arm64.whl", hash = "sha256:dfce05101dbd11833a0776716d5d1578641b7fddb537fe7fa956ab85d1769b69", size = 11982083, upload-time = "2025-07-29T22:32:33.881Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +]